Files
SimpleFarm/farm/server/session.py
T
snowy-yang fcd4cb4649 style: 引入 ruff,474 项告警清零
配置见 pyproject.toml:
- line-length=100:贴着现有代码定。若用默认的 88 会重排 122 行,
  噪音淹没真正有价值的修复。
- 启用 E/W/F/I/UP/B/C4/SIM/RET/PERF/RUF;**不**启用 PTH。因为本项目的路径
  多是"字符串身份"——要塞进 subprocess 命令行、写进 JSON 存档、打印给用户,
  全换 pathlib 只是包一层再拆一层,换来 70 处纯风格改动。
- 忽略 RUF001/002/003:中文全角标点是正确写法,不是"歧义字符"。
- 忽略 PERF203:剩下两处都是"逐项容错"语义(逐个牧场存档要立刻上报失败、
  收获全部要跳过失败地块)。把 try 提到循环外会毁掉"一项失败不影响其它项"
  这个行为,是错的优化。

自动修复 358 项:Dict/List/Optional 换成 PEP 585/604 写法、导入排序、
失效的 noqa、空占位 f-string 等。

手工修复的(这些是真问题,不是风格偏好):
- B904 x9:except 中 raise 补异常链。刻意区分——网络/IO 失败用 from exc 保留
  根因;参数解析失败用 from None,因为底层 ValueError 对玩家只是噪音。
- RUF012 x2:StateView 的 plots/animals 原本是**类级共享可变列表**,
  改成在 __init__ 里初始化(apply({}) 会提前返回,所以必须显式摆好)。
- F841:TUI 里一个赋值后再没用过的死变量。
- SIM105 x11:try/except/pass 统一改成 contextlib.suppress。
- SIM115:日志句柄改用 with。Popen 已把 fd 复制给子进程,父进程可安全关闭。
- E501 x7:拆超长行;main.py 那处顺手提取变量,避免超长嵌套表达式。
- E741:把 l/r 改成 left_line/right_line。
- 其余:RUF046 / PERF401 / RET504 / RUF005 / UP031 / B007。

ruff format 重排 24 个文件。

验证:97 项测试全绿。
2026-09-18 21:18:22 +08:00

313 lines
11 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
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)
# 由 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` 都用它。"""
snap = self.engine.snapshot()
return {
"farm": self.farm_id,
"owner": self.owner,
"day": snap["day"],
"gold": snap["gold"],
"level": snap["level"],
"net_worth": snap["net_worth"],
"plots": f"{snap['plot_count']}/{snap['max_plots']}",
"animals": snap["animal_count"],
"goal_index": snap["goal_index"],
"paused": snap["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:
if engine.paused:
# 暂停时把下一次推进顺延,恢复后仍有完整的一个 tick
engine.next_tick_at = now + engine.tick_seconds
continue
deadline = engine.next_tick_at
if deadline is None:
engine.next_tick_at = now + engine.tick_seconds
continue
if now >= deadline:
session.advance_day("时间流逝")
engine.next_tick_at = now + engine.tick_seconds
self.ticks += 1
except Exception:
# 单个牧场出错不影响其它牧场的时间
engine.next_tick_at = now + engine.tick_seconds
def stats(self) -> tuple[int, float]:
return self.ticks, self.accuracy