975 lines
42 KiB
Python
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)
|