src/bots/bridge_core/presenters.py
Rama main · réplica de lectura del repositorio original.
"""Shared text and card content presenters for DiceFrame bridge responses."""
from __future__ import annotations
import re
from typing import Any
from src.engine.language import localized_text
def command_example(command: str = "", *, command_prefix: str = "@我") -> str:
prefix = str(command_prefix or "").strip()
command = str(command or "").strip()
return f"{prefix} {command}".strip() if command else prefix
def format_action_result(result: dict[str, Any], language: str = "zh-CN") -> str:
if str(result.get("error_code") or "") == "ECONOMY_DECISION_PENDING":
if result.get("economy_proposals"):
return localized_text(language, {
"en": "An economy proposal is waiting for your decision. Send “pay” to review it.",
"zh-CN": "当前有经济提案待确认,请发送“支付”查看。",
"ja": "経済提案の確認待ちです。「支払い」で確認してください。",
"de": "Ein Wirtschaftsvorschlag wartet auf deine Entscheidung. Sende „pay“, um ihn zu prüfen.",
})
return localized_text(language, {
"en": "The game is waiting for the GM or another contributor to resolve an economy proposal.",
"zh-CN": "当前正在等待 GM 或其他参与者处理经济提案。",
"ja": "GM またはほかの参加者による経済提案の処理を待っています。",
"de": "Das Spiel wartet darauf, dass der GM oder ein anderer Teilnehmer einen Wirtschaftsvorschlag bearbeitet.",
})
lines = []
roll = result.get("roll") or {}
if roll:
lines.append(f"🎲 {str(roll.get('dice_system', '')).upper()} = {roll.get('value')}")
checks = result.get("check_results") if isinstance(result.get("check_results"), list) else []
lines.extend(format_check_result(check, language) for check in checks if isinstance(check, dict))
narration = str(result.get("narration") or "").strip()
if narration:
lines.append(narration)
pending_luck = result.get("pending_luck_decisions") if isinstance(result.get("pending_luck_decisions"), list) else []
if pending_luck:
lines.extend(luck_prompt_lines(pending_luck, language))
return "\n".join(lines) or localized_text(
language, {"en": "Action recorded.", "zh-CN": "行动已记录。", "ja": "行動が記録されました。", "de": "Aktion erfasst."}
)
def format_check_result(check: dict[str, Any], language: str = "zh-CN") -> str:
"""群聊没有网页卡片,以同一结构化结果渲染紧凑文本。"""
actor = str(check.get("actor_name") or check.get("actor_uid") or localized_text(
language, {"en": "Character", "zh-CN": "角色", "ja": "キャラクター", "de": "Charakter"}
))
label = str(check.get("label") or localized_text(
language, {"en": "Check", "zh-CN": "检定", "ja": "判定", "de": "Probe"}
))
dice = str(check.get("dice") or "d20")
roll = check.get("roll")
verdict_raw = str(check.get("verdict") or "")
verdict_map = {
"大成功": "Critical Success",
"极难成功": "Extreme Success",
"困难成功": "Hard Success",
"普通成功": "Regular Success",
"成功": "Success",
"失败": "Failure",
"大失败": "Critical Failure",
}
verdict_map_de = {
"大成功": "Kritischer Erfolg",
"极难成功": "Extremer Erfolg",
"困难成功": "Schwerer Erfolg",
"普通成功": "Normaler Erfolg",
"成功": "Erfolg",
"失败": "Fehlschlag",
"大失败": "Kritischer Fehlschlag",
}
verdict = localized_text(
language,
{
"en": verdict_map.get(verdict_raw, verdict_raw),
"zh-CN": verdict_raw,
"ja": verdict_raw,
"de": verdict_map_de.get(verdict_raw, verdict_raw),
},
)
if dice == "d100":
math = f"d100={roll} vs {check.get('threshold')}%"
else:
modifier = int(check.get("modifier", 0) or 0)
if check.get("opponent_name"):
math = (
f"d20={roll}({modifier:+d})={check.get('total')} vs {check.get('opponent_name')} "
f"d20={check.get('opponent_roll')}({int(check.get('opponent_modifier', 0) or 0):+d})="
f"{check.get('opponent_total')}"
)
else:
math = f"d20={roll}({modifier:+d})={check.get('total')} vs DC {check.get('dc')}"
return f"🎲 {actor} · {label} {math} → {verdict}"
def luck_prompt_lines(
checks: list[dict[str, Any]],
language: str = "zh-CN",
*,
command_prefix: str = "@me",
) -> list[str]:
"""格式化跨 Bridge 共用的幸运选择提示。"""
lines = [localized_text(language, {
"en": "Luck decision required:",
"zh-CN": "需要决定是否使用幸运:",
"ja": "幸運を使用するかどうか決めてください:",
"de": "Glücks-Entscheidung erforderlich:",
})]
multiple = len(checks) > 1
for index, check in enumerate(checks, 1):
prefix = f"{index}. " if multiple else ""
actor = str(check.get("actor_name") or check.get("actor_uid") or localized_text(
language, {"en": "Character", "zh-CN": "角色", "ja": "キャラクター", "de": "Charakter"}
))
label = str(check.get("label") or localized_text(
language, {"en": "Check", "zh-CN": "检定", "ja": "判定", "de": "Probe"}
))
roll = check.get("roll")
threshold = check.get("threshold")
cost = int(check.get("luck_cost", 0) or 0)
lines.append(localized_text(language, {
"en": f"{prefix}{actor} · {label}: d100={roll}/{threshold}, spend {cost} Luck for a regular success.",
"zh-CN": f"{prefix}{actor} · {label}:d100={roll}/{threshold},可消耗 {cost} 点幸运变为普通成功。",
"ja": f"{prefix}{actor} · {label}:d100={roll}/{threshold}、幸運を{cost}点消費して通常成功にできます。",
"de": f"{prefix}{actor} · {label}: d100={roll}/{threshold}, {cost} Glückspunkte für einen normalen Erfolg einsetzen.",
}))
lines.append(localized_text(language, {
"en": f"Use: {command_prefix} luck; keep failure: {command_prefix} no luck",
"zh-CN": f"使用:{command_prefix} 幸运;保留失败:{command_prefix} 不用幸运",
"ja": f"使用:{command_prefix} luck;失敗のまま残す:{command_prefix} no luck",
"de": f"Verwenden: {command_prefix} luck; Fehlschlag behalten: {command_prefix} no luck",
}))
if multiple:
lines.append(localized_text(language, {
"en": "Your character is matched automatically; add a number only if that character has multiple decisions.",
"zh-CN": "系统会自动匹配你的角色;只有同一角色有多个选择时才需要追加序号。",
"ja": "あなたのキャラクターは自動的にマッチします;同一キャラクターに複数の選択肢がある場合のみ番号を付けてください。",
"de": "Dein Charakter wird automatisch zugeordnet; füge nur dann eine Nummer hinzu, wenn dieser Charakter mehrere Entscheidungen hat.",
}))
return lines
def recap_text(detail: dict[str, Any], language: str = "zh-CN") -> str:
recap = detail.get("recap") if isinstance(detail.get("recap"), dict) else {}
scene = str(recap.get("current_scene") or detail.get("scene") or localized_text(
language, {"en": "Unknown scene", "zh-CN": "未知场景", "ja": "不明なシーン", "de": "Unbekannte Szene"}
))
round_no = recap.get("round_number") or detail.get("round_number") or "?"
lines = [localized_text(language, {"en": "Recap:", "zh-CN": "前情提要:", "ja": "これまでのあらすじ:", "de": "Rückblick:"})]
lines.append(localized_text(language, {
"en": f" Current: round {round_no}, scene “{scene}”.",
"zh-CN": f" 当前:第 {round_no} 轮,场景「{scene}」。",
"ja": f" 現在:第 {round_no} ラウンド、シーン「{scene}」。",
"de": f" Aktuell: Runde {round_no}, Szene „{scene}“.",
}))
narrative = str(recap.get("narrative") or "").strip()
if narrative:
lines.append(localized_text(language, {
"en": f" Overview: {narrative}",
"zh-CN": f" 总览:{narrative}",
"ja": f" 概要:{narrative}",
"de": f" Überblick: {narrative}",
}))
recent = recap.get("recent_rounds") if isinstance(recap.get("recent_rounds"), list) else []
if recent:
lines.append(localized_text(language, {"en": "Recent events:", "zh-CN": "最近发生:", "ja": "最近の出来事:", "de": "Letzte Ereignisse:"}))
for item in recent[-3:]:
if not isinstance(item, dict):
continue
gm_text = str(item.get("gm_response") or "").strip()
action_bits = []
actions = item.get("actions") if isinstance(item.get("actions"), list) else []
for action in actions[:3]:
if isinstance(action, dict):
name = str(action.get("character_name") or localized_text(
language, {"en": "Adventurer", "zh-CN": "冒险者", "ja": "冒険者", "de": "Abenteurer"}
))
text = str(action.get("text") or "").strip()
if text:
action_bits.append(localized_text(language, {
"en": f"{name}: {text}",
"zh-CN": f"{name}:{text}",
"ja": f"{name}:{text}",
"de": f"{name}: {text}",
}))
body = gm_text or localized_text(language, {
"en": "; ".join(action_bits),
"zh-CN": ";".join(action_bits),
"ja": ";".join(action_bits),
"de": "; ".join(action_bits),
})
if body:
lines.append(localized_text(language, {
"en": f" R{item.get('round', '?')}: {body}",
"zh-CN": f" R{item.get('round', '?')}:{body}",
"ja": f" R{item.get('round', '?')}:{body}",
"de": f" R{item.get('round', '?')}: {body}",
}))
waiting = (detail.get("multiplayer") or {}).get("waiting_players") if isinstance(detail.get("multiplayer"), dict) else []
if isinstance(waiting, list) and waiting:
names = [
str(item.get("character_name") or item.get("user_id") or "")
for item in waiting
if isinstance(item, dict) and str(item.get("character_name") or item.get("user_id") or "")
]
if names:
lines.append(localized_text(language, {
"en": "Waiting for actions from: " + ", ".join(names) + ".",
"zh-CN": "现在等待:" + "、".join(names) + " 行动。",
"ja": "アクション待ち:" + "、".join(names) + "。",
"de": "Warte auf Aktionen von: " + ", ".join(names) + ".",
}))
if len(lines) <= 2:
lines.append(localized_text(language, {
"en": " No previous rounds yet; submit an action to begin.",
"zh-CN": " 暂无历史回合;可以先发送行动开始冒险。",
"ja": " まだ過去のラウンドはありません;行動を送って冒険を始めてください。",
"de": " Noch keine vorherigen Runden; sende eine Aktion, um zu beginnen.",
}))
return "\n".join(lines)
def map_lines(data: dict[str, Any], language: str = "zh-CN") -> list[str]:
locations = data.get("locations") if isinstance(data.get("locations"), list) else []
current_scene = str(data.get("current_scene") or "").strip()
if not locations:
base = [localized_text(language, {
"en": "No map data yet. Add locations to the lorebook or continue the story.",
"zh-CN": "暂无地图数据;可以先在资料册补地点,或继续推进剧情。",
"ja": "まだマップデータがありません。ワールドブックに地点を追加するか、物語を進めてください。",
"de": "Noch keine Kartendaten. Füge Orte im Lorebook hinzu oder setze die Geschichte fort.",
})]
if current_scene:
base.insert(0, localized_text(language, {
"en": f"Current scene: {current_scene}",
"zh-CN": f"当前场景:{current_scene}",
"ja": f"現在のシーン:{current_scene}",
"de": f"Aktuelle Szene: {current_scene}",
}))
return base
by_id = {
str(loc.get("id") or ""): str(loc.get("name") or "").strip()
for loc in locations
if isinstance(loc, dict) and str(loc.get("name") or "").strip()
}
lines = [localized_text(language, {
"en": f"Current scene: {current_scene or 'Unknown'}",
"zh-CN": f"当前场景:{current_scene or '未知'}",
"ja": f"現在のシーン:{current_scene or '不明'}",
"de": f"Aktuelle Szene: {current_scene or 'Unbekannt'}",
})]
for loc in locations[:10]:
if not isinstance(loc, dict):
continue
name = str(loc.get("name") or "").strip()
if not name:
continue
marker = "★" if is_current_location(loc, current_scene) else "•"
content = re.sub(r"\s+", " ", str(loc.get("content") or "").strip())
if len(content) > 42:
content = content[:42] + "…"
lines.append(f"{marker} {name}" + (localized_text(language, {
"en": f": {content}",
"zh-CN": f":{content}",
"ja": f":{content}",
"de": f": {content}",
}) if content else ""))
edges: list[str] = []
seen: set[tuple[str, str]] = set()
for loc in locations:
if not isinstance(loc, dict):
continue
src = str(loc.get("name") or "").strip()
if not src:
continue
connected = loc.get("connected_to") if isinstance(loc.get("connected_to"), list) else []
for target in connected:
dst = by_id.get(str(target), str(target)).strip()
if not dst or dst == src:
continue
pair = tuple(sorted((src, dst)))
if pair in seen:
continue
seen.add(pair)
edges.append(f"{src} ↔ {dst}")
if len(edges) >= 8:
break
if len(edges) >= 8:
break
if edges:
lines.append(localized_text(language, {
"en": "Connections: " + "; ".join(edges),
"zh-CN": "连接:" + ";".join(edges),
"ja": "接続:" + ";".join(edges),
"de": "Verbindungen: " + "; ".join(edges),
}))
if len(locations) > 10:
lines.append(localized_text(language, {
"en": f"{len(locations) - 10} more locations are available on the web map.",
"zh-CN": f"另有 {len(locations) - 10} 个地点,可在网页地图查看。",
"ja": f"あと {len(locations) - 10} 地点はウェブマップで確認できます。",
"de": f"{len(locations) - 10} weitere Orte sind auf der Web-Karte verfügbar.",
}))
return lines
def is_current_location(loc: dict[str, Any], current_scene: str) -> bool:
name = str(loc.get("name") or "").strip()
tier = str(loc.get("tier") or "").strip()
return bool(
current_scene
and name
and (name == current_scene or name in current_scene or current_scene in name or tier == "current")
)
def map_text(lines: list[str], language: str = "zh-CN") -> str:
title = localized_text(language, {"en": "Scene map:\n", "zh-CN": "场景地图:\n", "ja": "シーンマップ:\n", "de": "Szenenkarte:\n"})
indent = localized_text(language, {"en": " ", "zh-CN": " ", "ja": " ", "de": " "})
return title + "\n".join(indent + line for line in lines)
def normalize_summary_line(text: str) -> str:
return (
re.sub(r"\s+", "", str(text or ""))
.replace("(", "(")
.replace(")", ")")
.replace(":", ":")
.replace(";", ";")
.replace(",", ",")
)
def text_contains_summary_line(text: str, line: str) -> bool:
normalized_line = normalize_summary_line(line)
if not normalized_line:
return False
return normalized_line in normalize_summary_line(text)
def payment_line(payment: dict[str, Any], index: int, language: str = "zh-CN") -> str:
amount = int(payment.get("amount", 0) or 0)
reason = str(payment.get("reason") or localized_text(language, {
"en": "GM-requested payment",
"zh-CN": "GM 建议支付",
"ja": "GM からの支払い要請",
"de": "Vom GM angeforderte Zahlung",
})).strip()
round_no = payment.get("round", "?")
kind = str(payment.get("kind") or "payment")
marker = localized_text(language, {
"en": "reward" if kind == "reward" else "payment",
"zh-CN": "奖励" if kind == "reward" else "支付",
"ja": "報酬" if kind == "reward" else "支払い",
"de": "Belohnung" if kind == "reward" else "Zahlung",
})
reference = int(payment.get("sequence", index) or index)
return localized_text(language, {
"en": f"#{reference} · R{round_no} {marker} {amount} gold: {reason}",
"zh-CN": f"#{reference} · R{round_no} {marker} {amount} 金币:{reason}",
"ja": f"#{reference} · R{round_no} {marker} {amount} ゴールド:{reason}",
"de": f"#{reference} · R{round_no} {marker} {amount} Gold: {reason}",
})
def roster_names(group: dict[str, Any], language: str = "zh-CN") -> str:
names = [
str(item.get("character_name") or "").strip()
for item in group.get("roster", [])
if isinstance(item, dict) and str(item.get("character_name") or "").strip()
]
return localized_text(language, {
"en": ", ".join(names[:12]) or "No characters yet (create one on the web page first)",
"zh-CN": "、".join(names[:12]) or "暂无角色(请先在网页创建角色)",
"ja": "、".join(names[:12]) or "まだキャラクターがいません(先にウェブページで作成してください)",
"de": ", ".join(names[:12]) or "Noch keine Charaktere (bitte zuerst auf der Webseite einen erstellen)",
})
def roster_name_by_uid(group: dict[str, Any], uid: str) -> str:
for item in group.get("roster", []):
if isinstance(item, dict) and str(item.get("user_id") or "") == uid:
return str(item.get("character_name") or uid)
return uid
def match_roster_character(roster: list[Any], query: str) -> list[dict[str, Any]]:
normalized_query = re.sub(r"\s+", "", str(query or ""))
candidates = [
item for item in roster
if isinstance(item, dict) and str(item.get("character_name") or "").strip()
]
exact = [
item for item in candidates
if re.sub(r"\s+", "", str(item.get("character_name") or "")) == normalized_query
]
if exact:
return exact
return [
item for item in candidates
if re.sub(r"\s+", "", str(item.get("character_name") or "")) in normalized_query
]
def bind_success_text(
result: dict[str, Any],
*,
command_prefix: str = "@我",
language: str = "zh-CN",
) -> str:
world = result.get("world_name") or result["game_key"]
names = roster_names({"roster": result.get("players", [])}, language)
return localized_text(language, {
"en": (
f"Bound to “{world}”; GM identity confirmed.\n"
"How to get started:\n"
f"1. Claim a character: {command_example('join Character Name', command_prefix=command_prefix)}\n"
f" Available: {names}\n"
f"2. Describe an action: {command_example('I inspect the area', command_prefix=command_prefix)}\n"
"3. Checks are adjudicated and rolled automatically after the table is ready.\n"
f"4. Catch up with {command_example('recap', command_prefix=command_prefix)} or "
f"{command_example('map', command_prefix=command_prefix)}; use "
f"{command_example('help', command_prefix=command_prefix)} if you get stuck."
),
"zh-CN": (
f"已绑定《{world}》,GM 身份已确认。\n"
"接下来这样玩:\n"
f"1. 玩家先认领角色:{command_example('加入 角色名', command_prefix=command_prefix)}\n"
f" 可认领:{names}\n"
f"2. 认领后直接描述行动:{command_example('我调查四周', command_prefix=command_prefix)}\n"
"3. 人齐后系统自动判断并掷骰,不需要发送掷骰命令。\n"
f"4. 补信息:{command_example('前情', command_prefix=command_prefix)} / {command_example('地图', command_prefix=command_prefix)};不知道做什么就发:{command_example('帮助', command_prefix=command_prefix)}"
),
"ja": (
f"《{world}》にバインドしました。GM として確認済みです。\n"
"遊び方:\n"
f"1. まずキャラクターを認領する:{command_example('join キャラクター名', command_prefix=command_prefix)}\n"
f" 認領可能:{names}\n"
f"2. 認領後は行動を直接記述する:{command_example('部屋の中を調べる', command_prefix=command_prefix)}\n"
"3. メンバーが揃うと自動で判定とダイスロールが行われます。ダイスコマンドは不要です。\n"
f"4. 補足:{command_example('recap', command_prefix=command_prefix)} / {command_example('map', command_prefix=command_prefix)};困ったら {command_example('help', command_prefix=command_prefix)} を送信してください。"
),
"de": (
f"An „{world}“ gebunden; GM-Identität bestätigt.\n"
"So geht's los:\n"
f"1. Charakter beanspruchen: {command_example('join Character Name', command_prefix=command_prefix)}\n"
f" Verfügbar: {names}\n"
f"2. Aktion beschreiben: {command_example('I inspect the area', command_prefix=command_prefix)}\n"
"3. Proben werden automatisch entschieden und gewürfelt, sobald die Runde bereit ist.\n"
f"4. Auf dem Laufenden bleiben mit {command_example('recap', command_prefix=command_prefix)} oder "
f"{command_example('map', command_prefix=command_prefix)}; nutze "
f"{command_example('help', command_prefix=command_prefix)}, wenn du nicht weiterkommst."
),
})
def unbound_group_text(*, command_prefix: str = "@我", language: str = "zh-CN") -> str:
return localized_text(language, {
"en": (
"This chat is not bound to a game yet.\n"
"GM setup:\n"
"1. Open the current game in the web app\n"
"2. Choose “One-time Bot binding” in GM controls\n"
f"3. Send the copied command here: {command_example('bind <game_key> <one-time-token>', command_prefix=command_prefix)}\n"
f"Players can then claim a character with: {command_example('join Character Name', command_prefix=command_prefix)}"
),
"zh-CN": (
"本群尚未绑定游戏。\n"
"GM 开团步骤:\n"
"1. 打开网页里的当前游戏\n"
"2. 右侧 GM 操作点“一次性 Bot 绑定”\n"
f"3. 把复制出的命令发到群里:{command_example('绑定 <game_key> <一次性凭证>', command_prefix=command_prefix)}\n"
f"玩家暂时不用操作,等 GM 绑定后再发送:{command_example('加入 角色名', command_prefix=command_prefix)}"
),
"ja": (
"このチャットはまだゲームにバインドされていません。\n"
"GM のセットアップ手順:\n"
"1. ウェブアプリで現在のゲームを開く\n"
"2. GM コントロールの「One-time Bot バインド」を選択\n"
f"3. コピーしたコマンドをここに送信:{command_example('bind <game_key> <one-time-token>', command_prefix=command_prefix)}\n"
f"プレイヤーはその後 {command_example('join Character Name', command_prefix=command_prefix)} でキャラクターを認領できます。"
),
"de": (
"Dieser Chat ist noch mit keinem Spiel verbunden.\n"
"GM-Einrichtung:\n"
"1. Öffne das aktuelle Spiel in der Web-App\n"
"2. Wähle „Einmalige Bot-Bindung“ in den GM-Steuerelementen\n"
f"3. Sende den kopierten Befehl hierher: {command_example('bind <game_key> <one-time-token>', command_prefix=command_prefix)}\n"
f"Spieler können danach einen Charakter beanspruchen mit: {command_example('join Character Name', command_prefix=command_prefix)}"
),
})
def unclaimed_player_text(
group: dict[str, Any],
*,
command_prefix: str = "@我",
language: str = "zh-CN",
) -> str:
return localized_text(language, {
"en": (
"You have not claimed a character yet, so you cannot submit actions.\n"
f"First: {command_example('join Character Name', command_prefix=command_prefix)}\n"
f"Available: {roster_names(group, language)}\n"
f"Example: {command_example('join Erin', command_prefix=command_prefix)}\n"
f"Catch up: {command_example('recap', command_prefix=command_prefix)}\n"
f"View locations: {command_example('map', command_prefix=command_prefix)}\n"
f"Then act with: {command_example('I inspect the area', command_prefix=command_prefix)}"
),
"zh-CN": (
"你还没认领角色,所以暂时不能行动。\n"
f"第一步:发送 {command_example('加入 角色名', command_prefix=command_prefix)}\n"
f"可认领:{roster_names(group)}\n"
f"例子:{command_example('加入 艾琳', command_prefix=command_prefix)}\n"
f"想先看剧情:{command_example('前情', command_prefix=command_prefix)}\n"
f"想看地点:{command_example('地图', command_prefix=command_prefix)}\n"
f"认领后就可以发送:{command_example('我观察四周', command_prefix=command_prefix)}"
),
"ja": (
"まだキャラクターを認領していないため、行動を送信できません。\n"
f"まず:{command_example('join Character Name', command_prefix=command_prefix)}\n"
f"認領可能:{roster_names(group, language)}\n"
f"例:{command_example('join Erin', command_prefix=command_prefix)}\n"
f"前情を確認:{command_example('recap', command_prefix=command_prefix)}\n"
f"地点を確認:{command_example('map', command_prefix=command_prefix)}\n"
f"認領後は:{command_example('部屋の中を調べる', command_prefix=command_prefix)}"
),
"de": (
"Du hast noch keinen Charakter beansprucht, daher kannst du keine Aktionen senden.\n"
f"Zuerst: {command_example('join Character Name', command_prefix=command_prefix)}\n"
f"Verfügbar: {roster_names(group, language)}\n"
f"Beispiel: {command_example('join Erin', command_prefix=command_prefix)}\n"
f"Auf dem Laufenden bleiben: {command_example('recap', command_prefix=command_prefix)}\n"
f"Orte ansehen: {command_example('map', command_prefix=command_prefix)}\n"
f"Dann handeln mit: {command_example('I inspect the area', command_prefix=command_prefix)}"
),
})
def bound_help_text(
group: dict[str, Any],
*,
command_prefix: str = "@我",
language: str = "zh-CN",
) -> str:
return localized_text(language, {
"en": (
"DiceFrame chat quick start:\n"
f"1. Claim a character: {command_example('join Character Name', command_prefix=command_prefix)}\n"
f" Available: {roster_names(group, language)}\n"
f" Need one? {command_example('create character', command_prefix=command_prefix)}; "
f"AI draft: {command_example('AI character', command_prefix=command_prefix)}\n"
f" Invite players: {command_example('invite', command_prefix=command_prefix)}\n"
f" Catch up: {command_example('recap', command_prefix=command_prefix)}; "
f"map: {command_example('map', command_prefix=command_prefix)}\n"
f"2. Describe an action: {command_example('I inspect the area', command_prefix=command_prefix)}\n"
f" Ask the GM without acting: {command_example('ask kp <question>', command_prefix=command_prefix)}\n"
"3. Checks are adjudicated and rolled automatically when the table is ready.\n"
f" If offered: {command_example('luck', command_prefix=command_prefix)} or {command_example('no luck', command_prefix=command_prefix)}\n"
f"4. Character status: {command_example('status', command_prefix=command_prefix)}\n"
f"5. Step away: {command_example('away', command_prefix=command_prefix)}; "
f"return: {command_example('back', command_prefix=command_prefix)}\n"
f"6. GM progression: {command_example('advance', command_prefix=command_prefix)}\n"
f" AI hosting: {command_example('host Character Name', command_prefix=command_prefix)} / {command_example('unhost Character Name', command_prefix=command_prefix)}\n"
f"7. Use {command_example('help', command_prefix=command_prefix)} whenever you get stuck."
),
"zh-CN": (
"DiceFrame 群聊新手指南:\n"
f"1. 先认领角色:{command_example('加入 角色名', command_prefix=command_prefix)}\n"
f" 可认领:{roster_names(group)}\n"
f" 没有角色:{command_example('新建角色', command_prefix=command_prefix)} / {command_example('车卡', command_prefix=command_prefix)};想 AI 辅助:{command_example('AI车卡', command_prefix=command_prefix)}\n"
f" 邀请玩家:{command_example('邀请', command_prefix=command_prefix)}\n"
f" 补前情:{command_example('前情', command_prefix=command_prefix)}\n"
f" 看地图:{command_example('地图', command_prefix=command_prefix)}\n"
f"2. 描述行动:{command_example('我观察四周', command_prefix=command_prefix)} / {command_example('我攻击守卫', command_prefix=command_prefix)}\n"
f" 桌外问 KP(不耗行动):{command_example('询问 <问题>', command_prefix=command_prefix)}\n"
"3. 人齐后系统自动判断并掷骰,不需要发送掷骰命令。\n"
f" 如果可以消耗幸运:{command_example('幸运', command_prefix=command_prefix)} / {command_example('不用幸运', command_prefix=command_prefix)}\n"
f"4. 查看自己状态:{command_example('状态', command_prefix=command_prefix)}\n"
f"5. 临时离开:{command_example('暂离', command_prefix=command_prefix)};回来:{command_example('回来', command_prefix=command_prefix)}\n"
f"6. GM 推进:{command_example('推进', command_prefix=command_prefix)} / {command_example('下一轮', command_prefix=command_prefix)}\n"
f" AI 托管:{command_example('托管 角色名', command_prefix=command_prefix)} / {command_example('取消托管 角色名', command_prefix=command_prefix)}\n"
"7. DND局小抄:优势=2d20取高,劣势=2d20取低,同时出现会抵消\n"
f"8. 看这份说明:{command_example('帮助', command_prefix=command_prefix)}"
),
"ja": (
"DiceFrame チャット クイックスタート:\n"
f"1. キャラクターを認領する:{command_example('join Character Name', command_prefix=command_prefix)}\n"
f" 認領可能:{roster_names(group, language)}\n"
f" まだない?{command_example('create character', command_prefix=command_prefix)};"
f"AI による下書き:{command_example('AI character', command_prefix=command_prefix)}\n"
f" プレイヤーを招待:{command_example('invite', command_prefix=command_prefix)}\n"
f" 前情を確認:{command_example('recap', command_prefix=command_prefix)};"
f"マップ:{command_example('map', command_prefix=command_prefix)}\n"
f"2. 行動を記述する:{command_example('部屋の中を調べる', command_prefix=command_prefix)}\n"
f" 行動せず GM に質問:{command_example('ask kp <question>', command_prefix=command_prefix)}\n"
"3. メンバーが揃うと自動で判定とダイスロールが行われます。\n"
f" 選択肢が出たら:{command_example('luck', command_prefix=command_prefix)} または {command_example('no luck', command_prefix=command_prefix)}\n"
f"4. キャラクターの状態:{command_example('status', command_prefix=command_prefix)}\n"
f"5. 一時離席:{command_example('away', command_prefix=command_prefix)};"
f"復帰:{command_example('back', command_prefix=command_prefix)}\n"
f"6. GM の進行:{command_example('advance', command_prefix=command_prefix)}\n"
f" AI 托管:{command_example('host Character Name', command_prefix=command_prefix)} / {command_example('unhost Character Name', command_prefix=command_prefix)}\n"
f"7. 困ったら {command_example('help', command_prefix=command_prefix)} を送ってください。"
),
"de": (
"DiceFrame Chat-Schnellstart:\n"
f"1. Charakter beanspruchen: {command_example('join Character Name', command_prefix=command_prefix)}\n"
f" Verfügbar: {roster_names(group, language)}\n"
f" Brauchst du einen? {command_example('create character', command_prefix=command_prefix)}; "
f"KI-Entwurf: {command_example('AI character', command_prefix=command_prefix)}\n"
f" Spieler einladen: {command_example('invite', command_prefix=command_prefix)}\n"
f" Auf dem Laufenden bleiben: {command_example('recap', command_prefix=command_prefix)}; "
f"Karte: {command_example('map', command_prefix=command_prefix)}\n"
f"2. Aktion beschreiben: {command_example('I inspect the area', command_prefix=command_prefix)}\n"
f" Den GM fragen, ohne zu handeln: {command_example('ask kp <question>', command_prefix=command_prefix)}\n"
"3. Proben werden automatisch entschieden und gewürfelt, sobald die Runde bereit ist.\n"
f" Falls angeboten: {command_example('luck', command_prefix=command_prefix)} oder {command_example('no luck', command_prefix=command_prefix)}\n"
f"4. Charakterstatus: {command_example('status', command_prefix=command_prefix)}\n"
f"5. Abwesend: {command_example('away', command_prefix=command_prefix)}; "
f"zurück: {command_example('back', command_prefix=command_prefix)}\n"
f"6. GM-Fortschritt: {command_example('advance', command_prefix=command_prefix)}\n"
f" KI-Führung: {command_example('host Character Name', command_prefix=command_prefix)} / {command_example('unhost Character Name', command_prefix=command_prefix)}\n"
f"7. Nutze {command_example('help', command_prefix=command_prefix)}, wenn du nicht weiterkommst."
),
})
def character_creation_lines(
data: dict[str, Any],
*,
command_prefix: str = "@我",
language: str = "zh-CN",
) -> list[str]:
attrs = data.get("rule_attrs") if isinstance(data.get("rule_attrs"), list) else []
meta = data.get("rule_meta") if isinstance(data.get("rule_meta"), dict) else {}
classes = data.get("rule_classes") if isinstance(data.get("rule_classes"), list) else []
attr_names = [
str(attr.get("display_name") or attr.get("name") or attr.get("key"))
for attr in attrs
if isinstance(attr, dict)
]
class_names = []
for item in classes:
if isinstance(item, dict):
name = str(item.get("name") or item.get("class_name") or "").strip()
else:
name = str(item or "").strip()
if name:
class_names.append(name)
attr_total = meta.get("attribute_points") or data.get("rule_attrs_total") or ""
max_skills = meta.get("max_skills") or ""
skill_points = meta.get("skill_point_total") or ""
max_skill_value = meta.get("max_skill_value") or ""
skill_hint = str(meta.get("skill_hint") or "").strip()
skill_pools = meta.get("skill_pools") if isinstance(meta.get("skill_pools"), dict) else {}
skill_examples: list[str] = []
for values in skill_pools.values():
if isinstance(values, list):
skill_examples.extend(str(value).strip() for value in values if str(value).strip())
skill_examples = list(dict.fromkeys(skill_examples))
attr_line = localized_text(language, {
"en": "Attributes: " + (", ".join(attr_names[:8]) if attr_names else "follow the web form"),
"zh-CN": "属性:" + ("、".join(attr_names[:8]) if attr_names else "按网页表单填写"),
"ja": "属性:" + ("、".join(attr_names[:8]) if attr_names else "ウェブフォームに従って入力"),
"de": "Attribute: " + (", ".join(attr_names[:8]) if attr_names else "dem Webformular folgen"),
})
if attr_total:
attr_line += localized_text(language, {
"en": f" (suggested total: {attr_total})",
"zh-CN": f"(建议总点数 {attr_total})",
"ja": f"(推奨合計:{attr_total})",
"de": f" (empfohlene Gesamtsumme: {attr_total})",
})
skill_bits = []
if max_skills:
skill_bits.append(localized_text(language, {
"en": f"choose about {max_skills}",
"zh-CN": f"建议选 {max_skills} 个左右",
"ja": f"約 {max_skills} 個を推奨",
"de": f"etwa {max_skills} wählen",
}))
if skill_points:
skill_bits.append(localized_text(language, {
"en": f"suggested skill points: {skill_points}",
"zh-CN": f"参考技能点 {skill_points}",
"ja": f"技能ポイント目安:{skill_points}",
"de": f"empfohlene Fertigkeitspunkte: {skill_points}",
}))
if max_skill_value:
skill_bits.append(localized_text(language, {
"en": f"suggested per-skill maximum: {max_skill_value}",
"zh-CN": f"单项参考 {max_skill_value}",
"ja": f"単一スキル上限の目安:{max_skill_value}",
"de": f"empfohlenes Maximum pro Fertigkeit: {max_skill_value}",
}))
if skill_examples:
skill_bits.append(localized_text(language, {
"en": "Examples: " + ", ".join(skill_examples[:6]),
"zh-CN": "例:" + "、".join(skill_examples[:6]),
"ja": "例:" + "、".join(skill_examples[:6]),
"de": "Beispiele: " + ", ".join(skill_examples[:6]),
}))
if skill_hint:
skill_bits.append(skill_hint)
return localized_text(language, {
"en": [
"1. Character name: what should others call you?",
"2. Species/identity: human, investigator, elf, etc.",
"3. Class/role: create your own" + (f"; examples: {', '.join(class_names[:6])}" if class_names else " based on the setting"),
"4. " + attr_line,
"5. Skills: " + ("; ".join(skill_bits) if skill_bits else "follow the current rules and web form"),
"6. Background: 1–3 sentences about your origin, goal, or secret",
f"Want an AI draft? {command_example('AI character', command_prefix=command_prefix)}",
f"When ready, return here and use: {command_example('join Character Name', command_prefix=command_prefix)}",
],
"zh-CN": [
"1. 角色名:你想被怎么称呼",
"2. 种族/身份:如人类、修士、调查员",
"3. 职业/定位:可自拟" + (f";参考:{'、'.join(class_names[:6])}" if class_names else ",按世界观填写"),
"4. " + attr_line,
"5. 技能:" + (";".join(skill_bits) if skill_bits else "按网页表单和当前规则填写"),
"6. 背景:1-3 句话说明来历、目标、秘密",
f"想 AI 辅助生成草稿:{command_example('AI车卡', command_prefix=command_prefix)}",
f"填完后回群聊:{command_example('加入 角色名', command_prefix=command_prefix)}",
],
"ja": [
"1. キャラクター名:どう呼ばれたいか",
"2. 種族/身分:人間、調査員、エルフなど",
"3. 職業/ロール:自由に設定" + (f";参考:{'、'.join(class_names[:6])}" if class_names else "、世界観に合わせて記入"),
"4. " + attr_line,
"5. スキル:" + (";".join(skill_bits) if skill_bits else "現在のルールとウェブフォームに従って記入"),
"6. 背景:来歴・目標・秘密を 1〜3 文で",
f"AI による下書き生成:{command_example('AI character', command_prefix=command_prefix)}",
f"記入が終わったらここで送信:{command_example('join Character Name', command_prefix=command_prefix)}",
],
"de": [
"1. Charaktername: Wie sollen andere dich nennen?",
"2. Spezies/Identität: Mensch, Ermittler, Elf usw.",
"3. Klasse/Rolle: frei wählbar" + (f"; Beispiele: {', '.join(class_names[:6])}" if class_names else " passend zum Setting"),
"4. " + attr_line,
"5. Fertigkeiten: " + ("; ".join(skill_bits) if skill_bits else "den aktuellen Regeln und dem Webformular folgen"),
"6. Hintergrund: 1-3 Sätze zu Herkunft, Ziel oder Geheimnis",
f"KI-Entwurf gewünscht? {command_example('AI character', command_prefix=command_prefix)}",
f"Wenn fertig, hier zurückkehren und senden: {command_example('join Character Name', command_prefix=command_prefix)}",
],
})
def character_creation_text(lines: list[str], link: str = "", language: str = "zh-CN") -> str:
text = localized_text(language, {
"en": "Create a character:\n",
"zh-CN": "新建角色 / 车卡:\n",
"ja": "キャラクター作成:\n",
"de": "Charakter erstellen:\n",
}) + "\n".join(lines)
if link:
text += localized_text(language, {
"en": f"\nWeb character creator: {link}",
"zh-CN": f"\n网页建卡入口:{link}",
"ja": f"\nウェブのキャラクター作成ページ:{link}",
"de": f"\nWeb-Charaktererstellung: {link}",
})
return text
def character_draft_lines(draft: dict[str, Any], language: str = "zh-CN") -> list[str]:
name = str(draft.get("character_name") or localized_text(
language, {"en": "Unnamed character", "zh-CN": "未命名角色", "ja": "名前のないキャラクター", "de": "Unbenannter Charakter"}
))
race = str(draft.get("race") or localized_text(
language, {"en": "Unspecified identity", "zh-CN": "未定身份", "ja": "未設定の身分", "de": "Nicht festgelegte Identität"}
))
cls = str(draft.get("class") or localized_text(
language, {"en": "Unspecified role", "zh-CN": "未定定位", "ja": "未設定のロール", "de": "Nicht festgelegte Rolle"}
))
attrs = format_character_attrs(draft.get("attributes"), language)
skills = format_character_skills(draft.get("skills"), language)
bg_lines = background_lines(str(draft.get("background") or ""), language)
equipment = format_character_items(draft.get("equipment") or draft.get("inventory"), language)
lines = localized_text(language, {
"en": [
f"Character: {name}",
f"Identity/role: {race} · {cls}",
f"Attributes: {attrs}",
f"Skills: {skills}",
*bg_lines,
],
"zh-CN": [
f"角色:{name}",
f"身份/定位:{race} · {cls}",
f"属性:{attrs}",
f"技能:{skills}",
*bg_lines,
],
"ja": [
f"キャラクター:{name}",
f"身分/ロール:{race} · {cls}",
f"属性:{attrs}",
f"スキル:{skills}",
*bg_lines,
],
"de": [
f"Charakter: {name}",
f"Identität/Rolle: {race} · {cls}",
f"Attribute: {attrs}",
f"Fertigkeiten: {skills}",
*bg_lines,
],
})
if equipment:
lines.append(localized_text(language, {
"en": f"Equipment/items: {equipment}",
"zh-CN": f"装备/物品:{equipment}",
"ja": f"装備/所持品:{equipment}",
"de": f"Ausrüstung/Gegenstände: {equipment}",
}))
return lines
def background_lines(background: str, language: str = "zh-CN") -> list[str]:
"""把角色背景按段落拆分,段间用空串标记(卡片渲染时段间留空行)。"""
raw = background.strip()
if not raw:
return [localized_text(language, {"en": "Background: none yet", "zh-CN": "背景:暂无背景", "ja": "背景:まだなし", "de": "Hintergrund: noch keiner"})]
segments = [re.sub(r"[ \t]+", " ", seg.strip()) for seg in re.split(r"\n\s*\n", raw) if seg.strip()]
if not segments:
segments = [re.sub(r"\s+", " ", raw)]
lines: list[str] = [localized_text(language, {"en": "Background:", "zh-CN": "背景:", "ja": "背景:", "de": "Hintergrund:"})]
for i, seg in enumerate(segments):
if i:
lines.append("")
lines.append(seg[:200])
return lines
def character_public_lines(draft: dict[str, Any], language: str = "zh-CN") -> list[str]:
return character_draft_lines(draft, language)
def character_draft_text(title: str, lines: list[str], link: str = "", language: str = "zh-CN") -> str:
indent = localized_text(language, {"en": " ", "zh-CN": " ", "ja": " ", "de": " "})
text = title + localized_text(language, {"en": ":\n", "zh-CN": ":\n", "ja": ":\n", "de": ":\n"}) + "\n".join(
"" if line == "" else indent + line for line in lines
)
if link:
text += localized_text(language, {
"en": f"\nWeb character creator: {link}",
"zh-CN": f"\n网页建卡入口:{link}",
"ja": f"\nウェブのキャラクター作成ページ:{link}",
"de": f"\nWeb-Charaktererstellung: {link}",
})
return text
def format_character_attrs(attrs: Any, language: str = "zh-CN") -> str:
if not isinstance(attrs, dict) or not attrs:
return localized_text(language, {"en": "Follow the web rules", "zh-CN": "按网页规则填写", "ja": "ウェブのルールに従って入力", "de": "Den Web-Regeln folgen"})
separator = localized_text(language, {"en": ", ", "zh-CN": "、", "ja": "、", "de": ", "})
return separator.join(f"{key} {value}" for key, value in list(attrs.items())[:8])
def format_character_skills(skills: Any, language: str = "zh-CN") -> str:
if not isinstance(skills, list) or not skills:
return localized_text(language, {"en": "Choose based on the character’s role", "zh-CN": "按角色定位选择", "ja": "キャラクターのロールに合わせて選択", "de": "Passend zur Rolle des Charakters wählen"})
names: list[str] = []
for item in skills[:8]:
if isinstance(item, dict):
name = str(item.get("name") or "").strip()
value = item.get("value")
if name:
names.append(f"{name}{f' {value}' if value not in (None, '') else ''}")
else:
value = str(item).strip()
if value:
names.append(value)
separator = localized_text(language, {"en": ", ", "zh-CN": "、", "ja": "、", "de": ", "})
return separator.join(names) or localized_text(language, {
"en": "Choose based on the character’s role",
"zh-CN": "按角色定位选择",
"ja": "キャラクターのロールに合わせて選択",
"de": "Passend zur Rolle des Charakters wählen",
})
def format_character_items(items: Any, language: str = "zh-CN") -> str:
if not isinstance(items, list) or not items:
return ""
names: list[str] = []
for item in items[:6]:
if isinstance(item, dict):
name = str(item.get("name") or "").strip()
else:
name = str(item).strip()
if name:
names.append(name)
return localized_text(language, {"en": ", ", "zh-CN": "、", "ja": "、", "de": ", "}).join(names)
def player_tutorial_lines(*, command_prefix: str = "@我", language: str = "zh-CN") -> list[str]:
return localized_text(language, {
"en": [
f"1. Catch up: {command_example('recap', command_prefix=command_prefix)}",
f"2. Need a character? {command_example('create character', command_prefix=command_prefix)}",
f" Want an AI draft? {command_example('AI character', command_prefix=command_prefix)}",
f"3. Claim one: {command_example('join Character Name', command_prefix=command_prefix)}",
f"4. Start playing: {command_example('I inspect the area', command_prefix=command_prefix)}",
f"5. Checks are adjudicated and rolled automatically; status: "
f"{command_example('status', command_prefix=command_prefix)}",
f"Extra: {command_example('map', command_prefix=command_prefix)}, "
f"{command_example('sense', command_prefix=command_prefix)}, "
f"{command_example('pay', command_prefix=command_prefix)}, or "
f"{command_example('help', command_prefix=command_prefix)}",
],
"zh-CN": [
f"1. 先补剧情:{command_example('前情', command_prefix=command_prefix)}",
f"2. 没角色:{command_example('新建角色', command_prefix=command_prefix)} / {command_example('车卡', command_prefix=command_prefix)},然后按提示填角色名、身份、职业、属性、技能、背景",
f" 想让 AI 辅助起草:{command_example('AI车卡', command_prefix=command_prefix)}",
f"3. 有角色:{command_example('加入 角色名', command_prefix=command_prefix)}",
f"4. 开始玩:{command_example('我观察四周', command_prefix=command_prefix)} / {command_example('我向守卫打听消息', command_prefix=command_prefix)}",
f"5. 检定由系统自动判断并掷骰;想看自己状态:{command_example('状态', command_prefix=command_prefix)}",
"DND局小抄:优势=2d20取高,劣势=2d20取低;同时出现会抵消",
f"额外:{command_example('地图', command_prefix=command_prefix)} 看地点;{command_example('感知', command_prefix=command_prefix)} 看私密信息;{command_example('支付', command_prefix=command_prefix)} 处理待确认付款;卡住就发 {command_example('帮助', command_prefix=command_prefix)}",
],
"ja": [
f"1. 前情を確認:{command_example('recap', command_prefix=command_prefix)}",
f"2. キャラクターがまだない場合:{command_example('create character', command_prefix=command_prefix)}、表示に従って名前・身分・職業・属性・技能・背景を入力",
f" AI による下書き:{command_example('AI character', command_prefix=command_prefix)}",
f"3. キャラクターがある場合:{command_example('join Character Name', command_prefix=command_prefix)}",
f"4. プレイ開始:{command_example('部屋の中を調べる', command_prefix=command_prefix)} / {command_example('衛兵に話を聞く', command_prefix=command_prefix)}",
f"5. 判定は自動で行われます;自分の状態を見る:{command_example('status', command_prefix=command_prefix)}",
"DND メモ:有利=2d20の高い方、不利=2d20の低い方;同時の場合は相殺",
f"その他:{command_example('map', command_prefix=command_prefix)} で地点、{command_example('sense', command_prefix=command_prefix)} でプライベート情報、{command_example('pay', command_prefix=command_prefix)} で支払い確認;困ったら {command_example('help', command_prefix=command_prefix)}",
],
"de": [
f"1. Auf dem Laufenden bleiben: {command_example('recap', command_prefix=command_prefix)}",
f"2. Brauchst du einen Charakter? {command_example('create character', command_prefix=command_prefix)}",
f" KI-Entwurf gewünscht? {command_example('AI character', command_prefix=command_prefix)}",
f"3. Beanspruchen: {command_example('join Character Name', command_prefix=command_prefix)}",
f"4. Losspielen: {command_example('I inspect the area', command_prefix=command_prefix)}",
f"5. Proben werden automatisch entschieden und gewürfelt; Status: "
f"{command_example('status', command_prefix=command_prefix)}",
f"Weitere Befehle: {command_example('map', command_prefix=command_prefix)}, "
f"{command_example('sense', command_prefix=command_prefix)}, "
f"{command_example('pay', command_prefix=command_prefix)} oder "
f"{command_example('help', command_prefix=command_prefix)}",
],
})
def player_tutorial_text(lines: list[str], language: str = "zh-CN") -> str:
title = localized_text(language, {
"en": "New player quick start:\n",
"zh-CN": "群聊跑团新玩家一图流:\n",
"ja": "新規プレイヤー クイックスタート:\n",
"de": "Schnellstart für neue Spieler:\n",
})
return title + "\n".join(lines)