Files

929 lines
40 KiB
Python

from __future__ import annotations
from copy import deepcopy
from typing import Any
from .models import GameState, utc_now_iso
ALLOWED_CLUES = {
"bloodied_key",
"torn_manifest",
"silver_bell",
"maid_diary",
"cellar_sigil",
"missing_guest_note",
}
FOGHARBOR_ITEM_LABELS = {
"bloodied_key": "染血钥匙",
"torn_manifest": "残破船务清单",
"silver_bell": "银铃",
"maid_diary": "女仆日记",
"cellar_sigil": "地下室印记",
"missing_guest_note": "失踪者便条",
}
FOGHARBOR_ITEM_ALIASES = {
**{key: key for key in FOGHARBOR_ITEM_LABELS},
**{value: key for key, value in FOGHARBOR_ITEM_LABELS.items()},
}
NPC_IDS = {"innkeeper", "maid", "silent_traveler"}
SCENES = {
"lobby": {
"chapter": 1,
"title": "大厅",
"description": "壁炉忽明忽暗,旅店老板正用审视的目光打量每个人。",
"choices": ["检查登记台", "询问旅店老板", "观察沉默旅客"],
},
"dining_room": {
"chapter": 2,
"title": "餐厅",
"description": "餐厅里摆着尚有余温的餐具,女仆反复擦拭同一张桌子。",
"choices": ["检查餐桌残渣", "说服女仆开口", "监听老板与旅客对话"],
},
"corridor": {
"chapter": 3,
"title": "走廊",
"description": "长走廊上挂满褪色油画,尽头传来断续的敲击声。",
"choices": ["追踪敲击声", "查看 301 房门", "搜索暗门机关"],
},
"guest_room": {
"chapter": 4,
"title": "客房",
"description": "客房内潮湿阴冷,地板下有若隐若现的符号刻痕。",
"choices": ["翻找床底箱子", "解读墙上符号", "寻找地下室入口"],
},
"basement": {
"chapter": 5,
"title": "地下室",
"description": "地下室中央摆着仪式台,真相与代价都已无处回避。",
"choices": ["破坏仪式", "与隐藏势力谈判", "尝试组织全员逃离"],
},
}
ENDING_SUMMARIES = {
"escaped": "你们揭穿了旅店真相并组织逃离,雾港旅店在身后沉入迷雾。",
"sacrifice": "真相被揭开,但有人留下挡住了灾厄,你们带着代价离开。",
"trapped": "危险失控,旅店的规则吞没了所有出口,你们被永困其中。",
"pact": "你们选择与隐藏势力交易,离开旅店,却成为新的规则执行者。",
"team_wipe": "队伍全员倒下,冒险在黑暗中终止。",
}
class StoryEngine:
def initial_state(
self,
room_id: str,
player_ids: list[str],
game_card: dict[str, Any] | None = None,
) -> GameState:
inventory = {player_id: [] for player_id in player_ids}
card = game_card or {}
opening_text = str(card.get("opening_text") or "暴风雨夜,你们推开雾港旅店大门,空气里有潮湿铁锈味。")
starter_goals = [str(item) for item in card.get("starter_goals", []) if isinstance(item, str)]
game_card_id = str(card.get("card_id", "fogharbor"))
game_card_title = str(card.get("title", "雾港旅店"))
state_schema = dict(card.get("state_schema", {}))
content_pack = dict(card.get("content_pack", {}))
data_scope = self._normalize_data_scope(state_schema.get("data_scope", {}))
visibility = self._normalize_visibility(state_schema.get("visibility", {}))
systems_payload = content_pack.get("systems", {}) if isinstance(content_pack, dict) else {}
runtime_options = self._normalize_runtime_options(
systems_payload.get("runtime_options", {}) if isinstance(systems_payload, dict) else {}
)
card_mode = "fogharbor" if game_card_id == "fogharbor" else "custom"
initial_scene = "lobby" if card_mode == "fogharbor" else "opening"
initial_choices = deepcopy(SCENES["lobby"]["choices"]) if card_mode == "fogharbor" else (
starter_goals[:3] if starter_goals else ["先观察环境", "与关键角色对话", "团队分工行动"]
)
metric_values: dict[str, int] = {}
for metric in state_schema.get("metrics", []):
key = str(metric.get("key", "")).strip()
if not key:
continue
metric_values[key] = int(metric.get("initial", 0))
metric_values_by_player: dict[str, dict[str, int]] = {}
if data_scope["metrics"] == "per_player":
for player_id in player_ids:
metric_values_by_player[player_id] = dict(metric_values)
shared_inventory: list[str] = [] if data_scope["inventory"] == "shared" else []
clues_by_player: dict[str, list[str]] = {player_id: [] for player_id in player_ids} if data_scope["clues"] == "per_player" else {}
player_states = {
player_id: {
"status": "alive",
"is_alive": True,
"deaths": 0,
"revives": 0,
"last_event": "spawn",
"updated_at": utc_now_iso(),
}
for player_id in player_ids
}
return GameState(
room_id=room_id,
game_phase="lobby",
chapter=0,
scene_id=initial_scene,
scene_description=opening_text,
global_flags={
"truth_revealed": False,
"escape_route_open": False,
"found_service_key": False,
"room_301_unlocked": False,
"cellar_entry_found": False,
"someone_sacrificed": False,
"allied_hidden_force": False,
"game_card_id": game_card_id,
"game_card_title": game_card_title,
"starter_goals": starter_goals,
"card_mode": card_mode,
"state_schema": state_schema,
"card_content_pack": content_pack,
"data_scope": data_scope,
"visibility": visibility,
"runtime_options": runtime_options,
"metric_values": metric_values,
"metric_values_by_player": metric_values_by_player,
"shared_inventory": shared_inventory,
"clues_by_player": clues_by_player,
"player_states": player_states,
},
discovered_clues=[],
inventory_by_player=inventory,
npc_states={
"innkeeper": {"mood": "guarded", "trust": 1, "location": "lobby"},
"maid": {"mood": "anxious", "trust": 1, "location": "dining_room"},
"silent_traveler": {"mood": "cold", "trust": 0, "location": "lobby"},
},
danger_level=1,
action_history=[],
pending_choices=initial_choices,
ending_state=None,
round_no=0,
)
def story_context(self, scene_id: str, state: dict[str, Any] | None = None) -> dict[str, Any]:
scene = SCENES.get(scene_id, SCENES["lobby"])
global_flags = (state or {}).get("global_flags", {})
card_mode = global_flags.get("card_mode", "fogharbor")
runtime_options = self._normalize_runtime_options(global_flags.get("runtime_options", {}))
if card_mode != "fogharbor":
content_pack = global_flags.get("card_content_pack", {})
return {
"setting": global_flags.get("game_card_title", "自定义剧本"),
"scene": {
"id": scene_id,
"title": scene_id,
"description": (state or {}).get("scene_description", ""),
"default_choices": list((state or {}).get("pending_choices", [])),
},
"content_pack": content_pack,
"runtime_options": runtime_options,
"allowed_clues": "ANY_STRING",
"npcs": "ANY_NPC",
"rules": [
"这是自定义游戏卡,状态字段以 state_schema 为准",
"玩家/共享数据范围以 state_schema.data_scope 为准",
"不要强制套用雾港旅店的固定场景和真相",
"结局判定优先由 content_pack.end_conditions 与服务端规则执行",
"输出必须仍满足 JSON schema",
],
}
return {
"setting": "雾港旅店",
"scene": {
"id": scene_id,
"title": scene["title"],
"description": scene["description"],
"default_choices": scene["choices"],
},
"allowed_clues": sorted(ALLOWED_CLUES),
"npcs": sorted(NPC_IDS),
"endings": list(ENDING_SUMMARIES.keys()),
"runtime_options": runtime_options,
"rules": [
"关键真相与结局由服务端状态机控制",
"危险值达到 10 会触发被困结局",
"多人冲突由系统先裁定,再交给 AI 叙述",
],
}
def scene_guidance(self, scene_id: str, round_no: int, pending_choices: list[str]) -> list[str]:
if round_no == 0:
return [
"先阅读场景描述,确认当前最可疑的人或地点。",
"至少一名玩家提交“调查/交谈”类行动,触发第一轮反馈。",
"多人局建议先分工:一人问 NPC,一人搜线索,一人记录关键信息。",
]
if pending_choices:
return [f"可优先尝试:{choice}" for choice in pending_choices[:3]]
scene = SCENES.get(scene_id, SCENES["lobby"])
return [f"继续推进:{choice}" for choice in scene["choices"][:3]]
def apply_turn(
self,
state_dict: dict[str, Any],
ai_payload: dict[str, Any],
merge_result: dict[str, Any],
players: list[dict[str, Any]],
) -> tuple[dict[str, Any], dict[str, Any]]:
state = GameState.from_dict(state_dict)
player_ids = {player["player_id"] for player in players}
for player in players:
state.inventory_by_player.setdefault(player["player_id"], [])
updates = ai_payload.get("state_updates", {})
card_mode = state.global_flags.get("card_mode", "fogharbor")
data_scope = self._normalize_data_scope(state.global_flags.get("data_scope", {}))
runtime_options = self._normalize_runtime_options(state.global_flags.get("runtime_options", {}))
state.global_flags["runtime_options"] = runtime_options
player_ids_list = [player["player_id"] for player in players]
danger_delta = int(updates.get("danger_delta", 0))
if card_mode == "fogharbor":
state.danger_level = max(0, min(10, state.danger_level + danger_delta))
clue_targets = merge_result.get("triggered_players", [])
if not isinstance(clue_targets, list) or not clue_targets:
clue_targets = player_ids_list
for clue in updates.get("add_clues", []):
if not isinstance(clue, str):
continue
clue_value = clue.strip()
if not clue_value:
continue
if card_mode == "fogharbor" and clue_value not in ALLOWED_CLUES:
continue
if data_scope["clues"] == "per_player":
clues_by_player = state.global_flags.get("clues_by_player", {})
if not isinstance(clues_by_player, dict):
clues_by_player = {}
for target_id in clue_targets:
if target_id not in player_ids:
continue
rows = clues_by_player.get(target_id, [])
if clue_value not in rows:
rows.append(clue_value)
clues_by_player[target_id] = rows
state.global_flags["clues_by_player"] = clues_by_player
else:
if clue_value not in state.discovered_clues:
state.discovered_clues.append(clue_value)
flags = updates.get("set_flags", {})
for key, value in flags.items():
if isinstance(key, str):
state.global_flags[key] = value
npc_changes = updates.get("npc_changes", {})
for npc_id, patch in npc_changes.items():
if npc_id not in NPC_IDS or not isinstance(patch, dict):
continue
state.npc_states.setdefault(npc_id, {})
for patch_key, patch_value in patch.items():
state.npc_states[npc_id][patch_key] = patch_value
inventory_add = updates.get("inventory_add", {})
inventory_remove = updates.get("inventory_remove", {})
if data_scope["inventory"] == "shared":
shared_inventory = state.global_flags.get("shared_inventory", [])
if not isinstance(shared_inventory, list):
shared_inventory = []
for _, items in inventory_add.items():
if not isinstance(items, list):
continue
for item in items:
if not isinstance(item, str):
continue
item_value = self._normalize_inventory_item_key(item.strip(), card_mode)
if not item_value:
continue
if card_mode == "fogharbor" and item_value not in ALLOWED_CLUES:
continue
if item_value not in shared_inventory:
shared_inventory.append(item_value)
for _, items in inventory_remove.items():
if not isinstance(items, list):
continue
for item in items:
if not isinstance(item, str):
continue
item_value = self._normalize_inventory_item_key(item.strip(), card_mode)
if not item_value:
continue
if card_mode == "fogharbor" and item_value not in ALLOWED_CLUES:
continue
shared_inventory = [row for row in shared_inventory if row != item_value]
state.global_flags["shared_inventory"] = shared_inventory
else:
for player_id, items in inventory_add.items():
if player_id not in player_ids:
continue
state.inventory_by_player.setdefault(player_id, [])
for item in items:
if not isinstance(item, str):
continue
item_value = self._normalize_inventory_item_key(item.strip(), card_mode)
if not item_value:
continue
if card_mode == "fogharbor" and item_value not in ALLOWED_CLUES:
continue
if item_value not in state.inventory_by_player[player_id]:
state.inventory_by_player[player_id].append(item_value)
for player_id, items in inventory_remove.items():
target_players = [player_id]
if player_id.strip().lower() in {"*", "all"}:
target_players = list(player_ids)
for target_player_id in target_players:
if target_player_id not in player_ids:
continue
state.inventory_by_player.setdefault(target_player_id, [])
if not isinstance(items, list):
continue
for item in items:
if not isinstance(item, str):
continue
item_value = self._normalize_inventory_item_key(item.strip(), card_mode)
if not item_value:
continue
if card_mode == "fogharbor" and item_value not in ALLOWED_CLUES:
continue
state.inventory_by_player[target_player_id] = [
row for row in state.inventory_by_player[target_player_id] if row != item_value
]
if card_mode != "fogharbor":
self._apply_custom_metrics(state, updates, flags, players, merge_result)
self._apply_player_survival_updates(state, flags, players, runtime_options)
state.scene_description = ai_payload.get("narration", state.scene_description)
state.pending_choices = ai_payload.get("choices", state.pending_choices)[:4]
state.round_no += 1
state.last_updated_at = utc_now_iso()
history_row = {
"round": state.round_no,
"scene_id": state.scene_id,
"merged_actions": merge_result,
"narration": ai_payload.get("narration", ""),
"npc_dialogues": ai_payload.get("npc_dialogues", []),
}
state.action_history.append(history_row)
if len(state.action_history) > 50:
state.action_history = state.action_history[-50:]
transitioned = None
ending_state = None
if card_mode == "fogharbor":
transitioned = self._auto_progress_scene(state)
ending_state = self._check_endings(state)
else:
transitioned = self._custom_scene_progress(state, ai_payload)
ending_state = self._check_custom_endings(state, ai_payload)
if (
runtime_options.get("allow_player_death")
and not ending_state
and self._alive_player_count(state) <= 0
):
ending_state = "team_wipe"
if not str(state.global_flags.get("ending_summary", "")).strip():
state.global_flags["ending_summary"] = ENDING_SUMMARIES["team_wipe"]
if ending_state:
state.game_phase = "ended"
state.ending_state = ending_state
ending_summary = ai_payload.get("ending_summary")
if isinstance(ending_summary, str) and ending_summary.strip():
state.global_flags["ending_summary"] = ending_summary.strip()
elif not str(state.global_flags.get("ending_summary", "")).strip():
fallback_summary = ENDING_SUMMARIES.get(ending_state, "")
if fallback_summary:
state.global_flags["ending_summary"] = fallback_summary
round_result = {
"narration": ai_payload.get("narration", ""),
"npc_dialogues": ai_payload.get("npc_dialogues", []),
"private_player_updates": ai_payload.get("private_player_updates", []),
"choices": state.pending_choices,
"merge_summary": merge_result.get("summary", ""),
"conflicts": merge_result.get("conflicts", []),
"triggered_players": merge_result.get("triggered_players", []),
"scene_transition": transitioned,
"ending_state": state.ending_state,
"ending_summary": str(state.global_flags.get("ending_summary", "")).strip() or ENDING_SUMMARIES.get(state.ending_state),
"player_states": state.global_flags.get("player_states", {}),
"runtime_options": runtime_options,
"guidance": self.scene_guidance(state.scene_id, state.round_no, state.pending_choices),
}
return state.to_dict(), round_result
def _apply_custom_metrics(
self,
state: GameState,
updates: dict[str, Any],
flags: dict[str, Any],
players: list[dict[str, Any]],
merge_result: dict[str, Any],
) -> None:
schema = dict(state.global_flags.get("state_schema", {}))
metrics = schema.get("metrics", [])
data_scope = self._normalize_data_scope(state.global_flags.get("data_scope", {}))
player_ids = [player["player_id"] for player in players]
triggered_players = merge_result.get("triggered_players", [])
if not isinstance(triggered_players, list) or not triggered_players:
triggered_players = list(player_ids)
if data_scope["metrics"] == "per_player":
self._apply_player_metrics(state, metrics, flags, updates, player_ids, triggered_players)
return
metric_values = dict(state.global_flags.get("metric_values", {}))
for metric in metrics:
key = str(metric.get("key", "")).strip()
if not key:
continue
current_value = int(metric_values.get(key, 0))
if isinstance(flags.get(key), (int, float)):
current_value = int(flags[key])
delta_key = f"{key}_delta"
if isinstance(flags.get(delta_key), (int, float)):
current_value += int(flags[delta_key])
if key == "danger_level":
current_value += int(updates.get("danger_delta", 0))
max_value = int(metric.get("max", 10))
min_value = int(metric.get("min", 0))
current_value = max(min_value, min(max_value, current_value))
metric_values[key] = current_value
state.global_flags["metric_values"] = metric_values
def _apply_player_metrics(
self,
state: GameState,
metrics: list[dict[str, Any]],
flags: dict[str, Any],
updates: dict[str, Any],
player_ids: list[str],
triggered_players: list[str],
) -> None:
map_by_player = state.global_flags.get("metric_values_by_player", {})
if not isinstance(map_by_player, dict):
map_by_player = {}
default_metric_values = state.global_flags.get("metric_values", {})
if not isinstance(default_metric_values, dict):
default_metric_values = {}
for player_id in player_ids:
player_metrics = map_by_player.get(player_id)
if not isinstance(player_metrics, dict):
player_metrics = dict(default_metric_values)
for metric in metrics:
key = str(metric.get("key", "")).strip()
if not key:
continue
if key not in player_metrics:
player_metrics[key] = int(metric.get("initial", 0))
map_by_player[player_id] = player_metrics
player_metric_set = flags.get("player_metric_set", {})
if not isinstance(player_metric_set, dict):
player_metric_set = {}
player_metric_delta = flags.get("player_metric_delta", {})
if not isinstance(player_metric_delta, dict):
player_metric_delta = {}
for metric in metrics:
key = str(metric.get("key", "")).strip()
if not key:
continue
min_value = int(metric.get("min", 0))
max_value = int(metric.get("max", 10))
if max_value < min_value:
max_value = min_value
for player_id in player_ids:
value = int(map_by_player[player_id].get(key, 0))
set_payload = player_metric_set.get(player_id, {})
if isinstance(set_payload, dict) and isinstance(set_payload.get(key), (int, float)):
value = int(set_payload[key])
delta_payload = player_metric_delta.get(player_id, {})
if isinstance(delta_payload, dict) and isinstance(delta_payload.get(key), (int, float)):
value += int(delta_payload[key])
if player_id in triggered_players:
if isinstance(flags.get(key), (int, float)):
value = int(flags[key])
delta_key = f"{key}_delta"
if isinstance(flags.get(delta_key), (int, float)):
value += int(flags[delta_key])
if key == "danger_level":
value += int(updates.get("danger_delta", 0))
value = max(min_value, min(max_value, value))
map_by_player[player_id][key] = value
state.global_flags["metric_values_by_player"] = map_by_player
def _alive_player_count(self, state: GameState) -> int:
player_states = state.global_flags.get("player_states", {})
if not isinstance(player_states, dict):
return 0
alive = 0
for row in player_states.values():
if not isinstance(row, dict):
continue
if bool(row.get("is_alive", True)):
alive += 1
return alive
def _safe_int(self, value: Any, default: int = 0) -> int:
try:
return int(value)
except (TypeError, ValueError):
return int(default)
def _normalize_runtime_options(self, raw_options: Any) -> dict[str, Any]:
options = raw_options if isinstance(raw_options, dict) else {}
def _to_bool(value: Any, default: bool) -> bool:
if isinstance(value, bool):
return value
text = str(value or "").strip().lower()
if text in {"1", "true", "yes", "on", "y"}:
return True
if text in {"0", "false", "no", "off", "n"}:
return False
return default
interaction_mode = str(options.get("interaction_mode", "hybrid")).strip().lower()
if interaction_mode not in {"cooperative", "split", "hybrid"}:
interaction_mode = "hybrid"
return {
"interaction_mode": interaction_mode,
"sync_all_player_ai_replies": _to_bool(options.get("sync_all_player_ai_replies", False), False),
"allow_player_death": _to_bool(options.get("allow_player_death", False), False),
"allow_revive": _to_bool(options.get("allow_revive", False), False),
"require_all_players_response": _to_bool(options.get("require_all_players_response", False), False),
"response_timeout_seconds": max(5, min(300, self._safe_int(options.get("response_timeout_seconds", 45), 45))),
}
def _ensure_player_states(self, state: GameState, players: list[dict[str, Any]]) -> dict[str, dict[str, Any]]:
raw_states = state.global_flags.get("player_states", {})
if not isinstance(raw_states, dict):
raw_states = {}
now = utc_now_iso()
out: dict[str, dict[str, Any]] = {}
for player in players:
player_id = str(player.get("player_id", "")).strip()
if not player_id:
continue
row = raw_states.get(player_id, {})
if not isinstance(row, dict):
row = {}
status = str(row.get("status", "")).strip().lower()
if status not in {"alive", "down", "dead", "failed"}:
status = "alive"
is_alive_raw = row.get("is_alive")
if isinstance(is_alive_raw, bool):
is_alive = is_alive_raw
else:
is_alive = status not in {"dead", "failed"}
out[player_id] = {
"status": status,
"is_alive": is_alive,
"deaths": max(0, self._safe_int(row.get("deaths", 0), 0)),
"revives": max(0, self._safe_int(row.get("revives", 0), 0)),
"last_event": str(row.get("last_event", "")).strip()[:24],
"updated_at": str(row.get("updated_at", "")).strip() or now,
}
state.global_flags["player_states"] = out
return out
def _iter_flagged_players(self, value: Any) -> list[str]:
out: list[str] = []
if isinstance(value, dict):
for key, enabled in value.items():
if isinstance(enabled, bool):
if enabled:
out.append(str(key).strip())
elif isinstance(enabled, (int, float)):
if int(enabled) != 0:
out.append(str(key).strip())
elif str(enabled or "").strip().lower() in {"1", "true", "yes", "on", "y"}:
out.append(str(key).strip())
elif isinstance(value, list):
for row in value:
player_id = str(row or "").strip()
if player_id:
out.append(player_id)
elif isinstance(value, str):
for row in value.split(","):
player_id = row.strip()
if player_id:
out.append(player_id)
return out
def _apply_player_survival_updates(
self,
state: GameState,
flags: dict[str, Any],
players: list[dict[str, Any]],
runtime_options: dict[str, Any],
) -> None:
player_states = self._ensure_player_states(state, players)
allow_death = bool(runtime_options.get("allow_player_death"))
allow_revive = bool(runtime_options.get("allow_revive"))
if not allow_death:
now = utc_now_iso()
for row in player_states.values():
row["status"] = "alive"
row["is_alive"] = True
row["updated_at"] = now
state.global_flags["player_states"] = player_states
return
def mark_dead(player_id: str, *, event: str = "dead") -> None:
player = player_states.get(player_id)
if not isinstance(player, dict):
return
if bool(player.get("is_alive", True)):
player["deaths"] = max(0, self._safe_int(player.get("deaths", 0), 0)) + 1
player["status"] = "dead"
player["is_alive"] = False
player["last_event"] = event[:24]
player["updated_at"] = utc_now_iso()
def mark_alive(player_id: str, *, event: str = "revived") -> None:
player = player_states.get(player_id)
if not isinstance(player, dict):
return
if not bool(player.get("is_alive", True)):
player["revives"] = max(0, self._safe_int(player.get("revives", 0), 0)) + 1
player["status"] = "alive"
player["is_alive"] = True
player["last_event"] = event[:24]
player["updated_at"] = utc_now_iso()
status_patch = flags.get("player_status", {})
if isinstance(status_patch, dict):
for raw_player_id, raw_status in status_patch.items():
player_id = str(raw_player_id or "").strip()
status = str(raw_status or "").strip().lower()
if player_id not in player_states or not status:
continue
if status in {"dead", "failed"}:
mark_dead(player_id, event=status)
elif status in {"alive", "revived"} and allow_revive:
mark_alive(player_id, event=status)
elif status == "down":
player = player_states[player_id]
player["status"] = "down"
player["is_alive"] = False
player["last_event"] = "down"
player["updated_at"] = utc_now_iso()
for player_id in self._iter_flagged_players(flags.get("player_dead", {})):
if player_id in player_states:
mark_dead(player_id, event="dead")
for player_id in self._iter_flagged_players(flags.get("player_death", {})):
if player_id in player_states:
mark_dead(player_id, event="dead")
survival_patch = flags.get("player_survival", {})
if isinstance(survival_patch, dict):
for raw_player_id, raw_alive in survival_patch.items():
player_id = str(raw_player_id or "").strip()
if player_id not in player_states:
continue
alive = None
if isinstance(raw_alive, bool):
alive = raw_alive
elif isinstance(raw_alive, (int, float)):
alive = int(raw_alive) != 0
else:
text = str(raw_alive or "").strip().lower()
if text in {"1", "true", "yes", "on", "y"}:
alive = True
elif text in {"0", "false", "no", "off", "n"}:
alive = False
if alive is None:
continue
if alive:
if allow_revive:
mark_alive(player_id, event="revived")
else:
mark_dead(player_id, event="failed")
if allow_revive:
for player_id in self._iter_flagged_players(flags.get("player_revive", {})):
if player_id in player_states:
mark_alive(player_id, event="revived")
state.global_flags["player_states"] = player_states
def _normalize_data_scope(self, raw_scope: Any) -> dict[str, str]:
scope = raw_scope if isinstance(raw_scope, dict) else {}
out = {
"metrics": str(scope.get("metrics", "shared")).strip().lower(),
"inventory": str(scope.get("inventory", "per_player")).strip().lower(),
"clues": str(scope.get("clues", "shared")).strip().lower(),
}
for key in ("metrics", "inventory", "clues"):
if out[key] not in {"shared", "per_player"}:
out[key] = "shared" if key != "inventory" else "per_player"
return out
def _normalize_visibility(self, raw_visibility: Any) -> dict[str, bool]:
visibility = raw_visibility if isinstance(raw_visibility, dict) else {}
def _to_bool(value: Any, default: bool) -> bool:
if isinstance(value, bool):
return value
text = str(value or "").strip().lower()
if text in {"1", "true", "yes", "on", "y"}:
return True
if text in {"0", "false", "no", "off", "n"}:
return False
return default
return {
"show_all_player_metrics": _to_bool(visibility.get("show_all_player_metrics", True), True),
"show_all_player_inventory": _to_bool(visibility.get("show_all_player_inventory", True), True),
}
def _normalize_inventory_item_key(self, item_value: str, card_mode: str) -> str:
value = str(item_value or "").strip()
if not value:
return ""
if card_mode == "fogharbor":
return FOGHARBOR_ITEM_ALIASES.get(value, value)
return value
def _custom_scene_progress(self, state: GameState, ai_payload: dict[str, Any]) -> dict[str, Any] | None:
if not ai_payload.get("should_end_scene"):
return None
prev_scene = state.scene_id
state.chapter = int(state.chapter) + 1
state.scene_id = f"scene_{state.chapter}"
return {
"from": prev_scene,
"to": state.scene_id,
"chapter": state.chapter,
"description": state.scene_description,
}
def _check_custom_endings(self, state: GameState, ai_payload: dict[str, Any]) -> str | None:
configured = self._evaluate_custom_end_conditions(state)
if configured:
ending_state, ending_summary = configured
state.global_flags["custom_ending_state"] = ending_state
if ending_summary:
state.global_flags["ending_summary"] = ending_summary
return ending_state
if ai_payload.get("should_end_game"):
custom = state.global_flags.get("custom_ending_state")
if isinstance(custom, str) and custom.strip():
return custom.strip()
return "custom_end"
return None
def _evaluate_custom_end_conditions(self, state: GameState) -> tuple[str, str] | None:
content_pack = state.global_flags.get("card_content_pack", {})
if not isinstance(content_pack, dict):
return None
conditions = content_pack.get("end_conditions", [])
if not isinstance(conditions, list):
return None
metrics = state.global_flags.get("metric_values", {})
if not isinstance(metrics, dict):
metrics = {}
for row in conditions[:40]:
if not isinstance(row, dict):
continue
has_clause = False
passed = True
round_gate = row.get("round")
if isinstance(round_gate, (int, float)):
has_clause = True
if int(state.round_no) < int(round_gate):
passed = False
metric_key = str(row.get("metric", "")).strip()
if metric_key:
has_clause = True
metric_value = metrics.get(metric_key, state.global_flags.get(metric_key))
expected_value = row.get("value")
op = str(row.get("op", ">=")).strip() or ">="
if not self._compare_condition(metric_value, op, expected_value):
passed = False
flag_key = str(row.get("flag", "")).strip()
if flag_key:
has_clause = True
expected_flag = row.get("flag_equals", True)
actual_flag = state.global_flags.get(flag_key)
if actual_flag != expected_flag:
passed = False
if not has_clause or not passed:
continue
ending_state = str(row.get("ending_state", "")).strip() or str(row.get("id", "")).strip() or "custom_end"
ending_summary = str(row.get("ending_summary", "")).strip()
return ending_state, ending_summary
return None
def _compare_condition(self, actual: Any, op: str, expected: Any) -> bool:
numeric_ops = {">", ">=", "<", "<="}
if op in numeric_ops:
if not isinstance(actual, (int, float)) or not isinstance(expected, (int, float)):
return False
if op == ">":
return actual > expected
if op == ">=":
return actual >= expected
if op == "<":
return actual < expected
if op == "<=":
return actual <= expected
if op == "==":
return actual == expected
if op == "!=":
return actual != expected
return False
def _auto_progress_scene(self, state: GameState) -> dict[str, Any] | None:
prev_scene = state.scene_id
flags = state.global_flags
clues = set(state.discovered_clues)
if state.scene_id == "lobby" and (
"torn_manifest" in clues or "missing_guest_note" in clues or flags.get("found_service_key")
):
state.scene_id = "dining_room"
elif state.scene_id == "dining_room" and (
flags.get("found_service_key") or "silver_bell" in clues or flags.get("room_301_unlocked")
):
state.scene_id = "corridor"
elif state.scene_id == "corridor" and (
flags.get("room_301_unlocked") or "maid_diary" in clues
):
state.scene_id = "guest_room"
elif state.scene_id == "guest_room" and (
flags.get("cellar_entry_found") or "cellar_sigil" in clues
):
state.scene_id = "basement"
if state.scene_id != prev_scene:
scene = SCENES[state.scene_id]
state.chapter = int(scene["chapter"])
state.scene_description = scene["description"]
if not state.pending_choices:
state.pending_choices = deepcopy(scene["choices"])
return {
"from": prev_scene,
"to": state.scene_id,
"chapter": state.chapter,
"description": scene["description"],
}
if state.chapter == 0:
state.chapter = 1
return None
def _check_endings(self, state: GameState) -> str | None:
flags = state.global_flags
clues = set(state.discovered_clues)
if state.danger_level >= 10:
return "trapped"
if (
state.scene_id == "basement"
and flags.get("truth_revealed")
and flags.get("escape_route_open")
and state.danger_level <= 7
and len(clues) >= 4
):
return "escaped"
if flags.get("truth_revealed") and flags.get("someone_sacrificed"):
return "sacrifice"
if state.scene_id == "basement" and flags.get("allied_hidden_force"):
return "pact"
return None