Files
Echo-Tavern/services/persistence.py
T

1247 lines
53 KiB
Python

from __future__ import annotations
import json
import re
import sqlite3
import threading
import uuid
from pathlib import Path
from typing import Any
from game.models import utc_now_iso
DEFAULT_CARD_ID = "fogharbor"
class SQLitePersistence:
def __init__(self, db_path: str):
self.db_path = str(Path(db_path))
self._lock = threading.RLock()
self.init_schema()
def _connect(self) -> sqlite3.Connection:
conn = sqlite3.connect(self.db_path, check_same_thread=False)
conn.row_factory = sqlite3.Row
return conn
def _ensure_column(self, conn: sqlite3.Connection, table: str, column: str, ddl: str) -> None:
rows = conn.execute(f"PRAGMA table_info({table})").fetchall()
columns = {row["name"] for row in rows}
if column not in columns:
conn.execute(f"ALTER TABLE {table} ADD COLUMN {column} {ddl}")
def init_schema(self) -> None:
with self._lock, self._connect() as conn:
conn.executescript(
"""
CREATE TABLE IF NOT EXISTS users (
user_id TEXT PRIMARY KEY,
username TEXT UNIQUE NOT NULL,
password_hash TEXT NOT NULL,
display_name TEXT NOT NULL,
role TEXT NOT NULL DEFAULT 'player',
avatar_color TEXT NOT NULL DEFAULT '#0070f2',
avatar_url TEXT NOT NULL DEFAULT '',
theme TEXT NOT NULL DEFAULT 'dopamine',
bio TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS game_cards (
card_id TEXT PRIMARY KEY,
title TEXT NOT NULL,
subtitle TEXT NOT NULL DEFAULT '',
summary TEXT NOT NULL,
cover_url TEXT NOT NULL,
opening_text TEXT NOT NULL,
starter_goals TEXT NOT NULL,
state_schema TEXT NOT NULL DEFAULT '{}',
content_pack TEXT NOT NULL DEFAULT '{}',
creator_user_id TEXT,
is_official INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS rooms (
room_id TEXT PRIMARY KEY,
host_player_id TEXT NOT NULL,
is_public INTEGER NOT NULL DEFAULT 0,
status TEXT NOT NULL,
chapter INTEGER NOT NULL,
scene_id TEXT NOT NULL,
game_state TEXT NOT NULL,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS players (
player_id TEXT PRIMARY KEY,
room_id TEXT NOT NULL,
name TEXT NOT NULL,
is_host INTEGER NOT NULL DEFAULT 0,
is_ready INTEGER NOT NULL DEFAULT 0,
is_online INTEGER NOT NULL DEFAULT 1,
joined_at TEXT NOT NULL,
last_seen TEXT NOT NULL,
FOREIGN KEY(room_id) REFERENCES rooms(room_id)
);
CREATE TABLE IF NOT EXISTS messages (
id INTEGER PRIMARY KEY AUTOINCREMENT,
room_id TEXT NOT NULL,
kind TEXT NOT NULL,
player_id TEXT,
visibility TEXT NOT NULL DEFAULT 'public',
content TEXT NOT NULL,
created_at TEXT NOT NULL,
FOREIGN KEY(room_id) REFERENCES rooms(room_id)
);
CREATE TABLE IF NOT EXISTS actions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
room_id TEXT NOT NULL,
round_no INTEGER NOT NULL,
player_id TEXT NOT NULL,
content TEXT NOT NULL,
created_at TEXT NOT NULL,
FOREIGN KEY(room_id) REFERENCES rooms(room_id)
);
"""
)
self._ensure_column(conn, "players", "user_id", "TEXT")
self._ensure_column(conn, "rooms", "game_card_id", f"TEXT NOT NULL DEFAULT '{DEFAULT_CARD_ID}'")
self._ensure_column(conn, "rooms", "is_public", "INTEGER NOT NULL DEFAULT 0")
self._ensure_column(conn, "game_cards", "state_schema", "TEXT NOT NULL DEFAULT '{}'")
self._ensure_column(conn, "game_cards", "content_pack", "TEXT NOT NULL DEFAULT '{}'")
self._ensure_column(conn, "users", "role", "TEXT NOT NULL DEFAULT 'player'")
self._ensure_column(conn, "users", "avatar_url", "TEXT NOT NULL DEFAULT ''")
self._ensure_column(conn, "users", "theme", "TEXT NOT NULL DEFAULT 'dopamine'")
conn.commit()
self._seed_default_cards(conn)
conn.commit()
def _seed_default_cards(self, conn: sqlite3.Connection) -> None:
now = utc_now_iso()
cards = [
{
"card_id": "fogharbor",
"title": "雾港旅店",
"subtitle": "合作推理 / 诡异悬疑",
"summary": "暴风雨夜,一间旅店困住了所有人。你们要在规则与谎言中找出真相。",
"cover_url": "/static/img/cards/fogharbor.svg",
"opening_text": "暴风雨夜,你们推开雾港旅店大门,空气里有潮湿铁锈味。",
"starter_goals": ["先查看大厅可疑区域", "与旅店老板交谈", "确定队伍分工"],
"state_schema": {
"metrics": [
{"key": "team_stability", "label": "队伍稳定度", "min": 0, "max": 100, "initial": 68, "color": "#ff8c42"},
{"key": "truth_progress", "label": "真相推进", "min": 0, "max": 100, "initial": 5, "color": "#4b7cff"},
{"key": "night_rule_pressure", "label": "夜规压迫", "min": 0, "max": 100, "initial": 20, "color": "#8b5cf6"},
],
"labels": {
"inventory": "个人携行物",
"clues": "线索墙",
"choices": "可执行指令",
"scene": "当前区域",
},
"scene_labels": {
"lobby": "大厅",
"dining_room": "餐厅",
"corridor": "走廊",
"guest_room": "客房",
"basement": "地下室",
},
"data_scope": {
"metrics": "per_player",
"inventory": "per_player",
"clues": "shared",
},
"visibility": {
"show_all_player_metrics": True,
"show_all_player_inventory": True,
},
"ui": {
"room_background_url": "/static/img/cards/fogharbor.svg",
"hero_status": {
"key": "night_rule_pressure",
"label": "夜规压迫",
"min": 0,
"max": 100,
"color": "#8b5cf6",
},
"status_tracks": [
{"key": "night_rule_pressure", "label": "夜规压迫", "min": 0, "max": 100, "color": "#8b5cf6"},
{"key": "truth_progress", "label": "真相推进", "min": 0, "max": 100, "color": "#4b7cff"},
{"key": "team_stability", "label": "队伍稳定度", "min": 0, "max": 100, "color": "#ff8c42"},
],
},
},
"content_pack": {
"genre_tags": ["悬疑", "诡异", "合作推理"],
"feature_badges": ["多结局", "NPC交锋", "规则怪谈"],
"world_lore": "雾港旅店坐落在暴雨断航的旧码头,每逢风暴夜就会重复一次失踪事件。",
"core_conflict": "玩家要在旅店规则与NPC谎言中找出失踪者去向,并决定是否牺牲某人换取逃离。",
"player_roles": ["调查者", "谈判者", "记录者", "潜行者"],
"interaction_modes": ["合作调查", "分头行动", "公开投票", "私下交易"],
"system_rules": [
"每轮至少提交一条调查或交涉行动",
"危险值越高,NPC越不可信",
"公开讨论可降低误判,但会抬升紧张度",
],
"npc_roster": [
"旅店老板:掌握旧账本,言辞含糊",
"女仆:知道地下室入口,害怕被追责",
"沉默旅客:与失踪者有关,但拒绝明说",
],
"scene_blueprint": [
"大厅:建立怀疑对象与分工",
"餐厅:线索交叉验证",
"走廊/客房:揭示隐藏关系",
"地下室:结局抉择",
],
"ending_catalog": ["成功逃离", "真相伴随牺牲", "被旅店困住", "与隐藏势力交易"],
"hidden_truths": ["失踪者并非单独行动", "旅店规则有人为操控", "地下室仪式可被篡改"],
"systems": {
"investigation": {"enabled": True, "suggestion": "多人交叉验证提高真相推进"},
"social": {"enabled": True, "trust_field": "team_stability"},
},
"end_conditions": [
{
"id": "escape_success",
"metric": "truth_progress",
"op": ">=",
"value": 88,
"flag": "escape_route_open",
"flag_equals": True,
"ending_state": "escaped",
"ending_summary": "你们破解了旅店夜规并打开逃生路线,成功离开雾港。",
},
{
"id": "night_consumed",
"metric": "night_rule_pressure",
"op": ">=",
"value": 100,
"ending_state": "trapped",
"ending_summary": "夜规彻底接管旅店,队伍被困在永夜轮回里。",
},
],
},
},
{
"card_id": "sunken_arcade",
"title": "沉没游乐城",
"subtitle": "探索冒险 / 机关解谜",
"summary": "被海水吞没的游乐城仍在夜里通电,机械与记忆一起复苏。",
"cover_url": "/static/img/cards/sunken_arcade.svg",
"opening_text": "霓虹在海雾中一闪一灭,沉没游乐城的闸门缓缓开启。",
"starter_goals": ["寻找主控电源", "确认出口是否畅通", "排查游乐设施异常"],
"state_schema": {
"metrics": [
{"key": "pressure", "label": "水压警戒", "min": 0, "max": 120, "initial": 24, "color": "#26b7ff"},
{"key": "power_grid", "label": "电网稳定", "min": 0, "max": 100, "initial": 40, "color": "#7affb3"},
{"key": "team_oxygen", "label": "队伍氧量", "min": 0, "max": 100, "initial": 85, "color": "#ffd166"},
],
"labels": {
"inventory": "潜行装备",
"clues": "异常记录",
"choices": "可执行指令",
"scene": "当前区段",
},
"data_scope": {
"metrics": "per_player",
"inventory": "shared",
"clues": "shared",
},
"visibility": {
"show_all_player_metrics": True,
"show_all_player_inventory": True,
},
"ui": {
"room_background_url": "/static/img/cards/sunken_arcade.svg",
"hero_status": {
"key": "pressure",
"label": "水压警戒",
"min": 0,
"max": 120,
"color": "#26b7ff",
},
"status_tracks": [
{"key": "pressure", "label": "水压警戒", "min": 0, "max": 120, "color": "#26b7ff"},
{"key": "power_grid", "label": "电网稳定", "min": 0, "max": 100, "color": "#7affb3"},
{"key": "team_oxygen", "label": "队伍氧量", "min": 0, "max": 100, "color": "#ffd166"},
],
},
},
"content_pack": {
"genre_tags": ["科幻", "探索", "解谜"],
"feature_badges": ["资源管理", "机关联动", "团队协作"],
"world_lore": "沉没游乐城位于海沟边缘,夜晚会短暂恢复供电并广播失落游客名单。",
"core_conflict": "玩家必须在水压持续上升前修复主控系统,决定牺牲供电区还是冒险突破。",
"player_roles": ["工程师", "侦查员", "安全员"],
"interaction_modes": ["区域分队", "同步解谜", "风险分摊"],
"system_rules": [
"每轮需声明要优先处理的设施",
"电网稳定度过低会关闭部分选项",
"冒险行动收益高但会显著增加水压警戒",
],
"npc_roster": [
"售票机器人:可提供旧地图但需要通电",
"失忆保安:记得撤离路线碎片",
"海沟回声:会伪装成队友语音",
],
"scene_blueprint": ["入口检修区", "霓虹主街", "维护中枢", "深层观景廊", "总控塔"],
"ending_catalog": ["全员撤离", "部分区段沉没", "系统失控永久封锁"],
"hidden_truths": ["事故由内部测试引发", "主控塔有备用逃生通道"],
"systems": {
"hazard": {"enabled": True, "primary_metric": "pressure"},
"resource": {"enabled": True, "shared_inventory": True},
},
"end_conditions": [
{
"id": "arcade_escape",
"metric": "power_grid",
"op": ">=",
"value": 85,
"flag": "escape_route_open",
"flag_equals": True,
"ending_state": "escaped",
"ending_summary": "你们重启了主控网络并打开泄压门,全员脱离沉没游乐城。",
},
{
"id": "oxygen_exhausted",
"metric": "team_oxygen",
"op": "<=",
"value": 0,
"ending_state": "trapped",
"ending_summary": "氧量耗尽,队伍被困在黑暗海沟中。",
},
],
},
},
{
"card_id": "skyrail_omega",
"title": "天轨终点站",
"subtitle": "科幻悬疑 / 团队抉择",
"summary": "无人列车驶向失落终点,车厢里的每个人都藏着不同目的。",
"cover_url": "/static/img/cards/skyrail_omega.svg",
"opening_text": "列车广播响起:下一站,终点站。窗外却只有无尽云海。",
"starter_goals": ["检查列车广播来源", "盘问可疑乘客", "确认车头控制权"],
"state_schema": {
"metrics": [
{"key": "suspicion", "label": "全列车怀疑度", "min": 0, "max": 100, "initial": 30, "color": "#ff6a6a"},
{"key": "signal_integrity", "label": "信号完整度", "min": 0, "max": 100, "initial": 55, "color": "#4d84ff"},
{"key": "authority", "label": "队伍控制权", "min": 0, "max": 100, "initial": 35, "color": "#7affb3"},
],
"labels": {
"inventory": "随身模块",
"clues": "乘客情报",
"choices": "可选路线",
"scene": "当前车厢",
},
"data_scope": {
"metrics": "shared",
"inventory": "per_player",
"clues": "shared",
},
"visibility": {
"show_all_player_metrics": True,
"show_all_player_inventory": True,
},
"ui": {
"room_background_url": "/static/img/cards/skyrail_omega.svg",
"hero_status": {
"key": "signal_integrity",
"label": "信号完整度",
"min": 0,
"max": 100,
"color": "#4d84ff",
},
"status_tracks": [
{"key": "signal_integrity", "label": "信号完整度", "min": 0, "max": 100, "color": "#4d84ff"},
{"key": "authority", "label": "队伍控制权", "min": 0, "max": 100, "color": "#7affb3"},
{"key": "suspicion", "label": "全列车怀疑度", "min": 0, "max": 100, "color": "#ff6a6a"},
],
},
},
"content_pack": {
"genre_tags": ["悬疑", "科幻", "心理博弈"],
"feature_badges": ["信任系统", "隐私事件", "阵营抉择"],
"world_lore": "天轨列车在云海上循环行驶,乘客档案会在每次广播后被篡改。",
"core_conflict": "玩家要查明列车终点的真实含义,并在互不信任的乘客间维持秩序。",
"player_roles": ["安保官", "情报员", "调度员", "医生"],
"interaction_modes": ["信息交换", "临时联盟", "公开审讯"],
"system_rules": [
"怀疑度过高会触发乘客暴动",
"信号完整度决定可访问车厢范围",
"隐私情报可影响公开投票结果",
],
"npc_roster": [
"乘务长:强行维持秩序",
"失忆科学家:掌握终点数据",
"匿名旅客:不断制造误导",
],
"scene_blueprint": ["前段车厢", "中段控制桥", "封闭实验舱", "终点前置区"],
"ending_catalog": ["重置列车并返航", "牺牲车厢换取生存", "接受终点协议"],
"hidden_truths": ["终点站并不存在实体站台", "广播系统受外部势力操纵"],
"systems": {
"trust": {"enabled": True, "metric": "suspicion"},
"command": {"enabled": True, "metric": "authority"},
},
"end_conditions": [
{
"id": "retake_control",
"metric": "authority",
"op": ">=",
"value": 88,
"flag": "escape_route_open",
"flag_equals": True,
"ending_state": "escaped",
"ending_summary": "你们重掌列车控制桥,终点协议被解除,列车返航。",
},
{
"id": "riot_failure",
"metric": "suspicion",
"op": ">=",
"value": 95,
"ending_state": "trapped",
"ending_summary": "乘客暴动失控,列车陷入不可逆循环。",
},
],
},
},
]
count_row = conn.execute("SELECT COUNT(*) AS c FROM game_cards").fetchone()
is_empty = bool(count_row and int(count_row["c"]) == 0)
for card in cards:
if is_empty:
conn.execute(
"""
INSERT INTO game_cards(
card_id, title, subtitle, summary, cover_url, opening_text,
starter_goals, state_schema, content_pack, creator_user_id, is_official, created_at, updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(
card["card_id"],
card["title"],
card["subtitle"],
card["summary"],
card["cover_url"],
card["opening_text"],
json.dumps(card["starter_goals"], ensure_ascii=False),
json.dumps(card["state_schema"], ensure_ascii=False),
json.dumps(card.get("content_pack", {}), ensure_ascii=False),
None,
1,
now,
now,
),
)
else:
row = conn.execute(
"SELECT card_id, state_schema, content_pack FROM game_cards WHERE card_id=?",
(card["card_id"],),
).fetchone()
if row is None:
conn.execute(
"""
INSERT INTO game_cards(
card_id, title, subtitle, summary, cover_url, opening_text,
starter_goals, state_schema, content_pack, creator_user_id, is_official, created_at, updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(
card["card_id"],
card["title"],
card["subtitle"],
card["summary"],
card["cover_url"],
card["opening_text"],
json.dumps(card["starter_goals"], ensure_ascii=False),
json.dumps(card["state_schema"], ensure_ascii=False),
json.dumps(card.get("content_pack", {}), ensure_ascii=False),
None,
1,
now,
now,
),
)
else:
conn.execute(
"""
UPDATE game_cards
SET
title=?,
subtitle=?,
summary=?,
cover_url=?,
opening_text=?,
starter_goals=?,
state_schema=?,
content_pack=?,
is_official=1,
updated_at=?
WHERE card_id=?
""",
(
card["title"],
card["subtitle"],
card["summary"],
card["cover_url"],
card["opening_text"],
json.dumps(card["starter_goals"], ensure_ascii=False),
json.dumps(card["state_schema"], ensure_ascii=False),
json.dumps(card.get("content_pack", {}), ensure_ascii=False),
now,
card["card_id"],
),
)
def create_room(
self,
room_id: str,
host_player_id: str,
game_state: dict[str, Any],
*,
game_card_id: str = DEFAULT_CARD_ID,
is_public: bool = False,
) -> None:
now = utc_now_iso()
with self._lock, self._connect() as conn:
conn.execute(
"""
INSERT INTO rooms(
room_id, host_player_id, is_public, status, chapter, scene_id,
game_state, game_card_id, created_at, updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(
room_id,
host_player_id,
1 if is_public else 0,
game_state.get("game_phase", "lobby"),
int(game_state.get("chapter", 0)),
game_state.get("scene_id", "lobby"),
json.dumps(game_state, ensure_ascii=False),
game_card_id,
now,
now,
),
)
conn.commit()
def add_or_update_player(
self,
room_id: str,
player_id: str,
name: str,
*,
is_host: bool = False,
is_ready: bool = False,
is_online: bool = True,
user_id: str | None = None,
) -> None:
now = utc_now_iso()
with self._lock, self._connect() as conn:
conn.execute(
"""
INSERT INTO players(
player_id, room_id, name, is_host, is_ready, is_online, user_id, joined_at, last_seen
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(player_id) DO UPDATE SET
name=excluded.name,
is_host=excluded.is_host,
is_ready=excluded.is_ready,
is_online=excluded.is_online,
user_id=COALESCE(excluded.user_id, players.user_id),
last_seen=excluded.last_seen
""",
(
player_id,
room_id,
name,
1 if is_host else 0,
1 if is_ready else 0,
1 if is_online else 0,
user_id,
now,
now,
),
)
conn.commit()
def set_room_host(self, room_id: str, host_player_id: str) -> None:
with self._lock, self._connect() as conn:
conn.execute(
"UPDATE rooms SET host_player_id=?, updated_at=? WHERE room_id=?",
(host_player_id, utc_now_iso(), room_id),
)
conn.execute(
"UPDATE players SET is_host=CASE WHEN player_id=? THEN 1 ELSE 0 END WHERE room_id=?",
(host_player_id, room_id),
)
conn.commit()
def get_room(self, room_id: str) -> dict[str, Any] | None:
with self._lock, self._connect() as conn:
row = conn.execute("SELECT * FROM rooms WHERE room_id=?", (room_id,)).fetchone()
if row is None:
return None
result = dict(row)
result["game_state"] = json.loads(result["game_state"])
result["is_public"] = bool(result.get("is_public", 0))
return result
def update_room_public(self, room_id: str, is_public: bool) -> None:
with self._lock, self._connect() as conn:
conn.execute(
"UPDATE rooms SET is_public=?, updated_at=? WHERE room_id=?",
(1 if is_public else 0, utc_now_iso(), room_id),
)
conn.commit()
def list_players(self, room_id: str) -> list[dict[str, Any]]:
with self._lock, self._connect() as conn:
rows = conn.execute(
"SELECT * FROM players WHERE room_id=? ORDER BY joined_at ASC", (room_id,)
).fetchall()
players = []
for row in rows:
item = dict(row)
item["is_host"] = bool(item["is_host"])
item["is_ready"] = bool(item["is_ready"])
item["is_online"] = bool(item["is_online"])
players.append(item)
return players
def get_player(self, room_id: str, player_id: str) -> dict[str, Any] | None:
with self._lock, self._connect() as conn:
row = conn.execute(
"SELECT * FROM players WHERE room_id=? AND player_id=?", (room_id, player_id)
).fetchone()
if row is None:
return None
item = dict(row)
item["is_host"] = bool(item["is_host"])
item["is_ready"] = bool(item["is_ready"])
item["is_online"] = bool(item["is_online"])
return item
def get_player_by_user(self, room_id: str, user_id: str) -> dict[str, Any] | None:
with self._lock, self._connect() as conn:
row = conn.execute(
"SELECT * FROM players WHERE room_id=? AND user_id=? ORDER BY joined_at ASC LIMIT 1",
(room_id, user_id),
).fetchone()
if row is None:
return None
item = dict(row)
item["is_host"] = bool(item["is_host"])
item["is_ready"] = bool(item["is_ready"])
item["is_online"] = bool(item["is_online"])
return item
def update_player_ready(self, room_id: str, player_id: str, ready: bool) -> None:
with self._lock, self._connect() as conn:
conn.execute(
"UPDATE players SET is_ready=?, last_seen=? WHERE room_id=? AND player_id=?",
(1 if ready else 0, utc_now_iso(), room_id, player_id),
)
conn.commit()
def update_player_online(self, room_id: str, player_id: str, is_online: bool) -> None:
with self._lock, self._connect() as conn:
conn.execute(
"UPDATE players SET is_online=?, last_seen=? WHERE room_id=? AND player_id=?",
(1 if is_online else 0, utc_now_iso(), room_id, player_id),
)
conn.commit()
def remove_player(self, room_id: str, player_id: str) -> None:
with self._lock, self._connect() as conn:
conn.execute("DELETE FROM players WHERE room_id=? AND player_id=?", (room_id, player_id))
conn.commit()
def update_room_state(self, room_id: str, status: str, game_state: dict[str, Any]) -> None:
with self._lock, self._connect() as conn:
conn.execute(
"""
UPDATE rooms
SET status=?, chapter=?, scene_id=?, game_state=?, updated_at=?
WHERE room_id=?
""",
(
status,
int(game_state.get("chapter", 0)),
game_state.get("scene_id", "lobby"),
json.dumps(game_state, ensure_ascii=False),
utc_now_iso(),
room_id,
),
)
conn.commit()
def append_message(
self,
room_id: str,
kind: str,
content: str,
*,
player_id: str | None = None,
visibility: str = "public",
) -> None:
with self._lock, self._connect() as conn:
conn.execute(
"""
INSERT INTO messages(room_id, kind, player_id, visibility, content, created_at)
VALUES (?, ?, ?, ?, ?, ?)
""",
(room_id, kind, player_id, visibility, content, utc_now_iso()),
)
conn.commit()
def list_messages(self, room_id: str, limit: int = 200) -> list[dict[str, Any]]:
with self._lock, self._connect() as conn:
rows = conn.execute(
"""
SELECT id, room_id, kind, player_id, visibility, content, created_at
FROM messages
WHERE room_id=?
ORDER BY id DESC
LIMIT ?
""",
(room_id, limit),
).fetchall()
return [dict(row) for row in reversed(rows)]
def append_action(self, room_id: str, round_no: int, player_id: str, content: str) -> None:
with self._lock, self._connect() as conn:
conn.execute(
"""
INSERT INTO actions(room_id, round_no, player_id, content, created_at)
VALUES (?, ?, ?, ?, ?)
""",
(room_id, round_no, player_id, content, utc_now_iso()),
)
conn.commit()
def delete_room(self, room_id: str) -> None:
with self._lock, self._connect() as conn:
conn.execute("DELETE FROM actions WHERE room_id=?", (room_id,))
conn.execute("DELETE FROM messages WHERE room_id=?", (room_id,))
conn.execute("DELETE FROM players WHERE room_id=?", (room_id,))
conn.execute("DELETE FROM rooms WHERE room_id=?", (room_id,))
conn.commit()
def load_room_snapshot(self, room_id: str) -> dict[str, Any] | None:
room = self.get_room(room_id)
if room is None:
return None
players = self.list_players(room_id)
messages = self.list_messages(room_id)
return {
"room": room,
"players": players,
"messages": messages,
}
def create_user(self, username: str, password_hash: str, display_name: str) -> dict[str, Any]:
user_id = uuid.uuid4().hex
now = utc_now_iso()
with self._lock, self._connect() as conn:
admin_row = conn.execute(
"SELECT COUNT(*) AS c FROM users WHERE role='admin'"
).fetchone()
role = "admin" if int(admin_row["c"] if admin_row else 0) == 0 else "player"
try:
conn.execute(
"""
INSERT INTO users(
user_id, username, password_hash, display_name, role, avatar_color, avatar_url, theme, bio, created_at, updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(user_id, username, password_hash, display_name, role, "#0070f2", "", "dopamine", "", now, now),
)
conn.commit()
except sqlite3.IntegrityError as exc:
raise ValueError("username_taken") from exc
user = self.get_user_by_id(user_id)
if user is None:
raise ValueError("user_create_failed")
return user
def get_user_by_username(self, username: str) -> dict[str, Any] | None:
with self._lock, self._connect() as conn:
row = conn.execute("SELECT * FROM users WHERE username=?", (username,)).fetchone()
return dict(row) if row else None
def get_user_by_id(self, user_id: str | None) -> dict[str, Any] | None:
if not user_id:
return None
with self._lock, self._connect() as conn:
row = conn.execute("SELECT * FROM users WHERE user_id=?", (user_id,)).fetchone()
return dict(row) if row else None
def update_user_profile(
self,
user_id: str,
*,
display_name: str | None = None,
avatar_color: str | None = None,
avatar_url: str | None = None,
theme: str | None = None,
bio: str | None = None,
) -> dict[str, Any]:
existing = self.get_user_by_id(user_id)
if existing is None:
raise ValueError("user_not_found")
merged_name = display_name if display_name is not None else existing["display_name"]
merged_color = avatar_color if avatar_color is not None else existing["avatar_color"]
merged_avatar_url = avatar_url if avatar_url is not None else existing.get("avatar_url", "")
merged_theme = theme if theme is not None else existing.get("theme", "minimal")
merged_bio = bio if bio is not None else existing["bio"]
with self._lock, self._connect() as conn:
conn.execute(
"""
UPDATE users
SET display_name=?, avatar_color=?, avatar_url=?, theme=?, bio=?, updated_at=?
WHERE user_id=?
""",
(merged_name, merged_color, merged_avatar_url, merged_theme, merged_bio, utc_now_iso(), user_id),
)
conn.commit()
updated = self.get_user_by_id(user_id)
if updated is None:
raise ValueError("user_not_found")
return updated
def list_user_rooms(self, user_id: str, limit: int = 10) -> list[dict[str, Any]]:
with self._lock, self._connect() as conn:
rows = conn.execute(
"""
SELECT r.room_id, r.status, r.scene_id, r.chapter, r.game_card_id,
r.is_public, p.player_id, p.name AS player_name, p.is_host, p.is_online,
r.updated_at
FROM players p
JOIN rooms r ON r.room_id = p.room_id
WHERE p.user_id=?
ORDER BY r.updated_at DESC
LIMIT ?
""",
(user_id, limit),
).fetchall()
results = []
for row in rows:
item = dict(row)
item["is_public"] = bool(item.get("is_public", 0))
item["is_host"] = bool(item["is_host"])
item["is_online"] = bool(item["is_online"])
results.append(item)
return results
def list_public_rooms(self, limit: int = 40) -> list[dict[str, Any]]:
with self._lock, self._connect() as conn:
rows = conn.execute(
"""
SELECT
r.room_id,
r.status,
r.chapter,
r.scene_id,
r.game_card_id,
r.updated_at,
r.created_at,
hp.name AS host_name,
(
SELECT COUNT(*) FROM players p WHERE p.room_id = r.room_id
) AS player_count,
(
SELECT COUNT(*) FROM players p WHERE p.room_id = r.room_id AND p.is_online = 1
) AS online_count
FROM rooms r
LEFT JOIN players hp ON hp.player_id = r.host_player_id
WHERE r.is_public = 1 AND r.status IN ('lobby', 'running')
ORDER BY CASE WHEN r.status='running' THEN 0 ELSE 1 END ASC, r.updated_at DESC
LIMIT ?
""",
(limit,),
).fetchall()
return [dict(row) for row in rows]
def _is_winning_ending(self, ending_state: Any) -> bool:
ending = str(ending_state or "").strip().lower()
if not ending:
return False
return ending in {
"escaped",
"sacrifice",
"pact",
"victory",
"success",
"win",
"custom_end",
}
def list_player_leaderboard(self, limit: int = 20) -> list[dict[str, Any]]:
try:
safe_limit = max(1, min(100, int(limit)))
except (TypeError, ValueError):
safe_limit = 20
with self._lock, self._connect() as conn:
rows = conn.execute(
"""
SELECT
u.user_id,
u.username,
u.display_name,
u.avatar_color,
u.avatar_url,
u.theme,
p.room_id,
r.game_state,
r.updated_at
FROM users u
JOIN players p ON p.user_id = u.user_id
JOIN rooms r ON r.room_id = p.room_id
WHERE r.status = 'ended'
ORDER BY r.updated_at DESC
"""
).fetchall()
summary: dict[str, dict[str, Any]] = {}
for row in rows:
item = dict(row)
user_id = str(item.get("user_id", "")).strip()
room_id = str(item.get("room_id", "")).strip()
if not user_id or not room_id:
continue
entry = summary.get(user_id)
if entry is None:
entry = {
"user_id": user_id,
"username": str(item.get("username", "")).strip(),
"display_name": str(item.get("display_name", "")).strip(),
"avatar_color": str(item.get("avatar_color", "#0070f2")).strip() or "#0070f2",
"avatar_url": str(item.get("avatar_url", "")).strip(),
"theme": str(item.get("theme", "dopamine")).strip() or "dopamine",
"played_games": 0,
"wins": 0,
"total_rounds": 0,
"last_played_at": "",
"_seen_rooms": set(),
}
summary[user_id] = entry
seen_rooms = entry["_seen_rooms"]
if room_id in seen_rooms:
continue
seen_rooms.add(room_id)
game_state_raw = item.get("game_state")
try:
game_state = json.loads(game_state_raw) if isinstance(game_state_raw, str) else {}
except json.JSONDecodeError:
game_state = {}
if not isinstance(game_state, dict):
game_state = {}
entry["played_games"] += 1
if self._is_winning_ending(game_state.get("ending_state")):
entry["wins"] += 1
round_no_raw = game_state.get("round_no", 0)
try:
round_no = max(0, int(round_no_raw))
except (TypeError, ValueError):
round_no = 0
entry["total_rounds"] += round_no
updated_at = str(item.get("updated_at", "")).strip()
if updated_at and updated_at > entry["last_played_at"]:
entry["last_played_at"] = updated_at
results: list[dict[str, Any]] = []
for entry in summary.values():
entry.pop("_seen_rooms", None)
played = int(entry["played_games"])
wins = int(entry["wins"])
win_rate = round((wins / played) * 100, 1) if played else 0.0
entry["win_rate"] = win_rate
entry["score"] = wins * 100 + played * 12 + int(entry["total_rounds"])
results.append(entry)
results.sort(
key=lambda x: (
int(x.get("wins", 0)),
int(x.get("played_games", 0)),
int(x.get("score", 0)),
str(x.get("last_played_at", "")),
str(x.get("username", "")),
),
reverse=True,
)
ranked = results[:safe_limit]
for idx, row in enumerate(ranked, start=1):
row["rank"] = idx
return ranked
def list_users(self, limit: int = 500) -> list[dict[str, Any]]:
with self._lock, self._connect() as conn:
rows = conn.execute(
"""
SELECT user_id, username, display_name, role, avatar_color, avatar_url, bio, created_at, updated_at
, theme
FROM users
ORDER BY created_at DESC
LIMIT ?
""",
(limit,),
).fetchall()
return [dict(row) for row in rows]
def update_user_role(self, user_id: str, role: str) -> dict[str, Any]:
role = str(role).strip()
if role not in {"player", "moderator", "admin"}:
raise ValueError("invalid_role")
existing = self.get_user_by_id(user_id)
if existing is None:
raise ValueError("user_not_found")
with self._lock, self._connect() as conn:
if existing.get("role") == "admin" and role != "admin":
row = conn.execute("SELECT COUNT(*) AS c FROM users WHERE role='admin'").fetchone()
admin_count = int(row["c"]) if row else 0
if admin_count <= 1:
raise ValueError("cannot_remove_last_admin")
conn.execute(
"UPDATE users SET role=?, updated_at=? WHERE user_id=?",
(role, utc_now_iso(), user_id),
)
conn.commit()
updated = self.get_user_by_id(user_id)
if updated is None:
raise ValueError("user_not_found")
return updated
def list_rooms_admin(self, limit: int = 300) -> list[dict[str, Any]]:
with self._lock, self._connect() as conn:
rows = conn.execute(
"""
SELECT
r.room_id,
r.status,
r.chapter,
r.scene_id,
r.game_card_id,
r.host_player_id,
hp.name AS host_name,
r.created_at,
r.updated_at,
(
SELECT COUNT(*) FROM players p WHERE p.room_id = r.room_id
) AS player_count
FROM rooms r
LEFT JOIN players hp ON hp.player_id = r.host_player_id
ORDER BY r.updated_at DESC
LIMIT ?
""",
(limit,),
).fetchall()
return [dict(row) for row in rows]
def _build_card_payload(self, row: sqlite3.Row | dict[str, Any]) -> dict[str, Any]:
item = dict(row)
try:
item["starter_goals"] = json.loads(item.get("starter_goals", "[]"))
except json.JSONDecodeError:
item["starter_goals"] = []
try:
item["state_schema"] = json.loads(item.get("state_schema", "{}"))
except json.JSONDecodeError:
item["state_schema"] = {}
try:
item["content_pack"] = json.loads(item.get("content_pack", "{}"))
except json.JSONDecodeError:
item["content_pack"] = {}
item["is_official"] = bool(item.get("is_official", 0))
return item
def list_game_cards(self) -> list[dict[str, Any]]:
with self._lock, self._connect() as conn:
rows = conn.execute(
"""
SELECT * FROM game_cards
ORDER BY is_official DESC, created_at DESC
"""
).fetchall()
return [self._build_card_payload(row) for row in rows]
def get_game_card(self, card_id: str) -> dict[str, Any] | None:
with self._lock, self._connect() as conn:
row = conn.execute("SELECT * FROM game_cards WHERE card_id=?", (card_id,)).fetchone()
if row is None:
return None
return self._build_card_payload(row)
def get_default_card(self) -> dict[str, Any] | None:
return self.get_game_card(DEFAULT_CARD_ID)
def create_game_card(
self,
*,
title: str,
subtitle: str,
summary: str,
cover_url: str,
opening_text: str,
starter_goals: list[str],
state_schema: dict[str, Any] | None,
content_pack: dict[str, Any] | None,
creator_user_id: str,
) -> dict[str, Any]:
base_slug = re.sub(r"[^a-z0-9]+", "-", title.lower()).strip("-") or "card"
card_id = base_slug[:32]
suffix = 2
while self.get_game_card(card_id) is not None:
card_id = f"{base_slug[:28]}-{suffix}"
suffix += 1
now = utc_now_iso()
with self._lock, self._connect() as conn:
conn.execute(
"""
INSERT INTO game_cards(
card_id, title, subtitle, summary, cover_url, opening_text,
starter_goals, state_schema, content_pack, creator_user_id, is_official, created_at, updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(
card_id,
title,
subtitle,
summary,
cover_url,
opening_text,
json.dumps(starter_goals, ensure_ascii=False),
json.dumps(state_schema or {}, ensure_ascii=False),
json.dumps(content_pack or {}, ensure_ascii=False),
creator_user_id,
0,
now,
now,
),
)
conn.commit()
card = self.get_game_card(card_id)
if card is None:
raise ValueError("card_create_failed")
return card
def update_game_card(
self,
card_id: str,
*,
title: str,
subtitle: str,
summary: str,
cover_url: str,
opening_text: str,
starter_goals: list[str],
state_schema: dict[str, Any] | None,
content_pack: dict[str, Any] | None,
) -> dict[str, Any]:
existing = self.get_game_card(card_id)
if existing is None:
raise ValueError("invalid_card_id")
with self._lock, self._connect() as conn:
conn.execute(
"""
UPDATE game_cards
SET
title=?,
subtitle=?,
summary=?,
cover_url=?,
opening_text=?,
starter_goals=?,
state_schema=?,
content_pack=?,
updated_at=?
WHERE card_id=?
""",
(
title,
subtitle,
summary,
cover_url,
opening_text,
json.dumps(starter_goals, ensure_ascii=False),
json.dumps(state_schema or {}, ensure_ascii=False),
json.dumps(content_pack or {}, ensure_ascii=False),
utc_now_iso(),
card_id,
),
)
conn.commit()
card = self.get_game_card(card_id)
if card is None:
raise ValueError("invalid_card_id")
return card
def delete_game_card(self, card_id: str, user_id: str, *, as_admin: bool = False) -> None:
card = self.get_game_card(card_id)
if card is None:
raise ValueError("invalid_card_id")
if not as_admin:
if card.get("is_official"):
raise ValueError("card_delete_forbidden")
if str(card.get("creator_user_id") or "") != str(user_id):
raise ValueError("card_delete_forbidden")
with self._lock, self._connect() as conn:
active_row = conn.execute(
"""
SELECT COUNT(*) AS c
FROM rooms
WHERE game_card_id=? AND status IN ('lobby', 'running')
""",
(card_id,),
).fetchone()
active_count = int(active_row["c"]) if active_row else 0
if active_count > 0:
raise ValueError("card_in_use")
conn.execute("DELETE FROM game_cards WHERE card_id=?", (card_id,))
conn.commit()
def set_game_card_official(self, card_id: str, is_official: bool) -> dict[str, Any]:
with self._lock, self._connect() as conn:
row = conn.execute("SELECT card_id FROM game_cards WHERE card_id=?", (card_id,)).fetchone()
if row is None:
raise ValueError("invalid_card_id")
conn.execute(
"UPDATE game_cards SET is_official=?, updated_at=? WHERE card_id=?",
(1 if is_official else 0, utc_now_iso(), card_id),
)
conn.commit()
card = self.get_game_card(card_id)
if card is None:
raise ValueError("invalid_card_id")
return card