`saves/farm.db` 一张 farms 表,farm_id 主键,**一个牧场一行**。 接口签名不变(save_state / load_state / delete_save 只多了 farm_id 参数), 调用方只改了路径语义。 设计取舍:标量列 + 整份 payload。 * day/gold/level/ap/weather/forecast/net_worth 单独成列——外部工具或 AI 一条 SELECT 就能排榜、画曲线,不必先把每份存档读进内存; * 完整状态仍整份存 payload JSON:游戏数据隔三差五长新字段,规范化成 几十张表意味着每加字段写一次迁移,而存档只做整体读、整体写, 所以结构化列管可查询,完整性交给 JSON——加字段不用改表结构。 崩溃安全升级:写入包在事务里。原来的"临时文件 + os.replace"能挡进程 崩溃,挡不住写到一半掉电;SQLite 的日志能。读取失败照旧降级开新档。 向后兼容:SAVE_VERSION=2 / MIN_SAVE_VERSION=1 不变。老 JSON 存档由 migrate_json_saves() 在服务器启动时自动导入:同名已存在就跳过(宁可不 迁不覆盖),导入成功原文件改名 *.json.migrated 留底,先写库再改名 保证幂等;坏档一律跳过——迁移不许有能力阻止服务器启动。 异常边界:StorageError = sqlite3.Error,刻意不是 OSError 子类—— 调用方原来的 except OSError 全部跟着改,否则写盘失败会穿透出去。 性能实测:PRAGMA journal_mode=WAL 是持久属性,建库时设一次就够, 每次连接重设白花 2.2ms;save_state 约 6.9ms/次,大头是 synchronous=FULL 的 fsync——不省,存档的价值就是不丢档。bench 热路径无回归。 顺手修一个真 bug:connect() 建表失败时连接会泄漏,Windows 上句柄没关 就删不掉文件,而调用方正是靠"读失败→开新档"容错的。补 close。 测试:StorageTestCase 重写(往返/版本/损坏降级/多牧场隔离/迁移), test_server 断言 *.json 的用例改查库。513 项全过,ruff/pyright 全绿。 端到端:预置 v1/v2 老档起真服务器验 22 项——自动迁移、字段还原、 list_farms() 排榜、重启后进度分毫不差,全过。
327 lines
12 KiB
Python
327 lines
12 KiB
Python
"""多牧场会话管理——服务器版的"世界"。
|
||
|
||
一个牧场 = 一个玩家 = 一份独立存档 + 一个独立时钟。
|
||
牧场之间互不干扰(各自的作物、金币、市场),但它们共享同一个服务器进程,
|
||
因此可以互相看到排行榜、串门看别人的农场。
|
||
|
||
并发模型
|
||
--------
|
||
* 每个牧场有一个 :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
|