日志系统 · 接口契约(施工图 v1)
本文是唯一真相源。前后端各自实现时,字段名/类型/语义必须与此处完全一致。 需求来源:界面需要一个「日志」页,用于查看各种汇报 —— 1 升级汇报(谁升级了、升了多少级);2 任务汇报(每个任务完成记一行,胜负入库)。 点击任务条目 → 打开浮窗显示详情:(a) 每人获得的资源 (b) 每人获得的经验 (c) 整个任务过程的小说式描述。
0. 三条硬约束
- 后端是权威:日志由后端生成、后端持有、后端存档。前端只读不写。
- 详情不进快照:快照每秒广播一次,任务详情含长叙事文本,塞进去是纯浪费。
快照只带最近 80 条摘要(无 detail),详情走独立接口按需拉取。
- 零三方依赖:
journal.py只能用标准库(与gamenet其余逻辑模块一致)。
1. 条目分类(kind)
| kind | 含义 | 可点开详情 |
|---|---|---|
levelup | 门人升级(境界突破)—— 需求1 | 否(信息已在一行内) |
building | 建筑升阶 | 否 |
task | 任务结算 —— 需求2 | 是 |
2. 摘要结构(进快照 + /api/journal)
{
"id": 7, // int,自增,从 1 开始,全局唯一(跨存档不复用)
"kind": "task", // "levelup" | "building" | "task"
"tick": 340, // int,作品 tick
"clock": "第2日 · 午时", // str,作品内时刻(用 clock.stamp()["text"])
"title": "护送银两车", // str,渲染时加粗的主体:升级=人名 / 建筑=建筑名 / 任务=任务名
"line": "镖车安然抵镇,全队无伤", // str,一行正文(已含结论与数值,前端不再拼)
"success": true, // bool | null —— 仅 task 有意义;其余为 null
"has_detail": true // bool,true 才渲染成可点击
}
渲染建议:【时刻】 <b>title</b> — line,success 决定行的色彩(成=朱红 / 败=青绿,注意这是中式配色非股票红绿)。
3. 详情结构(GET /api/journal/{id})
{
"id": 7,
"kind": "task",
"title": "护送银两车",
"type": "escort", // escort | bounty | inner
"subtype": "护镖",
"success": true,
"clock": "第2日 · 午时",
"tick": 340,
"duration": 120, // 实际耗时(tick)
"difficulty": 1,
"roll": 0.42, // 判定骰(0~1)
"fail_chance": 0.15, // 当时失手率
// ---- (a) 每人获得的资源:按人头均摊的展示值(实际入公库,见 resources)----
// ---- (b) 每人获得的经验:真实值,已逐人入账 ----
"members": [
{
"char_id": "chr_123",
"name": "张三",
"level": 4, // 任务开始时等级
"level_to": 5, // 任务结束时等级(没升则与 level 相同)
"realm": "菜鸟",
"realm_to": "菜鸟",
"exp": 80.0, // 本次获得经验(真实)
"loyalty": 2.0, // 忠诚变动(正=涨,负=跌)
"share": { "silver": 66.7 }, // 资源均摊展示值;失败时为空 {} 或扣减份额
"leveled": true
}
],
"resources": { "silver": 200, "exp": 80 }, // 门派**实际入库**总量(真值)
"penalty": {}, // 失败时的实际损失(真值)
"exp_total": 80.0,
"share_note": "资源按参与人数均摊展示,实际入本门公库", // 固定文案,消除歧义
// ---- (c) 小说式过程描述:4 段,每段 30~90 字 ----
"narrative": [
"辰时三刻,镖车碾过最后一道路障……",
"行至黑风坳,林间鸟雀骤静……",
"张三暴起,一柄单刀截住来路……",
"日暮时分,镖旗插回镇口。"
],
"outcome_line": "此行顺遂,无一损伤。" // 收尾一句
}
关键语义澄清(别写错):
resources= 门派公库真正加的量(沿用现有_tick_tasks逻辑,exp/loyalty从资源里剔除)。members[].share是展示层的均摊,不是真的分给个人。share_note必须在 UI 上出现,避免玩家误解。members[].exp是真实加给该门人的经验(现有逻辑c.gain_exp(exp))。
4. 后端实现要点
4.1 新文件 web/backend/gamenet/journal.py
MAX_ENTRIES = 500 # 超出丢最旧
MERGE_WINDOW_TICKS = 120 # 同一门人升级合并窗口(连升归一条)
class Journal:
def __init__(self) -> None: ...
# 写入
def add_levelup(self, name, char_id, level_from, level_to, tick, clock, realm_to) -> dict
def add_building(self, name, level, tick, clock) -> dict
def add_task(self, detail: dict, tick: int, clock: str) -> dict
# 读取
def recent(self, limit: int = 80, kind: str = "") -> list[dict] # 摘要,倒序(新→旧)
def get(self, entry_id: int) -> dict | None # 含 detail 全量
def to_dict(self) -> dict
def from_dict(self, d: dict) -> None
def narrate_task(task, members, success, rng) -> tuple[list[str], str]
"""返回 (4 段叙事, 收尾句)。按 task.type 分模板池:escort / bounty / inner。"""
4.2 升级记录(当前完全缺失,是本次重点)
现状:Character.gain_exp() 返回 bool,但三处调用全把返回值丢了,所以"谁升级了"根本没发生过记录。
改法(不改 gain_exp 签名,避免碰存档格式与既有断言):
# core/models.py
def train_tick(self, dt: float) -> list[dict]:
"""返回值从 None 改为「本 tick 升级名单」:
[{"char_id":..., "name":..., "level_from":..., "level_to":..., "realm_to":...}]
"""
engine.step_world()同样改为返回该列表(它现在返回 None)。
⚠️ step_world 里只有玩家门派的升级要记;AI 门派走 f.grow(),不要记进玩家日志。
engine.tick()收集后写self.journal.add_levelup(...)。_tick_tasks()里的c.gain_exp(exp)同样要抓取升级。_cmd_upgrade()(建筑升阶)追加journal.add_building(...)。
4.3 engine 接线
__init__/new_game():self.journal = Journal()snapshot()增加字段(只有两个数字,刻意不带摘要列表):
"journal": { "total": 137, "latest_id": 137 }
前端拿 latest_id 与本地已读编号的差值算未读角标(编号自增不跳号,差值即未读数); 发现 latest_id 变化才去拉列表 —— 平时一个请求都不发。
to_save()/load():带上"journal": self.journal.to_dict(),from_dict读回。
存档要能往返——读档后日志必须在。
- 保留原有
_emit("task_completed", ...)(前端 toast 依赖它,别删)。
4.4 server.py 新增只读接口
GET /api/journal → {"total": n, "entries": [摘要...]}
可选 query: ?limit=200&kind=task
GET /api/journal/{entry_id} → 详情全量;不存在返回 404 {"ok": false, "msg": "..."}
5. 前端实现要点
index.html:.tabs增<button class="tab" data-tab="journal">日志</button>;
.tab-body 增 <section class="tab-pane" id="pane-journal"></section>; 新增日志详情浮窗(独立于 #modal,别和战报抢同一个容器):
<div id="journal-modal" class="modal hidden">
<div class="modal-box wide">
<div class="modal-head"><span id="jm-title">任务详情</span><button id="jm-close">✕</button></div>
<div class="modal-body" id="jm-body"></div>
</div>
</div>
net/api.js:export const getJournal = (q) => jget('/api/journal' + (q || ''));
export const getJournalDetail = (id) => jget('/api/journal/' + id);
ui/hud.js:renderJournalPane():筛选条(全部 / 升级 / 任务)+ 条目列表。- 数据按需拉,不走快照:
ensureJournalData()进页面就拉最新(?limit=200)。
⚠️ 不要做成「只在快照说有新条目时才拉」 —— 见 7.8 节,那样会静默失效。 syncJournalSignal(jn) 只用来维护未读角标 + 停留在页面上时的自动追加。 _loadJournal() 必须防重入 —— 面板每秒重绘,不防会打出一串并发请求。 拉取失败要保留旧列表并置 _jStale=false,否则每秒重绘会反复重试把后端打爆。
- 空态要分三种成因:正在拉 / 拉不到 / 真没有 —— 不然三者长得一样,
会把"正在加载"误判成"功能坏了"。
- 点击
has_detail条目 →openJournalDetail(id)→ fetch → 渲染浮窗三段式(详情加缓存)。 - 未读角标:
max(0, latest_id − 已读编号),已读编号存 localStorage(后端不管未读)。
首个快照要把开局就有的积压直接标已读,否则一进作品就顶着「未读 25」, 玩家会以为漏了什么。
- 必须遵守的既有约定:
- 面板每秒随 tick 重绘 → 新面板也要保住
scrollTop(render()已有机制,别破坏)。 - 判定界面可见性必须用
getBoundingClientRect(),innerText对隐藏元素照样返回文本。 - 配色沿用现有 CSS 变量(
--paper/--ink/--red/--gold/--jade),宣纸+朱红调子,禁止另起一套。
6. 叙事文本风格要求((c) 的核心,别糊弄)
四段结构,像章回小说的一小节:
| 段 | 内容 | 示例 |
|---|---|---|
| 1 出发 | 时辰 + 天候 + 队伍动向 | 「辰时三刻,六骑出镇,镖旗在风里扯得笔直。」 |
| 2 途中 | 环境异动 + 不祥征兆 | 「行至黑风坳,林间鸟雀骤静,张三按住刀柄。」 |
| 3 交锋 | 点名某个门人的高光/失手瞬间 | 「李四抢身而出,一记扫堂腿把来人掀翻在泥里。」 |
| 4 结局 | 收束 + 赏罚落定 | 「日暮归镇,镖银一枚不少。」 |
要求:
- 必须点名门人(从
members里取真名),这是"有人味"的关键。 - 按
type分池:escort(镖车/山道/劫匪)、bounty(追踪/围捕/匪首)、inner(山门/巡查/内务)。 - 成败要改变叙事走向:胜利段的交峰是压制,失败段是溃散(且点出代价)。
- 用传入的
rng(guild.rng)选题,保证确定性可复现。 - 每类池子至少 4 种开局、4 种途中、5 种交锋、3 种胜局 / 3 种败局,避免读十次全一样。
7. 实现记录(2026-09-17 落地)
已按本契约实现完毕。以下是施工图没预见到、实测才暴露的七个问题与修法,后来者改这块前请先读:
7.1 途中段与交锋段不能用同一个占位符
两段都写 {lead} 时,会连续两段点名同一个人(实测读起来极单调)。 现改为:途中段用 {second}(同行另一人),交锋段用 {lead}(出手的人)。 "先察觉异动的同伴"与"出手的人"各有人,才像一支队伍在行动。
7.2 人数必须中文化
"3人默不作声地验过鞍具"不如"三人"。{n} 传入的是中文数字(1~10 映射)。
7.3 启程与归门必须用两个时刻
首版直接用结算时刻当"出发时刻",于是写出「酉时动身…掌灯时分抵镇」这种 同一时刻出发又抵达的死时间线。现 active_tasks 增 start_tick / start_clock, narrate_task(..., start_clock=) 单独接收启程时刻。 (旧存档无此字段 → 传空串,自动退回用结算时刻。)
7.4 升级合并窗口不能贪
MERGE_WINDOW_TICKS 初值给了 120,实测把每人一整天的突破吞成孤零零一条 「连升 5 级」—— 日志看着是空的,还丢掉了"什么时候升的"。 现值为 5。理由:单次 gain_exp 内部的多级连跳本来就被 level_from→level_to 记录了,不需要靠窗口合并;窗口只需吞掉"同一瞬间 的两次调用"(一个 tick 内既有操练又有任务结算)。
7.5 六个面板共用一个滚动容器
.tab-body 是所有 pane-* 的公共滚动区。从长列表切到日志页会停在半空, 页头与筛选条全看不见(实测截图抓到)。修法:切页时把 tab-body.scrollTop 归零。
7.6 日志列表不能进每秒广播的快照(最凶的一个)
快照原设计是「附带最近 80 条摘要」。实测该字段占掉快照 65% 的体积(26 KB / 40 KB)。 而快照每秒广播、30× 倍速下约每秒二十次 → JSON 序列化同步阻塞事件循环, 连 /api/state 自己都被拖到超时。症状极具迷惑性:端到端测试反复卡死在同一个位置 (同一行、同一报错),而后端日志全绿(请求根本没进到处理函数)。
修法:快照里的 journal 只留 {total, latest_id}(约 30 B), 列表改由前端进日志页时拉 GET /api/journal。
📌 2026-09-19 起时间控制(暂停 / 倍速)已整体删除,上面「30× 倍速」是当时的实测条件。 结论与修法不受影响 —— 快照体积 / 同步阻塞事件循环是与倍速无关的结构性问题。
教训:把「偶发查阅的数据」放进「持续广播的信道」是结构性错配, 靠裁字段治不了本,得换信道。前端侧配套:_loadJournal() 防重入(面板每秒重绘)、 拉取失败置 _jStale=false 并保留旧列表(否则每秒重绘会反复重试打爆后端)。
7.7 浮窗必须一起进 ESC 与关窗清理
新增 #journal-modal 后,ESC 只关战报会留下日志详情挂在屏幕上,按了像没反应。 且 e2e 里一旦浮窗没关,后续点击全被 intercepts pointer events 拦掉, 报错却显示"按钮点不到" —— 极难定位。故 e2e 加了 dismissOverlays() 兜底并在 每步点击前调用。
7.8 列表刷新不能只依赖快照信号
即使把列表请出了快照,还有一个陷阱:把刷新做成「快照的 latest_id 变了才去拉」。 高倍速下 WebSocket 会断连(30× 攒料时实测),断了就收不到快照 → _jStale 永远不被置位 → 列表冻在几 min ago的内容,而页面不报任何错。
📌 倍速档已不存在(见 7.6 的注),但"只依赖快照信号会静默冻住"这个结论照旧成立 —— 断连、切Admin、长帧都可能让快照停发,所以这类刷新必须有独立触发源。
端到端实测症状:进日志页看到 36 条全是升级,一条任务汇报都没有; 但同一次运行里「筛选 · 任务」却有 2 条 —— 因为那一步晚了 2 秒,WS 恰好重连、 把迟到的更新补上了。这种「同一次运行里前后数据不一致」最容易看走眼。
修法:进页面就无条件拉一次。 日志页是低频访问,多打一个本地请求, 远比「让玩家看到过期内容」划算。快照信号退居为「停留在页面上时的自动追加」。
推广:任何依赖 WS 推送来触发刷新的界面,都要考虑「WS 断了怎么办」。 低频页面用「进入即拉」;高频页面才需要长连接 + 重连补拉。
7.9 端到端里别用固定 sleep 等异步
日志列表与详情都是异步 fetch。e2e 原写 waitForTimeout(500) / (1800), 一旦后端稍慢就撞上竞态,报出「0 条汇报」「浮窗弹出」这类假失败—— 而代码其实是对的。已改为 waitForFunction 等具体条件(.jrow 出现 / #jm-body 含「行程纪要」),并在超时后仍让断言如实报告。
推广:e2e 里凡是要等一个「数据到达后的渲染结果」,就用条件等待,不用时间等待。
7.10 同一个文件不要并行发起多处编辑
本轮实测踩到:对 hud.js 一次性并行提交了 4 处 Edit,其中 import 那处丢掉了 —— 文件里的 getJournal 调用还在,定义却没进来。构建照常成功(打包器不校验未定义标识符), 运行时才抛 getJournal is not defined,被前端 catch 成一句 「案卷房暂不可达」,页面看起来一切正常。
同一轮里 docs/ 也出现过一次「改完却被后一次编辑覆盖回原样」。 改同一文件的多处,串行提交;改完 grep 关键符号确认落地。 (构建成功 ≠ 代码正确 —— 打包器允许运行时未定义引用。)
7.11 「指令回执」与「WS 事件」会重复播报同一件事
本轮 e2e 抓到:升级 toast 是 「山门」升至 2 级 / 「山门」升至 2 级,弹了两遍。 根因是前端有两条并行链路都在播报:
| 链路 | 位置 | 内容 |
|---|---|---|
| HTTP 指令回执 | hud.handleAction 末尾 if (res?.msg) toast(res.msg) | 「「山门」已动工升阶,预计 42 息(1 → 2 级)」 |
| WS 事件流 | main.js 的 net.on('events') | building_upgrade_started → 不播报(同下) |
📌 2026-09-22 变了一次事件口径(升阶改为要等时间 ⇒ 动工与竣工成了两件事):
| 时机 | 事件 | 谁播报 |
|---|---|---|
| 点「升阶」(同步) | building_upgrade_started | 指令回执(「已动工升阶,预计 N 息」) |
| 点「营造」(同步) | building_started | 指令回执(「已动工「X」,预计 N 息」) |
| 升阶竣工(异步) | building_upgraded | 日志簿(那一行「升阶至 N 级」),事件路径不 toast |
| 新盖竣工(异步) | building_completed | 日志簿(「营造竣工」),事件路径不 toast |
⚠️ 别因为"看着没人用"就删 building_upgrade_started 那个空分支 —— 它的作用就是 明确表态"这条是同步产生的、不该在事件里 toast",正是下面那条规矩要求的东西。
一次 POST /api/command 既返回 result.msg,又广播 events —— 于是建造 / 升级 / 征讨全都弹两遍(征讨两条文案不同:「征讨「玄武匪」得胜」+「战报:玩家门派 胜」, 更像两个事件,其实是一次)。
定则:指令的即时反馈由「指令回执」统一负责;WS 事件路径只驱动状态副作用 与真正的异步播报。 据此 main.js 里 building_completed / building_upgraded / battle_finished 三个分支不再自行 toast,只保留 forceMapNext(及战报弹窗)。
唯一必须留在事件路径的是 task_completed —— 任务完成由 tick 异步推进触发, 没有对应的指令回执,删了就没播报了。 新增事件时先问一句:它是不是某条指令同步产生的? 是 → 别在事件里 toast。
7.12 面板每秒全量重绘会「吞点击」——用幂等落笔治
六个面板每收到一个快照(每秒)就整体 innerHTML = ...。玩家点按钮那一刻,若正好 撞上重绘,点击落在一个已被销毁的节点上 → 事件不触达 → 指令一条都没发出, 而页面毫无报错、按钮看着也能点。
复现特征(e2e 里表现为两项顽固假红): 建造生效 Lv1→Lv1、接取任务 0→0 —— 副作用没发生,但也没有任何错误。 归因办法:监听 POST /api/command 网络请求,看点击后到底有没有指令发出。 本轮实测「0 条」→ 铁证是吞点击,不是后端拒绝。
治法是幂等落笔:内容没变就不碰 DOM,节点身份保持稳定,点在同一个节点上。
// hud.js
paint(pane, html) {
if (!pane) return;
if (pane.__painted === html) return; // 内容未变 → 不碰 DOM
pane.__painted = html;
pane.innerHTML = html;
}
六处面板落笔由 $(...).innerHTML = ... 改为 this.paint($(...), ...)。 新写面板时一律走 paint(),不要直接赋 innerHTML。 (判据:任何「每秒重绘 + 内含可点元素」的容器都必须幂等。)
7.13 固定 sleep 读「异步结果」= 一整类假红(本轮清掉三处)
paint() 修好后 e2e 一度在 42~44 之间漂移,失败点在轮次之间游走。 逐条查下来全是同一类毛病:用固定 sleep 去读一个异步才到位的结果。 产品每次都对,是断言读早了。
| 位置 | 原写法 | 真相 | 改法 | |
|---|---|---|---|---|
| 升级播报 | 读完 waitLevel 立刻 readToastLog | 等级涨 ≠ toast 已入 DOM(await this.send() 拿到 res 才 toast) | 轮询等 /动工/ 出现(2026-09-22 起升阶是动工、要等时间,回执不再说「升至」;正则写成 `/升至\ | 动工/` 两版都收) |
| 招募门人 | clickIn 内固定等 2 秒读快照 | 快照每秒才推一次,读到旧值 | 轮询等 members 真涨 | |
| 战报弹窗 | 固定等 1.5 秒查 #modal | 战报是异步结算的(征讨只是发起,战斗由 tick 推进结束) | 轮询等弹窗出现(最多 10 秒) |
铁证(招募那处):同一轮存档时门人是 6,断言那一刻读到 5 —— 功能早就成功了。
判据沉淀:
- 症状漂移 = 断言问题,不是产品问题。 产品 bug 是确定性的;失败点在轮次间游走
就先去查测试脚本。
- 凡是「发指令 → 等结果」,结果一律轮询,绝不定时。 需要等的量:
DOM 出现、快照字段变化、异步事件(战斗结算)。
- 反过来也要守住:「副作用类断言通过、反馈类断言失败」→ 基本可判定是读取时机。
本轮 读档·现场还原 ✓ / 读档·日志簿还原 ✓,唯独 读档·有反馈 ✗ —— 铁证。
同时别把「测不了」报成「测失败」:并发高峰时 /api/command 会 15s 超时, 升级根本没发生,播报计数就无从谈起。哨兵断言只认「≥2 条」= 真回归; 读到 0 条时标注为「未采集到(并发环境)」而不报红 —— 正例交给 web/tools/probe_upgrade_toast.cjs(20 秒出结果,不吃整轮 e2e 的并发噪音)。
8. 验收结果(2026-09-17)
| 项 | 结果 |
|---|---|
gamenet.selftest | 135 / 135 通过 |
日志链路专项(_tmp/verify_journal.py) | 32 / 32 通过 |
端到端(web/tools/e2e.cjs) | 45 / 45 通过,页面控制台零错误 |
| 视觉验收 | web/tools/shot_journal.cjs → render/web/journal/*.png(4 张) |
播报去重专项(web/tools/probe_upgrade_toast.cjs) | 点一次升阶 → 恰好 1 条 toast,页面零错误 |
e2e 关键项实测值:建造 建筑 2→3 / 银两 800→600;升级 Lv1→Lv2; 接取任务 在场任务 0→1;日志面板 50 行 / 任务 2 / 升级 48 / 筛选 3; 详情浮窗三段俱全(各人资源 / 各人经验 / 行程纪要,417 字符); 存读档 日志簿 50→50 往返还原。
播报去重实测(probe_upgrade_toast.cjs):
⚠️ 下面是 2026-09-21 那轮的输出,只作历史参考。2026-09-22 起升阶改成 「动工 + 等 upgrade_time() 息」⇒ 回执变成「已动工升阶,预计 N 息(L → L+1 级)」、 且等级当场不变(探针的等级断言已同步反向改成"等级没变")。
已建成 3 座,其中可升阶 2 座
目标建筑:dadian(Lv1)
升级后等级:Lv1 → Lv2
本次点击的 toast(共 1 条):
1. 「大殿」升至 2 级
✅ 恰好 1 条播报 —— 双 toast 已消除
专项覆盖:升级记录(改造前永久丢失)/任务详情三段内容/存读档往返 (含"缺 journal 字段的旧存档"兼容)/快照摘要不含 detail 且限 80 条/边界。
已知取舍:任务详情里的 members[].share 是按人头均摊的展示值, 资源实际入本门公库(沿用改造前的数值行为,逐字未改)。界面上以 share_note 明示,避免玩家误解为"分给个人"。
环境提醒:本机有并发会话同时跑 Playwright。负载高时首屏要加载 45 个 GLB
- SwiftShader 软渲染,
page.goto/waitForFunction会因资源竞争超时,
表现为 [fatal] page.waitForFunction: Timeout。判据:先 curl -H "Connection: close" 测 /api/health,若 >1 秒即为并发高峰,等回落(<0.02s)再跑。 这不是产品缺陷。