Files
snowy-yang ea346be484 fix: 自动存档几乎永不触发的真 bug——把 dirty 和 unsaved 拆开
顺着验收网页修复时发现: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。
2026-09-23 20:54:12 +08:00

341 lines
13 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""多牧场会话管理——服务器版的"世界"。
一个牧场 = 一个玩家 = 一份独立存档 + 一个独立时钟。
牧场之间互不干扰(各自的作物、金币、市场),但它们共享同一个服务器进程,
因此可以互相看到排行榜、串门看别人的农场。
并发模型
--------
* 每个牧场有一个 :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