Compare commits

..
3 Commits
Author SHA1 Message Date
snowy-yang d1692d7b10 docs: 记忆与验收技能跟上——存档改动必须真机验自动落盘
单元测试抓不住 autosave 失效(dirty 被高频清零这件事测试里复现
不了真机时序),所以技能里加一条:动了存档/会话层,必须 0 客户端
在线做一个动作、等 60s、直接查库那一行有没有更新。
2026-09-23 20:54:13 +08:00
snowy-yang ea346be484 fix: 自动存档几乎永不触发的真 bug——把 dirty 和 unsaved 拆开
顺着验收网页修复时发现:default 牧场根本没写进库,内存里每次
重启都是新档。根因是 session.dirty 一个标志位伺二主:

* Publisher 推送循环每 ~250ms 就把 dirty 清掉(无人在线时直接清,
  "没人在看就不做序列化,省 CPU");
* autosave() 每 60s 才看一眼 dirty。

dirty=True 的窗口只有约 250ms,自动存档撞上它的概率约 0.4%——
等于永不存盘。进度只在手动存档/优雅停机时落盘,进程被强杀全丢。

修法:新增 session.unsaved 专管「有变化没落盘」,_on_change / load /
touch / advance_day 置位,只有成功的 save() 清位;autosave() 改看
unsaved;dirty 继续只管推送节流,hub.py 的推送循环一字未动。

两只旧测试直接操纵 dirty 驱动 autosave,固化了被修掉的耦合,改成
unsaved 表达意图;新增回归测试:推送循环清了 dirty 也要照常落盘、
没有新变化不重复写库。

真机验证:0 客户端在线纯 HTTP 种一块地,70 秒内库行更新(金币
120→112,种子钱都对得上)。515 项测试 OK。
2026-09-23 20:54:12 +08:00
snowy-yang 305ef0dcbe fix: 网页播种/重置在嵌入预览里没反应——弃用 window.prompt/confirm
播种按钮原来弹 window.prompt 选作物,重置弹 confirm——这俩在
sandbox iframe(内置预览面板)里会被静默屏蔽:不弹窗、直接返回
null,表现为"点了没反应"。普通浏览器没事,所以一直没暴露。

改成页面内交互:播种弹 #pickmask 弹层,列出未锁定作物的按钮 +
取消,点遮罩关闭;重置改两步确认,第一次点变"再点一次确认",
3 秒不点自动还原。
2026-09-23 20:54:11 +08:00
6 changed files with 127 additions and 18 deletions

No files matched your search

+30
View File
@@ -0,0 +1,30 @@
## 2026-09-23 修网页版「点播种没反应」+ 顺手挖出自动存档失效的真 bug
- 用户报网页版点播种没反应。服务端 plant 动作完全正常 → 定位到前端。
- **根因 1(前端)**:播种按钮走 `window.prompt` 选作物,重置走 `window.confirm`
——这俩在 sandbox iframe(内置预览面板)里会被**静默屏蔽**:不弹窗、直接返回
null,于是"点了没反应"。普通浏览器里是好的,所以一直没暴露。
修复:改成页面内弹层(`#pickmask` + `openCropPick`,未锁定作物生成按钮 +
取消按钮,点遮罩关闭);重置改两步确认(第一次点变"再点一次确认",3 秒还原)。
- **根因 2(后端,真 bug)**:顺着验证存档时发现 `default` 牧场**根本没写进库**
——内存里每次重启都是新档。排查出 `session.dirty` 一个标志位伺二主:
Publisher 推送循环每 ~250ms 就把 dirty 清掉(无人在线时更是直接清),
而 `autosave()` 每 60s 才看一眼 dirty——窗口太小,**自动存档几乎永远不触发**
(约 0.4% 撞率)。进度只在手动存档/优雅停机时落盘,进程被强杀就全丢。
修复:新增 `session.unsaved` 专管「有变化没落盘」,`_on_change`/`load`/
`touch`/`advance_day` 置位、只有成功的 `save()` 清位;`autosave()` 改看
unsaved;dirty 继续只管推送节流。hub.py 的推送循环一字未动。
- 两只旧测试(`test_autosave_skips_when_clean` / `..._writes_when_dirty` /
`autosave_all...`)直接操纵 dirty 驱动 autosave,固化了被修掉的耦合——
全部改成用 unsaved 表达意图。新增回归测试:
「推送循环清了 dirty 也要照常落盘」「没有新变化不重复写库」。
- **真机验证**:0 客户端在线、纯 HTTP 种一块地,70 秒内看到库行更新
(gold 120→112,种子钱都对得上)。
- 门禁:ruff/pyright 全绿,515 项测试 OK(skip 35)。
### 教训
- 嵌入预览面板里 `window.prompt`/`confirm`/`alert` 都不可信,交互一律用页面内
弹层实现。
- 一个布尔标志被两个子系统共用时,其中一个高频清零,另一个低频读取——
低频的那个等于瞎了。存盘依据必须由"成功的写入"来清。
+3
View File
@@ -74,6 +74,9 @@ TUI 是纯客户端,同时开放 HTTP + SSE 接口给 AI/脚本调用(服务
方向键/空格/Tab/ESC 全部静默失效(字母键正常,所以很难注意到)。 方向键/空格/Tab/ESC 全部静默失效(字母键正常,所以很难注意到)。
动 `farm/keys.py` 时,`farm/tui.py` 里所有具名键引用都要跟着核一遍。 动 `farm/keys.py` 时,`farm/tui.py` 里所有具名键引用都要跟着核一遍。
读不到键盘时(管道 / 非 Win32 控制台)TUI 会用 `reader.reason` 在底部明示原因。 读不到键盘时(管道 / 非 Win32 控制台)TUI 会用 `reader.reason` 在底部明示原因。
- **session 的 dirty / unsaved 必须分开**:`dirty` 只管推送节流(Publisher ~250ms 清一次),
`unsaved` 才是自动存档依据(只有成功的 `save()` 清它)。别再合并——合回来 autosave 就瞎了。
网页前端禁用 `window.prompt`/`confirm`(嵌入预览里被静默屏蔽),交互用页面内弹层。
- **多牧场**:一个 farm = 一个玩家 = 库里一行(`saves/farm.db`)+ 一个引擎 + 独立时钟; - **多牧场**:一个 farm = 一个玩家 = 库里一行(`saves/farm.db`)+ 一个引擎 + 独立时钟;
时钟由**一个** `FarmClock` 线程统一驱动,不要给每个牧场开线程。 时钟由**一个** `FarmClock` 线程统一驱动,不要给每个牧场开线程。
- **存档类动作**:`save` / `reset` 必须由会话(FarmSession)处理, - **存档类动作**:`save` / `reset` 必须由会话(FarmSession)处理,
@@ -64,6 +64,11 @@ uv run main.py serve --port 8799 --tick 3 --save-dir .e2e-saves
动作回执带新字段、错误码符合预期(如 `nothing_to_sell` / `locked` / `unknown_building`)。 动作回执带新字段、错误码符合预期(如 `nothing_to_sell` / `locked` / `unknown_building`)。
错误码去 `farm/engine.py` 里核对,别猜。 错误码去 `farm/engine.py` 里核对,别猜。
**动了存档/会话层必须加验一条**:只走 HTTP(不连 SSE、0 客户端在线)做一个动作,
等 60 秒自动存档周期过去,直接查 `saves/farm.db` 那一行有没有更新。
单元测试抓不住 autosave 失效——2026-09-23 就漏过一次:`dirty` 被推送循环高频清零,
autosave 60s 才看一眼,等于永不存盘,全量测试照样绿。
## 第 3 步:写记忆 ## 第 3 步:写记忆
- 当天日志追加到 `.workbuddy/memory/YYYY-MM-DD.md`(append-only)。 - 当天日志追加到 `.workbuddy/memory/YYYY-MM-DD.md`(append-only)。
+16 -2
View File
@@ -85,6 +85,11 @@ class FarmSession:
self.engine.next_tick_at = time.time() + self.engine.tick_seconds self.engine.next_tick_at = time.time() + self.engine.tick_seconds
# 由 FarmClock 维护,Publisher 读取;用普通属性即可,都是原子读写 # 由 FarmClock 维护,Publisher 读取;用普通属性即可,都是原子读写
self.dirty = True self.dirty = True
# unsaved 与 dirty 分开:dirty 管推送节流(Publisher 会频繁清它),
# unsaved 管「有变化还没落盘」,只有成功的 save() 才清。
# 之前两个概念共用 dirty,而推送循环 ~250ms 就清一次,自动存档
# 60s 才看一眼——窗口太小,autosave 几乎永远不触发。
self.unsaved = True
self.published_day = 0 self.published_day = 0
self.last_events: list[str] = [] self.last_events: list[str] = []
self.created_at = time.time() self.created_at = time.time()
@@ -100,6 +105,7 @@ class FarmSession:
def _on_change(self) -> None: def _on_change(self) -> None:
"""引擎的任何状态变化都会走到这里(可能来自任意线程)。""" """引擎的任何状态变化都会走到这里(可能来自任意线程)。"""
self.dirty = True self.dirty = True
self.unsaved = True
def load(self) -> str: def load(self) -> str:
state, message = load_state(self.db_path, self.farm_id) state, message = load_state(self.db_path, self.farm_id)
@@ -112,17 +118,23 @@ class FarmSession:
self.load_message = f"{message},已开新档" self.load_message = f"{message},已开新档"
self.published_day = self.engine.state.day self.published_day = self.engine.state.day
self.dirty = True self.dirty = True
self.unsaved = True
return self.load_message return self.load_message
def save(self) -> str: def save(self) -> str:
with self._lock: with self._lock:
path = save_state(self.engine.state, self.db_path, self.farm_id) path = save_state(self.engine.state, self.db_path, self.farm_id)
self.saved_at = time.time() self.saved_at = time.time()
self.unsaved = False
return path return path
def autosave(self) -> bool: def autosave(self) -> bool:
"""只在有变化时落盘,返回是否真的写了。""" """只在有未落盘的变化时写,返回是否真的写了。
if not self.dirty or self.save_dir == "":
看的是 ``unsaved`` 而不是 ``dirty``:dirty 是推送节流用的,
Publisher 每 ~250ms 就清一次,拿来当存盘依据等于永不存盘。
"""
if not self.unsaved or self.save_dir == "":
return False return False
if time.time() - self.saved_at < 5: if time.time() - self.saved_at < 5:
return False return False
@@ -142,12 +154,14 @@ class FarmSession:
if count_action: if count_action:
self.actions_taken += 1 self.actions_taken += 1
self.dirty = True self.dirty = True
self.unsaved = True
def advance_day(self, reason: str = "时间流逝") -> list[str]: def advance_day(self, reason: str = "时间流逝") -> list[str]:
with self._lock: with self._lock:
events = self.engine.advance_day(reason=reason) events = self.engine.advance_day(reason=reason)
self.last_events = events self.last_events = events
self.dirty = True self.dirty = True
self.unsaved = True
return events return events
def reset(self) -> None: def reset(self) -> None:
+51 -11
View File
@@ -82,6 +82,15 @@ DASHBOARD_HTML = r"""<!DOCTYPE html>
.k-good { color: var(--green); } .k-bad { color: var(--red); } .k-good { color: var(--green); } .k-bad { color: var(--red); }
.k-warn { color: var(--gold); } .k-action { color: var(--cyan); } .k-warn { color: var(--gold); } .k-action { color: var(--cyan); }
.notice { font-size: 12px; color: var(--dim); min-height: 18px; } .notice { font-size: 12px; color: var(--dim); min-height: 18px; }
/* 播种选作物 / 二次确认的页面内弹层(不依赖 window.prompt,嵌入预览里也能用) */
.mask { position: fixed; inset: 0; background: rgba(30,40,30,.35);
display: none; align-items: center; justify-content: center; z-index: 30; }
.mask.show { display: flex; }
.pick { background: var(--card); border: 1px solid var(--line); border-radius: 12px;
padding: 14px 16px; min-width: 230px; box-shadow: 0 10px 30px rgba(0,0,0,.18); }
.pick h3 { margin: 0 0 10px; font-size: 14px; }
.pick .row { display: flex; flex-wrap: wrap; gap: 8px; }
.pick button.danger { border-color: var(--red); color: var(--red); }
.toast { .toast {
position: fixed; left: 50%; bottom: 26px; transform: translateX(-50%); position: fixed; left: 50%; bottom: 26px; transform: translateX(-50%);
background: #23301f; color: #fff; padding: 9px 18px; border-radius: 20px; background: #23301f; color: #fff; padding: 9px 18px; border-radius: 20px;
@@ -167,6 +176,13 @@ DASHBOARD_HTML = r"""<!DOCTYPE html>
<div class="toast" id="toast"></div> <div class="toast" id="toast"></div>
<div class="mask" id="pickmask">
<div class="pick">
<h3 id="picktitle">要种什么?</h3>
<div class="row" id="pickcrops"></div>
</div>
</div>
<script> <script>
const $ = (id) => document.getElementById(id); const $ = (id) => document.getElementById(id);
let es = null, state = null, catalog = null, pollTimer = null; let es = null, state = null, catalog = null, pollTimer = null;
@@ -421,17 +437,12 @@ document.addEventListener("click", (ev) => {
if (t.tagName !== "BUTTON") return; if (t.tagName !== "BUTTON") return;
if (t.dataset.w) act("water", { plot: +t.dataset.w }); if (t.dataset.w) act("water", { plot: +t.dataset.w });
else if (t.dataset.h) act("harvest", { plot: +t.dataset.h }); else if (t.dataset.h) act("harvest", { plot: +t.dataset.h });
else if (t.dataset.plant !== undefined) {
$("pickmask").classList.remove("show");
if (t.dataset.plant && pickPlot) act("plant", { plot: pickPlot, crop: t.dataset.plant });
}
else if (t.dataset.p) { else if (t.dataset.p) {
const plot = +t.dataset.p; openCropPick(+t.dataset.p);
const pool = state ? Object.keys(cropNames).filter((k) => {
const c = (catalog || { crops: [] }).crops.find((x) => x.key === k);
return !c || !c.locked;
}) : [];
const pick = window.prompt("要种什么?输入编号:\n" +
pool.map((k, i) => (i + 1) + ". " + cropNames[k]).join("\n"), "1");
const idx = parseInt(pick, 10);
if (!idx || idx < 1 || idx > pool.length) return;
act("plant", { plot: plot, crop: pool[idx - 1] });
} }
else if (t.dataset.f) act("feed", { animal_id: +t.dataset.f }); else if (t.dataset.f) act("feed", { animal_id: +t.dataset.f });
else if (t.dataset.c) act("collect", { animal_id: +t.dataset.c }); else if (t.dataset.c) act("collect", { animal_id: +t.dataset.c });
@@ -439,10 +450,39 @@ document.addEventListener("click", (ev) => {
else if (t.dataset.b) act("build", { key: t.dataset.b }); else if (t.dataset.b) act("build", { key: t.dataset.b });
else if (t.dataset.act === "buy_animal") act("buy_animal", { kind: $("buyanimal").value }); else if (t.dataset.act === "buy_animal") act("buy_animal", { kind: $("buyanimal").value });
else if (t.dataset.act === "buy_feed") act("buy_feed", { quantity: 10 }); else if (t.dataset.act === "buy_feed") act("buy_feed", { quantity: 10 });
else if (t.dataset.act === "reset") { if (confirm("确定重置牧场?所有进度都会清空")) act("reset"); } else if (t.dataset.act === "reset") {
if (t.dataset.armed) {
t.dataset.armed = ""; t.textContent = "重置";
act("reset");
} else {
t.dataset.armed = "1"; t.textContent = "再点一次确认";
setTimeout(() => { t.dataset.armed = ""; t.textContent = "重置"; }, 3000);
}
}
else if (t.dataset.act) act(t.dataset.act); else if (t.dataset.act) act(t.dataset.act);
}); });
/* 播种选作物:页面内弹层。window.prompt 在 sandbox iframe(内置预览面板)里
会被静默屏蔽——不弹窗、直接返回 null,表现为"点播种没反应"。 */
let pickPlot = 0;
function openCropPick(plot) {
if (!state) { toast("还没连接上服务器"); return; }
const pool = Object.keys(cropNames).filter((k) => {
const c = (catalog || { crops: [] }).crops.find((x) => x.key === k);
return !c || !c.locked;
});
if (!pool.length) { toast("还没有可种的作物"); return; }
pickPlot = plot;
$("picktitle").textContent = plot + " 号地 · 要种什么?";
$("pickcrops").innerHTML = pool.map((k) =>
'<button data-plant="' + k + '">' + cropNames[k] + "</button>").join("") +
'<button class="danger" data-plant="">取消</button>';
$("pickmask").classList.add("show");
}
$("pickmask").addEventListener("click", (ev) => {
if (ev.target.id === "pickmask") $("pickmask").classList.remove("show");
});
$("join").addEventListener("click", () => { $("join").addEventListener("click", () => {
localStorage.setItem("sf.farm", farmName()); localStorage.setItem("sf.farm", farmName());
localStorage.setItem("sf.token", token()); localStorage.setItem("sf.token", token());
+22 -5
View File
@@ -216,6 +216,23 @@ class FarmSessionTestCase(unittest.TestCase):
self.assertEqual(reopened.engine.state.gold, 777) self.assertEqual(reopened.engine.state.gold, 777)
self.assertIn("第 9 天", reopened.load_message) self.assertIn("第 9 天", reopened.load_message)
def test_autosave_survives_the_push_loop_clearing_dirty(self):
"""回归:dirty 是推送节流用的,Publisher ~250ms 就清一次;自动存档
如果也看 dirty,就等于永不存盘(进程被强杀进度全丢)。它必须看
只有成功的 save() 才清的 unsaved。"""
session = FarmSession("alice", self.dir, 2400.0)
session.touch()
session.dirty = False # 模拟 Publisher 清推送标记
self.assertTrue(session.autosave(), "推送循环清了 dirty 也要照常落盘")
reopened = FarmSession("alice", self.dir, 2400.0)
self.assertIn("已载入存档", reopened.load_message)
def test_autosave_skips_when_nothing_changed_since_last_save(self):
session = FarmSession("alice", self.dir, 2400.0)
session.touch()
self.assertTrue(session.autosave())
self.assertFalse(session.autosave(), "没有新变化就不该重复写库")
def test_broken_save_degrades_to_a_new_farm(self): def test_broken_save_degrades_to_a_new_farm(self):
path = os.path.join(self.dir, DB_NAME) path = os.path.join(self.dir, DB_NAME)
with open(path, "w", encoding="utf-8") as handle: with open(path, "w", encoding="utf-8") as handle:
@@ -247,13 +264,13 @@ class FarmSessionTestCase(unittest.TestCase):
def test_autosave_skips_when_clean(self): def test_autosave_skips_when_clean(self):
session = FarmSession("alice", self.dir, 2400.0) session = FarmSession("alice", self.dir, 2400.0)
session.dirty = False session.unsaved = False
session.saved_at = 0.0 session.saved_at = 0.0
self.assertFalse(session.autosave()) self.assertFalse(session.autosave())
def test_autosave_writes_when_dirty(self): def test_autosave_writes_when_unsaved(self):
session = FarmSession("alice", self.dir, 2400.0) session = FarmSession("alice", self.dir, 2400.0)
session.dirty = True session.unsaved = True
session.saved_at = 0.0 session.saved_at = 0.0
self.assertTrue(session.autosave()) self.assertTrue(session.autosave())
self.assertTrue(farm_exists(session.db_path, session.farm_id)) self.assertTrue(farm_exists(session.db_path, session.farm_id))
@@ -377,8 +394,8 @@ class SessionManagerTestCase(unittest.TestCase):
manager.get("alice") manager.get("alice")
bob = manager.get("bob") bob = manager.get("bob")
alice = manager.get("alice") alice = manager.get("alice")
alice.dirty = True alice.unsaved = True
bob.dirty = False bob.unsaved = False
alice.saved_at = bob.saved_at = 0.0 alice.saved_at = bob.saved_at = 0.0
self.assertEqual(manager.autosave_all(), 1) self.assertEqual(manager.autosave_all(), 1)