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;
}