diff --git a/CHANGELOG.md b/CHANGELOG.md
index cb6db3b..f27ba51 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -3,3 +3,4 @@
## 未发布
- 纯逻辑层:带种子的随机数、资产登记、地址账本(污点与成本随币走)、恒定乘积交易对、挖矿(显卡、矿机、全网算力、减半、损耗与暴毙)。
+- 纯逻辑层:市场(价格锚、情绪、游戏事件冲击、NPC 大户、多交易对套利回归、当日均价),三种稳定币(CRC 脱锚与减记、CRT 脱锚与冻结概率、CRA 补贴池与死亡螺旋),合规出金与 AML(大额与累计阈值、污点比例、轻度与重度命中、只对收益计税)。
diff --git a/src/Decentralized.Domain/Exchange.cs b/src/Decentralized.Domain/Exchange.cs
new file mode 100644
index 0000000..05d6fdd
--- /dev/null
+++ b/src/Decentralized.Domain/Exchange.cs
@@ -0,0 +1,76 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+
+namespace Decentralized.Domain
+{
+ public enum AmlOutcome { Clear, Light, Heavy }
+
+ /// 一次合规出金的结果。罪证由接入层记进游戏的罪证账本(销赃)。
+ public sealed class CashoutResult
+ {
+ public double Requested;
+ public double Received;
+ public double Confiscated;
+ public double Crime;
+ public double Tax;
+ public double HitChance;
+ public AmlOutcome Outcome;
+ }
+
+ ///
+ /// 合规交易所出金:CRC 兑回信用点,过 AML。审查按单笔金额、7 天累计金额、污点比例、是否来自混币器或被标记地址判定。
+ /// 轻度命中(污点 < 30%)没收污点部分、不记罪证;重度命中没收污点部分,并把污点额记为销赃罪证。
+ ///
+ public sealed class Exchange
+ {
+ /// 最近的出金记录:(天, 金额)。
+ public List> History = new List>();
+
+ public static double HitChance(double amount, double taint, double recentTotal, double rent, bool fromMixerOrFlagged)
+ {
+ if (amount <= 0) return 0;
+ double ratio = taint / amount;
+ double big = Tuning.AmlBigRents * rent, cum = Tuning.AmlCumRents * rent;
+ double p = 0;
+ if (ratio >= Tuning.AmlTaintRatio) p += ratio * 1.5;
+ p += Math.Max(0, (amount - big) / big) * 0.2;
+ p += Math.Max(0, (recentTotal + amount - cum) / cum) * 0.2;
+ if (fromMixerOrFlagged) p += Tuning.AmlMixerBonus;
+ return Math.Min(Tuning.AmlMaxChance, p);
+ }
+
+ ///
+ /// 从地址里取出 amount 个 CRC 兑成信用点(按 CRC 当前价格,平时为 1;脱锚期间按市价),扣手续费。污点与成本按比例随取出部分计算;
+ /// 只对超出成本的部分计税。
+ ///
+ public CashoutResult Cashout(Wallet wallet, double amount, Func price, int day, double rent, bool fromMixer, Rng rng)
+ {
+ amount = Math.Min(amount, wallet.Get(Assets.CRC));
+ var r = new CashoutResult { Requested = amount };
+ if (amount <= 0) return r;
+ double total = wallet.Value(price);
+ double share = total > 0 ? Math.Min(1, amount * price(Assets.CRC) / total) : 0;
+ double taint = wallet.Taint * share, basis = wallet.Basis * share;
+ History.RemoveAll(h => h.Key <= day - Tuning.AmlWindowDays);
+ double recent = History.Sum(h => h.Value);
+ double value = amount * price(Assets.CRC);
+ r.HitChance = HitChance(value, taint, recent, rent, fromMixer || wallet.Flagged);
+ wallet.Add(Assets.CRC, -amount);
+ wallet.Taint -= taint;
+ wallet.Basis -= basis;
+ History.Add(new KeyValuePair(day, value));
+ double got = value * (1 - Tuning.ExchangeFee);
+ if (rng.Chance(r.HitChance))
+ {
+ r.Outcome = taint / value >= Tuning.AmlHeavyRatio ? AmlOutcome.Heavy : AmlOutcome.Light;
+ r.Confiscated = Math.Min(got, taint);
+ got -= r.Confiscated;
+ if (r.Outcome == AmlOutcome.Heavy) r.Crime = taint;
+ }
+ r.Tax = Math.Max(0, got - basis) * Tuning.TaxRate;
+ r.Received = got;
+ return r;
+ }
+ }
+}
diff --git a/src/Decentralized.Domain/Market.cs b/src/Decentralized.Domain/Market.cs
new file mode 100644
index 0000000..80685dc
--- /dev/null
+++ b/src/Decentralized.Domain/Market.cs
@@ -0,0 +1,104 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+
+namespace Decentralized.Domain
+{
+ ///
+ /// 影响行情的游戏事件种类。接入层把游戏的新闻事件与派系行动映射到这里(例如全站停电、电力短缺 → Blackout)。
+ ///
+ public enum MarketEvent { Blackout, Crackdown, GangWar, RevRaidUpper, LowerUnrest }
+
+ ///
+ /// PSCoin 行情与全部交易对。PSCoin 有一个参考价(主交易对:合规交易所 PSC/CRC);
+ /// 夜间参考价向价格锚回归并加噪声,其余交易对由 NPC 套利者拉向参考价。
+ ///
+ public sealed class Market
+ {
+ public double Sentiment = 1.0;
+ public List Pools = new List();
+ /// 开门时的参考价,夜间用它和收盘价算当日均价(合约的标记价)。
+ public double OpenPrice = Tuning.PscStartPrice;
+
+ public const double ArbRate = 0.5; // 非主交易对每晚被拉回参考价的比例
+ public const double SideDepthShare = 0.3; // 非主交易对的深度占主交易对的比例
+
+ public static Market CreateDefault(int day)
+ {
+ var m = new Market();
+ double depth = Tuning.PoolDepth(day), p = Tuning.PscStartPrice;
+ m.Pools.Add(Pool.Create(Venue.Exchange, Assets.PSC, Assets.CRC, p, depth, Tuning.ExchangeFee));
+ m.Pools.Add(Pool.Create(Venue.Dex, Assets.PSC, Assets.CRT, p, depth * SideDepthShare, Tuning.DexFee));
+ m.Pools.Add(Pool.Create(Venue.Dex, Assets.PSC, Assets.CRA, p, depth * SideDepthShare, Tuning.DexFee));
+ m.Pools.Add(Pool.Create(Venue.Dex, Assets.CRT, Assets.CRC, 1, depth * SideDepthShare, Tuning.DexFee));
+ return m;
+ }
+
+ public Pool Main => Pools[0];
+
+ public Pool Find(Venue venue, string baseAsset, string quote) =>
+ Pools.FirstOrDefault(x => x.Venue == venue && x.Base == baseAsset && x.Quote == quote);
+
+ /// PSCoin 的参考价(信用点)。
+ public double Price => Main.Price;
+
+ public double Anchor(ICollection active)
+ {
+ double a = Tuning.PscStartPrice * Sentiment;
+ if (active.Contains(MarketEvent.Blackout)) a *= 1.10;
+ if (active.Contains(MarketEvent.Crackdown)) a *= 0.92;
+ return a;
+ }
+
+ public static double Volatility(ICollection active)
+ {
+ double v = Tuning.PscVol;
+ if (active.Contains(MarketEvent.GangWar)) v *= 2;
+ else if (active.Contains(MarketEvent.RevRaidUpper)) v *= 1.5;
+ return v;
+ }
+
+ /// 开门:一次日内波动(幅度为日波动率的一半)。
+ public void Open(ICollection active, Rng rng, int day)
+ {
+ double p = Price * Math.Exp(rng.Gauss() * Volatility(active) / 2);
+ Main.SetPrice(p, Tuning.PoolDepth(day));
+ OpenPrice = p;
+ }
+
+ /// NPC 大户:每周约 2 笔、每笔 2,000–8,000 信用点,随机买或卖。返回成交额(正为买入),没有交易时为 0。
+ public double Whale(Rng rng)
+ {
+ if (!rng.Chance(2.0 / 7)) return 0;
+ double size = rng.Range(2000.0, 8000.0);
+ if (rng.Chance(0.5)) { Main.BuyBase(size); return size; }
+ Main.SellBase(size / Price);
+ return -size;
+ }
+
+ /// 当日均价:合约强平与借贷估值用它,白天的插针不触发强平。
+ public double Mark => (OpenPrice + Price) / 2;
+
+ ///
+ /// 夜间:情绪做均值回归随机游走;参考价向锚回归 15% 并加噪声,限制在锚的 0.3–3 倍;
+ /// 其余交易对按各自计价币的价格拉向参考价。
+ ///
+ public void Night(ICollection active, Rng rng, int day, Func stablePrice)
+ {
+ Sentiment *= Math.Exp(Tuning.SentimentKappa * -Math.Log(Sentiment) + rng.Gauss() * Tuning.SentimentVol);
+ double anchor = Anchor(active);
+ double lp = Math.Log(Price);
+ lp += Tuning.Revert * (Math.Log(anchor) - lp) + rng.Gauss() * Volatility(active);
+ double p = Math.Max(Tuning.PscStartPrice * Tuning.PriceFloor, Math.Min(Tuning.PscStartPrice * Tuning.PriceCeiling, Math.Exp(lp)));
+ double depth = Tuning.PoolDepth(day);
+ Main.SetPrice(p, depth);
+ foreach (var pool in Pools.Skip(1))
+ {
+ double baseCredits = pool.Base == Assets.PSC ? p : stablePrice(pool.Base);
+ double quoteCredits = pool.Quote == Assets.PSC ? p : stablePrice(pool.Quote);
+ if (quoteCredits <= 0) continue;
+ pool.Revert(baseCredits / quoteCredits, ArbRate, depth * SideDepthShare / quoteCredits);
+ }
+ }
+ }
+}
diff --git a/src/Decentralized.Domain/Stablecoins.cs b/src/Decentralized.Domain/Stablecoins.cs
new file mode 100644
index 0000000..e7f1fab
--- /dev/null
+++ b/src/Decentralized.Domain/Stablecoins.cs
@@ -0,0 +1,99 @@
+using System;
+using System.Collections.Generic;
+
+namespace Decentralized.Domain
+{
+ ///
+ /// 三种稳定币的价格与状态:CRC(上层,托管银行出事时脱锚、可能减记),CRT(黑市,镇压或火并时脱锚,
+ /// 镇压期间按黑市声望出卖客户),CRA(革命军,质押付息,补贴池告急或外生触发时进入三天死亡螺旋)。
+ ///
+ public sealed class Stablecoins
+ {
+ public double Crc = 1, Crt = 1, Cra = 1;
+ public int CrcDepegEnd = -1, CrtDepegEnd = -1;
+ public bool CrcHaircutPending;
+ public double CraSubsidy = Tuning.CraSubsidy0;
+ public int CraWarnDay = -1, CraSpiralDay = -1;
+ public double RevPower = 50;
+ public List RevHistory = new List();
+
+ public double Price(string asset)
+ {
+ switch (asset)
+ {
+ case Assets.CRC: return Crc;
+ case Assets.CRT: return Crt;
+ case Assets.CRA: return Cra;
+ default: return 1;
+ }
+ }
+
+ /// 减记发生时返回 CRC 持有量要乘的系数(其余情况为 1)。
+ public double OnEventStart(MarketEvent ev, int day, Rng rng)
+ {
+ if (ev == MarketEvent.RevRaidUpper && CrcDepegEnd < 0 && rng.Chance(Tuning.CrcDepegChance))
+ {
+ Crc = rng.Range(Tuning.CrcDepegMin, Tuning.CrcDepegMax);
+ CrcDepegEnd = day + rng.Range(Tuning.CrcDepegDaysMin, Tuning.CrcDepegDaysMax);
+ CrcHaircutPending = rng.Chance(Tuning.CrcHaircutChance);
+ }
+ if ((ev == MarketEvent.GangWar || ev == MarketEvent.Crackdown) && CrtDepegEnd < 0 && rng.Chance(Tuning.CrtDepegChance))
+ {
+ Crt = rng.Range(Tuning.CrtDepegMin, Tuning.CrtDepegMax);
+ CrtDepegEnd = day + rng.Range(Tuning.CrtDepegDaysMin, Tuning.CrtDepegDaysMax);
+ }
+ return 1;
+ }
+
+ /// 镇压开始时,玩家持有的 CRT 被冻结一半的概率:黑市声望 0 时 40%,50 以上为 0。
+ public static double CrtFreezeChance(double blackMarketRep) =>
+ Math.Max(0, Tuning.CrtFreezeBase - blackMarketRep * Tuning.CrtFreezePerRep);
+
+ public bool CraSubsidyLow => CraSubsidy <= Tuning.CraSubsidy0 * Tuning.CraSubsidyWarn;
+
+ /// CRA 的预兆:外生螺旋的新闻已出,或补贴池告急(利率下调)。螺旋开始后不再算预兆。
+ public bool CraWarning(int day) => (CraWarnDay >= 0 && CraWarnDay <= day || CraSubsidyLow) && Cra >= 0.97;
+
+ public double CraRate => CraSubsidyLow ? Tuning.CraRateLow : Tuning.CraRate;
+
+ ///
+ /// 夜间结算。返回 CRC 持有量要乘的系数(减记兑现时为 0.95,否则为 1)。
+ /// playerStaked:玩家的 CRA 质押额,用来算补贴支出(玩家的利息由调用方按 发放)。
+ ///
+ public double Night(int day, Rng rng, double playerStaked)
+ {
+ double crcFactor = 1;
+ if (CrcDepegEnd == day)
+ {
+ Crc = 1;
+ if (CrcHaircutPending) crcFactor = Tuning.CrcHaircut;
+ CrcDepegEnd = -1;
+ CrcHaircutPending = false;
+ }
+ if (CrtDepegEnd == day)
+ {
+ Crt = 1;
+ CrtDepegEnd = -1;
+ }
+ RevHistory.Add(RevPower);
+ if (RevHistory.Count > 7) RevHistory.RemoveAt(0);
+ RevPower = Math.Max(0, Math.Min(100, RevPower + rng.Gauss() * 3));
+
+ if (CraSpiralDay < 0)
+ {
+ CraSubsidy -= (Tuning.CraNpcStake + playerStaked) * CraRate;
+ bool revFalling = RevHistory.Count == 7 && RevPower < RevHistory[0] - 5;
+ double weekly = Tuning.CraSpiralWeekly * (revFalling ? 3 : 1);
+ double daily = 1 - Math.Pow(1 - weekly, 1.0 / 7);
+ if (CraWarnDay < 0 && (rng.Chance(daily) || CraSubsidy <= 0))
+ {
+ CraWarnDay = day;
+ CraSpiralDay = day + rng.Range(Tuning.CraWarnLeadMin, Tuning.CraWarnLeadMax);
+ }
+ }
+ if (CraSpiralDay > 0 && day >= CraSpiralDay)
+ Cra = Tuning.CraSpiralPath[Math.Min(day - CraSpiralDay, Tuning.CraSpiralPath.Length - 1)];
+ return crcFactor;
+ }
+ }
+}
diff --git a/src/Decentralized.Domain/Tuning.cs b/src/Decentralized.Domain/Tuning.cs
index fc3a849..bb858f4 100644
--- a/src/Decentralized.Domain/Tuning.cs
+++ b/src/Decentralized.Domain/Tuning.cs
@@ -28,6 +28,30 @@ namespace Decentralized.Domain
public const double SentimentKappa = 0.02, SentimentVol = 0.02;
public const double PriceFloor = 0.3, PriceCeiling = 3.0;
+ // 稳定币
+ public const double CrcDepegChance = 0.30, CrcDepegMin = 0.88, CrcDepegMax = 0.93;
+ public const int CrcDepegDaysMin = 2, CrcDepegDaysMax = 3;
+ public const double CrcHaircutChance = 0.15, CrcHaircut = 0.95;
+ public const double CrtDepegChance = 0.40, CrtDepegMin = 0.92, CrtDepegMax = 0.96;
+ public const int CrtDepegDaysMin = 1, CrtDepegDaysMax = 2;
+ public const double CrtFreezeBase = 0.40, CrtFreezePerRep = 0.008, CrtFreezeShare = 0.5;
+ public const int CrtUnfreezeAfter = 3;
+ public const double CrtSwapFee = 0.02;
+ public const double CraRate = 0.005, CraRateLow = 0.002;
+ public const int CraUnlockDays = 4;
+ public const double CraSubsidy0 = 30000, CraSubsidyWarn = 0.2, CraNpcStake = 20000;
+ public const double CraSpiralWeekly = 0.02;
+ public const int CraWarnLeadMin = 4, CraWarnLeadMax = 7;
+ public static readonly double[] CraSpiralPath = { 0.85, 0.30, 0.02 };
+
+ // 合规出金与 AML(阈值以当前房租为单位)
+ public const double AmlBigRents = 5, AmlCumRents = 15;
+ 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;
+ /// 单个金融违规事件记入的罪证上限。
+ public const double CrimeEventCap = 400;
+
/// 主交易对(信用点一侧)的深度随天数增长。
public static double PoolDepth(int day) => PoolDepth0 * (1 + day / 60.0);
}
diff --git a/tests/Decentralized.Domain.Tests/Program.cs b/tests/Decentralized.Domain.Tests/Program.cs
index 504cb1c..2a4de91 100644
--- a/tests/Decentralized.Domain.Tests/Program.cs
+++ b/tests/Decentralized.Domain.Tests/Program.cs
@@ -1,4 +1,5 @@
using System;
+using System.Collections.Generic;
using System.Linq;
using Decentralized.Domain;
@@ -158,6 +159,123 @@ internal static class Program
$"PMD 20% / 76% / 95%:{q[0]:P0} / {q[1]:P0} / {q[2]:P0}");
});
+ Test("市场:价格锚受停电、镇压影响;夜间向锚回归并限制在 0.3–3 倍", () =>
+ {
+ var m = Market.CreateDefault(1);
+ var none = new MarketEvent[0];
+ Check(Near(m.Anchor(none), 100, 1e-9) && Near(m.Anchor(new[] { MarketEvent.Blackout }), 110, 1e-9)
+ && Near(m.Anchor(new[] { MarketEvent.Crackdown }), 92, 1e-9), "锚:基准 100,停电 ×1.1,镇压 ×0.92");
+ Check(Market.Volatility(new[] { MarketEvent.GangWar }) == Tuning.PscVol * 2, "火并时波动 ×2");
+ m.Main.SetPrice(60, Tuning.PoolDepth(1));
+ var rng = new Rng(5);
+ double sum = 0;
+ for (int i = 0; i < 400; i++)
+ {
+ var mm = Market.CreateDefault(1);
+ mm.Main.SetPrice(60, Tuning.PoolDepth(1));
+ mm.Night(none, new Rng((ulong)i), 1, a => 1);
+ sum += Math.Log(mm.Price);
+ }
+ double expect = Math.Log(60) + 0.15 * (Math.Log(100) - Math.Log(60));
+ Check(Near(sum / 400, expect, 0.01), $"从 60 起一晚平均回到 {Math.Exp(expect):0.0} 附近:{Math.Exp(sum / 400):0.0}");
+ var hi = Market.CreateDefault(1);
+ hi.Main.SetPrice(1000, Tuning.PoolDepth(1));
+ hi.Night(none, rng, 1, a => 1);
+ Check(hi.Price <= 300 + 1e-9, $"上限 3 倍:{hi.Price:0.0}");
+ });
+
+ Test("市场:其他交易对每晚被套利拉向参考价,三角价差收敛", () =>
+ {
+ var m = Market.CreateDefault(1);
+ var crt = m.Find(Venue.Dex, Assets.PSC, Assets.CRT);
+ crt.SetPrice(130, crt.QuoteReserve);
+ for (int d = 1; d <= 5; d++) m.Night(new MarketEvent[0], new Rng((ulong)d), 1, a => 1);
+ double gap = Math.Abs(crt.Price / m.Price - 1);
+ Check(gap < 0.03, $"五晚后 PSC/CRT 与参考价相差 {gap:P1}");
+ var crtCrc = m.Find(Venue.Dex, Assets.CRT, Assets.CRC);
+ m.Night(new MarketEvent[0], new Rng(9), 1, a => a == Assets.CRT ? 0.9 : 1);
+ Check(crtCrc.Price < 0.98, $"CRT 脱锚时 CRT/CRC 向 0.9 靠拢:{crtCrc.Price:0.000}");
+ });
+
+ Test("稳定币:CRC 脱锚与减记,CRT 脱锚与冻结概率", () =>
+ {
+ int depegs = 0, haircuts = 0;
+ for (int i = 0; i < 2000; i++)
+ {
+ var s = new Stablecoins();
+ var rng = new Rng((ulong)i);
+ s.OnEventStart(MarketEvent.RevRaidUpper, 10, rng);
+ if (s.Crc < 1)
+ {
+ depegs++;
+ Check(s.Crc >= 0.88 && s.Crc <= 0.93 && s.CrcDepegEnd >= 12 && s.CrcDepegEnd <= 13, "CRC 脱锚深度与持续");
+ double f = 1;
+ for (int d = 10; d <= s.CrcDepegEnd; d++) f *= s.Night(d, rng, 0);
+ if (f < 1) haircuts++;
+ Check(s.Crc == 1, "到期恢复");
+ }
+ }
+ Check(Near(depegs / 2000.0, 0.30, 0.03), $"革命军袭击上层时 30% 脱锚:{depegs / 2000.0:P0}");
+ Check(Near(haircuts / (double)depegs, 0.15, 0.04), $"其中 15% 减记:{haircuts / (double)depegs:P0}");
+ Check(Near(Stablecoins.CrtFreezeChance(0), 0.40, 1e-9) && Stablecoins.CrtFreezeChance(50) == 0 && Near(Stablecoins.CrtFreezeChance(25), 0.2, 1e-9),
+ "CRT 冻结概率:声望 0 为 40%,25 为 20%,50 为 0");
+ });
+
+ Test("稳定币:CRA 补贴池告急即为预兆;螺旋三天 0.85 / 0.30 / 0.02", () =>
+ {
+ var s = new Stablecoins { CraSubsidy = Tuning.CraSubsidy0 * 0.19 };
+ Check(s.CraWarning(1) && s.CraRate == Tuning.CraRateLow, "补贴池低于 20%:预兆出现、利率降到 0.2%");
+ var t = new Stablecoins { CraSubsidy = 1 };
+ var rng = new Rng(3);
+ t.Night(1, rng, 100000);
+ Check(t.CraWarnDay == 1 && t.CraSpiralDay >= 5 && t.CraSpiralDay <= 8, $"补贴池吃空触发螺旋,提前 4–7 天:第 {t.CraSpiralDay} 天开始");
+ var path = new List();
+ for (int d = 2; d <= t.CraSpiralDay + 3; d++) { t.Night(d, rng, 0); if (d >= t.CraSpiralDay) path.Add(t.Cra); }
+ Check(path.Take(3).SequenceEqual(new[] { 0.85, 0.30, 0.02 }), $"螺旋路径:{string.Join(" / ", path)}");
+ Check(!t.CraWarning(t.CraSpiralDay + 1), "螺旋开始后不再算预兆");
+ });
+
+ Test("合规出金:干净小额不查;污点高时重度命中记罪证;轻度命中只没收污点部分;只对收益计税", () =>
+ {
+ Func px = a => 1;
+ Check(Exchange.HitChance(500, 0, 0, 200, false) == 0, "干净、低于 5 倍房租:不查");
+ Check(Near(Exchange.HitChance(2000, 0, 0, 200, false), 0.2, 1e-9), "单笔为大额阈值 2 倍:20%");
+ Check(Near(Exchange.HitChance(1000, 500, 0, 200, false), 0.75, 1e-9), "污点 50%:75%");
+ Check(Exchange.HitChance(100000, 100000, 1e6, 200, true) == Tuning.AmlMaxChance, "上限 90%");
+ int heavy = 0;
+ double crime = 0;
+ for (int i = 0; i < 1000; i++)
+ {
+ var w = new Wallet { Taint = 500, Basis = 1000 };
+ w.Add(Assets.CRC, 1000);
+ var r = new Exchange().Cashout(w, 1000, px, 1, 200, false, new Rng((ulong)i));
+ if (r.Outcome == AmlOutcome.Heavy) { heavy++; crime = r.Crime; Check(Near(r.Received, 998 - 500, 1e-6), "重度:没收污点部分"); }
+ else Check(Near(r.Received, 998, 1e-6) && r.Tax == 0, "未命中:全额到账;本金不计税");
+ }
+ Check(Near(heavy / 1000.0, 0.75, 0.04) && crime == 500, $"污点 50% 时约 75% 重度命中,罪证 = 污点额:{heavy / 1000.0:P0}");
+ var light = new Wallet { Taint = 150 };
+ light.Add(Assets.CRC, 1000);
+ CashoutResult lr = null;
+ for (ulong i = 0; lr == null || lr.Outcome == AmlOutcome.Clear; i++)
+ {
+ var w = new Wallet { Taint = 150 };
+ w.Add(Assets.CRC, 1000);
+ lr = new Exchange().Cashout(w, 1000, px, 1, 200, false, new Rng(i));
+ }
+ Check(lr.Outcome == AmlOutcome.Light && lr.Crime == 0 && Near(lr.Confiscated, 150, 1e-6), "污点 15%:轻度命中,没收 150、不记罪证");
+ Check(Near(lr.Tax, (998 - 150) * Tuning.TaxRate, 1e-6), "成本为 0 时到账部分全额计税");
+ var ex = new Exchange();
+ var big = new Wallet();
+ big.Add(Assets.CRC, 4000);
+ ex.Cashout(big, 2000, px, 1, 200, false, new Rng(1));
+ Check(Near(Exchange.HitChance(2000, 0, ex.History.Sum(h => h.Value), 200, false), 0.2 + 0.2 * 1000 / 3000.0, 1e-9),
+ "7 天内累计超过 15 倍房租也会提高命中率");
+ var dep = new Wallet { Basis = 1000 };
+ dep.Add(Assets.CRC, 1000);
+ var dr = new Exchange().Cashout(dep, 1000, a => 0.9, 1, 200, false, new Rng(1));
+ Check(Near(dr.Received, 900 * (1 - Tuning.ExchangeFee), 1e-6), "CRC 脱锚时按市价兑出");
+ });
+
Console.WriteLine($"\n{_passed} 通过,{_failed} 失败");
return _failed == 0 ? 0 : 1;
}