Files

975 lines
42 KiB
Python

from __future__ import annotations
import html
import secrets
import string
import threading
import uuid
from dataclasses import dataclass, field
from datetime import datetime, timedelta, timezone
from typing import Any, Callable
from .action_resolver import ActionResolver
from .badges import badge_effect_payload, badge_matches_card, build_badge_progress
from .models import PlayerAction
from .prompts import build_messages
from .schemas import fallback_ai_payload, parse_json_payload, validate_ai_payload
from .state_manager import StateManager
from .story_engine import StoryEngine
from services.deepseek_client import DeepSeekClient
from services.persistence import SQLitePersistence
@dataclass
class RoomRuntime:
lock: threading.RLock = field(default_factory=threading.RLock)
pending_actions: dict[str, PlayerAction] = field(default_factory=dict)
sid_by_player: dict[str, str] = field(default_factory=dict)
resolving_round: bool = False
class RoomManager:
def __init__(
self,
persistence: SQLitePersistence,
state_manager: StateManager,
story_engine: StoryEngine,
action_resolver: ActionResolver,
deepseek_client: DeepSeekClient,
max_players: int = 8,
max_action_length: int = 280,
badge_definitions_provider: Callable[[], list[dict[str, Any]]] | None = None,
):
self.persistence = persistence
self.state_manager = state_manager
self.story_engine = story_engine
self.action_resolver = action_resolver
self.deepseek_client = deepseek_client
self.max_players = max_players
self.max_action_length = max_action_length
self.badge_definitions_provider = badge_definitions_provider
self._lock = threading.RLock()
self._rooms: dict[str, RoomRuntime] = {}
self._sid_index: dict[str, tuple[str, str]] = {}
def _badge_definitions(self) -> list[dict[str, Any]] | None:
provider = self.badge_definitions_provider
if provider is None:
return None
try:
rows = provider()
except Exception:
return None
return rows if isinstance(rows, list) else None
def _generate_room_id(self) -> str:
alphabet = string.ascii_uppercase + string.digits
for _ in range(20):
room_id = "".join(secrets.choice(alphabet) for _ in range(6))
if self.persistence.get_room(room_id) is None:
return room_id
raise RuntimeError("failed_to_generate_room_id")
def _runtime(self, room_id: str) -> RoomRuntime:
with self._lock:
runtime = self._rooms.get(room_id)
if runtime is None:
runtime = RoomRuntime()
self._rooms[room_id] = runtime
return runtime
def _sanitize(self, content: str) -> str:
normalized = " ".join(content.strip().split())
if len(normalized) > self.max_action_length:
normalized = normalized[: self.max_action_length]
return html.escape(normalized)
def _normalize_ai_narration(self, content: Any, default: str) -> str:
text = " ".join(str(content or "").strip().split())
if not text:
return default
if len(text) > 420:
return f"{text[:417]}..."
return text
def _runtime_options_from_state(self, state: dict[str, Any]) -> dict[str, Any]:
global_flags = state.get("global_flags", {}) if isinstance(state, dict) else {}
raw = global_flags.get("runtime_options", {}) if isinstance(global_flags, dict) else {}
options = raw if isinstance(raw, 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"
timeout_raw = options.get("response_timeout_seconds", 45)
try:
timeout = int(timeout_raw)
except (TypeError, ValueError):
timeout = 45
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, timeout)),
}
def _parse_iso(self, value: Any) -> datetime | None:
text = str(value or "").strip()
if not text:
return None
try:
if text.endswith("Z"):
text = f"{text[:-1]}+00:00"
dt = datetime.fromisoformat(text)
except ValueError:
return None
if dt.tzinfo is None:
dt = dt.replace(tzinfo=timezone.utc)
return dt.astimezone(timezone.utc)
def _alive_player_ids(self, state: dict[str, Any], players: list[dict[str, Any]]) -> list[str]:
player_states = state.get("global_flags", {}).get("player_states", {}) if isinstance(state, dict) else {}
if not isinstance(player_states, dict):
player_states = {}
out: list[str] = []
for player in players:
player_id = str(player.get("player_id", "")).strip()
if not player_id:
continue
row = player_states.get(player_id, {})
if isinstance(row, dict) and not bool(row.get("is_alive", True)):
continue
out.append(player_id)
return out
def _set_round_deadline(self, state: dict[str, Any], players: list[dict[str, Any]]) -> None:
global_flags = state.get("global_flags", {})
if not isinstance(global_flags, dict):
global_flags = {}
state["global_flags"] = global_flags
options = self._runtime_options_from_state(state)
now = datetime.now(timezone.utc)
global_flags["round_started_at"] = now.isoformat()
if options.get("require_all_players_response"):
timeout = int(options.get("response_timeout_seconds", 45))
global_flags["round_deadline_at"] = (now + timedelta(seconds=timeout)).isoformat()
global_flags["round_expected_player_ids"] = self._alive_player_ids(state, players)
else:
global_flags["round_deadline_at"] = ""
global_flags["round_expected_player_ids"] = []
def _round_deadline_reached(self, state: dict[str, Any]) -> bool:
global_flags = state.get("global_flags", {})
if not isinstance(global_flags, dict):
return False
deadline = self._parse_iso(global_flags.get("round_deadline_at"))
if deadline is None:
return False
return datetime.now(timezone.utc) >= deadline
def _expected_player_ids_for_round(self, state: dict[str, Any], players: list[dict[str, Any]]) -> list[str]:
global_flags = state.get("global_flags", {})
if isinstance(global_flags, dict):
raw = global_flags.get("round_expected_player_ids", [])
if isinstance(raw, list):
ids = [str(row).strip() for row in raw if str(row).strip()]
if ids:
return ids
return self._alive_player_ids(state, players)
def _apply_badges_on_game_start(
self,
state: dict[str, Any],
players: list[dict[str, Any]],
card: dict[str, Any],
) -> None:
badge_definitions = self._badge_definitions()
global_flags = state.get("global_flags", {})
if not isinstance(global_flags, dict):
global_flags = {}
state["global_flags"] = global_flags
data_scope = global_flags.get("data_scope", {})
if not isinstance(data_scope, dict):
data_scope = {}
inventory_scope = str(data_scope.get("inventory", "per_player")).strip().lower()
clues_scope = str(data_scope.get("clues", "shared")).strip().lower()
if inventory_scope not in {"shared", "per_player"}:
inventory_scope = "per_player"
if clues_scope not in {"shared", "per_player"}:
clues_scope = "shared"
inventory_by_player = state.get("inventory_by_player", {})
if not isinstance(inventory_by_player, dict):
inventory_by_player = {}
state["inventory_by_player"] = inventory_by_player
shared_inventory = global_flags.get("shared_inventory", [])
if not isinstance(shared_inventory, list):
shared_inventory = []
clues_by_player = global_flags.get("clues_by_player", {})
if not isinstance(clues_by_player, dict):
clues_by_player = {}
discovered_clues = state.get("discovered_clues", [])
if not isinstance(discovered_clues, list):
discovered_clues = []
state["discovered_clues"] = discovered_clues
badge_effects = global_flags.get("badge_effects_by_player", {})
if not isinstance(badge_effects, dict):
badge_effects = {}
special_choices = global_flags.get("badge_special_actions", [])
if not isinstance(special_choices, list):
special_choices = []
for player in players:
player_id = str(player.get("player_id", "")).strip()
user_id = str(player.get("user_id") or "").strip()
if not player_id or not user_id:
continue
user_row = self.persistence.get_user_by_id(user_id) or {}
stats = self.persistence.get_user_game_stats(user_id)
progress = build_badge_progress(stats, user_row.get("active_badge", ""), badge_definitions)
active_badge = str(progress.get("active_badge", "")).strip()
if not active_badge:
continue
if not badge_matches_card(active_badge, card, badge_definitions):
continue
effect = badge_effect_payload(active_badge, badge_definitions)
effect_item = str(effect.get("item", "")).strip()
effect_clue = str(effect.get("clue", "")).strip()
effect_choice = str(effect.get("special_choice", "")).strip()
if effect_item:
if inventory_scope == "shared":
if effect_item not in shared_inventory:
shared_inventory.append(effect_item)
else:
rows = inventory_by_player.get(player_id, [])
if not isinstance(rows, list):
rows = []
if effect_item not in rows:
rows.append(effect_item)
inventory_by_player[player_id] = rows
if effect_clue:
if clues_scope == "per_player":
rows = clues_by_player.get(player_id, [])
if not isinstance(rows, list):
rows = []
if effect_clue not in rows:
rows.append(effect_clue)
clues_by_player[player_id] = rows
elif effect_clue not in discovered_clues:
discovered_clues.append(effect_clue)
if effect_choice and effect_choice not in special_choices:
special_choices.append(effect_choice)
badge_effects[player_id] = {
"badge_id": active_badge,
"badge_name": str(effect.get("name", "")).strip()[:24],
"item": effect_item,
"clue": effect_clue,
"special_choice": effect_choice,
}
if inventory_scope == "shared":
global_flags["shared_inventory"] = shared_inventory
global_flags["clues_by_player"] = clues_by_player
global_flags["badge_effects_by_player"] = badge_effects
global_flags["badge_special_actions"] = special_choices
pending_choices = state.get("pending_choices", [])
if not isinstance(pending_choices, list):
pending_choices = []
for choice in special_choices:
choice_text = str(choice or "").strip()
if choice_text and choice_text not in pending_choices:
pending_choices.append(choice_text)
state["pending_choices"] = pending_choices[:8]
def _ensure_private_updates_for_all_players(
self,
ai_payload: dict[str, Any],
players: list[dict[str, Any]],
actions: list[PlayerAction],
) -> None:
updates_raw = ai_payload.get("private_player_updates", [])
updates = updates_raw if isinstance(updates_raw, list) else []
by_player: dict[str, str] = {}
for row in updates:
if not isinstance(row, dict):
continue
player_id = str(row.get("player_id", "")).strip()
message = " ".join(str(row.get("message", "")).strip().split())
if not player_id or not message:
continue
if player_id not in by_player:
by_player[player_id] = message[:220]
action_by_player: dict[str, PlayerAction] = {row.player_id: row for row in actions}
for player in players:
player_id = str(player.get("player_id", "")).strip()
if not player_id:
continue
if player_id in by_player:
continue
action = action_by_player.get(player_id)
player_name = str(player.get("name") or player_id).strip()
if action:
action_text = " ".join(str(action.content or "").strip().split())
if len(action_text) > 56:
action_text = f"{action_text[:56]}..."
by_player[player_id] = f"{player_name},你的行动“{action_text}”已计入结算,请根据新线索继续推进。"
else:
by_player[player_id] = f"{player_name},本轮未记录到你的行动,下一轮请尽快提交你的选择。"
ai_payload["private_player_updates"] = [
{"player_id": player_id, "message": message}
for player_id, message in by_player.items()
]
def _sync_private_updates_for_all_players(self, ai_payload: dict[str, Any], players: list[dict[str, Any]]) -> None:
updates_raw = ai_payload.get("private_player_updates", [])
updates = updates_raw if isinstance(updates_raw, list) else []
base_message = ""
for row in updates:
if not isinstance(row, dict):
continue
message = " ".join(str(row.get("message", "")).strip().split())
if message:
base_message = message
break
if not base_message:
base_message = self._normalize_ai_narration(
ai_payload.get("narration", ""),
"全队同步简报:本轮信息已同步,继续协作推进剧情。",
)
if len(base_message) > 220:
base_message = f"{base_message[:220]}..."
synced = []
for player in players:
player_id = str(player.get("player_id", "")).strip()
if not player_id:
continue
synced.append({"player_id": player_id, "message": base_message})
ai_payload["private_player_updates"] = synced
def _ensure_unique_name(self, name: str, existing_names: set[str]) -> str:
if name not in existing_names:
return name
suffix = 2
while f"{name}{suffix}" in existing_names:
suffix += 1
return f"{name}{suffix}"
def create_room(
self,
host_name: str,
*,
user_id: str | None = None,
game_card: dict[str, Any] | None = None,
) -> dict[str, Any]:
clean_name = self._sanitize(host_name) or "冒险者"
room_id = self._generate_room_id()
host_player_id = uuid.uuid4().hex[:12]
card = game_card or self.persistence.get_default_card() or {}
self.state_manager.create_new_room(
room_id,
host_player_id,
clean_name,
host_user_id=user_id,
game_card=card,
)
card_title = str(card.get("title", "雾港旅店"))
self.persistence.append_message(room_id, "system", f"{clean_name} 创建了房间。剧本卡:{card_title}")
return {
"room_id": room_id,
"player_id": host_player_id,
"player_name": clean_name,
"is_host": True,
}
def join_room(
self,
room_id: str,
name: str,
player_id: str | None = None,
*,
user_id: str | None = None,
) -> tuple[dict[str, Any], dict[str, Any]]:
room = self.persistence.get_room(room_id)
if room is None:
raise ValueError("room_not_found")
players = self.persistence.list_players(room_id)
clean_name = self._sanitize(name) or "冒险者"
existing = None
if user_id:
existing = self.persistence.get_player_by_user(room_id, user_id)
if existing:
self.persistence.add_or_update_player(
room_id,
existing["player_id"],
existing["name"],
is_host=existing["is_host"],
is_ready=existing["is_ready"],
is_online=True,
user_id=user_id,
)
return (
{
"room_id": room_id,
"player_id": existing["player_id"],
"player_name": existing["name"],
"is_host": existing["is_host"],
},
room,
)
if player_id:
existing = self.persistence.get_player(room_id, player_id)
if existing:
self.persistence.add_or_update_player(
room_id,
player_id,
existing["name"],
is_host=existing["is_host"],
is_ready=existing["is_ready"],
is_online=True,
user_id=existing.get("user_id"),
)
return (
{
"room_id": room_id,
"player_id": player_id,
"player_name": existing["name"],
"is_host": existing["is_host"],
},
room,
)
if room["status"] != "lobby":
raise ValueError("game_in_progress")
if len(players) >= self.max_players:
raise ValueError("room_full")
existing_names = {player["name"] for player in players}
clean_name = self._ensure_unique_name(clean_name, existing_names)
new_player_id = uuid.uuid4().hex[:12]
self.state_manager.add_player(
room_id,
new_player_id,
clean_name,
is_host=False,
user_id=user_id,
)
self.persistence.append_message(room_id, "system", f"{clean_name} 加入了房间。", player_id=new_player_id)
return (
{
"room_id": room_id,
"player_id": new_player_id,
"player_name": clean_name,
"is_host": False,
},
room,
)
def attach_socket(self, room_id: str, player_id: str, sid: str) -> None:
runtime = self._runtime(room_id)
with runtime.lock, self._lock:
runtime.sid_by_player[player_id] = sid
self._sid_index[sid] = (room_id, player_id)
self.persistence.update_player_online(room_id, player_id, True)
player = self.persistence.get_player(room_id, player_id)
user_id = str((player or {}).get("user_id") or "").strip()
if user_id:
self.persistence.touch_user_activity(user_id, is_online=True)
def detach_socket(self, sid: str) -> tuple[str, str] | None:
with self._lock:
mapping = self._sid_index.pop(sid, None)
if not mapping:
return None
room_id, player_id = mapping
runtime = self._runtime(room_id)
with runtime.lock:
if runtime.sid_by_player.get(player_id) == sid:
runtime.sid_by_player.pop(player_id, None)
self.persistence.update_player_online(room_id, player_id, False)
player = self.persistence.get_player(room_id, player_id)
user_id = str((player or {}).get("user_id") or "").strip()
if user_id:
self.persistence.touch_user_activity(user_id, is_online=None)
return room_id, player_id
def transfer_host(self, room_id: str, operator_player_id: str, target_player_id: str) -> dict[str, Any]:
room = self.persistence.get_room(room_id)
if room is None:
raise ValueError("room_not_found")
if room["host_player_id"] != operator_player_id:
raise ValueError("only_host_can_transfer")
if operator_player_id == target_player_id:
raise ValueError("cannot_transfer_to_self")
target = self.persistence.get_player(room_id, target_player_id)
if target is None:
raise ValueError("target_player_not_found")
self.persistence.set_room_host(room_id, target_player_id)
self.persistence.append_message(room_id, "system", f"房主已手动移交给 {target['name']}。")
return {
"room_id": room_id,
"host_player_id": target_player_id,
"host_player_name": target["name"],
}
def kick_player(self, room_id: str, operator_player_id: str, target_player_id: str) -> dict[str, Any]:
room = self.persistence.get_room(room_id)
if room is None:
raise ValueError("room_not_found")
if room["host_player_id"] != operator_player_id:
raise ValueError("only_host_can_kick")
if room["status"] != "lobby" or room["game_state"].get("game_phase") != "lobby":
raise ValueError("only_lobby_can_kick")
target = self.persistence.get_player(room_id, target_player_id)
if target is None:
raise ValueError("target_player_not_found")
if target.get("is_host"):
raise ValueError("cannot_kick_host")
runtime = self._runtime(room_id)
with runtime.lock, self._lock:
runtime.pending_actions.pop(target_player_id, None)
sid = runtime.sid_by_player.pop(target_player_id, None)
if sid:
self._sid_index.pop(sid, None)
state = room["game_state"]
inventory_map = state.get("inventory_by_player", {})
if isinstance(inventory_map, dict):
inventory_map.pop(target_player_id, None)
global_flags = state.get("global_flags", {})
if isinstance(global_flags, dict):
clues_by_player = global_flags.get("clues_by_player", {})
if isinstance(clues_by_player, dict):
clues_by_player.pop(target_player_id, None)
metric_values_by_player = global_flags.get("metric_values_by_player", {})
if isinstance(metric_values_by_player, dict):
metric_values_by_player.pop(target_player_id, None)
self.persistence.remove_player(room_id, target_player_id)
self.state_manager.set_room_state(room_id, room["status"], state)
self.persistence.append_message(room_id, "system", f"{target['name']} 已被房主移出房间。")
return {"room_id": room_id, "target_player_id": target_player_id, "target_name": target["name"]}
def delete_room(
self,
room_id: str,
player_id: str,
*,
requester_user_id: str | None = None,
) -> None:
room = self.persistence.get_room(room_id)
if room is None:
raise ValueError("room_not_found")
player = self.persistence.get_player(room_id, player_id)
if player is None:
raise ValueError("player_not_in_room")
bound_user_id = str(player.get("user_id") or "")
if requester_user_id and bound_user_id and bound_user_id != requester_user_id:
raise ValueError("player_not_in_room")
if not player.get("is_host"):
raise ValueError("only_host_can_delete")
self.persistence.delete_room(room_id)
with self._lock:
runtime = self._rooms.pop(room_id, None)
if runtime:
for sid in runtime.sid_by_player.values():
self._sid_index.pop(sid, None)
def set_room_public(self, room_id: str, operator_player_id: str, is_public: bool) -> dict[str, Any]:
room = self.persistence.get_room(room_id)
if room is None:
raise ValueError("room_not_found")
if room["host_player_id"] != operator_player_id:
raise ValueError("only_host_can_manage_public")
self.persistence.update_room_public(room_id, is_public)
return {"room_id": room_id, "is_public": bool(is_public)}
def force_delete_room(self, room_id: str) -> None:
room = self.persistence.get_room(room_id)
if room is None:
raise ValueError("room_not_found")
self.persistence.delete_room(room_id)
with self._lock:
runtime = self._rooms.pop(room_id, None)
if runtime:
for sid in runtime.sid_by_player.values():
self._sid_index.pop(sid, None)
def set_ready(self, room_id: str, player_id: str, ready: bool) -> None:
room = self.persistence.get_room(room_id)
if room is None:
raise ValueError("room_not_found")
if room["status"] != "lobby":
raise ValueError("game_already_started")
self.persistence.update_player_ready(room_id, player_id, ready)
def start_game(self, room_id: str, player_id: str) -> None:
room = self.persistence.get_room(room_id)
if room is None:
raise ValueError("room_not_found")
if room["host_player_id"] != player_id:
raise ValueError("only_host_can_start")
if room["status"] != "lobby" or room["game_state"].get("game_phase") != "lobby":
raise ValueError("game_already_started")
players = self.persistence.list_players(room_id)
non_host_players = [row for row in players if not row["is_host"]]
if non_host_players and not all(row["is_ready"] for row in non_host_players):
raise ValueError("players_not_ready")
state = room["game_state"]
state["game_phase"] = "running"
if int(state.get("chapter", 0)) < 1:
state["chapter"] = 1
if not state.get("pending_choices"):
starter = state.get("global_flags", {}).get("starter_goals", [])
if isinstance(starter, list) and starter:
state["pending_choices"] = [str(row) for row in starter[:4]]
else:
state["pending_choices"] = ["先观察环境", "与关键角色交谈", "团队分工行动"]
card = self.persistence.get_game_card(room.get("game_card_id", "")) or {}
self._apply_badges_on_game_start(state, players, card)
self._set_round_deadline(state, players)
self.state_manager.set_room_state(room_id, "running", state)
self.persistence.append_message(room_id, "system", "房主已开始游戏,第一幕开启。")
scene_intro = str(state.get("scene_description", "")).strip()
if scene_intro:
self.persistence.append_message(room_id, "ai", scene_intro)
def submit_chat(self, room_id: str, player_id: str, content: str) -> dict[str, Any]:
room = self.persistence.get_room(room_id)
if room is None:
raise ValueError("room_not_found")
if room["status"] == "ended" or room["game_state"].get("game_phase") == "ended":
raise ValueError("game_ended")
text = self._sanitize(content)
if not text:
raise ValueError("empty_message")
player = self.persistence.get_player(room_id, player_id)
if not player:
raise ValueError("player_not_found")
rendered = f"{player['name']}: {text}"
self.persistence.append_message(room_id, "chat", rendered, player_id=player_id)
return {"kind": "chat", "player_id": player_id, "content": rendered}
def submit_action(self, room_id: str, player_id: str, content: str) -> dict[str, Any]:
room = self.persistence.get_room(room_id)
if room is None:
raise ValueError("room_not_found")
state = room["game_state"]
if room["status"] != "running" or state.get("game_phase") != "running":
raise ValueError("game_not_running")
runtime_options = self._runtime_options_from_state(state)
player = self.persistence.get_player(room_id, player_id)
if not player:
raise ValueError("player_not_found")
if runtime_options.get("allow_player_death"):
player_states = state.get("global_flags", {}).get("player_states", {})
if isinstance(player_states, dict):
row = player_states.get(player_id, {})
if isinstance(row, dict) and not bool(row.get("is_alive", True)):
raise ValueError("player_is_dead")
require_all = bool(runtime_options.get("require_all_players_response", False))
deadline_reached = self._round_deadline_reached(state) if require_all else False
if require_all and deadline_reached:
raise ValueError("action_submission_timeout")
text = self._sanitize(content)
if not text:
raise ValueError("empty_action")
runtime = self._runtime(room_id)
with runtime.lock:
if runtime.resolving_round:
raise ValueError("round_resolving")
if player_id in runtime.pending_actions:
raise ValueError("action_already_submitted")
runtime.pending_actions[player_id] = PlayerAction(
player_id=player_id,
player_name=player["name"],
content=text,
action_type="action",
)
pending_count = len(runtime.pending_actions)
self.persistence.append_action(room_id, int(state.get("round_no", 0)) + 1, player_id, text)
self.persistence.append_message(
room_id,
"action",
f"{player['name']}: {text}",
player_id=player_id,
)
players = self.persistence.list_players(room_id)
expected_ids = self._expected_player_ids_for_round(state, players)
if require_all:
should_auto_resolve = pending_count >= max(1, len(expected_ids))
else:
online_players_count = sum(1 for row in players if row["is_online"])
should_auto_resolve = pending_count >= max(1, online_players_count)
return {
"kind": "action",
"player_id": player_id,
"player_name": player["name"],
"content": text,
"pending_count": pending_count,
"should_auto_resolve": should_auto_resolve,
"require_all_players_response": require_all,
"expected_player_count": len(expected_ids),
"round_deadline_at": state.get("global_flags", {}).get("round_deadline_at", ""),
}
def resolve_round(self, room_id: str, resolver_player_id: str) -> dict[str, Any]:
room = self.persistence.get_room(room_id)
if room is None:
raise ValueError("room_not_found")
state = room["game_state"]
if room["status"] != "running" or state.get("game_phase") != "running":
raise ValueError("game_not_running")
players = self.persistence.list_players(room_id)
runtime_options = self._runtime_options_from_state(state)
require_all = bool(runtime_options.get("require_all_players_response", False))
deadline_reached = self._round_deadline_reached(state) if require_all else False
expected_ids = self._expected_player_ids_for_round(state, players)
runtime = self._runtime(room_id)
actions: list[PlayerAction] = []
with runtime.lock:
if runtime.resolving_round:
raise ValueError("round_resolving")
submitted_ids = set(runtime.pending_actions.keys())
if require_all and (not deadline_reached) and submitted_ids and len(submitted_ids) < max(1, len(expected_ids)):
raise ValueError("round_waiting_for_players")
if require_all and deadline_reached:
player_map = {str(row.get("player_id", "")): row for row in players}
for expect_id in expected_ids:
if expect_id in runtime.pending_actions:
continue
player_row = player_map.get(expect_id, {})
name = str(player_row.get("name", expect_id)).strip() or expect_id
runtime.pending_actions[expect_id] = PlayerAction(
player_id=expect_id,
player_name=name,
content="(超时未行动)",
action_type="timeout",
)
actions = list(runtime.pending_actions.values())
if not actions:
raise ValueError("no_pending_actions")
runtime.resolving_round = True
runtime.pending_actions = {}
try:
merge_result = self.action_resolver.merge_actions(actions, room_id, int(state.get("round_no", 0)) + 1)
user_cache: dict[str, dict[str, Any]] = {}
for player in players:
user_id = str(player.get("user_id") or "")
if not user_id or user_id in user_cache:
continue
user_cache[user_id] = self.persistence.get_user_by_id(user_id) or {}
prompt_context = {
"story_context": self.story_engine.story_context(state.get("scene_id", "lobby"), state),
"game_state": state,
"players": [
{
"player_id": player["player_id"],
"name": player["name"],
"username": user_cache.get(str(player.get("user_id") or ""), {}).get("username", ""),
"display_name": user_cache.get(str(player.get("user_id") or ""), {}).get("display_name", ""),
"is_online": player["is_online"],
}
for player in players
],
"merged_actions": merge_result,
}
ai_payload = None
ai_notice = ""
ai_call = self.deepseek_client.chat_json(build_messages(prompt_context))
if ai_call.get("ok"):
raw_content = str(ai_call.get("content") or "")
parsed, parse_error = parse_json_payload(raw_content)
if parse_error:
ai_payload = fallback_ai_payload(
reason=self._normalize_ai_narration(
raw_content,
"AI 输出了非 JSON 文本,系统已自动兼容并继续推进剧情。",
)
)
ai_notice = "AI 返回非结构化文本,已自动兼容。"
else:
ok, errors, normalized = validate_ai_payload(parsed or {})
ai_payload = normalized
if not ok:
ai_notice = f"AI 输出字段不完整({','.join(errors[:3])}),已自动修复。"
else:
ai_error = str(ai_call.get("error", "ai_call_failed")).strip() or "ai_call_failed"
ai_payload = fallback_ai_payload(
reason=f"AI 暂时不可用({ai_error}),本轮由主持规则接管推进。"
)
ai_notice = f"AI 调用异常({ai_error}),已切换规则主持。"
if ai_payload is None:
raise ValueError("ai_call_failed:empty_ai_payload")
self._ensure_private_updates_for_all_players(ai_payload, players, actions)
if runtime_options.get("sync_all_player_ai_replies"):
self._sync_private_updates_for_all_players(ai_payload, players)
next_state, round_result = self.story_engine.apply_turn(state, ai_payload, merge_result, players)
next_status = "ended" if next_state.get("game_phase") == "ended" else "running"
if next_status == "running":
self._set_round_deadline(next_state, players)
self.state_manager.set_room_state(room_id, next_status, next_state)
if ai_notice:
self.persistence.append_message(room_id, "system", ai_notice)
if round_result.get("merge_summary"):
self.persistence.append_message(room_id, "system", round_result["merge_summary"])
if round_result.get("narration"):
self.persistence.append_message(room_id, "ai", round_result["narration"])
for row in round_result.get("npc_dialogues", []):
npc = row.get("npc", "NPC")
line = row.get("line", "")
if line:
self.persistence.append_message(room_id, "npc", f"{npc}: {line}")
private_payload: dict[str, list[str]] = {}
for row in round_result.get("private_player_updates", []):
player_id = row.get("player_id")
message = row.get("message")
if not player_id or not message:
continue
private_payload.setdefault(player_id, []).append(message)
self.persistence.append_message(
room_id,
"private",
message,
player_id=player_id,
visibility="private",
)
return {
"round_result": round_result,
"private_updates": private_payload,
"snapshot": self.get_snapshot(room_id),
}
except Exception:
with runtime.lock:
if runtime.resolving_round:
for action in actions:
runtime.pending_actions[action.player_id] = action
raise
finally:
with runtime.lock:
runtime.resolving_round = False
def get_snapshot(self, room_id: str) -> dict[str, Any]:
room = self.persistence.get_room(room_id)
if room is None:
raise ValueError("room_not_found")
runtime = self._runtime(room_id)
with runtime.lock:
pending_actions = [action.to_dict() for action in runtime.pending_actions.values()]
resolving_round = bool(runtime.resolving_round)
players = self.persistence.list_players(room_id)
messages = self.persistence.list_messages(room_id)
game_card = self.persistence.get_game_card(room.get("game_card_id", ""))
state = room["game_state"]
badge_definitions = self._badge_definitions()
user_cache: dict[str, dict[str, Any]] = {}
for player in players:
user_id = str(player.get("user_id") or "")
if not user_id or user_id in user_cache:
continue
user_cache[user_id] = self.persistence.get_user_by_id(user_id) or {}
scene_guidance = self.story_engine.scene_guidance(
state.get("scene_id", "lobby"),
int(state.get("round_no", 0)),
list(state.get("pending_choices", [])),
)
return {
"room_id": room_id,
"status": room["status"],
"is_public": bool(room.get("is_public", False)),
"host_player_id": room["host_player_id"],
"game_card": game_card,
"players": [
{
"player_id": player["player_id"],
"name": player["name"],
"user_id": player.get("user_id"),
"username": user_cache.get(str(player.get("user_id") or ""), {}).get("username", ""),
"display_name": user_cache.get(str(player.get("user_id") or ""), {}).get("display_name", ""),
"avatar_color": user_cache.get(str(player.get("user_id") or ""), {}).get("avatar_color", "#0070f2"),
"avatar_url": user_cache.get(str(player.get("user_id") or ""), {}).get("avatar_url", ""),
"active_badge": user_cache.get(str(player.get("user_id") or ""), {}).get("active_badge", ""),
"active_badge_name": badge_effect_payload(
user_cache.get(str(player.get("user_id") or ""), {}).get("active_badge", ""),
badge_definitions,
).get("name", ""),
"is_host": player["is_host"],
"is_ready": player["is_ready"],
"is_online": player["is_online"],
}
for player in players
],
"game_state": state,
"scene_guidance": scene_guidance,
"pending_actions": pending_actions,
"is_resolving_round": resolving_round,
"messages": messages,
}
def room_sids(self, room_id: str) -> list[str]:
runtime = self._runtime(room_id)
with runtime.lock:
return list(runtime.sid_by_player.values())
def sid_for_player(self, room_id: str, player_id: str) -> str | None:
runtime = self._runtime(room_id)
with runtime.lock:
return runtime.sid_by_player.get(player_id)