顺着验收网页修复时发现:default 牧场根本没写进库,内存里每次 重启都是新档。根因是 session.dirty 一个标志位伺二主: * Publisher 推送循环每 ~250ms 就把 dirty 清掉(无人在线时直接清, "没人在看就不做序列化,省 CPU"); * autosave() 每 60s 才看一眼 dirty。 dirty=True 的窗口只有约 250ms,自动存档撞上它的概率约 0.4%—— 等于永不存盘。进度只在手动存档/优雅停机时落盘,进程被强杀全丢。 修法:新增 session.unsaved 专管「有变化没落盘」,_on_change / load / touch / advance_day 置位,只有成功的 save() 清位;autosave() 改看 unsaved;dirty 继续只管推送节流,hub.py 的推送循环一字未动。 两只旧测试直接操纵 dirty 驱动 autosave,固化了被修掉的耦合,改成 unsaved 表达意图;新增回归测试:推送循环清了 dirty 也要照常落盘、 没有新变化不重复写库。 真机验证:0 客户端在线纯 HTTP 种一块地,70 秒内库行更新(金币 120→112,种子钱都对得上)。515 项测试 OK。
341 lines
13 KiB
Python
341 lines
13 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
|
||
# unsaved 与 dirty 分开:dirty 管推送节流(Publisher 会频繁清它),
|
||
# unsaved 管「有变化还没落盘」,只有成功的 save() 才清。
|
||
# 之前两个概念共用 dirty,而推送循环 ~250ms 就清一次,自动存档
|
||
# 60s 才看一眼——窗口太小,autosave 几乎永远不触发。
|
||
self.unsaved = 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
|
||
self.unsaved = 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
|
||
self.unsaved = 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()
|
||
self.unsaved = False
|
||
return path
|
||
|
||
def autosave(self) -> bool:
|
||
"""只在有未落盘的变化时写,返回是否真的写了。
|
||
|
||
看的是 ``unsaved`` 而不是 ``dirty``:dirty 是推送节流用的,
|
||
Publisher 每 ~250ms 就清一次,拿来当存盘依据等于永不存盘。
|
||
"""
|
||
if not self.unsaved 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
|
||
self.unsaved = 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
|
||
self.unsaved = 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
|