"""多牧场会话管理——服务器版的"世界"。 一个牧场 = 一个玩家 = 一份独立存档 + 一个独立时钟。 牧场之间互不干扰(各自的作物、金币、市场),但它们共享同一个服务器进程, 因此可以互相看到排行榜、串门看别人的农场。 并发模型 -------- * 每个牧场有一个 :class:`FarmEngine`,引擎内部自带 ``RLock``; * 同一牧场的多个请求由这把锁串行化(不会出现"两个人同时种同一块地"); * 不同牧场之间完全并行,互不阻塞; * 所有牧场的时钟由**一个** :class:`FarmClock` 线程统一驱动, 而不是每个牧场一个线程——牧场多了也不会有一堆线程在空转。 """ from __future__ import annotations import contextlib import os import re import threading import time from typing import Any from .. import config from ..engine import FarmEngine, GameError, tick_decision from ..storage import ( DB_NAME, StorageError, load_state, migrate_json_saves, save_state, ) DEFAULT_FARM = "default" MAX_FARM_ID = 24 # 牧场 ID 会变成存档文件名,所以只允许"安全且可读"的字符 _FARM_ID_RE = re.compile(rf"^[A-Za-z0-9_\-\u4e00-\u9fff]{{1,{MAX_FARM_ID}}}$") def validate_farm_id(raw: Any, default: str = DEFAULT_FARM) -> str: """校验牧场 ID,返回规范化后的值。""" if raw is None or raw == "": return default if isinstance(raw, (int, float)): raw = str(raw) if not isinstance(raw, str): raise GameError("bad_farm", "牧场 ID 必须是字符串") fid = raw.strip() if not _FARM_ID_RE.match(fid): raise GameError( "bad_farm", f"牧场 ID {fid!r} 不合法:只允许 1~{MAX_FARM_ID} 个汉字、字母、数字、下划线或短横线", ) return fid def validate_owner(raw: Any) -> str: if raw is None: return "" if not isinstance(raw, (str, int, float)): raise GameError("bad_param", "owner 必须是字符串") text = str(raw).strip() if len(text) > 32: raise GameError("bad_param", "owner 最长 32 个字符") return text class FarmSession: """一个独立的农场玩家。""" def __init__(self, farm_id: str, save_dir: str, tick_seconds: float, owner: str = "") -> None: self.farm_id = farm_id self.owner = validate_owner(owner) or farm_id self.save_dir = save_dir # 所有牧场共用同一个库文件,靠 farm_id 这行主键区分 self.db_path = os.path.join(save_dir, DB_NAME) self._lock = threading.RLock() self.engine = FarmEngine(tick_seconds=tick_seconds) # 立刻排好第一次推进:否则新建牧场要等时钟线程下一轮(≤ accuracy 秒) # 才有 deadline,这段时间 snapshot 里的倒计时是 None, # 客户端会误以为"已暂停 / 手动模式"。 self.engine.next_tick_at = time.time() + self.engine.tick_seconds # 由 FarmClock 维护,Publisher 读取;用普通属性即可,都是原子读写 self.dirty = True self.published_day = 0 self.last_events: list[str] = [] self.created_at = time.time() self.last_active = time.time() self.saved_at = 0.0 self.actions_taken = 0 self.load_message = "" self.engine.subscribe(self._on_change) self.load() # ------------------------------------------------------------ 生命周期 def _on_change(self) -> None: """引擎的任何状态变化都会走到这里(可能来自任意线程)。""" self.dirty = True def load(self) -> str: state, message = load_state(self.db_path, self.farm_id) if state is not None: self.engine.state = state self.load_message = message self.engine.add_log(f"服务器载入存档:第 {state.day} 天继续经营", "info") else: self.engine.add_log(f"新的牧场「{self.farm_id}」开张,欢迎光临!", "info") self.load_message = f"{message},已开新档" self.published_day = self.engine.state.day self.dirty = True return self.load_message def save(self) -> str: with self._lock: path = save_state(self.engine.state, self.db_path, self.farm_id) self.saved_at = time.time() return path def autosave(self) -> bool: """只在有变化时落盘,返回是否真的写了。""" if not self.dirty or self.save_dir == "": return False if time.time() - self.saved_at < 5: return False try: self.save() return True except (OSError, StorageError): return False def shutdown(self) -> None: # 停机时尽力落盘;写失败也不能挡住进程退出 with contextlib.suppress(OSError, StorageError): self.save() def touch(self, count_action: bool = False) -> None: self.last_active = time.time() if count_action: self.actions_taken += 1 self.dirty = True def advance_day(self, reason: str = "时间流逝") -> list[str]: with self._lock: events = self.engine.advance_day(reason=reason) self.last_events = events self.dirty = True return events def reset(self) -> None: with self._lock: self.engine.reset() self.touch(count_action=True) # ------------------------------------------------------------ 查询 def snapshot(self) -> dict[str, Any]: data = self.engine.snapshot() data["farm"] = self.farm_id data["owner"] = self.owner data["tick_seconds"] = self.engine.tick_seconds return data def summary(self, online: int = 0) -> dict[str, Any]: """给小而全的一行摘要:排行榜、`/api/world`、命令行 `status` 都用它。 走 :meth:`FarmEngine.brief_source` 而**不是** ``snapshot()``: 排行榜会对每个牧场调一次,而这里只用得上十几个标量——为它序列化全量 地块与牲畜,成本会随牧场数线性放大(32 个牧场实测 ~1.9ms)。 """ brief = self.engine.brief_source() return { "farm": self.farm_id, "owner": self.owner, "day": brief["day"], "gold": brief["gold"], "level": brief["level"], "net_worth": brief["net_worth"], "plots": f"{brief['plot_count']}/{brief['max_plots']}", "animals": brief["animal_count"], "goal_index": brief["goal_index"], "paused": brief["paused"], "online": online, "actions": self.actions_taken, "idle_seconds": round(time.time() - self.last_active, 1), } def state_message(self, seq: int = 0, note: str = "") -> dict[str, Any]: from .hub import build_state_message return build_state_message(self, seq, note) class SessionManager: """所有牧场的登记处。""" def __init__( self, save_dir: str, tick_seconds: float = config.DEFAULT_TICK_SECONDS, max_sessions: int = 64, idle_timeout: float = 1800.0, ) -> None: self.save_dir = save_dir self.tick_seconds = tick_seconds self.max_sessions = max_sessions self.idle_timeout = idle_timeout self._lock = threading.RLock() self._sessions: dict[str, FarmSession] = {} os.makedirs(save_dir, exist_ok=True) # 老版本是一牧场一个 JSON;第一次起来时把它们并进库里。 # 迁移失败不该挡住启动,所以这里只看结果、不抛。 self.migrated: list[str] = migrate_json_saves(save_dir) # ------------------------------------------------------------ 取用 def get(self, farm_id: str, create: bool = True, owner: str = "") -> FarmSession: farm_id = validate_farm_id(farm_id) with self._lock: session = self._sessions.get(farm_id) if session is not None: return session if not create: raise GameError("no_such_farm", f"牧场「{farm_id}」不存在", status=404) if len(self._sessions) >= self.max_sessions: raise GameError( "too_many_farms", f"服务器牧场数量已达上限({self.max_sessions}),请稍后再试或联系管理员扩容", status=503, ) session = FarmSession(farm_id, self.save_dir, self.tick_seconds, owner) self._sessions[farm_id] = session return session def find(self, farm_id: str) -> FarmSession | None: with self._lock: return self._sessions.get(validate_farm_id(farm_id)) def all(self) -> list[FarmSession]: with self._lock: return list(self._sessions.values()) def ids(self) -> list[str]: with self._lock: return sorted(self._sessions) def count(self) -> int: with self._lock: return len(self._sessions) # ------------------------------------------------------------ 世界视图 def leaderboard(self, online_map: dict[str, int] | None = None) -> list[dict[str, Any]]: online_map = online_map or {} rows = [s.summary(online_map.get(s.farm_id, 0)) for s in self.all()] rows.sort(key=lambda row: (-row["net_worth"], -row["day"], row["farm"])) for rank, row in enumerate(rows, start=1): row["rank"] = rank return rows # ------------------------------------------------------------ 维护 def autosave_all(self) -> int: saved = 0 for session in self.all(): if session.autosave(): saved += 1 return saved def reap(self, online_map: dict[str, int] | None = None) -> list[str]: """回收长时间无人访问的牧场:先落盘,再从内存里移除。""" online_map = online_map or {} now = time.time() dropped: list[str] = [] for session in self.all(): if online_map.get(session.farm_id, 0) > 0: continue if session.engine.paused: continue # 暂停是人为操作,说明玩家还会回来 if now - session.last_active < self.idle_timeout: continue try: session.shutdown() except (OSError, StorageError): continue # 存不下就别丢,宁可占着内存 with self._lock: self._sessions.pop(session.farm_id, None) dropped.append(session.farm_id) return dropped def shutdown(self) -> None: for session in self.all(): session.shutdown() class FarmClock(threading.Thread): """统一驱动所有牧场的游戏时间。""" def __init__(self, manager: SessionManager, accuracy: float = 0.2) -> None: super().__init__(name="farm-clock", daemon=True) self.manager = manager self.accuracy = accuracy self._stop = threading.Event() self.ticks = 0 def stop(self) -> None: self._stop.set() def run(self) -> None: while not self._stop.wait(self.accuracy): now = time.time() for session in self.manager.all(): engine = session.engine try: # tick 决策与单机 Scheduler 共用同一份纯函数,避免两处逻辑漂移 advance, engine.next_tick_at = tick_decision( engine.paused, engine.next_tick_at, engine.tick_seconds, now ) if advance: session.advance_day("时间流逝") self.ticks += 1 except Exception: # 单个牧场出错不影响其它牧场的时间 engine.next_tick_at = now + engine.tick_seconds def stats(self) -> tuple[int, float]: return self.ticks, self.accuracy