Files
SimpleFarm/farm/server/session.py
T
snowy-yang f8e093c98f feat: 实时通道改 SSE、服务器换 uvicorn+zttp、节奏改 40 分钟一天,并重建测试与优化热路径
三件事凑成一次不兼容变更:WebSocket 通道、Hypercorn 服务器、「手动推进一天」都消失了。

实时通道:WebSocket -> SSE
- hub 改为 SSE 订阅池,GET /api/stream 单向推送 welcome/state/day/notice/bye,
  250ms 节流 + 15s 心跳注释;写操作一律走 HTTP POST /api/actions,不再有 WS 回执。
- 停机三处一起修才干净:close_all() 发 bye + timeout_graceful_shutdown 兜底 +
  客户端 stop() 直接 shutdown 底层 socket(光 close() 会等读线程的锁,最长卡到下次心跳)。
- 网页版改 EventSource + fetch;tools/ws_demo.py 换成 tools/sse_demo.py。

服务器:Hypercorn -> uvicorn + zttp 的 HTTP/2
- 同一端口同时说 HTTP/1.1 与 HTTP/2。uvicorn 0.53 不再内置 h2,必须装 zttp 并显式
  http="zttp",否则 Config.load() 直接 raise。
- 信号接管改成子类覆盖 Server.handle_exit(0.53 没有 install_signal_handlers 可覆盖),
  不调 super()、不写 _captured_signals,退出时不重放信号;第二次信号强制退出。
- 入口启动前用 daemon.port_busy() 自检端口:Windows 上 SO_REUSEADDR 会让第二个实例
  「绑上」已监听端口并抢走新连接,所以判断「能不能连上」而不是「能不能 bind」。
- 修掉 clear_pid_file 的 Windows 真 bug:文件还开着就 os.remove 会失败且被
  except OSError 吞掉,导致每次正常退出都留 stale pid 文件。

节奏:一天 15 秒 -> 40 分钟,并整体移除「休息」
- DEFAULT_TICK_SECONDS=2400,MAX_TICK_SECONDS 放宽到 86400(旧上限会把 40 分钟夹回去)。
- engine.sleep / SLEEP_COOLDOWN / /api/sleep / TUI 的 [N]休息 / 网页版按钮全部删掉,
  时间只能自然流动;机器人改为 wait_for_next_day(timeout),CLI 加 bot --wait。
- 时长显示统一走 models.format_duration(),不再出现 2399.6s 这种写法。

键盘:修复方向键/空格/Tab/ESC 全部静默失效
- keys.py 的具名键统一小写并成为唯一来源,tui.py 一律引用 keys.XXX 而不手写字符串
  (历史 bug 就是 Windows 侧产大写 UP、TUI 里写小写 up,字母键正常所以很难发现)。
- 顺带修 POSIX 路径一次 os.read 只取首字节、其余按键静默丢弃的老毛病。

测试:重建 500 项
- test_engine 110 / test_actions 35 / test_server 131(skip 35) / test_tui_keys 75 /
  test_bot 36 / test_client 113。未安装的第三方依赖用 skipUnless 自动跳过,其余照跑,
  所以是全绿而不是靠跳过伪装。
- 被测试逼出两处真隐患并修掉:RemoteFarm._normalize() 必须「先补 scheme 再削尾斜杠」
  (顺序反了畸形端点会溜到系统代理、报一个和真实原因无关的 502);StateView 空视图要
  预置 plots/animals,否则连不上服务器时读 state.plots 直接 AttributeError。

优化:先量后改,依据与前后数字都在 tools/bench.py
- 新增 engine.brief_source():brief/summary/动作回执只要十几个标量,不再为它们序列化
  整局;to_dict 里 net_worth 只算一次。brief -76%、summary -77%、leaderboard(32) -78%、
  GET /api/world -31%、POST /api/actions?full=1 -16%。
- 拆掉三个巨型函数:create_app 475->32 行(_Services + _register_*)、
  build_parser 与 available_actions 分组、__main__.main 160->7 行。
- 消除重复:等级门槛 5 处 -> _is_locked/_require_level/_newly_unlocked;hub 的
  broadcast/broadcast_all/close_all -> _push_many;daemon.probe/world -> _get_json;
  main.py 的 _add_endpoint_args/_leaderboard_lines。
- tools/bench.py 作为长期性能守卫(含调用计数器),谁把轻量路径改回 snapshot() 会立刻报警。

门禁:ruff format/check 全绿、pyright 0 错误、500 项测试 OK(skip 35)。
2026-09-19 12:55:45 +08:00

317 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 load_state, 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
self.path = os.path.join(save_dir, f"{farm_id}.json")
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.path)
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.path)
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:
return False
def shutdown(self) -> None:
# 停机时尽力落盘;写失败也不能挡住进程退出
with contextlib.suppress(OSError):
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)
# ------------------------------------------------------------ 取用
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:
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