From 09a5cae69c04f38e1b9265e3aeab67e76578b242 Mon Sep 17 00:00:00 2001 From: ZHU MINCHAO <48154958+MinchaoZhu@users.noreply.github.com> Date: Tue, 3 Nov 2026 16:39:40 +0800 Subject: [PATCH] Phase 2: mixer (Web3 tab in the network, terminal page, sanctions, night return), unlock registration per upgrade Co-Authored-By: Claude Sonnet 5.5 --- CHANGELOG.md | 3 + src/Decentralized.Domain/Mixer.cs | 98 +++++++++++++++++++ src/Decentralized.Domain/Tuning.cs | 3 + src/Decentralized/CoinCustomers.cs | 6 ++ .../ModFiles/Localization/en.csv | 25 +++++ .../ModFiles/Localization/zh.csv | 25 +++++ src/Decentralized/Night.cs | 13 ++- src/Decentralized/Session.cs | 1 + src/Decentralized/TerminalWindow.cs | 72 +++++++++++++- src/Decentralized/Unlock.cs | 32 +++--- tests/Decentralized.Domain.Tests/Program.cs | 28 ++++++ 11 files changed, 290 insertions(+), 16 deletions(-) create mode 100644 src/Decentralized.Domain/Mixer.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index 39307c9..89db048 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,9 @@ ### 第二阶段(进行中) +- 人脉网络里本 mod 单独一页"Web3":入口(400)和混币器(700,需先加入)都放在这页。 +- 混币器:终端多一页"混币器"(买了升级才出现)。选币种、数量、1–5 轮,存入后每轮扣 2% 手续费并降低污点(基准每轮 35%,存入越大清洗越差,匿名集 = 2 万信用点背景量 + 存入量),轮数即等待天数,到期提回同一个地址;提回后一周内出金审查按"来自混币器"加重。镇压事件开始时 50% 被制裁 5 天,期间提出的币地址被标记,并上报纸;每晚 0.4% 跑路。夜间日志写提回与污点变化。开发者模式 F9 有 `Tainted wallet (test)`。游戏内验证了解锁、页面、存入与污点带走;夜间提回只有单元测试(测试存档里无法正常过夜)。 + - 用币付款的顾客:排队的买家里,上层付 CRC(12%)、黑市付 CRT(25%)、革命派付 CRA(25%)、下层付 PSC(10%)。成交时扣回信用点(现金不变),按当日币价换成币进热钱包,顾客当场说一句"成交,N CRT 已转入热钱包";赃物款项不记销赃,改成币带污点(货值 × 赃物热度%);夜间日志汇总当天用币结算的额度。开发者模式 F9 有测试入口(Coin buyer)。已在游戏里实测:成交后现金不变,四笔种子按币价记入 CRT,顾客对话显示。 ### 第一阶段完成(入门、挖矿、显卡鉴定、终端) diff --git a/src/Decentralized.Domain/Mixer.cs b/src/Decentralized.Domain/Mixer.cs new file mode 100644 index 0000000..0b3d2ff --- /dev/null +++ b/src/Decentralized.Domain/Mixer.cs @@ -0,0 +1,98 @@ +using System; +using System.Collections.Generic; +using System.Linq; + +namespace Decentralized.Domain +{ + /// 混币器里一笔在途的存入。 + public sealed class MixDeposit + { + public string Asset; + public double Amount; + /// 存入时一并带走的污点与成本(信用点)。 + public double Taint, Basis; + public int Rounds; + public int ReadyDay; + } + + /// 一笔混币结束时的结果,供夜间日志使用。 + public sealed class MixResult + { + public bool Rugged; + public string Asset; + public double Amount, TaintBefore, TaintAfter; + public bool Flagged; + } + + /// + /// 混币器:把币存进去,每轮扣手续费、降低污点,轮数 = 等待天数,到期提回同一个地址。 + /// 匿名集 = NPC 背景量 + 玩家存入量,玩家占比越大清洗效果越差。镇压事件开始时可能被制裁: + /// 制裁期间提出的币视为被标记(出金审查按"来自混币器或被标记地址"加重,且地址被标记)。每晚有极小概率跑路。 + /// + public sealed class Mixer + { + public List Pending = new List(); + public int SanctionedUntil = -1; + /// 提回的币最近一次从混币器出来的天数,用来给出金审查加重。 + public int LastReturnDay = -999; + + public bool Sanctioned(int day) => day <= SanctionedUntil; + + /// 资金来自混币器:最近 AML 窗口内提回过币,或者被标记。 + public bool RecentlyMixed(int day) => day - LastReturnDay <= Tuning.AmlWindowDays; + + /// 一轮的清洗率:基准 × 匿名集系数。 + public static double RoundRate(double depositValue) => Tuning.MixerCleanRate * Tuning.MixerNpcVolume / (Tuning.MixerNpcVolume + Math.Max(0, depositValue)); + + /// 存入:从钱包按价值比例带走污点与成本。轮数超出范围或余额不足返回 null。 + public MixDeposit Deposit(Wallet w, string asset, double amount, int rounds, int day, Func price) + { + if (rounds < 1 || rounds > Tuning.MixerMaxRounds || amount <= 0 || w.Get(asset) + 1e-9 < amount) return null; + double value = amount * price(asset); + if (value < Tuning.MixerMin) return null; + double total = w.Value(price); + double share = total > 0 ? Math.Min(1, value / total) : 0; + double taint = w.Taint * share, basis = w.Basis * share; + w.Add(asset, -amount); + w.Taint -= taint; + w.Basis -= basis; + var d = new MixDeposit { Asset = asset, Amount = amount, Taint = taint, Basis = basis, Rounds = rounds, ReadyDay = day + rounds }; + Pending.Add(d); + return d; + } + + /// 镇压事件开始:有一定概率被制裁,持续若干天。 + public void OnEvent(MarketEvent ev, int day, Rng rng) + { + if (ev == MarketEvent.Crackdown && rng.Chance(Tuning.MixerSanctionChance)) SanctionedUntil = day + Tuning.MixerSanctionDays; + } + + /// 每晚:到期的存入提回钱包(污点按轮数降低),在途的各有小概率跑路。 + public List Night(Wallet w, int day, Func price, Rng rng) + { + var results = new List(); + foreach (var d in Pending.ToList()) + { + if (rng.Chance(Tuning.MixerRugChance)) + { + Pending.Remove(d); + results.Add(new MixResult { Rugged = true, Asset = d.Asset, Amount = d.Amount }); + continue; + } + if (day < d.ReadyDay) continue; + Pending.Remove(d); + double rate = RoundRate(d.Amount * price(d.Asset)); + double taint = d.Taint * Math.Pow(1 - rate, d.Rounds); + double amount = d.Amount * Math.Pow(1 - Tuning.MixerFee, d.Rounds); + bool flagged = Sanctioned(day); + w.Add(d.Asset, amount); + w.Taint += taint; + w.Basis += d.Basis; + if (flagged) w.Flagged = true; + LastReturnDay = day; + results.Add(new MixResult { Asset = d.Asset, Amount = amount, TaintBefore = d.Taint, TaintAfter = taint, Flagged = flagged }); + } + return results; + } + } +} diff --git a/src/Decentralized.Domain/Tuning.cs b/src/Decentralized.Domain/Tuning.cs index e0a18be..332e7af 100644 --- a/src/Decentralized.Domain/Tuning.cs +++ b/src/Decentralized.Domain/Tuning.cs @@ -57,6 +57,9 @@ namespace Decentralized.Domain public const int AmlWindowDays = 7; public const double AmlTaintRatio = 0.10, AmlHeavyRatio = 0.30, AmlMixerBonus = 0.30, AmlMaxChance = 0.9; public const double TaxRate = 0.08; + /// 混币器:每轮手续费、基准清洗率、NPC 背景量(信用点)、最大轮数、最小存入、制裁概率与天数、每晚跑路概率。 + public const double MixerFee = 0.02, MixerCleanRate = 0.35, MixerNpcVolume = 20000, MixerMin = 100, MixerSanctionChance = 0.5, MixerRugChance = 0.004; + public const int MixerMaxRounds = 5, MixerSanctionDays = 5; /// 各派系顾客用币付款的概率。 public const double CoinPayCrc = 0.12, CoinPayCrt = 0.25, CoinPayCra = 0.25, CoinPayPsc = 0.10; /// 单个金融违规事件记入的罪证上限。 diff --git a/src/Decentralized/CoinCustomers.cs b/src/Decentralized/CoinCustomers.cs index 0ca3b6e..2329fee 100644 --- a/src/Decentralized/CoinCustomers.cs +++ b/src/Decentralized/CoinCustomers.cs @@ -15,6 +15,12 @@ namespace Decentralized public static void Subscribe() { ModHook.OnGenerateCustomerLate += OnGenerate; + ModHelper.AddDebugFunction(ModInfo.Id, ModInfo.Name, "Tainted wallet (test)", () => Log.Guard("带污点的钱包(测试)", () => + { + var w = Session.Data.Hot; + w.Add(Assets.PSC, 100); w.Taint += 3000; w.Basis += 2000; + Log.Info($"钱包:PSC {w.Get(Assets.PSC):0.##},污点 {w.Taint:0}"); + })); ModHelper.AddDebugFunction(ModInfo.Id, ModInfo.Name, "Coin buyer (test)", () => Log.Guard("用币付款顾客(测试)", () => { var c = new StoreClient diff --git a/src/Decentralized/ModFiles/Localization/en.csv b/src/Decentralized/ModFiles/Localization/en.csv index 1478c65..7a3fa35 100644 --- a/src/Decentralized/ModFiles/Localization/en.csv +++ b/src/Decentralized/ModFiles/Localization/en.csv @@ -1,5 +1,6 @@ # Probably Decentralized localization table: English (en) # Source language and fallback. Format: key,text (text may be quoted; \n is a line break). +upgrade.tab,Web3 upgrade.title,Web3 Governance Community upgrade.desc,"Members receive a trading terminal, a mining rig, an RZX 60 graphics card and a graphics card appraisal guide. The terminal holds the bank, the Poinbase exchange and the Web3 wallet. More cards and rigs are sold by Dr. Jackson." item.terminal.name,Trading Terminal @@ -174,3 +175,27 @@ stable.spiral,"Collapsing" aml.window,"Withdrawn in the last {0} days: {1} credits (cumulative line {2})" aml.taint,"Wallet taint share: {0}%" aml.chance,"At this amount, estimated review chance: {0}%" +mixer.h,Mixer +mixer.ok,Status: operating normally +mixer.sanctioned,Status: sanctioned ({0} more day(s)). Coins withdrawn meanwhile are flagged. +mixer.pending,Pending deposits +mixer.none,No pending deposits. +mixer.col.taint,Taint +mixer.col.ready,Ready +mixer.day,Day {0} +mixer.deposit,Deposit +mixer.hint,"Pick asset, amount and rounds.|Each round: fee, less taint.|Rounds = days of waiting.|Coins return to this address.|Bigger deposits clean worse." +mixer.balance,Balance: {1} {0} +mixer.rounds,Rounds +mixer.preview,"Expect {0} {1} back, taint down about {2}%, ready on day {3}." +mixer.go,Deposit to mixer +mixer.min,Deposits must be worth at least {0} credits. +mixer.done,Deposited {0} {1}; back on day {2}. +mixer.upgrade.title,Mixer +mixer.upgrade.desc,"Unlocks the Mixer page in the terminal: deposit tainted coins, get them back days later with less taint. Cash-outs of freshly mixed coins are screened harder; during crackdowns the mixer may be sanctioned and coins withdrawn meanwhile are flagged." +news.sanction.title,Mixer put on sanctions list +news.sanction.desc,"Security has sanctioned a batch of mixing services. Funds withdrawn from mixers in the next few days will be treated as suspicious." +nightlog.mixer,Mixer returned {0} {1}; taint {2} -> {3}. +nightlog.mixer_flag, (withdrawn during sanctions; address flagged) +nightlog.mixer_rug,The mixer's operator ran off with it: {0} {1} never came back. +tab.mixer,Mixer diff --git a/src/Decentralized/ModFiles/Localization/zh.csv b/src/Decentralized/ModFiles/Localization/zh.csv index 0b4addc..76b2892 100644 --- a/src/Decentralized/ModFiles/Localization/zh.csv +++ b/src/Decentralized/ModFiles/Localization/zh.csv @@ -1,4 +1,5 @@ # Probably Decentralized 本地化表:简体中文(zh) +upgrade.tab,Web3 upgrade.title,Web3 治理社区 upgrade.desc,"成员可获得一台交易终端、一个矿架、一张 RZX 60 显卡和一本显卡鉴定手册。终端里有银行、Poinbase 交易所和 Web3 钱包。更多显卡和矿架由杰克逊博士出售。" item.terminal.name,交易终端 @@ -173,3 +174,27 @@ stable.spiral,"崩盘中" aml.window,"近 {0} 天已提取 {1} 信用点(累计线 {2})" aml.taint,"钱包污点比例:{0}%" aml.chance,"按这个数量,预计审查概率:{0}%" +mixer.h,混币器 +mixer.ok,状态:运行正常 +mixer.sanctioned,状态:已被制裁(还有 {0} 天),此期间提出的币视为被标记。 +mixer.pending,在途存入 +mixer.none,没有在途的存入。 +mixer.col.taint,污点 +mixer.col.ready,到期 +mixer.day,第 {0} 天 +mixer.deposit,存入 +mixer.hint,"选币种、填数量和轮数。|每轮扣手续费并降低污点。|轮数即等待天数。|到期提回同一个地址。|存入越多,清洗越差。" +mixer.balance,余额:{1} {0} +mixer.rounds,轮数 +mixer.preview,"预计提回 {0} {1},污点约降低 {2}%,第 {3} 天到期。" +mixer.go,存入混币器 +mixer.min,存入价值不得低于 {0} 信用点。 +mixer.done,已存入 {0} {1},第 {2} 天提回。 +mixer.upgrade.title,混币器 +mixer.upgrade.desc,"解锁终端里的混币器页:把带污点的币存进去,几天后提回,污点降低。出金审查对刚从混币器出来的币更严格;镇压期间混币器可能被制裁,期间提出的币会被标记。" +news.sanction.title,混币器被列入制裁名单 +news.sanction.desc,"安保部门宣布制裁一批混币服务。此后几天从混币器提出的资金将被视为可疑。" +nightlog.mixer,混币器提回 {0} {1},污点 {2} → {3}。 +nightlog.mixer_flag,(制裁期间提出,地址被标记) +nightlog.mixer_rug,混币器的运营者卷款跑路了:{0} {1} 没有提回。 +tab.mixer,混币器 diff --git a/src/Decentralized/Night.cs b/src/Decentralized/Night.cs index fe012e3..b9a5148 100644 --- a/src/Decentralized/Night.cs +++ b/src/Decentralized/Night.cs @@ -82,7 +82,9 @@ namespace Decentralized var d = Session.Data; int day = StoreStation.GetDayCounter(); var events = ActiveEvents(); - foreach (var ev in events.Where(e => !d.ActiveEvents.Contains(e))) d.Stable.OnEventStart(ev, day, d.Rng); + bool wasSanctioned = d.Mixer.Sanctioned(day); + foreach (var ev in events.Where(e => !d.ActiveEvents.Contains(e))) { d.Stable.OnEventStart(ev, day, d.Rng); d.Mixer.OnEvent(ev, day, d.Rng); } + if (!wasSanctioned && d.Mixer.Sanctioned(day) && Unlock.MixerUnlocked) News.Show("pd_news_sanction_" + day, L.T("news.sanction.title"), L.T("news.sanction.desc"), 3); d.ActiveEvents = events; d.Network.Availability = (events.Contains(MarketEvent.Blackout) ? 0.8 : 1) * (events.Contains(MarketEvent.LowerUnrest) ? 0.9 : 1); @@ -117,6 +119,15 @@ namespace Decentralized var ps = PlayerStore.Instance; if (mined > 0) ps.AddNightLog(L.T("nightlog.mined", Fmt.Num(mined))); + foreach (var m in d.Mixer.Night(d.Hot, day, Session.Price, d.Rng)) + { + if (m.Rugged) ps.AddNightLog(L.T("nightlog.mixer_rug", Fmt.Num(m.Amount), m.Asset), "cc7755"); + else + { + string line = L.T("nightlog.mixer", Fmt.Num(m.Amount), m.Asset, Fmt.Credits(m.TaintBefore), Fmt.Credits(m.TaintAfter)) + (m.Flagged ? L.T("nightlog.mixer_flag") : ""); + if (m.Flagged) ps.AddNightLog(line, "cc7755"); else ps.AddNightLog(line); + } + } foreach (var name in report.Burnt) ps.AddNightLog(L.T("nightlog.burnt", name), "cc7755"); int unpowered = report.Rigs - report.PoweredRigs; if (unpowered > 0 && units.Count + unpowered > 0) ps.AddNightLog(L.T("nightlog.unpowered", unpowered), "cc7755"); diff --git a/src/Decentralized/Session.cs b/src/Decentralized/Session.cs index 4bc7b1d..fb3ec9a 100644 --- a/src/Decentralized/Session.cs +++ b/src/Decentralized/Session.cs @@ -25,6 +25,7 @@ namespace Decentralized public Network Network = new Network(); public Wallet Hot = new Wallet { Address = "hot" }; public Exchange Exchange = new Exchange(); + public Mixer Mixer = new Mixer(); public List ActiveEvents = new List(); public NightReport LastNight; public bool OpeningNewsShown; diff --git a/src/Decentralized/TerminalWindow.cs b/src/Decentralized/TerminalWindow.cs index 0655526..cb15600 100644 --- a/src/Decentralized/TerminalWindow.cs +++ b/src/Decentralized/TerminalWindow.cs @@ -13,7 +13,7 @@ namespace Decentralized public static class TerminalWindow { public const string Id = "pd_terminal"; - private enum Page { Home, Bank, Poinbase, Web3, Report } + private enum Page { Home, Bank, Poinbase, Web3, Mixer, Report } private static Page _page = Page.Home; private static string _amount = ""; @@ -28,6 +28,7 @@ namespace Decentralized b.BeginRow(); foreach (Page p in Enum.GetValues(typeof(Page))) { + if (p == Page.Mixer && !Unlock.MixerUnlocked) continue; var page = p; string name = L.T("tab." + p.ToString().ToLowerInvariant()); b.AddButton(page == _page ? $"[{name}]" : name, () => { _page = page; _message = null; Open(); }); @@ -41,6 +42,7 @@ namespace Decentralized case Page.Bank: BankPage(b); break; case Page.Poinbase: ExchangePage(b, Venue.Exchange); break; case Page.Web3: Web3Page(b); break; + case Page.Mixer: MixerPage(b); break; default: ReportPage(b); break; } b.End(); @@ -252,6 +254,72 @@ namespace Decentralized }); } + private static string _mixAsset = Assets.PSC; + private static int _mixRounds = 2; + private static readonly float[] MixW = { 70f, 100f, 100f, 80f }; + + /// 混币器:把币存进去,几轮之后提回同一个地址,污点降低,每轮扣手续费。 + private static void MixerPage(CustomUIBuilder b) + { + var d = Session.Data; + var w = d.Hot; + int day = StoreStation.GetDayCounter(); + Columns(b, l => + { + l.AddHeader(L.T("mixer.h")); + l.AddLabel(d.Mixer.Sanctioned(day) ? L.T("mixer.sanctioned", d.Mixer.SanctionedUntil - day + 1) : L.T("mixer.ok")); + if (w.Taint > 0.5) l.AddLabel(L.T("wallet.taint", Fmt.Credits(w.Taint))); + l.AddSeparator(); + l.AddHeader(L.T("mixer.pending")); + if (d.Mixer.Pending.Count == 0) l.AddLabel(L.T("mixer.none")); + else + { + TableHeader(l, MixW, L.T("col.asset"), L.T("col.amount"), L.T("mixer.col.taint"), L.T("mixer.col.ready")); + foreach (var p in d.Mixer.Pending) + TableRow(l, MixW, p.Asset, Fmt.Num(p.Amount), Fmt.Credits(p.Taint), L.T("mixer.day", p.ReadyDay)); + } + }, r => + { + r.AddHeader(L.T("mixer.deposit")); + foreach (var line in L.T("mixer.hint").Split('|')) r.AddLabel(line); + r.BeginRow(); + foreach (var a in new[] { Assets.PSC, Assets.CRC, Assets.CRT, Assets.CRA }) + { + var asset = a; + r.AddButton(asset == _mixAsset ? $"[{asset}]" : asset, () => { _mixAsset = asset; Open(); }); + } + r.End(); + r.AddLabel(L.T("mixer.balance", _mixAsset, Fmt.Num(w.Get(_mixAsset)))); + AmountRow(r); + r.AddLabel(L.T("mixer.rounds")); + r.BeginRow(); + for (int n = 1; n <= Tuning.MixerMaxRounds; n++) + { + int rounds = n; + r.AddButton(rounds == _mixRounds ? $"[{rounds}]" : rounds.ToString(), () => { _mixRounds = rounds; Open(); }); + } + r.End(); + if (double.TryParse((_amount ?? "").Trim(), NumberStyles.Float, CultureInfo.InvariantCulture, out double v) && v > 0) + { + double rate = Mixer.RoundRate(v * Session.Price(_mixAsset)); + r.AddLabel(L.T("mixer.preview", Fmt.Num(v * Math.Pow(1 - Tuning.MixerFee, _mixRounds)), _mixAsset, (int)Math.Round((1 - Math.Pow(1 - rate, _mixRounds)) * 100), day + _mixRounds)); + } + r.AddButton(L.T("mixer.go"), MixDeposit); + }); + } + + private static void MixDeposit() + { + if (!ReadAmount(out double v)) return; + var d = Session.Data; + int day = StoreStation.GetDayCounter(); + if (d.Hot.Get(_mixAsset) + 1e-9 < v) { _message = L.T("msg.balance"); Open(); return; } + var dep = d.Mixer.Deposit(d.Hot, _mixAsset, v, _mixRounds, day, Session.Price); + _message = dep == null ? L.T("mixer.min", Tuning.MixerMin) : L.T("mixer.done", Fmt.Num(v), _mixAsset, dep.ReadyDay); + Session.Save(); + Open(); + } + private static string AvgText(Wallet w, string asset) => w.AvgBuy(asset) is double a ? Fmt.Num(a) : L.T("avg.none"); private static void ReportPage(CustomUIBuilder b) @@ -299,7 +367,7 @@ namespace Decentralized if (!ReadAmount(out double v)) return; var d = Session.Data; if (d.Hot.Get(Assets.CRC) + 1e-9 < v) { _message = L.T("msg.balance"); Open(); return; } - var r = d.Exchange.Cashout(d.Hot, v, Session.Price, StoreStation.GetDayCounter(), Session.Rent(), false, d.Rng); + var r = d.Exchange.Cashout(d.Hot, v, Session.Price, StoreStation.GetDayCounter(), Session.Rent(), d.Mixer.RecentlyMixed(StoreStation.GetDayCounter()), d.Rng); int got = (int)Math.Floor(r.Received - r.Tax); ModHelper.AddCredit(got, true); _message = L.T("msg.cashout", got, Fmt.Credits(r.Tax)); diff --git a/src/Decentralized/Unlock.cs b/src/Decentralized/Unlock.cs index 7b71317..5fa6b74 100644 --- a/src/Decentralized/Unlock.cs +++ b/src/Decentralized/Unlock.cs @@ -8,24 +8,30 @@ namespace Decentralized public const string Id = "PD_WEB3_COMMUNITY"; public const int Price = 400; private const string Card = "Service"; + /// 人脉网络里本 mod 自己的一页:入口、混币器等 Web3 相关升级都放在这里。 + public const string Tab = "PD_WEB3"; + + public const string MixerId = "PD_MIXER"; + public const int MixerPrice = 700; + public static bool MixerUnlocked => PlayerStore.Instance != null && ModHelper.IsNetworkUpgradeUnlocked(MixerId); public static bool IsUnlocked => PlayerStore.Instance != null && ModHelper.IsNetworkUpgradeUnlocked(Id); public static void Register() { - try - { - // 分类用游戏自己的 "Service" 键,和 Probably Automated 一样归进现有的"服务"栏; - // 标题和说明用回调:OnEnable 时 mod 本地化表还没加载 - ModHelper.AddNetworkUpgrade(ModInfo.Id, Id, Price, Card, - () => L.T("upgrade.title"), () => L.T("upgrade.desc"), OnPurchased, - iconKey: ModInfo.Id + ":terminal"); - } - catch (NullReferenceException) - { - // 登记定义后游戏会访问 PlayerStore.Instance,主菜单阶段为空会抛异常;定义已登记,读档时游戏会把升级补进这一局。 - Log.Info("人脉升级已登记(主菜单阶段访问 PlayerStore 失败,已忽略)"); - } + // 分类用游戏自己的 "Service" 键,和 Probably Automated 一样归进现有的"服务"栏; + // 标题和说明用回调:OnEnable 时 mod 本地化表还没加载 + ModHelper.SetNetworkTabLabel(Tab, () => L.T("upgrade.tab")); + // 登记定义后游戏会访问 PlayerStore.Instance,主菜单阶段为空会抛异常;定义已登记,读档时游戏会把升级补进这一局。 + // 每个升级单独 try,免得前一个抛异常时后面的没登记上。 + Add(Id, Price, () => L.T("upgrade.title"), () => L.T("upgrade.desc"), OnPurchased, "terminal", null); + Add(MixerId, MixerPrice, () => L.T("mixer.upgrade.title"), () => L.T("mixer.upgrade.desc"), () => Log.Info("混币器已解锁"), "ui_web3", new[] { Id }); + } + + private static void Add(string id, int price, Func title, Func desc, Action onUnlock, string icon, string[] prerequisites) + { + try { ModHelper.AddNetworkUpgrade(ModInfo.Id, id, price, Card, title, desc, onUnlock, tab: Tab, iconKey: ModInfo.Id + ":" + icon, prerequisites: prerequisites); } + catch (NullReferenceException) { Log.Info($"人脉升级 {id} 已登记(主菜单阶段访问 PlayerStore 失败,已忽略)"); } } private static void OnPurchased() diff --git a/tests/Decentralized.Domain.Tests/Program.cs b/tests/Decentralized.Domain.Tests/Program.cs index 270531a..df99341 100644 --- a/tests/Decentralized.Domain.Tests/Program.cs +++ b/tests/Decentralized.Domain.Tests/Program.cs @@ -498,6 +498,34 @@ internal static class Program Check(CoinPayment.Receive(w, Assets.CRC, 0, 1, 10) == 0, "零金额不收"); }); + Test("混币器:按价值比例带走污点、轮数即天数、手续费与清洗、匿名集、制裁标记", () => + { + Func px = a => a == Assets.PSC ? 10 : 1; + var w = new Wallet { Address = "t" }; + w.Add(Assets.CRC, 1000); w.Add(Assets.PSC, 100); // 总价值 2000 + w.Taint = 400; w.Basis = 1000; + var m = new Mixer(); + Check(m.Deposit(w, Assets.CRC, 1000, 9, 1, px) == null && m.Deposit(w, Assets.CRC, 50, 1, 1, px) == null, "轮数超限、低于最小额不收"); + var d = m.Deposit(w, Assets.CRC, 1000, 2, 1, px); + Check(d != null && Near(d.Taint, 200, 1e-9) && Near(w.Taint, 200, 1e-9) && Near(w.Get(Assets.CRC), 0, 1e-9), "存入 1000 / 2000:带走一半污点"); + var rng = new Rng(3); + Check(m.Night(w, 2, px, rng).Count == 0, "未到期不提回"); + var res = m.Night(w, 3, px, rng); + Check(res.Count == 1 && !res[0].Rugged, "到期提回"); + double rate = Mixer.RoundRate(1000); + Check(Near(w.Get(Assets.CRC), 1000 * 0.98 * 0.98, 1e-6) && Near(res[0].TaintAfter, 200 * (1 - rate) * (1 - rate), 1e-6), "两轮:手续费与污点按轮数递减"); + Check(Mixer.RoundRate(100000) < Mixer.RoundRate(1000) / 3, "存入越大匿名集占比越大,清洗越差"); + Check(m.RecentlyMixed(3) && !m.RecentlyMixed(3 + Tuning.AmlWindowDays + 1), "提回后一段时间内出金按混币来源加重"); + m.OnEvent(MarketEvent.Crackdown, 5, new Rng(1)); + int hit = 0; for (int i = 0; i < 400; i++) { var t = new Mixer(); t.OnEvent(MarketEvent.Crackdown, 1, new Rng((ulong)i + 1)); if (t.Sanctioned(2)) hit++; } + Check(hit > 150 && hit < 250, $"镇压时约一半概率被制裁:{hit}/400"); + var w2 = new Wallet { Address = "u" }; w2.Add(Assets.CRC, 500); + var m2 = new Mixer { SanctionedUntil = 10 }; + m2.Deposit(w2, Assets.CRC, 500, 1, 1, px); + m2.Night(w2, 2, px, new Rng(5)); + Check(w2.Flagged, "制裁期间提出的币被标记"); + }); + Console.WriteLine($"\n{_passed} 通过,{_failed} 失败"); return _failed == 0 ? 0 : 1; }