为什么:游戏的时间(40 分钟一天、只能自然流动)本身就是唯一的行动节奏 阀门,再叠一层"每天 10 点行动点"纯属重复惩罚——浇水还要体力既不拟真 也不好玩。删掉之后,稀缺的只剩时间本身,放置玩法的定位反而更纯粹。 改动范围(引擎到界面连根拔): - config 删 START_AP/AP_COST;models 删 ap 字段与 max_ap,存档 payload 里的旧 ap 键被 from_dict 自然忽略; - engine 删 _spend_ap 与全部 8 个调用点、每日恢复逻辑与日志; available_actions 不再按行动点筛,cost 只剩金币; - storage 建表去掉 ap 列,老库在 connect() 里 ALTER TABLE DROP COLUMN 无痛迁移(实测 saves/farm.db 已自动迁完); - bot 删 AP 分支(无 AP 后 available_actions 恒可用);client/TUI/CLI/ README/帮助文本同步清理。 网页端顺带两件事: - "下一天"倒计时改为客户端本地走秒——SSE 只在有变化时推送,以前倒计时 只在推送瞬间跳变,现在每秒都在动(记基点 + 1s 定时器补齐); - 字体优化:页面字体栈补齐 MiSans/HarmonyOS Sans/PingFang,正文字号 14→15,统计数字 tabular-nums;canvas 小字统一字体栈并加粗提对比度。 测试适配:删 2 个 AP 专属用例(无 AP 可测)、改名/收敛 5 个, 532 项全绿(skip 39),ruff/pyright 干净。真机验证:老库迁移、 统计条无行动点、倒计时逐秒递减(39:04→39:02)、动作不再报 no_ap。
148 lines
5.6 KiB
Python
148 lines
5.6 KiB
Python
#!/usr/bin/env python3
|
||
"""SSE 通道演示脚本:证明"服务器主动推、客户端用 HTTP 发"这一对方向都通。
|
||
|
||
它同时是一个"外部客户端"的样板——完全不知道游戏内部结构,只知道协议:
|
||
|
||
连上 /api/stream → 收 welcome(含状态)→ 用 HTTP 发一个动作 → 收 state 推送
|
||
|
||
只用标准库(urllib 流式读长连接),所以任何有 Python 的环境都能跑。
|
||
|
||
用法:
|
||
python tools/sse_demo.py --url http://127.0.0.1:8765 --farm demo --seconds 8
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import argparse
|
||
import json
|
||
import os
|
||
import sys
|
||
import time
|
||
import urllib.parse
|
||
import urllib.request
|
||
|
||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||
|
||
from farm import net
|
||
|
||
|
||
def stream_url(endpoint: str, farm: str, token: str | None) -> str:
|
||
base = endpoint if "://" in endpoint else "http://" + endpoint
|
||
query = {"farm": farm, "name": "sse-demo"}
|
||
if token:
|
||
query["token"] = token
|
||
return f"{base.rstrip('/')}/api/stream?{urllib.parse.urlencode(query)}"
|
||
|
||
|
||
def action_url(endpoint: str, farm: str, token: str | None) -> str:
|
||
base = endpoint if "://" in endpoint else "http://" + endpoint
|
||
query = {"farm": farm}
|
||
if token:
|
||
query["token"] = token
|
||
return f"{base.rstrip('/')}/api/actions?{urllib.parse.urlencode(query)}"
|
||
|
||
|
||
def post_action(url: str, action: str, params: dict | None = None) -> dict:
|
||
"""用普通 HTTP POST 提交动作——上行不占用实时通道。"""
|
||
body = json.dumps({"action": action, "params": params or {}}).encode("utf-8")
|
||
request = urllib.request.Request(
|
||
url, data=body, method="POST", headers={"Content-Type": "application/json"}
|
||
)
|
||
with net.open_url(request, timeout=8.0) as response:
|
||
return json.loads(response.read().decode("utf-8"))
|
||
|
||
|
||
def run(endpoint: str, farm: str, token: str | None, seconds: float) -> int:
|
||
url = stream_url(endpoint, farm, token)
|
||
print(f"连接 {url}")
|
||
request = urllib.request.Request(url, headers={"Accept": "text/event-stream"})
|
||
|
||
counts: dict[str, int] = {}
|
||
acted = False
|
||
deadline = time.time() + seconds
|
||
|
||
# 读超时必须比服务器心跳(15 秒)宽裕,否则空闲时会被自己的超时打断
|
||
with net.open_url(request, timeout=45.0) as response:
|
||
while True:
|
||
try:
|
||
raw_line = response.readline()
|
||
except (TimeoutError, OSError) as exc:
|
||
print(f"(读超时:{exc})")
|
||
break
|
||
if not raw_line:
|
||
print("(服务器关闭了长连接)")
|
||
break
|
||
line = raw_line.decode("utf-8", errors="replace").strip()
|
||
if not line or line.startswith(":"):
|
||
continue
|
||
if not line.startswith("data:"):
|
||
continue
|
||
message = json.loads(line[len("data:") :].strip())
|
||
kind = message.get("type", "?")
|
||
counts[kind] = counts.get(kind, 0) + 1
|
||
|
||
if kind == "welcome":
|
||
state = message.get("state", {})
|
||
print(
|
||
f"← welcome:牧场 {message.get('farm')} · 第 {state.get('day')} 天 · "
|
||
f"金币 {state.get('gold')} · 候选动作 {len(message.get('actions', []))} 个"
|
||
)
|
||
# welcome 里就带着当前合法动作,挑第一个执行,
|
||
# 验证"两条通道改的是同一个引擎"
|
||
candidates = message.get("actions") or []
|
||
if candidates:
|
||
pick = candidates[0]
|
||
reply = post_action(
|
||
action_url(endpoint, farm, token),
|
||
pick["action"],
|
||
pick.get("params") or {},
|
||
)
|
||
acted = True
|
||
print(
|
||
f"→ POST /api/actions {pick['action']} "
|
||
f"{pick.get('params') or {}}:{reply.get('message')}"
|
||
)
|
||
elif kind == "state":
|
||
snapshot = message.get("state", {})
|
||
print(
|
||
f"← state(seq={message.get('seq')}):第 {snapshot.get('day')} 天 · "
|
||
f"金币 {snapshot.get('gold')} · "
|
||
f"在线 {message.get('online')}"
|
||
)
|
||
if acted:
|
||
# 收到了动作触发的推送,两条路都验证完了
|
||
break
|
||
elif kind == "day":
|
||
print(f"← day:进入第 {message.get('day')} 天,事件 {message.get('events')}")
|
||
elif kind == "notice":
|
||
print(f"← notice:{message.get('text')}")
|
||
else:
|
||
print(f"← {kind}")
|
||
|
||
if time.time() >= deadline:
|
||
break
|
||
|
||
print(f"\n消息统计:{counts}")
|
||
if counts.get("welcome", 0) < 1:
|
||
print("✗ 没有收到 welcome,SSE 长连接可能没建立")
|
||
return 1
|
||
if counts.get("state", 0) < 1:
|
||
print("✗ 动作之后没有收到 state 推送(引擎与推送线程之间可能断了)")
|
||
return 1
|
||
print("✓ SSE 通道正常:状态会主动下行,动作经由 HTTP 上行")
|
||
return 0
|
||
|
||
|
||
def main() -> int:
|
||
parser = argparse.ArgumentParser(description="SSE 通道演示")
|
||
parser.add_argument("--url", default="http://127.0.0.1:8765")
|
||
parser.add_argument("--farm", default="sse-demo")
|
||
parser.add_argument("--token", default=None)
|
||
parser.add_argument("--seconds", type=float, default=8.0)
|
||
args = parser.parse_args()
|
||
return run(args.url, args.farm, args.token, args.seconds)
|
||
|
||
|
||
if __name__ == "__main__":
|
||
sys.exit(main())
|