Compare commits
3
Commits
cb292f9f53
...
d1692d7b10
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d1692d7b10 | ||
|
|
ea346be484 | ||
|
|
305ef0dcbe |
No files matched your search
@@ -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` 都不可信,交互一律用页面内
|
||||||
|
弹层实现。
|
||||||
|
- 一个布尔标志被两个子系统共用时,其中一个高频清零,另一个低频读取——
|
||||||
|
低频的那个等于瞎了。存盘依据必须由"成功的写入"来清。
|
||||||
@@ -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
@@ -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
@@ -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
@@ -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)
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user