src/commands/round_actions.py
Rama main · réplica de lectura del repositorio original.
"""回合行动输入整理与前置处理。"""
from __future__ import annotations
import logging
import re
from typing import Any
from src.engine import progression
from src.engine.checks import build_check_request, roll_check_request
from src.engine.character_utils import is_conscious
from src.engine.game_instance import GameInstance
from src.engine import module_hooks
from src.engine.language import localized_text
from src.content_modules.projection import entries_for_world
logger = logging.getLogger("trpg")
def ensure_round_managers(instance: GameInstance) -> None:
"""确保回合处理需要的运行期管理器已存在。"""
instance.ensure_round_managers()
def format_action_line(instance: GameInstance, action: dict) -> str:
"""把单个行动格式化成给 GM/LLM 看的归因文本。"""
name = instance.players[action['user_id']].get('character_name', action['user_id'])
text = action.get('text', '')
parts = []
selected_attr = action.get('selected_attribute', '')
selected_skill = action.get('selected_skill', '')
if selected_attr or selected_skill:
label = localized_text(instance.language, {"en": "Check", "zh-CN": "检定", "ja": "判定", "de": "Probe"})
parts.append(f"{label}:{selected_attr or '?'}" + (f"/{selected_skill}" if selected_skill else ""))
target = action.get('target_text', '')
if target:
label = localized_text(instance.language, {"en": "Target", "zh-CN": "目标", "ja": "対象", "de": "Ziel"})
parts.append(f"{label}:{target}")
tag = f" [{' '.join(parts)}]" if parts else ""
return f"【{name}】{text}{tag}"
def collect_actions_text(instance: GameInstance) -> str:
"""收集本轮玩家行动;GM 私密指令不得参与行动/检定识别。"""
player_lines = [
format_action_line(instance, action)
for action in instance.action_queue
if action.get("user_id") in instance.players
and is_conscious(instance.get_character_sheet(str(action.get("user_id") or "")))
]
actions_text = "\n".join(player_lines)
if not actions_text:
actions_text = "本轮没有玩家行动。"
return actions_text
def collect_gm_directives_text(instance: GameInstance) -> tuple[str, list[str]]:
"""返回本轮可用的 GM 私密指令文本及其 ID,供成功结算后消费。"""
current_round = progression.current_round(instance)
entries = [
entry for entry in module_hooks.read(instance, module_hooks.GM_DIRECTIVES)
if int(entry.get("target_round", current_round) or current_round) <= current_round
]
if not entries:
return "", []
heading = localized_text(instance.language, {
"en": "[Private GM Directives]",
"zh-CN": "【GM私密指令】",
"ja": "【GMプライベート指示】",
"de": "[Private GM-Anweisungen]",
})
requirement = localized_text(instance.language, {
"en": (
"Apply these directives to narration only. Never quote or reveal them to players. "
"They must not change, override, or regenerate any server-provided CheckResult."
),
"zh-CN": "以下内容只用于修正本轮叙事,禁止向玩家复述、引用或展示;"
"不得改变、覆盖或重新生成任何由服务端给出的 CheckResult。",
"ja": (
"これらの指示はナレーションの修正にのみ使用し、プレイヤーに復唱・引用・"
"開示してはならない。サーバーが提供した CheckResult を変更・上書き・"
"再生成してはならない。"
),
"de": (
"Verwende diese Anweisungen nur zur Anpassung der Erzählung. Zitiere oder enthülle sie "
"niemals gegenüber den Spielern. Sie dürfen kein von der Server-Seite geliefertes "
"CheckResult ändern, überschreiben oder neu erzeugen."
),
})
body = "\n".join(f"- {entry.get('text', '')}" for entry in entries if entry.get("text"))
return f"\n\n{heading}\n{requirement}\n{body}", [str(entry.get("id") or "") for entry in entries]
def format_check_results_constraint(instance: GameInstance, checks: list[dict]) -> str:
"""把已结算 CheckResult 重新格式化为 GM 私有硬约束,供 swipe 等重生成复用。"""
if not checks:
return ""
blocks: list[str] = []
for check in checks:
dice = str(check.get("dice") or "")
roll_value = check.get("roll")
if dice == "d100":
math_text = f"d100={roll_value} vs {check.get('threshold')}"
difficulty = str(check.get("difficulty") or "")
if difficulty in {"hard", "extreme"}:
math_text += localized_text(instance.language, {
"en": f" (needs a {difficulty} success: ≤{check.get('required_threshold')})",
"zh-CN": f"(需要{'困难' if difficulty == 'hard' else '极难'}成功:≤{check.get('required_threshold')})",
"ja": f"({'ハード' if difficulty == 'hard' else 'イクストリーム'}成功が必要:≤{check.get('required_threshold')})",
})
opposed = check.get("opposed") if isinstance(check.get("opposed"), dict) else None
if opposed:
math_text += localized_text(instance.language, {
"en": (f"; opposed by {check.get('opponent_name')} {opposed.get('opponent_skill')} "
f"d100={opposed.get('opponent_roll')} vs {opposed.get('opponent_threshold')} "
f"({opposed.get('opponent_verdict')})"),
"zh-CN": (f";对抗 {check.get('opponent_name')} 的{opposed.get('opponent_skill')} "
f"d100={opposed.get('opponent_roll')} vs {opposed.get('opponent_threshold')}"
f"({opposed.get('opponent_verdict')})"),
"ja": (f";対抗 {check.get('opponent_name')} の{opposed.get('opponent_skill')} "
f"d100={opposed.get('opponent_roll')} vs {opposed.get('opponent_threshold')}"
f"({opposed.get('opponent_verdict')})"),
})
else:
modifier = int(check.get("modifier", 0) or 0)
total = check.get("total")
dc = check.get("dc")
if check.get("opponent_name"):
math_text = (
f"d20={roll_value} {modifier:+d} = {total} vs "
f"{check.get('opponent_name')} d20={check.get('opponent_roll')} "
f"{int(check.get('opponent_modifier', 0) or 0):+d} = {check.get('opponent_total')}"
)
else:
math_text = f"d20={roll_value} {modifier:+d} = {total} vs DC {dc}"
blocks.append(localized_text(instance.language, {
"en": (
"[System Check - Must Follow]\n"
f"Actor: {check.get('actor_name') or check.get('actor_uid')}\n"
f"Check: {check.get('label') or dice}: {math_text}\n"
f"Result: {check.get('verdict')}\n"
"Requirement: keep this server-resolved outcome unchanged."
),
"zh-CN": (
"【系统检定·必须遵循】\n"
f"角色: {check.get('actor_name') or check.get('actor_uid')}\n"
f"检定: {check.get('label') or dice}:{math_text}\n"
f"结果: {check.get('verdict')}\n"
"要求: 这是服务端已结算结果,不得重掷或改判。"
),
"ja": (
"【システム判定・厳守】\n"
f"キャラクター: {check.get('actor_name') or check.get('actor_uid')}\n"
f"判定: {check.get('label') or dice}:{math_text}\n"
f"結果: {check.get('verdict')}\n"
"要件: これはサーバーで確定済みの結果であり、再ロールや改変をしてはならない。"
),
"de": (
"[Systemseitige Probe - Muss befolgt werden]\n"
f"Akteur: {check.get('actor_name') or check.get('actor_uid')}\n"
f"Probe: {check.get('label') or dice}: {math_text}\n"
f"Ergebnis: {check.get('verdict')}\n"
"Anforderung: Dieses vom Server ermittelte Ergebnis bleibt unverändert."
),
}))
push = check.get("push")
if isinstance(push, dict) and push.get("status") == "resolved":
import json
# An explicit domain record, not executable narrative instructions.
blocks.append("[Accepted Push decision] " + json.dumps({
"check_id": check.get("check_id"), "original_roll": push.get("original", {}).get("roll"),
"new_roll": check.get("roll"), "new_approach": push.get("reason"),
"failed": push.get("failed"), "failure_consequence": push.get("consequence"),
}, ensure_ascii=False) + "\nApply the accepted consequence only if Push failed. Do not re-roll or spend Luck. No automatic damage multiplier.")
return "\n\n" + "\n\n".join(blocks)
def initialize_puzzles_from_lorebook(
instance: GameInstance,
lorebook_store: Any,
*,
world_data: dict | None = None,
content_projection: Any | None = None,
) -> None:
"""从世界书初始化谜题(仅新增未注册的谜题)。"""
if not instance.world_id or not lorebook_store or not instance.puzzle_manager:
return
from src.content.worlds import localize_lorebook_entries
if content_projection is not None:
entries = content_projection.for_game(
instance, viewer_kind="gm",
)
else:
entries = entries_for_world(lorebook_store, instance.world_id)
all_entries = localize_lorebook_entries(entries, world_data)
for entry in all_entries:
if entry.get("type") != "puzzle":
continue
puzzle_id = entry.get("id", "")
if instance.puzzle_manager.get_puzzle(puzzle_id):
continue
from src.engine.puzzle import create_puzzle_from_lorebook
puzzle = create_puzzle_from_lorebook(entry)
if puzzle:
instance.puzzle_manager.add_puzzle(puzzle)
logger.info("谜题初始化: %s (%s)", puzzle.name, puzzle_id)
def build_dice_constraint_block(
instance: GameInstance,
actions_text: str,
rule: Any,
dice_system: str,
dice_resolver: Any,
*,
planned_only: bool = False,
skip_action_indexes: set[int] | None = None,
) -> str:
"""逐个结算玩家行动中的 CheckRequest;原始骰值只生成一次。"""
if dice_system == "none":
return ""
blocks: list[str] = []
legacy_roll_re = re.compile(r"\(系统掷骰:\s*(d20|d100)=(\d+)\)")
skipped = skip_action_indexes or set()
for action_index, action in enumerate(instance.action_queue):
if action_index in skipped:
action.pop("check_request", None)
action.pop("dice_value", None)
action.pop("dice_rolls", None)
action.pop("dice_system", None)
action.pop("dice_roll_source", None)
action.pop("dice_pending", None)
continue
if action.get("user_id") not in instance.players:
continue
request = action.get("check_request")
if not isinstance(request, dict):
if planned_only:
continue
request = build_check_request(instance, action, rule)
legacy_match = legacy_roll_re.search(str(action.get("text") or ""))
if not request and legacy_match:
uid = str(action.get("user_id") or "")
request = {
"check_id": "",
"required": True,
"actor_uid": uid,
"actor_name": instance.players.get(uid, {}).get("character_name", uid),
"dice_system": legacy_match.group(1),
"label": "行动检定",
"intent": "legacy",
"skill": str(action.get("selected_skill") or ""),
"attribute": str(action.get("selected_attribute") or ""),
"advantage_mode": "",
"advantage_note": None,
}
if not request:
continue
action["check_request"] = request
if not action.get("dice_value"):
legacy_match = legacy_roll_re.search(str(action.get("text") or ""))
if legacy_match:
action["dice_value"] = int(legacy_match.group(2))
action["dice_rolls"] = [int(legacy_match.group(2))]
action["dice_system"] = legacy_match.group(1)
action["dice_roll_source"] = "legacy"
else:
rolled = roll_check_request(request, rule)
action["dice_value"] = rolled["value"]
action["dice_rolls"] = rolled["rolls"]
action["dice_system"] = rolled["dice_system"]
action["dice_roll_source"] = "system"
action["dice_pending"] = False
block = dice_resolver.resolve_action_check(instance, action, rule)
if block:
blocks.append(block)
return "\n".join(blocks)