diff --git a/CHANGELOG.md b/CHANGELOG.md
index cd5eb8d..b8e9cc0 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,6 +2,14 @@
## 未发布
+### 第四阶段:Web3 黑市与 Ponero(进行中)
+
+- 人脉网络新增"Web3 黑市"(前置:Web3 治理社区,1500):黑市派系在链上的实体;终端多一页"黑市",需要黑市声望 ≥ 10,镇压期间下线。
+- 现货柜台:每周刷新一批违禁品(游戏已有的 32 种,低 / 中 / 高三档随声望 10 / 40 / 80 放开;件数 4 + 声望/40),价格 = 游戏基础价 × 1.3,只收 PSC / CRT / CRA / Ponero,不收 CRC。付出去的币连同按比例的污点留在卖家那边,货放进储藏区,不带赃物标签。
+- Ponero 兑换处(声望 ≥ 30):任意币兑入 Ponero 时这部分价值的污点留在原地,兑入、兑出各收 8%,每天额度 1000 信用点;Ponero 价 = PSC × 0.5。从 Ponero 兑出的币在出金审查里按"刚出混币器"加重。
+- 游戏内验证:声望 75 时上架 5 种;用 CRT 买一把左轮 312 点,余额 3000 → 2688,污点 2000 → 1875(按付款占钱包的比例);5 PSC 兑入得 9.2 Ponero(500 × 0.92 / 50),当天额度剩 500。
+- 还没做:订单(托管、送货、死投点)、平台跑路、包裹截获、蜜罐、受控交付。
+
### 第三阶段:数值调优(进行中)
- 混币器每轮手续费 6% → 4%;用币付款的顾客概率提到上层 30%、黑市 50%、革命派 50%、下层 25%,并多付 12%(溢价只加在币数上,污点仍按货值算)。
diff --git a/src/Decentralized.Domain/BlackMarket.cs b/src/Decentralized.Domain/BlackMarket.cs
new file mode 100644
index 0000000..845f155
--- /dev/null
+++ b/src/Decentralized.Domain/BlackMarket.cs
@@ -0,0 +1,129 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+
+namespace Decentralized.Domain
+{
+ /// 黑市现货柜台上的一件货:游戏物品 id、剩余件数、含溢价的单价(信用点)。
+ public sealed class BmOffer
+ {
+ public string Item;
+ public int Qty;
+ public int Price;
+ public int Level;
+ }
+
+ ///
+ /// Web3 黑市:黑市派系在链上的实体。现货柜台每周刷新一批违禁品(档次随黑市声望放开),只收币,脏币也收,
+ /// 付出去的币连同按比例的污点一起留在卖家那边;Ponero 兑换处把任意币兑成无法追踪的 Ponero(污点留在原地),有每日额度和手续费。
+ /// 镇压期间整个平台下线。
+ ///
+ public sealed class BlackMarket
+ {
+ public List Offers = new List();
+ public int RefreshedDay = -999;
+ /// Ponero 兑换处当天已兑换的价值(信用点)及其日期。
+ public double DeskToday;
+ public int DeskDay = -1;
+
+ /// 现货池:游戏物品 id 与违禁等级(1 低、2 中、3 高)。全部来自游戏已有的违禁品。
+ public static readonly KeyValuePair[] Pool =
+ {
+ P("small_pistol_ammo", 1), P("heavy_pistol_ammo", 1), P("handmade_pistol", 1), P("printed_gun", 1), P("stun_baton", 1),
+ P("dream_cap_seed", 1), P("eng_keycard", 1), P("med_keycard", 1), P("ser_keycard", 1), P("sup_keycard", 1),
+ P("crypto_module_ser", 1), P("crypto_module_sup", 1),
+ P("revolver", 2), P("heavy_handmade_pistol", 2), P("small_pistol_ammo_p", 2), P("heavy_pistol_ammo_p", 2),
+ P("flashbang_grenade", 2), P("smoke_grenade", 2), P("dream_dust", 2), P("restricted_chemical", 2),
+ P("sci_keycard", 2), P("sec_keycard", 2), P("crypto_module_eng", 2), P("crypto_module_med", 2), P("unlicensed_phagimycin_pill", 2),
+ P("shotgun", 3), P("smg", 3), P("c4", 3), P("fanta_pill", 3), P("nightmare_dust", 3), P("cmd_keycard", 3), P("crypto_module_sec", 3),
+ };
+
+ private static KeyValuePair P(string id, int level) => new KeyValuePair(id, level);
+
+ public static bool Open(int rep) => rep >= Tuning.BmOpenRep;
+ public static int MaxLevel(int rep) => rep >= Tuning.BmHighRep ? 3 : rep >= Tuning.BmMidRep ? 2 : rep >= Tuning.BmOpenRep ? 1 : 0;
+ public static bool DeskOpen(int rep) => rep >= Tuning.PoneroOpenRep;
+ public static int StockCount(int rep) => Tuning.BmStockBase + (int)Math.Max(0, rep / Tuning.BmStockPerRep);
+
+ /// 溢价后的单价(信用点)。
+ public static int PriceOf(long baseValue) => (int)Math.Max(1, Math.Round(baseValue * (1 + Tuning.BmPremium)));
+
+ /// 每周刷新一次(或首次开放时):按声望开放的档次抽 StockCount 种货,每种 1–3 件。value 给出物品的游戏基础价。
+ public bool Refresh(int day, int rep, Rng rng, Func value)
+ {
+ if (!Open(rep) || day - RefreshedDay < Tuning.BmRefreshDays) return false;
+ RefreshedDay = day;
+ Offers.Clear();
+ var pool = Pool.Where(x => x.Value <= MaxLevel(rep)).ToList();
+ int n = Math.Min(StockCount(rep), pool.Count);
+ for (int i = 0; i < n; i++)
+ {
+ int k = rng.Range(0, pool.Count - 1);
+ var pick = pool[k];
+ pool.RemoveAt(k);
+ long v = value(pick.Key);
+ if (v <= 0) continue;
+ Offers.Add(new BmOffer { Item = pick.Key, Level = pick.Value, Qty = pick.Value == 3 ? 1 : rng.Range(1, 3), Price = PriceOf(v) });
+ }
+ return true;
+ }
+
+ /// 买一件:付 asset 币。钱包里按价值比例带走的污点和成本留在卖家那边。成功返回 true,库存减一。
+ public bool Buy(BmOffer o, Wallet w, string asset, Func price)
+ {
+ if (o == null || o.Qty <= 0 || asset == Assets.CRC) return false;
+ double p = price(asset);
+ if (p <= 0) return false;
+ double qty = o.Price / p;
+ if (w.Avail(asset) + 1e-9 < qty) return false;
+ double total = w.Value(price);
+ double share = total > 0 ? Math.Min(1, o.Price / total) : 0;
+ w.Taint -= w.Taint * share;
+ w.Basis -= w.Basis * share;
+ w.Add(asset, -qty);
+ o.Qty--;
+ if (o.Qty <= 0) Offers.Remove(o);
+ return true;
+ }
+
+ /// Ponero 对信用点的价格:跟随 PSC。
+ public static double PoneroPrice(double pscPrice) => pscPrice * Tuning.PoneroPscRatio;
+
+ public double DeskLeft(int day) => Math.Max(0, Tuning.PoneroDailyCap - (DeskDay == day ? DeskToday : 0));
+
+ /// 兑入 Ponero:from 币 amount 个,扣手续费后按 Ponero 价到账;这部分价值带的污点留在原地(不跟着 Ponero)。返回到账数量,额度或余额不足返回 -1。
+ public double ToPonero(Wallet w, string from, double amount, int day, Func price)
+ {
+ double value = amount * price(from);
+ if (from == Assets.Ponero || amount <= 0 || value <= 0 || w.Avail(from) + 1e-9 < amount || value > DeskLeft(day) + 1e-9) return -1;
+ double total = w.Value(price);
+ double share = total > 0 ? Math.Min(1, value / total) : 0;
+ w.Taint -= w.Taint * share;
+ w.Add(from, -amount);
+ double got = value * (1 - Tuning.PoneroFee) / price(Assets.Ponero);
+ w.Add(Assets.Ponero, got);
+ w.RecordBuy(Assets.Ponero, got, value);
+ Spend(day, value);
+ return got;
+ }
+
+ /// Ponero 兑出成 to 币:手续费同上,计入当天额度。返回到账数量,不足返回 -1。
+ public double FromPonero(Wallet w, string to, double amount, int day, Func price)
+ {
+ double value = amount * price(Assets.Ponero);
+ if (to == Assets.Ponero || amount <= 0 || value <= 0 || w.Avail(Assets.Ponero) + 1e-9 < amount || value > DeskLeft(day) + 1e-9 || price(to) <= 0) return -1;
+ w.Add(Assets.Ponero, -amount);
+ double got = value * (1 - Tuning.PoneroFee) / price(to);
+ w.Add(to, got);
+ w.RecordBuy(to, got, value);
+ Spend(day, value);
+ return got;
+ }
+
+ private void Spend(int day, double value)
+ {
+ if (DeskDay != day) { DeskDay = day; DeskToday = 0; }
+ DeskToday += value;
+ }
+ }
+}
diff --git a/src/Decentralized.Domain/Tuning.cs b/src/Decentralized.Domain/Tuning.cs
index f880e17..25be896 100644
--- a/src/Decentralized.Domain/Tuning.cs
+++ b/src/Decentralized.Domain/Tuning.cs
@@ -83,6 +83,14 @@ namespace Decentralized.Domain
public const double CardValueFloor = 0.25;
/// 非主交易对每晚被拉回参考价的比例,及其深度占主交易对的比例。
public const double SideArbRate = 0.5, SideDepthShare = 0.3;
+ /// Web3 黑市:开放所需黑市声望,中 / 高档货所需声望,现货每周刷新的天数与基础件数(每高 BmStockPerRep 声望多一件),现货溢价。
+ public const int BmOpenRep = 10, BmMidRep = 40, BmHighRep = 80, BmRefreshDays = 7, BmStockBase = 4;
+ public const double BmStockPerRep = 40, BmPremium = 0.30;
+ /// Ponero 兑换处:开放所需黑市声望、每次兑入 / 兑出的手续费、每天可兑换的额度(信用点)、对 PSC 的价格比。
+ public const int PoneroOpenRep = 30;
+ public const double PoneroFee = 0.08, PoneroDailyCap = 1000, PoneroPscRatio = 0.5;
+ /// 黑市人脉升级的价格。
+ public const int BmUnlockPrice = 1500;
/// 单个金融违规事件记入的罪证上限。
public const double CrimeEventCap = 400;
diff --git a/src/Decentralized/CoinCustomers.cs b/src/Decentralized/CoinCustomers.cs
index 7602c45..412c8f6 100644
--- a/src/Decentralized/CoinCustomers.cs
+++ b/src/Decentralized/CoinCustomers.cs
@@ -47,6 +47,24 @@ namespace Decentralized
w.Add(Assets.PSC, 100); w.Add(Assets.CRT, 1000); w.Taint += 3000; w.Basis += 2000;
Log.Info($"钱包:PSC {w.Get(Assets.PSC):0.##},污点 {w.Taint:0}");
}));
+ ModHelper.AddDebugFunction(ModInfo.Id, ModInfo.Name, "Terminal (test)", () => Log.Guard("终端(测试)", TerminalWindow.Open));
+ ModHelper.AddDebugFunction(ModInfo.Id, ModInfo.Name, "Black market +50 rep, restock (test)", () => Log.Guard("黑市(测试)", () =>
+ {
+ StoreReputation.ModReputation("FACTION_BLACK_MARKET", 50);
+ if (!Unlock.BlackUnlocked) ModHelper.GetNetworkUpgrade(Unlock.BlackId)?.Unlock();
+ var d = Session.Data;
+ d.Black.RefreshedDay = -999;
+ d.Hot.Add(Assets.PSC, 20); d.Hot.Add(Assets.CRT, 3000); d.Hot.Taint += 2000; d.Hot.Basis += 1000;
+ d.Black.Refresh(StoreStation.GetDayCounter(), StoreReputation.GetBMReputation(), d.Rng, id =>
+ {
+ var it = ModHelper.SpawnItem(id);
+ if (it == null) return 0;
+ long v = it.GetBaseValue();
+ it.Destroy();
+ return v;
+ });
+ Log.Info($"黑市:声望 {StoreReputation.GetBMReputation()},上架 {d.Black.Offers.Count} 种:{string.Join(", ", d.Black.Offers.Select(o => o.Item + "×" + o.Qty + "@" + o.Price))},解锁 {Unlock.BlackUnlocked}");
+ }));
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 1ad7253..c663f58 100644
--- a/src/Decentralized/ModFiles/Localization/en.csv
+++ b/src/Decentralized/ModFiles/Localization/en.csv
@@ -206,3 +206,27 @@ nightlog.crt_freeze,The black market handed over addresses during the crackdown:
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."
+tab.black,Black market
+black.upgrade.title,Web3 Black Market
+black.upgrade.desc,"Unlocks the terminal's black-market page: the black-market faction's on-chain arm. A spot counter restocks weekly with contraband, payable only in coin (PSC, CRT, CRA, Ponero), tainted coin accepted, at a premium; higher standing also opens a Ponero desk. Needs black-market standing; offline during crackdowns."
+black.offline,The black market's on-chain entrance is offline during the crackdown.
+black.closed,"You need black-market standing of {0} before anyone lets you in (currently {1})."
+black.sub.shop,Spot counter
+black.sub.desk,Ponero desk
+black.shop.h,Spot counter
+black.rep,"Black-market standing {0}, goods tier {1}/3 visible. Restocks weekly."
+black.empty,Sold out for this week.
+black.offer,"{0} x{1} {2} credits"
+black.buy,"Buy with coin ({0})"
+black.bought,"Deal done: {0} is in storage."
+black.pay.h,Payment
+black.hint,"Coin only, no CRC.|Prices are 30% above normal channels.|The coin you pay leaves its share of taint with the seller; the goods come without a stolen tag."
+black.desk.h,Ponero desk
+black.desk.hint,"Ponero is a privacy coin; it cannot be traced on-chain.|Swapping any coin into Ponero leaves that value's taint behind.|{0}% fee each way, with a daily limit.|Coin swapped out of Ponero has no origin; cash-out screening gets stricter."
+black.desk.left,"Left today: {0} (limit {1})"
+black.desk.price,"Ponero price: {0}"
+black.desk.swap,Swap
+black.desk.in,Into Ponero
+black.desk.out,Out of Ponero
+black.desk.go,Swap
+black.desk.fail,Swap failed: balance, limit or amount is wrong.
diff --git a/src/Decentralized/ModFiles/Localization/zh.csv b/src/Decentralized/ModFiles/Localization/zh.csv
index e4e4d3e..18cf393 100644
--- a/src/Decentralized/ModFiles/Localization/zh.csv
+++ b/src/Decentralized/ModFiles/Localization/zh.csv
@@ -205,3 +205,27 @@ nightlog.crt_freeze,镇压期间黑市交出了一批地址:你的 {0} CRT 被
nightlog.thaw,冻结的 {0} {1} 已解冻。
news.crtfreeze.title,黑市配合镇压冻结部分 CRT 地址
news.crtfreeze.desc,"传闻黑市为换取放行,向安保交出了一批 CRT 地址。声望低的持有者更容易被冻结。"
+tab.black,黑市
+black.upgrade.title,Web3 黑市
+black.upgrade.desc,"解锁终端的黑市页:黑市派系在链上的实体。现货柜台每周上一批违禁品,只收币(PSC、CRT、CRA、Ponero),脏币也收,价格比正常渠道高;声望够高还有 Ponero 兑换处。需要黑市声望,镇压期间下线。"
+black.offline,镇压期间,黑市的链上入口已经下线。
+black.closed,"黑市声望达到 {0} 才会有人带你进去(现在 {1})。"
+black.sub.shop,现货柜台
+black.sub.desk,Ponero 兑换处
+black.shop.h,现货柜台
+black.rep,"黑市声望 {0},可见货物档次 {1}/3。每周刷新。"
+black.empty,这周的货卖完了。
+black.offer,"{0} ×{1} {2} 信用点等值"
+black.buy,"用币购买({0})"
+black.bought,"成交:{0} 已放进储藏区。"
+black.pay.h,付款
+black.hint,"只收币,不收 CRC。|价格比正常渠道高 30%。|付出去的币带着按比例的污点一起留在卖家那边,拿到手的货不带赃物标签。"
+black.desk.h,Ponero 兑换处
+black.desk.hint,"Ponero 是隐私币,链上无法追踪。|任何币兑入 Ponero 时,这部分价值的污点留在原地。|兑入、兑出各收 {0}% 手续费,每天有额度。|从 Ponero 兑出的币来路不明,出金审查会加重。"
+black.desk.left,"今天还能兑换 {0}(上限 {1})"
+black.desk.price,"Ponero 价格:{0}"
+black.desk.swap,兑换
+black.desk.in,兑入 Ponero
+black.desk.out,兑出 Ponero
+black.desk.go,兑换
+black.desk.fail,兑换失败:余额、额度或数量不对。
diff --git a/src/Decentralized/Night.cs b/src/Decentralized/Night.cs
index 3c4ce8b..54d6476 100644
--- a/src/Decentralized/Night.cs
+++ b/src/Decentralized/Night.cs
@@ -101,6 +101,15 @@ namespace Decentralized
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;
+ if (Unlock.BlackUnlocked && !events.Contains(MarketEvent.Crackdown))
+ d.Black.Refresh(day, StoreReputation.GetBMReputation(), d.Rng, id =>
+ {
+ var it = ModHelper.SpawnItem(id);
+ if (it == null) return 0;
+ long v = it.GetBaseValue();
+ it.Destroy();
+ return v;
+ });
d.Network.Availability = (events.Contains(MarketEvent.Blackout) ? 0.8 : 1) * (events.Contains(MarketEvent.LowerUnrest) ? 0.9 : 1);
var units = Session.TonightCards.Select(c => (item: c.Key, unit: Items.ToUnit(c.Key), bonus: c.Value)).ToList();
diff --git a/src/Decentralized/Session.cs b/src/Decentralized/Session.cs
index fb3ec9a..60776dd 100644
--- a/src/Decentralized/Session.cs
+++ b/src/Decentralized/Session.cs
@@ -26,6 +26,7 @@ namespace Decentralized
public Wallet Hot = new Wallet { Address = "hot" };
public Exchange Exchange = new Exchange();
public Mixer Mixer = new Mixer();
+ public BlackMarket Black = new BlackMarket();
public List ActiveEvents = new List();
public NightReport LastNight;
public bool OpeningNewsShown;
@@ -70,6 +71,7 @@ namespace Decentralized
{
if (asset == Assets.PSC) return Data.Market.Price;
var spec = Assets.Get(asset);
+ if (asset == Assets.Ponero) return BlackMarket.PoneroPrice(Data.Market.Price);
if (spec != null && spec.Stable) return Data.Stable.Price(asset);
return 0;
}
diff --git a/src/Decentralized/TerminalWindow.cs b/src/Decentralized/TerminalWindow.cs
index 845ec88..e16f1cf 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, Black, 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.Black && !Unlock.BlackUnlocked) 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.Black: BlackPage(b); break;
default: ReportPage(b); break;
}
b.End();
@@ -262,6 +264,120 @@ namespace Decentralized
});
}
+ private static bool _blackDesk;
+ private static string _deskAsset = Assets.PSC;
+ private static bool _deskIn = true;
+ private static string _payAsset = Assets.CRT;
+
+ /// Web3 黑市:黑市派系的链上实体。现货柜台(只收币)与 Ponero 兑换处。
+ private static void BlackPage(CustomUIBuilder b)
+ {
+ int rep = StoreReputation.GetBMReputation();
+ int day = StoreStation.GetDayCounter();
+ var d = Session.Data;
+ if (d.ActiveEvents.Contains(MarketEvent.Crackdown)) { b.AddLabel(L.T("black.offline")); return; }
+ if (!BlackMarket.Open(rep)) { b.AddLabel(L.T("black.closed", Tuning.BmOpenRep, rep)); return; }
+ bool desk = BlackMarket.DeskOpen(rep);
+ if (desk)
+ {
+ b.BeginRow();
+ b.AddButton(_blackDesk ? L.T("black.sub.shop") : $"[{L.T("black.sub.shop")}]", () => { _blackDesk = false; _message = null; Open(); });
+ b.AddButton(_blackDesk ? $"[{L.T("black.sub.desk")}]" : L.T("black.sub.desk"), () => { _blackDesk = true; _message = null; Open(); });
+ b.End();
+ }
+ if (desk && _blackDesk) { DeskPage(b, rep, day); return; }
+ var w = d.Hot;
+ Columns(b, l =>
+ {
+ l.AddHeader(L.T("black.shop.h"));
+ l.AddLabel(L.T("black.rep", rep, BlackMarket.MaxLevel(rep)));
+ if (d.Black.Offers.Count == 0) l.AddLabel(L.T("black.empty"));
+ foreach (var o in d.Black.Offers.ToList())
+ {
+ var offer = o;
+ l.AddLabel(L.T("black.offer", ItemName(offer.Item), offer.Qty, offer.Price));
+ l.AddButton(L.T("black.buy", offer.Price), () => BuyOffer(offer));
+ }
+ }, r =>
+ {
+ r.AddHeader(L.T("black.pay.h"));
+ foreach (var line in L.T("black.hint").Split('|')) r.AddLabel(line);
+ r.BeginRow();
+ foreach (var a in new[] { Assets.PSC, Assets.CRT, Assets.CRA, Assets.Ponero })
+ {
+ var asset = a;
+ r.AddButton(asset == _payAsset ? $"[{asset}]" : asset, () => { _payAsset = asset; Open(); });
+ }
+ r.End();
+ r.AddLabel(L.T("mixer.balance", _payAsset, Fmt.Num(w.Avail(_payAsset))));
+ if (w.Taint > 0.5) r.AddLabel(L.T("wallet.taint", Fmt.Credits(w.Taint)));
+ });
+ }
+
+ private static string ItemName(string id)
+ {
+ var it = ModHelper.SpawnItem(id);
+ if (it == null) return id;
+ string n = it.name;
+ it.Destroy();
+ return n;
+ }
+
+ private static void BuyOffer(BmOffer o)
+ {
+ var d = Session.Data;
+ if (!d.Black.Buy(o, d.Hot, _payAsset, Session.Price)) { _message = L.T("msg.balance"); Open(); return; }
+ Items.Give(ModHelper.SpawnItem(o.Item));
+ _message = L.T("black.bought", ItemName(o.Item));
+ Session.Save();
+ Open();
+ }
+
+ private static void DeskPage(CustomUIBuilder b, int rep, int day)
+ {
+ var d = Session.Data;
+ var w = d.Hot;
+ Columns(b, l =>
+ {
+ l.AddHeader(L.T("black.desk.h"));
+ foreach (var line in L.T("black.desk.hint", (int)Math.Round(Tuning.PoneroFee * 100)).Split('|')) l.AddLabel(line);
+ l.AddLabel(L.T("black.desk.left", Fmt.Credits(d.Black.DeskLeft(day)), Fmt.Credits(Tuning.PoneroDailyCap)));
+ l.AddLabel(L.T("black.desk.price", Fmt.Num(Session.Price(Assets.Ponero))));
+ Holdings(l, w, a => a == Assets.Ponero || a == _deskAsset);
+ }, r =>
+ {
+ r.AddHeader(L.T("black.desk.swap"));
+ r.BeginRow();
+ r.AddButton(_deskIn ? $"[{L.T("black.desk.in")}]" : L.T("black.desk.in"), () => { _deskIn = true; Open(); });
+ r.AddButton(_deskIn ? L.T("black.desk.out") : $"[{L.T("black.desk.out")}]", () => { _deskIn = false; Open(); });
+ r.End();
+ r.BeginRow();
+ foreach (var a in new[] { Assets.PSC, Assets.CRT, Assets.CRA })
+ {
+ var asset = a;
+ r.AddButton(asset == _deskAsset ? $"[{asset}]" : asset, () => { _deskAsset = asset; Open(); });
+ }
+ r.End();
+ r.AddLabel(_deskIn ? L.T("mixer.balance", _deskAsset, Fmt.Num(w.Avail(_deskAsset))) : L.T("mixer.balance", Assets.Ponero, Fmt.Num(w.Avail(Assets.Ponero))));
+ AmountRow(r);
+ r.AddButton(L.T("black.desk.go"), DeskSwap);
+ });
+ }
+
+ private static void DeskSwap()
+ {
+ if (!ReadAmount(out double v)) return;
+ var d = Session.Data;
+ int day = StoreStation.GetDayCounter();
+ double got = _deskIn ? d.Black.ToPonero(d.Hot, _deskAsset, v, day, Session.Price) : d.Black.FromPonero(d.Hot, _deskAsset, v, day, Session.Price);
+ if (got < 0) { _message = L.T("black.desk.fail"); Open(); return; }
+ // 从 Ponero 出来的币来源不明:出金审查按"来自混币器"加重
+ if (!_deskIn) d.Mixer.LastReturnDay = day;
+ _message = _deskIn ? L.T("msg.trade", Fmt.Num(v), _deskAsset, Fmt.Num(got), Assets.Ponero) : L.T("msg.trade", Fmt.Num(v), Assets.Ponero, Fmt.Num(got), _deskAsset);
+ Session.Save();
+ Open();
+ }
+
private static bool _web3Mixer;
private static string _mixAsset = Assets.PSC;
private static int _mixRounds = 2;
diff --git a/src/Decentralized/Unlock.cs b/src/Decentralized/Unlock.cs
index 36ee44b..66a51a1 100644
--- a/src/Decentralized/Unlock.cs
+++ b/src/Decentralized/Unlock.cs
@@ -15,6 +15,9 @@ namespace Decentralized
public const int MixerPrice = Domain.Tuning.MixerUnlockPrice;
public static bool MixerUnlocked => PlayerStore.Instance != null && ModHelper.IsNetworkUpgradeUnlocked(MixerId);
+ public const string BlackId = "PD_BLACKMARKET";
+ public static bool BlackUnlocked => PlayerStore.Instance != null && ModHelper.IsNetworkUpgradeUnlocked(BlackId);
+
public static bool IsUnlocked => PlayerStore.Instance != null && ModHelper.IsNetworkUpgradeUnlocked(Id);
public static void Register()
@@ -26,6 +29,7 @@ namespace Decentralized
// 每个升级单独 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 });
+ Add(BlackId, Domain.Tuning.BmUnlockPrice, () => L.T("black.upgrade.title"), () => L.T("black.upgrade.desc"), () => Log.Info("Web3 黑市已解锁"), "ui_web3", new[] { Id });
}
private static void Add(string id, int price, Func title, Func desc, Action onUnlock, string icon, string[] prerequisites)
diff --git a/tests/Decentralized.Domain.Tests/Program.cs b/tests/Decentralized.Domain.Tests/Program.cs
index 7375c70..d6b2943 100644
--- a/tests/Decentralized.Domain.Tests/Program.cs
+++ b/tests/Decentralized.Domain.Tests/Program.cs
@@ -560,6 +560,29 @@ internal static class Program
Check(low > 700 && low < 900 && high == 0, $"声望 0 约 40% 被冻:{low}/2000;声望 50 不会被冻:{high}");
});
+ Test("Web3 黑市", () =>
+ {
+ Func price = a => a == Assets.Ponero ? 50 : a == Assets.PSC ? 100 : 1;
+ var bm = new BlackMarket();
+ Check(!bm.Refresh(10, 5, new Rng(1), id => 100) && bm.Offers.Count == 0, "声望不够不开放");
+ Check(bm.Refresh(10, 45, new Rng(1), id => 100) && bm.Offers.Count > 0 && bm.Offers.All(o => o.Level <= 2 && o.Price == 130), "声望 45 开放中档,溢价 30%");
+ Check(!bm.Refresh(12, 45, new Rng(2), id => 100) && bm.Refresh(17, 45, new Rng(2), id => 100), "每周刷新");
+ var w = new Wallet { Address = "t" };
+ w.Add(Assets.PSC, 10); w.Add(Assets.CRT, 1000); w.Taint = 400; w.Basis = 800; // 总价值 2000
+ var offer = new BmOffer { Item = "x", Qty = 1, Price = 500, Level = 1 };
+ bm.Offers.Add(offer);
+ Check(!bm.Buy(offer, w, Assets.CRC, price), "不收 CRC");
+ Check(bm.Buy(offer, w, Assets.CRT, price) && Near(w.Get(Assets.CRT), 500, 1e-9) && Near(w.Taint, 300, 1e-6) && Near(w.Basis, 600, 1e-6) && !bm.Offers.Contains(offer), "付币:污点与成本按比例留给卖家,卖完下架");
+ w = new Wallet { Address = "t" };
+ w.Add(Assets.PSC, 10); w.Taint = 500; w.Basis = 1000; // 总价值 1000,PSC 价 100
+ double got = bm.ToPonero(w, Assets.PSC, 5, 20, price); // 价值 500
+ Check(Near(got, 500 * (1 - Tuning.PoneroFee) / 50, 1e-9) && Near(w.Taint, 250, 1e-6) && Near(w.Get(Assets.PSC), 5, 1e-9), "兑入 Ponero:扣手续费,这部分污点留在原地");
+ Check(bm.ToPonero(w, Assets.PSC, 5.1, 20, price) < 0, "超过每日额度不能兑");
+ Check(bm.ToPonero(w, Assets.PSC, 5, 21, price) >= 0, "第二天额度恢复");
+ double back = bm.FromPonero(w, Assets.CRT, 10, 22, price);
+ Check(Near(back, 10 * 50 * (1 - Tuning.PoneroFee), 1e-9) && Near(w.Get(Assets.CRT), back, 1e-9), "兑出 Ponero");
+ });
+
Console.WriteLine($"\n{_passed} 通过,{_failed} 失败");
return _failed == 0 ? 0 : 1;
}