Files
SimpleFarm/farm/server/session.py
T
snowy-yang bc3ae96a38 refactor: 存档层从「一牧场一个 JSON」换成 SQLite(单库单表)
`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() 排榜、重启后进度分毫不差,全过。
2026-09-19 19:04:30 +08:00

327 lines
12 KiB
Python
Raw 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
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