diff --git a/CHANGELOG.md b/CHANGELOG.md index b8e9cc0..104e00d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,7 +8,11 @@ - 现货柜台:每周刷新一批违禁品(游戏已有的 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。 -- 还没做:订单(托管、送货、死投点)、平台跑路、包裹截获、蜜罐、受控交付。 +- 订单:黑市页新增"订单"和"我的订单与情报"。目录每周刷新,4 个卖家(成交数、好评率公开,15% 是骗子,多半是新号,也有刷过好评的老号),价格 = 基础价 × 0.85。托管多付 5%,收货后放款,卖家跑路时退款(污点与成本一并还回,不能借退款洗钱);提前放款再打九折,只有成交 ≥ 30 的卖家接受,骗子拿钱就跑。 +- 配送:送货上门 3 天,到货后开店时快递员作为顾客上门,对话结束把货放进储藏区;死投点 2 天,到货后晚上去垃圾场,货进外出背包。上门被截获 8%,死投 3%,死投另有 12% 被别人先挖走;镇压期间到期顺延、截获 +15%。 +- 平台跑路:每晚概率 0.2% + 在途款项每万信用点 0.4%(封顶 5%),跑路后在途款项归零、目录与现货清空、入口关闭 10 天。论坛情报:骗子卖家有一半概率被人点名,跑路风险升高时出现提币变慢的帖子。 +- 游戏内验证:下单后在途显示在"我的订单";过夜到期,两单到货,一单骗子卖家托管退款(CRT +143,污点 +57);快递员对话走完,货进储藏区,快递员自动离开。没有在游戏里验证:死投点取货(要去垃圾场)、平台跑路与截获的实际触发(只有单元测试)。 +- 还没做:蜜罐、受控交付(第三批)。 ### 第三阶段:数值调优(进行中) diff --git a/src/Decentralized.Domain/BlackMarket.cs b/src/Decentralized.Domain/BlackMarket.cs index 845f155..b35185d 100644 --- a/src/Decentralized.Domain/BlackMarket.cs +++ b/src/Decentralized.Domain/BlackMarket.cs @@ -18,7 +18,7 @@ namespace Decentralized.Domain /// 付出去的币连同按比例的污点一起留在卖家那边;Ponero 兑换处把任意币兑成无法追踪的 Ponero(污点留在原地),有每日额度和手续费。 /// 镇压期间整个平台下线。 /// - public sealed class BlackMarket + public sealed partial class BlackMarket { public List Offers = new List(); public int RefreshedDay = -999; diff --git a/src/Decentralized.Domain/BmOrders.cs b/src/Decentralized.Domain/BmOrders.cs new file mode 100644 index 0000000..b717122 --- /dev/null +++ b/src/Decentralized.Domain/BmOrders.cs @@ -0,0 +1,185 @@ +using System; +using System.Collections.Generic; +using System.Linq; + +namespace Decentralized.Domain +{ + public enum PayMode { Escrow, Advance } + public enum Delivery { Door, Drop } + public enum OrderState { Pending, Ready, Done, Refunded, Lost } + + /// 黑市上的卖家:成交数与好评率公开;是不是骗子是隐藏的。 + public sealed class BmSeller + { + public string Name; + public int Trades; + public int Rating; // 好评率 % + public bool Scam; + /// 只有成交数够多的卖家才接受提前放款。 + public bool Advance => Trades >= Tuning.BmAdvanceMinTrades; + } + + /// 订单目录里的一件货:卖家、货、价格(订单价,已折扣)。 + public sealed class BmListing + { + public int Seller; + public string Item; + public int Level; + public int Price; + } + + public sealed class BmOrder + { + public string Item; + public string Seller; + public bool SellerScam; + public PayMode Mode; + public Delivery Delivery; + public int Price; + public string Asset; + public double Qty; + /// 付款时留在卖家那边的污点与成本:退款时一并还回,免得"付脏币再退款"洗钱。 + public double Taint, Basis; + public int PlacedDay, DueDay; + public OrderState State = OrderState.Pending; + /// 终态的原因:done / scam / rug / intercept / theft / refund。 + public string Why; + } + + /// 夜间发生的订单事件,供日志与新闻使用。 + public sealed class BmEvent + { + public string Kind; // ready / scam_refund / scam_loss / rug / intercept / theft / reopen + public BmOrder Order; + public double Amount; + } + + public sealed partial class BlackMarket + { + public List Sellers = new List(); + public List Catalog = new List(); + public List Orders = new List(); + public List Forum = new List(); + /// 平台跑路后关闭到这一天(含)。 + public int ClosedUntil = -1; + + private static readonly string[] NameA = { "rat", "grey", "static", "null", "dust", "iron", "pale", "wire", "salt", "ghost" }; + private static readonly string[] NameB = { "king", "mule", "broker", "hand", "cat", "fox", "tooth", "lamp", "nine", "dealer" }; + + public bool Closed(int day) => day <= ClosedUntil; + + /// 托管中和已提前放款但尚未交付的资金(信用点),平台跑路的概率随它增加。 + public double Held => Orders.Where(o => o.State == OrderState.Pending).Sum(o => (double)o.Price); + + public static int OrderPrice(long baseValue) => (int)Math.Max(1, Math.Round(baseValue * (1 - Tuning.BmOrderDiscount))); + public static int EscrowFee(int price) => (int)Math.Ceiling(price * Tuning.BmEscrowFee); + /// 下单实付(信用点):托管 = 订单价 + 托管费;提前放款 = 订单价打折。 + public static int Pay(int price, PayMode m) => m == PayMode.Escrow ? price + EscrowFee(price) : (int)Math.Round(price * (1 - Tuning.BmAdvanceDiscount)); + public static int Days(Delivery d) => d == Delivery.Door ? Tuning.BmDoorDays : Tuning.BmDropDays; + + public double RugChance => Math.Min(Tuning.BmRugMax, Tuning.BmRugBase + Held / 10000.0 * Tuning.BmRugPerTenK); + + /// 每周重新生成卖家、目录和论坛情报(与现货同一天刷新)。 + public void RefreshOrders(int rep, Rng rng, Func value) + { + Sellers.Clear(); Catalog.Clear(); Forum.Clear(); + for (int i = 0; i < Tuning.BmSellerCount; i++) + { + bool scam = rng.Chance(Tuning.BmScamSellerShare); + // 骗子多半是新号,也有刷过好评的老号 + bool fresh = scam ? rng.Chance(0.6) : rng.Chance(0.25); + var s = new BmSeller + { + Name = NameA[rng.Range(0, NameA.Length - 1)] + "_" + NameB[rng.Range(0, NameB.Length - 1)] + rng.Range(1, 99), + Scam = scam, + Trades = fresh ? rng.Range(1, 12) : rng.Range(25, 220), + Rating = scam && !fresh ? rng.Range(94, 99) : fresh ? rng.Range(70, 100) : rng.Range(90, 99), + }; + Sellers.Add(s); + if (scam && rng.Chance(0.5)) Forum.Add("scam:" + s.Name); + } + var pool = Pool.Where(x => x.Value <= MaxLevel(rep)).ToList(); + for (int i = 0; i < Tuning.BmSellerCount * 2 && pool.Count > 0; 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; + Catalog.Add(new BmListing { Seller = i % Sellers.Count, Item = pick.Key, Level = pick.Value, Price = OrderPrice(v) }); + } + if (RugChance > Tuning.BmRugBase * 3) Forum.Add("rug"); + } + + /// 下单:钱包里的币按实付折算扣除,污点与成本按比例留在卖家那边(记在订单上)。成功返回订单。 + public BmOrder Place(BmListing l, PayMode mode, Delivery del, Wallet w, string asset, int day, Func price) + { + if (l == null || asset == Assets.CRC || Closed(day)) return null; + var seller = Sellers[l.Seller]; + if (mode == PayMode.Advance && !seller.Advance) return null; + double p = price(asset); + if (p <= 0) return null; + int credits = Pay(l.Price, mode); + double qty = credits / p; + if (w.Avail(asset) + 1e-9 < qty) return null; + double total = w.Value(price); + double share = total > 0 ? Math.Min(1, credits / total) : 0; + double taint = w.Taint * share, basis = w.Basis * share; + w.Taint -= taint; w.Basis -= basis; + w.Add(asset, -qty); + var o = new BmOrder + { + Item = l.Item, Seller = seller.Name, SellerScam = seller.Scam, Mode = mode, Delivery = del, Price = credits, Asset = asset, Qty = qty, + Taint = taint, Basis = basis, PlacedDay = day, DueDay = day + Days(del), + }; + Orders.Add(o); + return o; + } + + private static void Refund(Wallet w, BmOrder o) + { + w.Add(o.Asset, o.Qty); + w.Taint += o.Taint; w.Basis += o.Basis; + } + + /// 每晚推进订单:平台跑路、到期的订单(卖家跑路、被截获、死投被挖走)、转入可取状态。镇压期间到期顺延一天。 + public List Night(int day, bool crackdown, Wallet w, Rng rng) + { + var ev = new List(); + if (ClosedUntil == day) ev.Add(new BmEvent { Kind = "reopen" }); + if (!Closed(day) && Orders.Any(o => o.State == OrderState.Pending) && rng.Chance(RugChance)) + { + foreach (var o in Orders.Where(o => o.State == OrderState.Pending)) + { + o.State = OrderState.Lost; o.Why = "rug"; + ev.Add(new BmEvent { Kind = "rug", Order = o, Amount = o.Price }); + } + ClosedUntil = day + Tuning.BmRugClosedDays; + Offers.Clear(); Catalog.Clear(); Sellers.Clear(); + return ev; + } + foreach (var o in Orders.Where(x => x.State == OrderState.Pending).ToList()) + { + if (day < o.DueDay) continue; + if (crackdown) { o.DueDay = day + 1; continue; } + if (o.SellerScam) + { + if (o.Mode == PayMode.Escrow) { Refund(w, o); o.State = OrderState.Refunded; o.Why = "scam"; ev.Add(new BmEvent { Kind = "scam_refund", Order = o, Amount = o.Price }); } + else { o.State = OrderState.Lost; o.Why = "scam"; ev.Add(new BmEvent { Kind = "scam_loss", Order = o, Amount = o.Price }); } + continue; + } + double ic = (o.Delivery == Delivery.Door ? Tuning.BmInterceptDoor : Tuning.BmInterceptDrop) + (crackdown ? Tuning.BmInterceptCrackdown : 0); + if (rng.Chance(ic)) { o.State = OrderState.Lost; o.Why = "intercept"; ev.Add(new BmEvent { Kind = "intercept", Order = o, Amount = o.Price }); continue; } + if (o.Delivery == Delivery.Drop && rng.Chance(Tuning.BmDropTheft)) { o.State = OrderState.Lost; o.Why = "theft"; ev.Add(new BmEvent { Kind = "theft", Order = o, Amount = o.Price }); continue; } + o.State = OrderState.Ready; + ev.Add(new BmEvent { Kind = "ready", Order = o }); + } + Orders.RemoveAll(o => o.State != OrderState.Pending && o.State != OrderState.Ready && day - o.DueDay > 14); + return ev; + } + + public void Deliver(BmOrder o) { o.State = OrderState.Done; o.Why = "done"; } + + public IEnumerable ReadyFor(Delivery d) => Orders.Where(o => o.State == OrderState.Ready && o.Delivery == d); + } +} diff --git a/src/Decentralized.Domain/Tuning.cs b/src/Decentralized.Domain/Tuning.cs index 25be896..3439295 100644 --- a/src/Decentralized.Domain/Tuning.cs +++ b/src/Decentralized.Domain/Tuning.cs @@ -89,6 +89,11 @@ namespace Decentralized.Domain /// Ponero 兑换处:开放所需黑市声望、每次兑入 / 兑出的手续费、每天可兑换的额度(信用点)、对 PSC 的价格比。 public const int PoneroOpenRep = 30; public const double PoneroFee = 0.08, PoneroDailyCap = 1000, PoneroPscRatio = 0.5; + /// 黑市订单:订单价相对游戏基础价的折扣、托管费、提前放款再折扣、提前放款所需卖家成交数、上门 / 死投的天数、 + /// 上门 / 死投被截获的概率(镇压期间再加)、死投被别人先挖走的概率、骗子卖家占比、每晚平台跑路基础概率与每万信用点托管资金加的概率(封顶)、跑路后关闭的天数。 + public const double BmOrderDiscount = 0.15, BmEscrowFee = 0.05, BmAdvanceDiscount = 0.10, BmInterceptDoor = 0.08, BmInterceptDrop = 0.03, BmInterceptCrackdown = 0.15; + public const double BmDropTheft = 0.12, BmScamSellerShare = 0.15, BmRugBase = 0.002, BmRugPerTenK = 0.004, BmRugMax = 0.05; + public const int BmAdvanceMinTrades = 30, BmDoorDays = 3, BmDropDays = 2, BmRugClosedDays = 10, BmSellerCount = 4; /// 黑市人脉升级的价格。 public const int BmUnlockPrice = 1500; /// 单个金融违规事件记入的罪证上限。 diff --git a/src/Decentralized/BlackDelivery.cs b/src/Decentralized/BlackDelivery.cs new file mode 100644 index 0000000..f7ea4a4 --- /dev/null +++ b/src/Decentralized/BlackDelivery.cs @@ -0,0 +1,142 @@ +using System.Linq; +using Decentralized.Domain; + +namespace Decentralized +{ + /// + /// Web3 黑市的货物进出:每周刷新现货与订单目录,夜间推进订单并写夜间日志, + /// 上门(快递员当顾客上门)和死投(去垃圾场取货)把到货交给玩家。 + /// + public static class BlackDelivery + { + public static void Subscribe() + { + ModHook.OnGenerateCustomerNormal += OnGenerate; + ModHook.OnAfterhourArriveLate += OnArrive; + } + + public static void Unsubscribe() + { + ModHook.OnGenerateCustomerNormal -= OnGenerate; + ModHook.OnAfterhourArriveLate -= OnArrive; + } + + public static long Value(string id) + { + var it = ModHelper.SpawnItem(id); + if (it == null) return 0; + long v = it.GetBaseValue(); + it.Destroy(); + return v; + } + + public static string ItemName(string id) + { + var it = ModHelper.SpawnItem(id); + if (it == null) return id; + string n = it.name; + it.Destroy(); + return n; + } + + /// 每周刷新:现货与订单目录一起。返回是否刷新了。 + public static bool Restock(SaveData d, int day, bool force = false) + { + int rep = StoreReputation.GetBMReputation(); + if (d.Black.Closed(day)) return false; + if (force) d.Black.RefreshedDay = -999; + if (!d.Black.Refresh(day, rep, d.Rng, Value)) return false; + d.Black.RefreshOrders(rep, d.Rng, Value); + return true; + } + + public static void Night(SaveData d, int day, bool crackdown) + { + var ps = PlayerStore.Instance; + foreach (var e in d.Black.Night(day, crackdown, d.Hot, d.Rng)) + { + string name = e.Order != null ? ItemName(e.Order.Item) : null; + Log.Info($"黑市订单事件:{e.Kind} {e.Order?.Item} {e.Order?.Seller} {e.Order?.Mode}/{e.Order?.Delivery} 金额 {e.Amount:0}"); + switch (e.Kind) + { + case "ready": ps.AddNightLog(L.T("nightlog.bm.ready." + e.Order.Delivery, name), "99bb66"); break; + case "scam_refund": ps.AddNightLog(L.T("nightlog.bm.scam_refund", e.Order.Seller, name), "cc7755"); break; + case "scam_loss": ps.AddNightLog(L.T("nightlog.bm.scam_loss", e.Order.Seller, name, Fmt.Num(e.Amount)), "cc7755"); break; + case "intercept": ps.AddNightLog(L.T("nightlog.bm.intercept", name, Fmt.Num(e.Amount)), "cc7755"); break; + case "theft": ps.AddNightLog(L.T("nightlog.bm.theft", name, Fmt.Num(e.Amount)), "cc7755"); break; + case "rug": ps.AddNightLog(L.T("nightlog.bm.rug", name, Fmt.Num(e.Amount)), "cc7755"); break; + case "reopen": ps.AddNightLog(L.T("nightlog.bm.reopen"), "99bb66"); break; + } + if (e.Kind == "rug") Night_RugNews(day); + } + if (Unlock.BlackUnlocked && !crackdown) Restock(d, day); + } + + private static void Night_RugNews(int day) => News.Show("pd_news_bmrug_" + day, L.T("news.bmrug.title"), L.T("news.bmrug.desc"), 4); + + // ---- 上门:开门后有到货的当天,快递员排进顾客队伍 ---- + private static int courierDay = -1; + + private static void OnGenerate(StoreClientManager manager) + { + if (Session.Data == null || !Unlock.BlackUnlocked) return; + Log.Guard("黑市快递员", () => + { + int day = StoreStation.GetDayCounter(); + if (day == courierDay || !Session.Data.Black.ReadyFor(Delivery.Door).Any()) return; + courierDay = day; + manager.AddClient(CreateCourier()); + }); + } + + public static StoreClient CreateCourier() + { + var rng = Session.Data.Rng; + var c = new StoreClient + { + identifier = "pd_bm_courier", + displayName = L.T("courier.name"), + spriteName = SpriteDict.GetRandomScavSpriteName(), + clientIntent = StoreClient.ClientIntent.SELL, + }; + StoreClientFactionSetup.InitScav(c); + c.mainDialogue.SetText(c.displayName, L.T("courier.line1." + rng.Range(1, 2))).NextDialogue().SetText(c.displayName, L.T("courier.line2." + rng.Range(1, 2))) + .SetEndAction(() => Log.Guard("黑市快递交货", () => { DeliverDoor(); PlayerStore.Instance.DismissCurrentClient(); })); + c.AddBasicDialog(); + c.CompleteClientCreation(); + return c; + } + + private static void DeliverDoor() + { + var d = Session.Data; + foreach (var o in d.Black.ReadyFor(Delivery.Door).ToList()) + { + Items.Give(ModHelper.SpawnItem(o.Item)); + d.Black.Deliver(o); + Log.Info($"黑市快递:{o.Item} 已放进储藏区"); + } + Session.Save(); + } + + // ---- 死投:晚上去垃圾场,货在那里 ---- + private static void OnArrive(string locationId) + { + if (locationId != "dumping_ground" || Session.Data == null || !Unlock.BlackUnlocked) return; + Log.Guard("黑市死投", () => + { + var d = Session.Data; + var list = d.Black.ReadyFor(Delivery.Drop).ToList(); + foreach (var o in list) + { + var item = ModHelper.SpawnItem(o.Item); + GeneralHelper.SetItemOwned(item, true); + GraphUtils.EmporiumTryAdd(EmporiumEntry.Instance.afterhourInventory, item); + d.Black.Deliver(o); + Log.Info($"黑市死投:{o.Item} 已放进外出背包"); + } + if (list.Count > 0) { Session.Save(); } + }); + } + } +} diff --git a/src/Decentralized/CoinCustomers.cs b/src/Decentralized/CoinCustomers.cs index 412c8f6..a85e7a9 100644 --- a/src/Decentralized/CoinCustomers.cs +++ b/src/Decentralized/CoinCustomers.cs @@ -51,20 +51,20 @@ namespace Decentralized ModHelper.AddDebugFunction(ModInfo.Id, ModInfo.Name, "Black market +50 rep, restock (test)", () => Log.Guard("黑市(测试)", () => { StoreReputation.ModReputation("FACTION_BLACK_MARKET", 50); + if (!Unlock.IsUnlocked) ModHelper.GetNetworkUpgrade(Unlock.Id)?.Unlock(); 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; - }); + BlackDelivery.Restock(d, StoreStation.GetDayCounter(), true); 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, "Courier (test)", () => Log.Guard("黑市快递(测试)", () => + { + foreach (var o in Session.Data.Black.Orders.Where(x => x.State == OrderState.Pending)) { o.State = OrderState.Ready; } + PlayerStore.Instance.currentClientInstance = null; + PlayerStore.Instance.storeClientManager.clientStack.Insert(0, BlackDelivery.CreateCourier()); + PlayerStore.Instance.TryCallNextClient(); + })); ModHelper.AddDebugFunction(ModInfo.Id, ModInfo.Name, "Coin buyer (test)", () => Log.Guard("用币付款顾客(测试)", () => { var c = new StoreClient diff --git a/src/Decentralized/ModEntry.cs b/src/Decentralized/ModEntry.cs index 48d2fc5..7ac01af 100644 --- a/src/Decentralized/ModEntry.cs +++ b/src/Decentralized/ModEntry.cs @@ -17,6 +17,7 @@ namespace Decentralized DealerStock.Subscribe(); UsedDealerClient.Subscribe(); CoinCustomers.Subscribe(); + BlackDelivery.Subscribe(); GpuInspectView.Ensure(); ModHook.OnGameLoadedLate += Session.Load; ModHook.OnNewGameStarted += OnNewGame; @@ -30,6 +31,7 @@ namespace Decentralized DealerStock.Unsubscribe(); UsedDealerClient.Unsubscribe(); CoinCustomers.Unsubscribe(); + BlackDelivery.Unsubscribe(); GpuInspectView.Remove(); ModHook.OnGameLoadedLate -= Session.Load; ModHook.OnNewGameStarted -= OnNewGame; diff --git a/src/Decentralized/ModFiles/Localization/en.csv b/src/Decentralized/ModFiles/Localization/en.csv index c663f58..10c4354 100644 --- a/src/Decentralized/ModFiles/Localization/en.csv +++ b/src/Decentralized/ModFiles/Localization/en.csv @@ -230,3 +230,50 @@ 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. +black.sub.orders,Orders +black.sub.mine,My orders & intel +black.rugged,"The platform is down; nobody will reopen for {0} more days." +black.seller,"Seller {0} trades {1} rating {2}%" +black.orders.h,Order catalog +black.listing,"{0} pay {1} credits" +black.order,Order +black.noadvance,This seller does not take advance release +black.orders.hint,"Cheaper than spot, but it takes days|and things can go wrong.|Escrow: {0}% fee, released on delivery;|refunded if the seller runs.|Advance: {1}% off, but a scammer keeps it;|only sellers with >= {2} trades accept.|The platform can rug too; escrow and|advance money both sit with it." +black.mode.Escrow,Escrow +black.mode.Advance,Advance release +black.del.Door,Door delivery +black.del.Drop,Dead drop +black.del.hint.Door,"A courier comes by in {0} days (as a customer when you open); higher interception chance." +black.del.hint.Drop,"Left at the dumping ground in {0} days, pick it up at night; low interception chance, but someone may dig it up first." +black.ordered,"Ordered: {0}, due day {1}." +black.mine.h,My orders +black.mine.none,No orders yet. +black.mine.line,"{0} ({1}, {2}): {3}, day {4}{5}" +black.mine.drop,", pick up at the dumping ground" +black.state.Pending,in transit +black.state.Ready,arrived +black.state.Done,picked up +black.state.Refunded.scam,seller ran, refunded +black.state.Lost.scam,seller ran, money gone +black.state.Lost.rug,platform rugged, money gone +black.state.Lost.intercept,intercepted +black.state.Lost.theft,dug up by someone else +black.forum.h,Forum intel +black.forum.quiet,Quiet lately. +black.forum.scam,"Someone says {0} takes the money and never ships." +black.forum.rug,"Withdrawals have been slow lately, the site is running promos, and a lot of complaint threads got deleted." +nightlog.bm.ready.Door,"Black market: {0} arrived; a courier will come by once you open." +nightlog.bm.ready.Drop,"Black market: {0} is at the dumping ground, go get it at night." +nightlog.bm.scam_refund,"Black market: seller {0} ran; the escrow for {1} was refunded." +nightlog.bm.scam_loss,"Black market: seller {0} took the advance and ran; {1} is gone ({2} credits)." +nightlog.bm.intercept,"Black market: the parcel with {0} was intercepted; {1} credits of payment lost." +nightlog.bm.theft,"Black market: {0} at the dumping ground was dug up by someone else; {1} credits lost." +nightlog.bm.rug,"Black market: the platform rugged; the in-transit funds for {0} ({1}) are gone." +nightlog.bm.reopen,Black market: someone reopened at the old spot. +news.bmrug.title,Black-market platform rugged +news.bmrug.desc,"The black market's on-chain entrance went dark overnight, wiping out all escrow and advance funds. A new entrance will appear in a few days." +courier.name,Courier +courier.line1.1,"Delivery's here." +courier.line1.2,"Package for you." +courier.line2.1,"Don't tell me what's inside. I don't want to know." +courier.line2.2,"Dropped it here. I'm gone." diff --git a/src/Decentralized/ModFiles/Localization/zh.csv b/src/Decentralized/ModFiles/Localization/zh.csv index 18cf393..873d242 100644 --- a/src/Decentralized/ModFiles/Localization/zh.csv +++ b/src/Decentralized/ModFiles/Localization/zh.csv @@ -229,3 +229,50 @@ black.desk.in,兑入 Ponero black.desk.out,兑出 Ponero black.desk.go,兑换 black.desk.fail,兑换失败:余额、额度或数量不对。 +black.sub.orders,订单 +black.sub.mine,我的订单与情报 +black.rugged,"平台出事了,入口关闭,还要 {0} 天才会有人重新开张。" +black.seller,"卖家 {0} 成交 {1} 好评 {2}%" +black.orders.h,订单目录 +black.listing,"{0} 实付 {1} 信用点等值" +black.order,下单 +black.noadvance,这个卖家不接受提前放款 +black.orders.hint,"比现货便宜,但要等几天,|路上可能出事。|托管:多付 {0}% 托管费,|收货后放款,卖家跑路时退款。|提前放款:再打 {1}% 折,|卖家若是骗子钱就没了;|成交 ≥ {2} 的卖家才接受。|平台自己也可能跑路,|托管和提前放款的钱都在它手里。" +black.mode.Escrow,托管 +black.mode.Advance,提前放款 +black.del.Door,"送货上门" +black.del.Drop,死投点 +black.del.hint.Door,"{0} 天后快递员上门(开店时来个顾客),被截获的概率较高。" +black.del.hint.Drop,"{0} 天后货放在垃圾场,晚上去取;被截获的概率低,但可能被别人先挖走。" +black.ordered,"已下单:{0},预计第 {1} 天到。" +black.mine.h,我的订单 +black.mine.none,还没有订单。 +black.mine.line,"{0}({1},{2}):{3},第 {4} 天{5}" +black.mine.drop,,去垃圾场取 +black.state.Pending,在途 +black.state.Ready,到货 +black.state.Done,已取 +black.state.Refunded.scam,卖家跑路,已退款 +black.state.Lost.scam,卖家跑路,钱没了 +black.state.Lost.rug,平台跑路,钱没了 +black.state.Lost.intercept,被截获 +black.state.Lost.theft,被别人先挖走 +black.forum.h,论坛情报 +black.forum.quiet,最近很安静。 +black.forum.scam,"有人说 {0} 收了钱不发货。" +black.forum.rug,"最近提币变慢了,全场还在搞促销,投诉帖被删了不少。" +nightlog.bm.ready.Door,"黑市:{0} 到货,开店后会有快递员上门。" +nightlog.bm.ready.Drop,"黑市:{0} 已放在垃圾场,晚上去取。" +nightlog.bm.scam_refund,"黑市:卖家 {0} 跑路了,{1} 的托管款已退回。" +nightlog.bm.scam_loss,"黑市:卖家 {0} 拿了提前放款的钱跑路,{1} 没有了({2} 信用点等值)。" +nightlog.bm.intercept,"黑市:{0} 的包裹被截获,{1} 信用点等值的货款没了。" +nightlog.bm.theft,"黑市:垃圾场的 {0} 被别人先挖走了,{1} 信用点等值的货款没了。" +nightlog.bm.rug,"黑市:平台跑路,{0} 的在途款项({1})归零。" +nightlog.bm.reopen,黑市:有人在老地方重新开张了。 +news.bmrug.title,黑市平台跑路 +news.bmrug.desc,"黑市的链上入口一夜之间下线,托管和提前放款的资金全部清零。几天后会有新的入口。" +courier.name,快递员 +courier.line1.1,"东西到了。" +courier.line1.2,"有你的包裹。" +courier.line2.1,"别问我里面是什么,我也不想知道。" +courier.line2.2,"货放这儿了,我走了。" diff --git a/src/Decentralized/Night.cs b/src/Decentralized/Night.cs index 54d6476..d891300 100644 --- a/src/Decentralized/Night.cs +++ b/src/Decentralized/Night.cs @@ -101,15 +101,7 @@ 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; - }); + if (Unlock.BlackUnlocked) BlackDelivery.Night(d, day, events.Contains(MarketEvent.Crackdown)); 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/TerminalWindow.cs b/src/Decentralized/TerminalWindow.cs index e16f1cf..29f274e 100644 --- a/src/Decentralized/TerminalWindow.cs +++ b/src/Decentralized/TerminalWindow.cs @@ -264,7 +264,10 @@ namespace Decentralized }); } - private static bool _blackDesk; + private enum BlackTab { Shop, Orders, Mine, Desk } + private static BlackTab _blackTab = BlackTab.Shop; + private static PayMode _mode = PayMode.Escrow; + private static Delivery _delivery = Delivery.Door; private static string _deskAsset = Assets.PSC; private static bool _deskIn = true; private static string _payAsset = Assets.CRT; @@ -278,14 +281,19 @@ namespace Decentralized 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(); + foreach (BlackTab t in Enum.GetValues(typeof(BlackTab))) { - 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 (t == BlackTab.Desk && !desk) continue; + var tab = t; + string name = L.T("black.sub." + t.ToString().ToLowerInvariant()); + b.AddButton(tab == _blackTab ? $"[{name}]" : name, () => { _blackTab = tab; _message = null; Open(); }); } - if (desk && _blackDesk) { DeskPage(b, rep, day); return; } + b.End(); + if (d.Black.Closed(day)) { b.AddLabel(L.T("black.rugged", d.Black.ClosedUntil - day + 1)); return; } + if (_blackTab == BlackTab.Desk && desk) { DeskPage(b, rep, day); return; } + if (_blackTab == BlackTab.Orders) { OrdersPage(b, day); return; } + if (_blackTab == BlackTab.Mine) { MinePage(b, day); return; } var w = d.Hot; Columns(b, l => { @@ -314,14 +322,7 @@ namespace Decentralized }); } - 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 string ItemName(string id) => BlackDelivery.ItemName(id); private static void BuyOffer(BmOffer o) { @@ -333,6 +334,87 @@ namespace Decentralized Open(); } + private static string SellerText(BmSeller s) => L.T("black.seller", s.Name, s.Trades, s.Rating); + + /// 订单目录:比现货便宜,要等几天,可能被截获、被骗。 + private static void OrdersPage(CustomUIBuilder b, int day) + { + var d = Session.Data; + var w = d.Hot; + Columns(b, l => + { + l.AddHeader(L.T("black.orders.h")); + if (d.Black.Catalog.Count == 0) l.AddLabel(L.T("black.empty")); + foreach (var x in d.Black.Catalog.ToList()) + { + var listing = x; + var seller = d.Black.Sellers[listing.Seller]; + l.AddLabel(L.T("black.listing", ItemName(listing.Item), BlackMarket.Pay(listing.Price, _mode))); + l.AddLabel(" " + SellerText(seller)); + bool ok = _mode == PayMode.Escrow || seller.Advance; + l.AddButton(ok ? L.T("black.order") : L.T("black.noadvance"), () => { if (ok) PlaceOrder(listing); }); + } + }, r => + { + r.AddHeader(L.T("black.pay.h")); + foreach (var line in L.T("black.orders.hint", (int)Math.Round(Tuning.BmEscrowFee * 100), (int)Math.Round(Tuning.BmAdvanceDiscount * 100), Tuning.BmAdvanceMinTrades).Split('|')) r.AddLabel(line); + r.BeginRow(); + foreach (PayMode m in Enum.GetValues(typeof(PayMode))) + { + var mode = m; + string n = L.T("black.mode." + m); + r.AddButton(mode == _mode ? $"[{n}]" : n, () => { _mode = mode; Open(); }); + } + r.End(); + r.BeginRow(); + foreach (Delivery dl in Enum.GetValues(typeof(Delivery))) + { + var del = dl; + string n = L.T("black.del." + dl, Tuning.BmDoorDays); + r.AddButton(del == _delivery ? $"[{n}]" : n, () => { _delivery = del; Open(); }); + } + r.End(); + r.AddLabel(L.T("black.del.hint." + _delivery, BlackMarket.Days(_delivery))); + 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)))); + }); + } + + private static void PlaceOrder(BmListing l) + { + var d = Session.Data; + var o = d.Black.Place(l, _mode, _delivery, d.Hot, _payAsset, StoreStation.GetDayCounter(), Session.Price); + if (o == null) { _message = L.T("msg.balance"); Open(); return; } + _message = L.T("black.ordered", ItemName(o.Item), o.DueDay); + Session.Save(); + Open(); + } + + /// 我的订单与论坛情报。 + private static void MinePage(CustomUIBuilder b, int day) + { + var d = Session.Data; + Columns(b, l => + { + l.AddHeader(L.T("black.mine.h")); + if (d.Black.Orders.Count == 0) l.AddLabel(L.T("black.mine.none")); + foreach (var o in d.Black.Orders.OrderByDescending(x => x.PlacedDay)) + l.AddLabel(L.T("black.mine.line", ItemName(o.Item), o.Seller, L.T("black.mode." + o.Mode), L.T("black.state." + o.State + (o.Why != null && o.State != OrderState.Done ? "." + o.Why : "")), o.DueDay, o.Delivery == Delivery.Drop && o.State == OrderState.Ready ? L.T("black.mine.drop") : "")); + }, r => + { + r.AddHeader(L.T("black.forum.h")); + if (d.Black.Forum.Count == 0) r.AddLabel(L.T("black.forum.quiet")); + foreach (var f in d.Black.Forum) + r.AddLabel(f.StartsWith("scam:") ? L.T("black.forum.scam", f.Substring(5)) : L.T("black.forum." + f)); + }); + } + private static void DeskPage(CustomUIBuilder b, int rep, int day) { var d = Session.Data; diff --git a/tests/Decentralized.Domain.Tests/Program.cs b/tests/Decentralized.Domain.Tests/Program.cs index d6b2943..c2a8cd9 100644 --- a/tests/Decentralized.Domain.Tests/Program.cs +++ b/tests/Decentralized.Domain.Tests/Program.cs @@ -9,6 +9,8 @@ internal static class Program { private static int _failed, _passed; + private static double BlackMarketTestRug() => Tuning.BmRugBase * 3; + private static void Check(bool ok, string what) { if (ok) _passed++; @@ -583,6 +585,55 @@ internal static class Program Check(Near(back, 10 * 50 * (1 - Tuning.PoneroFee), 1e-9) && Near(w.Get(Assets.CRT), back, 1e-9), "兑出 Ponero"); }); + Test("黑市订单", () => + { + Func price = a => a == Assets.CRT ? 1 : 100; + Func value = id => 200; + var bm = new BlackMarket(); + bm.RefreshOrders(75, new Rng(5), value); + Check(bm.Sellers.Count == Tuning.BmSellerCount && bm.Catalog.Count > 0 && bm.Catalog.All(l => l.Price == 170), "目录:卖家与订单价(基础价 × 0.85)"); + Check(BlackMarket.Pay(170, PayMode.Escrow) == 170 + 9 && BlackMarket.Pay(170, PayMode.Advance) == 153, "托管多付 5%,提前放款打九折"); + var good = new BmSeller { Name = "g", Trades = 100, Rating = 95 }; + var bad = new BmSeller { Name = "b", Trades = 100, Rating = 95, Scam = true }; + bm.Sellers.Clear(); bm.Sellers.Add(good); bm.Sellers.Add(bad); + var lg = new BmListing { Seller = 0, Item = "x", Price = 1000 }; + var lb = new BmListing { Seller = 1, Item = "x", Price = 1000 }; + var w = new Wallet { Address = "t" }; + w.Add(Assets.CRT, 10000); w.Taint = 4000; w.Basis = 5000; + var o = bm.Place(lb, PayMode.Escrow, Delivery.Door, w, Assets.CRT, 10, price); + Check(o != null && Near(w.Get(Assets.CRT), 10000 - 1050, 1e-9) && w.Taint < 4000 && bm.Place(lg, PayMode.Escrow, Delivery.Door, w, Assets.CRC, 10, price) == null, "托管下单:扣币、污点留在订单上;不收 CRC"); + double tBefore = 4000; + var ev = bm.Night(13, false, w, new Rng(1)); + Check(ev.Any(e => e.Kind == "scam_refund") && Near(w.Get(Assets.CRT), 10000, 1e-6) && Near(w.Taint, tBefore, 1e-6) && Near(w.Basis, 5000, 1e-6), "骗子卖家 + 托管:退款,污点与成本一并还回(不能借退款洗钱)"); + var o2 = bm.Place(lb, PayMode.Advance, Delivery.Drop, w, Assets.CRT, 20, price); + var ev2 = bm.Night(22, false, w, new Rng(2)); + Check(o2.State == OrderState.Lost && Near(w.Get(Assets.CRT), 10000 - 900, 1e-6), "骗子卖家 + 提前放款:钱没了"); + var o3 = bm.Place(lg, PayMode.Escrow, Delivery.Door, w, Assets.CRT, 30, price); + Check(bm.Night(31, false, w, new Rng(3)).Count == 0 && o3.State == OrderState.Pending, "没到期不动"); + int ready = 0, lost = 0; + for (int i = 0; i < 400; i++) + { + var t = new BlackMarket(); t.Sellers.Add(good); + var ww = new Wallet(); ww.Add(Assets.CRT, 5000); + var oo = t.Place(new BmListing { Seller = 0, Item = "x", Price = 100 }, PayMode.Escrow, Delivery.Door, ww, Assets.CRT, 1, price); + t.Night(oo.DueDay, false, ww, new Rng((ulong)i + 7)); + if (oo.State == OrderState.Ready) ready++; else lost++; + } + Check(ready > 340 && lost > 10, $"上门:约 {Tuning.BmInterceptDoor:P0} 被截获(跑路另算):到货 {ready},丢失 {lost}"); + var c = new BlackMarket(); c.Sellers.Add(good); + var cw = new Wallet(); cw.Add(Assets.CRT, 5000); + var co = c.Place(new BmListing { Seller = 0, Item = "x", Price = 100 }, PayMode.Escrow, Delivery.Door, cw, Assets.CRT, 1, price); + c.Night(co.DueDay, true, cw, new Rng(1)); + Check(co.State == OrderState.Pending && co.DueDay == 5, "镇压期间到期顺延一天"); + // 跑路:托管资金多则概率高;跑路后关闭 + var r = new BlackMarket(); r.Sellers.Add(good); + var rw = new Wallet(); rw.Add(Assets.CRT, 1000000); + for (int i = 0; i < 20; i++) r.Place(new BmListing { Seller = 0, Item = "x", Price = 10000 }, PayMode.Escrow, Delivery.Door, rw, Assets.CRT, 1, price); + Check(r.RugChance > BlackMarketTestRug(), "托管资金越多越容易跑路"); + r.ClosedUntil = 40; + Check(r.Closed(40) && !r.Closed(41) && r.Place(new BmListing { Seller = 0, Item = "x", Price = 1 }, PayMode.Escrow, Delivery.Door, rw, Assets.CRT, 30, price) == null, "关闭期间不能下单"); + }); + Console.WriteLine($"\n{_passed} 通过,{_failed} 失败"); return _failed == 0 ? 0 : 1; }