diff --git a/CHANGELOG.md b/CHANGELOG.md
index 949f37a..6860768 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4,6 +4,8 @@
### 第二阶段(进行中)
+- CRT 冻结:镇压事件开始时,按黑市声望(声望 0 时 40%,每点 −0.8%,50 以上不会)冻结钱包里一半的 CRT,3 天后解冻;冻结部分仍计入余额与估值,但不能交易、不能存混币器。Web3 页钱包下显示"CRT 冻结 N,第 X 天解冻";上报纸,夜间日志写冻结与解冻。开发者模式 F9 有 `Frozen CRT (test)`。游戏内验证了冻结显示与超额卖出被拒;镇压事件触发和夜间解冻只有单元测试。税与稽查不做(游戏本身没有税)。
+
- 人脉网络里本 mod 单独一页"Web3":入口(400)和混币器(700,需先加入)都放在这页。
- 混币器:终端 Web3 页里多一个"混币器"子页(买了升级才出现,和"交易"并列)。选币种、数量、1–5 轮,存入后每轮扣 2% 手续费并降低污点(基准每轮 35%,存入越大清洗越差,匿名集 = 2 万信用点背景量 + 存入量),轮数即等待天数,到期提回同一个地址;提回后一周内出金审查按"来自混币器"加重。镇压事件开始时 50% 被制裁 5 天,期间提出的币地址被标记,并上报纸;每晚 0.4% 跑路。夜间日志写提回与污点变化。开发者模式 F9 有 `Tainted wallet (test)`。游戏内验证了解锁、页面、存入与污点带走;夜间提回只有单元测试(测试存档里无法正常过夜)。
diff --git a/src/Decentralized.Domain/Mixer.cs b/src/Decentralized.Domain/Mixer.cs
index 0b3d2ff..e848916 100644
--- a/src/Decentralized.Domain/Mixer.cs
+++ b/src/Decentralized.Domain/Mixer.cs
@@ -47,7 +47,7 @@ namespace Decentralized.Domain
/// 存入:从钱包按价值比例带走污点与成本。轮数超出范围或余额不足返回 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;
+ if (rounds < 1 || rounds > Tuning.MixerMaxRounds || amount <= 0 || w.Avail(asset) + 1e-9 < amount) return null;
double value = amount * price(asset);
if (value < Tuning.MixerMin) return null;
double total = w.Value(price);
diff --git a/src/Decentralized.Domain/Stablecoins.cs b/src/Decentralized.Domain/Stablecoins.cs
index e7f1fab..42e6d85 100644
--- a/src/Decentralized.Domain/Stablecoins.cs
+++ b/src/Decentralized.Domain/Stablecoins.cs
@@ -49,6 +49,9 @@ namespace Decentralized.Domain
public static double CrtFreezeChance(double blackMarketRep) =>
Math.Max(0, Tuning.CrtFreezeBase - blackMarketRep * Tuning.CrtFreezePerRep);
+ /// 镇压开始时是否冻结玩家的 CRT:按黑市声望,声望越低越容易被黑市"出卖"。
+ public static bool CrtFrozenByCrackdown(double blackMarketRep, Rng rng) => rng.Chance(CrtFreezeChance(blackMarketRep));
+
public bool CraSubsidyLow => CraSubsidy <= Tuning.CraSubsidy0 * Tuning.CraSubsidyWarn;
/// CRA 的预兆:外生螺旋的新闻已出,或补贴池告急(利率下调)。螺旋开始后不再算预兆。
diff --git a/src/Decentralized.Domain/Wallet.cs b/src/Decentralized.Domain/Wallet.cs
index 9133c3a..0c70d82 100644
--- a/src/Decentralized.Domain/Wallet.cs
+++ b/src/Decentralized.Domain/Wallet.cs
@@ -32,6 +32,36 @@ namespace Decentralized.Domain
/// 买入均价(信用点);没有买入记录(例如全是挖出来的)返回 null。
public double? AvgBuy(string asset) => BuyQty.TryGetValue(asset, out var q) && q > 1e-9 ? BuyCost[asset] / q : (double?)null;
+ /// 被发行方冻结的数量(只有可冻结的币:CRT 在镇压期间、CRC),和解冻的日子。冻结的币仍计入余额与估值,但不能花、不能转。
+ public Dictionary Frozen = new Dictionary();
+ public Dictionary FrozenUntil = new Dictionary();
+
+ /// 可动用的余额(扣掉冻结部分)。
+ public double Avail(string asset) => Math.Max(0, Get(asset) - (Frozen.TryGetValue(asset, out var f) ? f : 0));
+
+ /// 冻结该币当前余额的 share 比例,直到 untilDay(含)。已冻结的不重复冻。返回冻结的数量。
+ public double Freeze(string asset, double share, int untilDay)
+ {
+ double amount = Avail(asset) * Math.Max(0, Math.Min(1, share));
+ if (amount <= 1e-9) return 0;
+ Frozen[asset] = (Frozen.TryGetValue(asset, out var f) ? f : 0) + amount;
+ FrozenUntil[asset] = Math.Max(untilDay, FrozenUntil.TryGetValue(asset, out var u) ? u : 0);
+ return amount;
+ }
+
+ /// 解冻到期的币,返回 (币种, 数量) 列表。
+ public List> Thaw(int day)
+ {
+ var list = new List>();
+ foreach (var a in new List(FrozenUntil.Keys))
+ if (day >= FrozenUntil[a])
+ {
+ list.Add(new KeyValuePair(a, Frozen.TryGetValue(a, out var f) ? f : 0));
+ Frozen.Remove(a); FrozenUntil.Remove(a);
+ }
+ return list;
+ }
+
public double Get(string asset) => Balances.TryGetValue(asset, out var v) ? v : 0;
public void Add(string asset, double amount)
diff --git a/src/Decentralized/CoinCustomers.cs b/src/Decentralized/CoinCustomers.cs
index 2329fee..8cdb35e 100644
--- a/src/Decentralized/CoinCustomers.cs
+++ b/src/Decentralized/CoinCustomers.cs
@@ -15,6 +15,13 @@ namespace Decentralized
public static void Subscribe()
{
ModHook.OnGenerateCustomerLate += OnGenerate;
+ ModHelper.AddDebugFunction(ModInfo.Id, ModInfo.Name, "Frozen CRT (test)", () => Log.Guard("冻结 CRT(测试)", () =>
+ {
+ var w = Session.Data.Hot;
+ w.Add(Assets.CRT, 1000);
+ double f = w.Freeze(Assets.CRT, Tuning.CrtFreezeShare, StoreStation.GetDayCounter() + Tuning.CrtUnfreezeAfter);
+ Log.Info($"CRT {w.Get(Assets.CRT):0},冻结 {f:0},可动用 {w.Avail(Assets.CRT):0}");
+ }));
ModHelper.AddDebugFunction(ModInfo.Id, ModInfo.Name, "Tainted wallet (test)", () => Log.Guard("带污点的钱包(测试)", () =>
{
var w = Session.Data.Hot;
diff --git a/src/Decentralized/ModFiles/Localization/en.csv b/src/Decentralized/ModFiles/Localization/en.csv
index 5b61a08..ca1c4ee 100644
--- a/src/Decentralized/ModFiles/Localization/en.csv
+++ b/src/Decentralized/ModFiles/Localization/en.csv
@@ -200,3 +200,9 @@ nightlog.mixer_flag, (withdrawn during sanctions; address flagged)
nightlog.mixer_rug,The mixer's operator ran off with it: {0} {1} never came back.
web3.sub.trade,Trade
web3.sub.mixer,Mixer
+msg.frozen,Those coins are frozen until they thaw.
+wallet.frozen,"{0}: {1} frozen, thaws on day {2}"
+nightlog.crt_freeze,The black market handed over addresses during the crackdown: {0} of your CRT is frozen until day {1}.
+nightlog.thaw,Frozen {0} {1} thawed.
+news.crtfreeze.title,Black market freezes some CRT addresses for the crackdown
+news.crtfreeze.desc,"Word is the black market gave security a list of CRT addresses in exchange for being left alone. Holders with low standing are likelier to be frozen."
diff --git a/src/Decentralized/ModFiles/Localization/zh.csv b/src/Decentralized/ModFiles/Localization/zh.csv
index c7a1c6c..51aaea3 100644
--- a/src/Decentralized/ModFiles/Localization/zh.csv
+++ b/src/Decentralized/ModFiles/Localization/zh.csv
@@ -199,3 +199,9 @@ nightlog.mixer_flag,(制裁期间提出,地址被标记)
nightlog.mixer_rug,混币器的运营者卷款跑路了:{0} {1} 没有提回。
web3.sub.trade,交易
web3.sub.mixer,混币器
+msg.frozen,这部分币被冻结了,到期前不能动用。
+wallet.frozen,"{0} 冻结 {1},第 {2} 天解冻"
+nightlog.crt_freeze,镇压期间黑市交出了一批地址:你的 {0} CRT 被冻结,第 {1} 天解冻。
+nightlog.thaw,冻结的 {0} {1} 已解冻。
+news.crtfreeze.title,黑市配合镇压冻结部分 CRT 地址
+news.crtfreeze.desc,"传闻黑市为换取放行,向安保交出了一批 CRT 地址。声望低的持有者更容易被冻结。"
diff --git a/src/Decentralized/Night.cs b/src/Decentralized/Night.cs
index b9a5148..1a7935f 100644
--- a/src/Decentralized/Night.cs
+++ b/src/Decentralized/Night.cs
@@ -83,7 +83,21 @@ namespace Decentralized
int day = StoreStation.GetDayCounter();
var events = ActiveEvents();
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); }
+ 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);
+ // 镇压开始:黑市为换取放行,按声望出卖一批 CRT 地址
+ if (ev == MarketEvent.Crackdown && Stablecoins.CrtFrozenByCrackdown(StoreReputation.GetBMReputation(), d.Rng))
+ {
+ double frozen = d.Hot.Freeze(Assets.CRT, Tuning.CrtFreezeShare, day + Tuning.CrtUnfreezeAfter);
+ if (frozen > 0)
+ {
+ PlayerStore.Instance.AddNightLog(L.T("nightlog.crt_freeze", Fmt.Num(frozen), day + Tuning.CrtUnfreezeAfter), "cc7755");
+ News.Show("pd_news_crtfreeze_" + day, L.T("news.crtfreeze.title"), L.T("news.crtfreeze.desc"), 3);
+ }
+ }
+ }
+ foreach (var t in d.Hot.Thaw(day)) PlayerStore.Instance.AddNightLog(L.T("nightlog.thaw", Fmt.Num(t.Value), t.Key));
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);
diff --git a/src/Decentralized/TerminalWindow.cs b/src/Decentralized/TerminalWindow.cs
index 33644fd..845ec88 100644
--- a/src/Decentralized/TerminalWindow.cs
+++ b/src/Decentralized/TerminalWindow.cs
@@ -212,6 +212,8 @@ namespace Decentralized
TableRow(b, HoldW, kv.Key, Fmt.Num(kv.Value), Fmt.Credits(kv.Value * Session.Price(kv.Key)), AvgText(w, kv.Key));
}
if (!any) b.AddLabel(L.T("wallet.empty"));
+ foreach (var kv in w.Frozen.OrderBy(k => k.Key).Where(k => show(k.Key)))
+ b.AddLabel(L.T("wallet.frozen", kv.Key, Fmt.Num(kv.Value), w.FrozenUntil.TryGetValue(kv.Key, out var u) ? u : 0));
}
/// Poinbase:合规交易所。左边账户持仓,右边交易;信用点的存取在银行。
@@ -296,7 +298,7 @@ namespace Decentralized
r.AddButton(asset == _mixAsset ? $"[{asset}]" : asset, () => { _mixAsset = asset; Open(); });
}
r.End();
- r.AddLabel(L.T("mixer.balance", _mixAsset, Fmt.Num(w.Get(_mixAsset))));
+ r.AddLabel(L.T("mixer.balance", _mixAsset, Fmt.Num(w.Avail(_mixAsset))));
AmountRow(r);
r.AddLabel(L.T("mixer.rounds"));
r.BeginRow();
@@ -320,13 +322,15 @@ namespace Decentralized
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; }
+ if (d.Hot.Avail(_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 bool frozenPay(Wallet w, string asset) => w.Frozen.ContainsKey(asset);
+
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)
@@ -393,7 +397,7 @@ namespace Decentralized
if (!ReadAmount(out double v)) return;
var w = Session.Data.Hot;
string pay = buy ? p.Quote : p.Base, get = buy ? p.Base : p.Quote;
- if (w.Get(pay) + 1e-9 < v) { _message = L.T("msg.balance"); Open(); return; }
+ if (w.Avail(pay) + 1e-9 < v) { _message = L.T(frozenPay(w, pay) ? "msg.frozen" : "msg.balance"); Open(); return; }
double got = buy ? p.BuyBase(v) : p.SellBase(v);
double spent = v * Session.Price(pay);
w.Add(pay, -v);
diff --git a/tests/Decentralized.Domain.Tests/Program.cs b/tests/Decentralized.Domain.Tests/Program.cs
index df99341..a495b3b 100644
--- a/tests/Decentralized.Domain.Tests/Program.cs
+++ b/tests/Decentralized.Domain.Tests/Program.cs
@@ -526,6 +526,20 @@ internal static class Program
Check(w2.Flagged, "制裁期间提出的币被标记");
});
+ Test("CRT 冻结:冻一半、可动用余额减少、到期解冻;声望越高越不容易被冻", () =>
+ {
+ var w = new Wallet { Address = "f" };
+ w.Add(Assets.CRT, 1000);
+ double fz = w.Freeze(Assets.CRT, Tuning.CrtFreezeShare, 10);
+ Check(Near(fz, 500, 1e-9) && Near(w.Avail(Assets.CRT), 500, 1e-9) && Near(w.Get(Assets.CRT), 1000, 1e-9), "冻结一半:余额不变,可动用 500");
+ Check(Near(w.Freeze(Assets.CRT, 1, 12), 500, 1e-9) && Near(w.Avail(Assets.CRT), 0, 1e-9), "再冻一次只冻可动用部分");
+ Check(w.Thaw(9).Count == 0 && w.Thaw(12).Count == 1 && Near(w.Avail(Assets.CRT), 1000, 1e-9), "到期(取较晚的日子)全部解冻");
+ Check(new Mixer().Deposit(w, Assets.CRT, 2000, 1, 1, a => 1) == null, "余额不足不能存入混币器");
+ int low = 0, high = 0;
+ for (int i = 0; i < 2000; i++) { if (Stablecoins.CrtFrozenByCrackdown(0, new Rng((ulong)i + 1))) low++; if (Stablecoins.CrtFrozenByCrackdown(50, new Rng((ulong)i + 1))) high++; }
+ Check(low > 700 && low < 900 && high == 0, $"声望 0 约 40% 被冻:{low}/2000;声望 50 不会被冻:{high}");
+ });
+
Console.WriteLine($"\n{_passed} 通过,{_failed} 失败");
return _failed == 0 ? 0 : 1;
}