From 4501807cd2310c48c0fc45eb83f256c43d3b3e9f Mon Sep 17 00:00:00 2001 From: ZHU MINCHAO <48154958+MinchaoZhu@users.noreply.github.com> Date: Wed, 4 Nov 2026 10:02:02 +0800 Subject: [PATCH] Fix wallet destroy fund loss, Tumbler progress timing, settle idempotence, bounds guards (from external review) Co-Authored-By: Claude Sonnet 5.5 --- CHANGELOG.md | 6 ++++++ src/Decentralized/Night.cs | 3 ++- src/Decentralized/PredictGame.cs | 3 +++ src/Decentralized/TerminalWindow.cs | 6 +++++- src/Decentralized/TumblerStory.cs | 30 +++++++++++++++++++++-------- 5 files changed, 38 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4345140..599a397 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,12 @@ ### 第九阶段:剧情与收尾 +- 外部评审(agy 的整体评审报告,逐条核实后)修复的真实问题: + - 销毁钱包会让资金凭空消失:收益池利息和时间锁取款按钱包地址找人,钱包销毁后找不到就跳过,款项被丢掉。夜间结算改为找不到就回到当前钱包(和预测、质押一致),销毁钱包时有预测持仓、收益池存款或取款、质押、混币在途也一律拒绝。 + - Tumbler 剧情进度在生成顾客时就推进:当天没聊完、被清场或读档重来,进度已经走了,最糟的是第 3 次(三选一)没选就跳到第 4 次的"没做选择"分支,剧情报废。改成对话真正走完才推进;第 3 次在做出选择时推进;没聊完明天照样来。游戏内验证:第 1 次聊完日志"进度 1",没聊完的第 2 次不推进。 + - 夜间结算加幂等保护:同一天已经结算过就跳过,防止其他 mod 或调试让钩子触发两次造成双重扣费。 + - 预测盘口读游戏的 `futurEvents` / `futurEventsDay` 两个并行列表时加了下标保护。 + - 核实后没有采纳:静态变量串档(`ClosePrice` 等每晚结算前先赋值再用,`Pending` 读档时已清;实际无串档)、除零(SPARK 螺旋也只到约 0.5、CRA 最低 0.02、全网算力有 0.8 的可用性下限,不会为零)、全局随机数流拆分(设计取舍)、`Charged` 字典增长(体积极小)。报告里还有几处引用错误(页签数量、不存在的 `out_fullsim.md`)。 - 收尾补验证(游戏内,用游戏自己的开日流程:调试"Run night"和 `day 1` 过夜,墙上的卷帘门按钮开门): - 伪造转账顾客:新增调试入口"Coin buyer forged"。开口"CRT 我已经转过去了,截图给你看……"(正常顾客说"我用 CRT 付,链上确认要一会儿");成交 4 件共 118 信用点后现金没变(信用点被扣回),日志逐件记"伪造转账",晨间日志列出四条"拿走了……说付了……链上没有这笔"。 - 内幕被查:线人排期的停电盘口押「是」花 600(2,046 份,超过上限的 40%,标内幕),结算夜"内幕下注被查:本金和收益 2,046 全部没收,记诈骗罪证",罪证账本 FRAUD 380。 diff --git a/src/Decentralized/Night.cs b/src/Decentralized/Night.cs index a0fcccb..3a0d21e 100644 --- a/src/Decentralized/Night.cs +++ b/src/Decentralized/Night.cs @@ -142,6 +142,7 @@ namespace Decentralized { var d = Session.Data; int day = StoreStation.GetDayCounter(); + if (d.Day == day) { Log.Info($"第 {day} 天夜间结算已执行过,跳过重复调用"); return; } StoryLinks.Night(d, day); TumblerStory.Night(d, day); var events = ActiveEvents(); @@ -256,7 +257,7 @@ namespace Decentralized News.Show("pd_news_ponzi_" + day, L.T("news.ponzi.title"), L.T("news.ponzi.desc"), 4); Log.Info($"理财崩盘:{ponzi.Kind},池子 {ponzi.Amount:0} 被没收"); } - foreach (var e in d.Scams.Night(day, d.Rng, d.Own, new[] { Assets.CRT, Assets.PSC, Assets.CRC, Assets.CRA }, Session.Price)) + foreach (var e in d.Scams.Night(day, d.Rng, d.WalletAt, new[] { Assets.CRT, Assets.PSC, Assets.CRC, Assets.CRA }, Session.Price)) { var sw = d.Own(e.Wallet); string wn = sw != null ? WalletWindow.NameOf(sw) : e.Wallet; diff --git a/src/Decentralized/PredictGame.cs b/src/Decentralized/PredictGame.cs index 75b0caf..aceaed3 100644 --- a/src/Decentralized/PredictGame.cs +++ b/src/Decentralized/PredictGame.cs @@ -65,6 +65,7 @@ namespace Decentralized var e = mgr.futurEvents[i]; if (knownOnly && !e.IsKnown) continue; bool match = m.Kind == PredictKind.Event ? e.identifier == m.Key : e.isFactionOperation && e.sourceFaction == m.Key; + if (i >= mgr.futurEventsDay.Count) break; int start = today + mgr.futurEventsDay[i]; if (match && start >= m.FromDay && start <= m.ToDay) return start; } @@ -154,6 +155,7 @@ namespace Decentralized for (int i = 0; i < mgr.futurEvents.Count; i++) { var e = mgr.futurEvents[i]; + if (i >= mgr.futurEventsDay.Count) break; int start = day + mgr.futurEventsDay[i]; if (!e.IsKnown || start < day + 2 || e.eventType == StoreEvent.EventType.COSMETIC) continue; bool op = e.isFactionOperation; @@ -294,6 +296,7 @@ namespace Decentralized if (mgr != null) for (int i = 0; i < mgr.futurEvents.Count; i++) { + if (i >= mgr.futurEventsDay.Count) break; var e = mgr.futurEvents[i]; if (e.IsKnown && (e.identifier == "crackdown" || e.identifier == "stationWideBlackout" || e.identifier == "powerShortage")) list.Add(L.T("predict.clue.queued2", today + mgr.futurEventsDay[i], e.GetDisplayName())); diff --git a/src/Decentralized/TerminalWindow.cs b/src/Decentralized/TerminalWindow.cs index 5544089..c7ea9b8 100644 --- a/src/Decentralized/TerminalWindow.cs +++ b/src/Decentralized/TerminalWindow.cs @@ -972,7 +972,11 @@ namespace Decentralized var d = Session.Data; int idx = d.Wallets.IndexOf(w); if (idx <= 0) return; - if (w.Balances.Values.Any(v => v > 1e-9) || w.Frozen.Values.Any(v => v > 1e-9)) { _destroyPick = null; _message = L.T("wallets.destroy.notempty", WalletWindow.NameOf(w)); Open(); return; } + bool busy = d.Predict.Positions.Any(p => ChainAddress.Same(p.Wallet, w.Address)) + || d.Scams.Pools.Any(p => p.Of(w.Address) > 0 || p.Pending.Any(q => ChainAddress.Same(q.Wallet, w.Address))) + || d.Staking.Lots.Any(l => ChainAddress.Same(l.Wallet, w.Address) && (l.Amount > 1e-9 || l.Unlocking.Count > 0)) + || d.Mixer.Pending.Any(m => ChainAddress.Same(m.Wallet, w.Address)); + if (w.Balances.Values.Any(v => v > 1e-9) || w.Frozen.Values.Any(v => v > 1e-9) || busy) { _destroyPick = null; _message = L.T("wallets.destroy.notempty", WalletWindow.NameOf(w)); Open(); return; } if (_destroyPick != w.Address) { _destroyPick = w.Address; _message = null; Open(); return; } _destroyPick = null; string name = WalletWindow.NameOf(w); diff --git a/src/Decentralized/TumblerStory.cs b/src/Decentralized/TumblerStory.cs index abd50ea..36705c4 100644 --- a/src/Decentralized/TumblerStory.cs +++ b/src/Decentralized/TumblerStory.cs @@ -26,8 +26,8 @@ namespace Decentralized if (t.TryStart(day, Unlock.MixerUnlocked)) Log.Info($"Tumbler 剧情开始:第 {t.StartDay} 天第一次上门"); int due = t.Due(day); if (due == 0) return; - t.Visited(day); - if (due == 4 && t.Choice == Tumbler.Report) { Log.Info("Tumbler 已被举报,不再上门"); Session.Save(); return; } + // 举报之后第 4 次不上门;其余的上门要等对话真正走完才算数(当天没聊完、被清场或读档重来,明天照样来) + if (due == 4 && t.Choice == Tumbler.Report) { t.Visited(day); Log.Info("Tumbler 已被举报,不再上门"); Session.Save(); return; } var c = Visit(due, t.Choice); manager.AddNextClient(c); Log.Info($"Tumbler 第 {due} 次上门(选择 {t.Choice})"); @@ -71,8 +71,8 @@ namespace Decentralized var d = Session.Data; switch (n) { - case 1: Lines(c, c.mainDialogue, "tumbler.v1.", 4).SetEndAction(Leave); break; - case 2: Lines(c, c.mainDialogue, "tumbler.v2.", 3).SetEndAction(Leave); break; + case 1: Lines(c, c.mainDialogue, "tumbler.v1.", 4).SetEndAction(() => Finish(1)); break; + case 2: Lines(c, c.mainDialogue, "tumbler.v2.", 3).SetEndAction(() => Finish(2)); break; case 3: { var ask = new Dialogue(); @@ -91,11 +91,11 @@ namespace Decentralized if (choice == Tumbler.Hide) { bool has = EmporiumEntry.Instance != null && EmporiumEntry.Instance.GetAllOwnedItems().Any(i => i != null && i.identifier == Items.KeyDrive); - if (has) Lines(c, c.mainDialogue, "tumbler.v4.back.", 3).SetEndAction(() => { Returned(); Leave(); }); - else Lines(c, c.mainDialogue, "tumbler.v4.lost.", 3).SetEndAction(Leave); + if (has) Lines(c, c.mainDialogue, "tumbler.v4.back.", 3).SetEndAction(() => { Returned(); Finish(4); }); + else Lines(c, c.mainDialogue, "tumbler.v4.lost.", 3).SetEndAction(() => Finish(4)); } - else if (choice == Tumbler.Sell) Lines(c, c.mainDialogue, "tumbler.v4.sold.", 3).SetEndAction(Leave); - else Lines(c, c.mainDialogue, "tumbler.v4.none.", 1).SetEndAction(Leave); // 第 3 次没做选择(被遣散) + else if (choice == Tumbler.Sell) Lines(c, c.mainDialogue, "tumbler.v4.sold.", 3).SetEndAction(() => Finish(4)); + else Lines(c, c.mainDialogue, "tumbler.v4.none.", 1).SetEndAction(() => Finish(4)); break; } c.AddBasicDialog(); @@ -105,6 +105,19 @@ namespace Decentralized private static void Leave() => DialogUIManager.Instance.CurrentClientLeave(); + /// 第 n 次上门的对话走完:进度推进一步(只在这一次还没记过时),存档,顾客离开。 + private static void Finish(int n) + { + Log.Guard("Tumbler 上门结束", () => + { + var t = Session.Data.Story.Tumbler; + if (t.Stage == n - 1) t.Visited(StoreStation.GetDayCounter()); + Log.Info($"Tumbler 第 {n} 次上门结束,进度 {t.Stage}"); + Session.Save(); + }); + Leave(); + } + /// 第 3 次上门做出的选择。 private static void Choose(int choice) { @@ -112,6 +125,7 @@ namespace Decentralized { var d = Session.Data; d.Story.Tumbler.Choice = choice; + if (d.Story.Tumbler.Stage == 2) d.Story.Tumbler.Visited(StoreStation.GetDayCounter()); int day = StoreStation.GetDayCounter(); switch (choice) {