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