Phase 2: mixer (Web3 tab in the network, terminal page, sanctions, night return), unlock registration per upgrade

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
ZHU MINCHAOandClaude Sonnet 5.5 committed 2026-11-03 16:39:40 +08:00
1 parent 133367b58d
commit 09a5cae69c
11 files changed
+290 -16

No files matched your search

+3
View File
@@ -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,顾客对话显示。
### 第一阶段完成(入门、挖矿、显卡鉴定、终端)
+98
View File
@@ -0,0 +1,98 @@
using System;
using System.Collections.Generic;
using System.Linq;
namespace Decentralized.Domain
{
/// <summary>混币器里一笔在途的存入。</summary>
public sealed class MixDeposit
{
public string Asset;
public double Amount;
/// <summary>存入时一并带走的污点与成本(信用点)。</summary>
public double Taint, Basis;
public int Rounds;
public int ReadyDay;
}
/// <summary>一笔混币结束时的结果,供夜间日志使用。</summary>
public sealed class MixResult
{
public bool Rugged;
public string Asset;
public double Amount, TaintBefore, TaintAfter;
public bool Flagged;
}
/// <summary>
/// 混币器:把币存进去,每轮扣手续费、降低污点,轮数 = 等待天数,到期提回同一个地址。
/// 匿名集 = NPC 背景量 + 玩家存入量,玩家占比越大清洗效果越差。镇压事件开始时可能被制裁:
/// 制裁期间提出的币视为被标记(出金审查按"来自混币器或被标记地址"加重,且地址被标记)。每晚有极小概率跑路。
/// </summary>
public sealed class Mixer
{
public List<MixDeposit> Pending = new List<MixDeposit>();
public int SanctionedUntil = -1;
/// <summary>提回的币最近一次从混币器出来的天数,用来给出金审查加重。</summary>
public int LastReturnDay = -999;
public bool Sanctioned(int day) => day <= SanctionedUntil;
/// <summary>资金来自混币器:最近 AML 窗口内提回过币,或者被标记。</summary>
public bool RecentlyMixed(int day) => day - LastReturnDay <= Tuning.AmlWindowDays;
/// <summary>一轮的清洗率:基准 × 匿名集系数。</summary>
public static double RoundRate(double depositValue) => Tuning.MixerCleanRate * Tuning.MixerNpcVolume / (Tuning.MixerNpcVolume + Math.Max(0, depositValue));
/// <summary>存入:从钱包按价值比例带走污点与成本。轮数超出范围或余额不足返回 null。</summary>
public MixDeposit Deposit(Wallet w, string asset, double amount, int rounds, int day, Func<string, double> 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;
}
/// <summary>镇压事件开始:有一定概率被制裁,持续若干天。</summary>
public void OnEvent(MarketEvent ev, int day, Rng rng)
{
if (ev == MarketEvent.Crackdown && rng.Chance(Tuning.MixerSanctionChance)) SanctionedUntil = day + Tuning.MixerSanctionDays;
}
/// <summary>每晚:到期的存入提回钱包(污点按轮数降低),在途的各有小概率跑路。</summary>
public List<MixResult> Night(Wallet w, int day, Func<string, double> price, Rng rng)
{
var results = new List<MixResult>();
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;
}
}
}
+3
View File
@@ -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;
/// <summary>混币器:每轮手续费、基准清洗率、NPC 背景量(信用点)、最大轮数、最小存入、制裁概率与天数、每晚跑路概率。</summary>
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;
/// <summary>各派系顾客用币付款的概率。</summary>
public const double CoinPayCrc = 0.12, CoinPayCrt = 0.25, CoinPayCra = 0.25, CoinPayPsc = 0.10;
/// <summary>单个金融违规事件记入的罪证上限。</summary>
+6
View File
@@ -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
@@ -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
1 # Probably Decentralized localization table: English (en)
2 # Source language and fallback. Format: key
3 upgrade.tab
4 upgrade.title
5 upgrade.desc
6 item.terminal.name
175 aml.window
176 aml.taint
177 aml.chance
178 mixer.h
179 mixer.ok
180 mixer.sanctioned
181 mixer.pending
182 mixer.none
183 mixer.col.taint
184 mixer.col.ready
185 mixer.day
186 mixer.deposit
187 mixer.hint
188 mixer.balance
189 mixer.rounds
190 mixer.preview
191 mixer.go
192 mixer.min
193 mixer.done
194 mixer.upgrade.title
195 mixer.upgrade.desc
196 news.sanction.title
197 news.sanction.desc
198 nightlog.mixer
199 nightlog.mixer_flag
200 nightlog.mixer_rug
201 tab.mixer
@@ -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,混币器
1 # Probably Decentralized 本地化表:简体中文(zh)
2 upgrade.tab
3 upgrade.title
4 upgrade.desc
5 item.terminal.name
174 aml.window
175 aml.taint
176 aml.chance
177 mixer.h
178 mixer.ok
179 mixer.sanctioned
180 mixer.pending
181 mixer.none
182 mixer.col.taint
183 mixer.col.ready
184 mixer.day
185 mixer.deposit
186 mixer.hint
187 mixer.balance
188 mixer.rounds
189 mixer.preview
190 mixer.go
191 mixer.min
192 mixer.done
193 mixer.upgrade.title
194 mixer.upgrade.desc
195 news.sanction.title
196 news.sanction.desc
197 nightlog.mixer
198 nightlog.mixer_flag
199 nightlog.mixer_rug
200 tab.mixer
+12 -1
View File
@@ -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");
+1
View File
@@ -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<MarketEvent> ActiveEvents = new List<MarketEvent>();
public NightReport LastNight;
public bool OpeningNewsShown;
+70 -2
View File
@@ -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 };
/// <summary>混币器:把币存进去,几轮之后提回同一个地址,污点降低,每轮扣手续费。</summary>
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));
+19 -13
View File
@@ -8,24 +8,30 @@ namespace Decentralized
public const string Id = "PD_WEB3_COMMUNITY";
public const int Price = 400;
private const string Card = "Service";
/// <summary>人脉网络里本 mod 自己的一页:入口、混币器等 Web3 相关升级都放在这里。</summary>
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<string> title, Func<string> 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()
@@ -498,6 +498,34 @@ internal static class Program
Check(CoinPayment.Receive(w, Assets.CRC, 0, 1, 10) == 0, "零金额不收");
});
Test("混币器:按价值比例带走污点、轮数即天数、手续费与清洗、匿名集、制裁标记", () =>
{
Func<string, double> 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;
}