第十一阶段 stage 2:场外换币台(暗灯招换币客户)

- 新人脉升级“场外换币台”(OtcUnlockPrice 500),附赠一盏暗灯
- 包装原版暗灯工厂:切换顺序末尾加目标 pd_otc(只接管进出这一步,其余交给原切换函数);顾客工厂写进公开的 StoreClientListDict.storeClientDict,顾客面板与概率走原版逻辑
- 换色:底图换成原版熄灭的暗灯,叠加层画黄绿色光晕(otc_lamp_on),不进存档
- 换币客户(OtcClient)一半卖币、一半买币:卖币并入原收黑币(Domain/Otc.cs 取代 HotCoins.cs,HotCoinSeller 删除),污点比例随机、越脏越便宜、地址来历写进账本;买币三档报价,PLEDGER 转账确认后现金到账,当晚未付作废
- 只在生成顾客的回调窗口里生成报价、写账本(原版取名字也会调用工厂)
- 存档数据版本 4:旧存档出块奖励、NPC 算力按 MiningScale 放大
- 8 种语言文字、手册“换币客户”一节;单元测试

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ZHU MINCHAOandClaude Opus 5.5 committed 2026-11-08 14:29:49 +08:00
1 parent bc23ae056b
commit ec578a39d9
25 files changed
+833 -321

No files matched your search

+222
View File
@@ -0,0 +1,222 @@
using System;
using System.Collections.Generic;
using Decentralized.Domain;
namespace Decentralized
{
/// <summary>
/// 换币客户(游戏的对话顾客),由切到"换币客户"的小偷灯招来,见 <see cref="OtcLamp"/> 与 <see cref="Otc"/>。
/// 卖币:付现金,币进已连接的钱包(污点按比例、带上限),便衣会没收现金并记销赃罪证。
/// 买币:玩家选报价档,客户接受后离开,玩家在 PLEDGER 上把币转到客户地址,确认后现金到账;当晚没付的账单作废。
/// 原版取顾客名字时也会调用工厂:只有在生成顾客的那段时间里(普通顾客回调之间,原版在这里按展示柜生成顾客)才生成报价、写链上账本。
/// </summary>
public static class OtcClient
{
public const string Id = OtcLamp.Target;
private static bool spawning;
/// <summary>买币客户开出的账单:夜里还没付就作废。</summary>
private static WalletWindow.Bill pendingBill;
public static void Subscribe()
{
StoreClientListDict.storeClientDict[Id] = Create;
ModHook.OnGenerateCustomerNormal += Open;
ModHook.OnGenerateCustomerLate += Close;
ModHook.OnGoingSleepEarly += VoidUnpaid;
ModHelper.AddDebugFunction(ModInfo.Id, ModInfo.Name, "OTC client (test)", () => Log.Guard("换币客户(测试)", () =>
{
spawning = true;
StoreClient c;
try { c = Create(); } finally { spawning = false; }
PlayerStore.Instance.currentClientInstance = null;
PlayerStore.Instance.storeClientManager.clientStack.Insert(0, c);
PlayerStore.Instance.TryCallNextClient();
}));
}
public static void Unsubscribe()
{
StoreClientListDict.storeClientDict.Remove(Id);
ModHook.OnGenerateCustomerNormal -= Open;
ModHook.OnGenerateCustomerLate -= Close;
ModHook.OnGoingSleepEarly -= VoidUnpaid;
}
private static void Open(StoreClientManager _) => spawning = true;
private static void Close(StoreClientManager _) => spawning = false;
/// <summary>顾客表里的工厂。</summary>
private static StoreClient Create()
{
StoreClient c = null;
if (spawning && Session.Data != null && Unlock.OtcUnlocked) Log.Guard("换币客户上门", () => c = Real());
return c ?? Shell();
}
/// <summary>只有名字的顾客(原版取名字时用;生成失败时上门说一句就走)。</summary>
private static StoreClient Shell()
{
var c = Base();
c.mainDialogue.SetText(c.displayName, L.T("otc.refused")).SetEndAction(Leave);
Finish(c);
return c;
}
private static StoreClient Base() => new StoreClient
{
identifier = Id, displayName = L.T("otc.name"), spriteName = SpriteDict.GetRandomShadySpriteName(),
clientIntent = StoreClient.ClientIntent.DIALOGUE,
};
private static void Finish(StoreClient c)
{
ModHelper.SetClientSource(c, ModInfo.Id);
c.AddBasicDialog();
c.CompleteClientCreation();
}
private static StoreClient Real()
{
var d = Session.Data;
var side = Otc.PickSide(d.Rng);
bool ponero = Unlock.BlackUnlocked && BlackMarket.DeskOpen(StoreReputation.GetBMReputation());
var o = side == OtcSide.Sell ? Otc.MakeSell(Night.ActiveEvents(), ponero, Session.Price, Session.Rent(), d.Rng) : Otc.MakeBuy(Session.Price, Session.Rent(), d.Rng);
if (o == null) return null;
string addr = d.Address("otc:" + (++d.Otc.Seq));
var c = Base();
if (o.Side == OtcSide.Sell) { WriteHistory(d, o, addr); BuildSell(c, o, addr); }
else BuildBuy(c, o, addr);
Finish(c);
Log.Info(o.Side == OtcSide.Sell
? $"换币客户(卖币):{o.Qty:0.##} {o.Asset},市值 {o.Value:0},污点比例 {o.TaintRatio:0.00},要价 {o.Cash}{(o.Sting ? "(便衣)" : "")}"
: $"换币客户(买币):{o.Qty:0.##} {o.Asset},市值 {o.Value:0}");
Session.Save();
return c;
}
/// <summary>客户地址的来历写进链上账本(Ponero 不上账本):脏的来源是混币器、黑市平台和骗局地址,干净的是 Poinbase 和 DEX。</summary>
private static void WriteHistory(SaveData d, OtcOffer o, string addr)
{
if (o.Asset == Assets.Ponero) return;
var dirty = new List<string> { d.Address("mixer"), d.Address("platform") };
dirty.AddRange(d.Scams.ScamAddrs);
var clean = new List<string> { d.Address("poinbase"), d.Address("dex") };
foreach (var kv in Otc.History(o, dirty, clean, d.Rng)) d.Chain(kv.Key, addr, o.Asset, o.Qty * kv.Value);
}
// ---------- 卖币给玩家 ----------
private static void BuildSell(StoreClient c, OtcOffer o, string addr)
{
// 选项的动作先于下一句执行:在动作里结算,再把客户的回话换成结果
var yes = new Dialogue();
var reply = yes.SetText("PLAYER", L.T("otc.say.yes")).NextDialogue();
reply.SetText(c.displayName, L.T("otc.refused")).SetEndAction(Leave);
var no = new Dialogue();
no.SetText("PLAYER", L.T("otc.say.no")).NextDialogue().SetText(c.displayName, L.T("otc.refused")).SetEndAction(Leave);
string intro = o.TaintRatio >= 1 || o.Asset == Assets.Ponero ? "otc.intro." + o.Asset : "otc.intro.sell";
c.mainDialogue.SetText(c.displayName, L.T(intro, o.Asset)).NextDialogue()
.SetText(c.displayName, L.T("otc.offer", Fmt.Num(o.Qty), o.Asset, Fmt.Credits(o.Value), Fmt.Credits(o.Cash), ChainAddress.Short(addr)))
.AddChoice(DirectoryMaster.Item("yes"), L.T("otc.choice.yes"), "", yes, () => reply.SetText(c.displayName, L.T(AcceptSell(o, addr))))
.AddChoice(DirectoryMaster.Item("cancel"), L.T("otc.choice.no"), "", no, null);
}
/// <summary>收下:现金不够或没连钱包就作罢;便衣没收现金、记销赃罪证;否则付现金、币进钱包。返回客户回话的键。</summary>
private static string AcceptSell(OtcOffer o, string addr)
{
string line = "otc.refused";
Log.Guard("换币客户成交(卖币)", () =>
{
var d = Session.Data;
var ps = PlayerStore.Instance;
if (!d.Connected) { line = "otc.nowallet"; return; }
if (ps.playerCash < o.Cash) { line = "otc.nocash"; return; }
ModHelper.AddCredit(-o.Cash, false);
if (o.Sting)
{
ps.secData.CommitCrime("FENCING", (int)Math.Min(Tuning.CrimeEventCap, o.Cash));
line = "otc.sting";
Log.Info($"换币客户:便衣,现金 {o.Cash} 没收,记销赃罪证");
}
else
{
double over = Otc.Receive(d.Hot, o, Session.Price);
d.Chain(addr, d.Hot.Address, o.Asset, o.Qty);
d.Otc.Record(o.Cash);
string extra = TaintCrime.Commit(over);
if (extra.Length > 0) ps.AddNightLog(extra.Trim(), "cc7755");
line = "otc.done";
Log.Info($"换币客户:{o.Qty:0.##} {o.Asset} 进 {WalletWindow.NameOf(d.Hot)},付现金 {o.Cash},钱包污点 {d.Hot.Taint:0},超出 {over:0}");
}
Session.Save();
});
return line;
}
// ---------- 向玩家买币 ----------
private static void BuildBuy(StoreClient c, OtcOffer o, string addr)
{
var ask = c.mainDialogue.SetText(c.displayName, L.T("otc.buy.ask", Fmt.Num(o.Qty), o.Asset, Fmt.Credits(o.Value)));
for (int tier = 0; tier < Tuning.OtcPremium.Length; tier++)
{
int t = tier, price = Otc.Quote(o, t);
var say = new Dialogue();
var reply = say.SetText("PLAYER", L.T("otc.buy.say", Fmt.Credits(price))).NextDialogue();
reply.SetText(c.displayName, L.T("otc.buy.reject")).SetEndAction(Leave);
ask.AddChoice(DirectoryMaster.Item("$"), L.T("otc.tier", (Tuning.OtcPremium[t] * 100).ToString("0"), Fmt.Credits(price)), "", say,
() => reply.SetText(c.displayName, AcceptBuy(o, addr, t, price)));
}
var no = new Dialogue();
no.SetText("PLAYER", L.T("otc.buy.no")).NextDialogue().SetText(c.displayName, L.T("otc.refused")).SetEndAction(Leave);
ask.AddChoice(DirectoryMaster.Item("cancel"), L.T("otc.choice.no"), "", no, null);
}
/// <summary>报价:客户按这一档的概率接受。接受就开账单(转到客户地址),确认后现金到账。返回客户的回话。</summary>
private static string AcceptBuy(OtcOffer o, string addr, int tier, int price)
{
string line = L.T("otc.buy.reject");
Log.Guard("换币客户报价(买币)", () =>
{
var d = Session.Data;
if (!Otc.Accepts(tier, d.Rng)) { Log.Info($"换币客户:第 {tier + 1} 档 {price} 没接受"); return; }
double qty = WalletWindow.RoundUp(o.Qty);
string asset = o.Asset;
WalletWindow.Bill bill = null;
bill = new WalletWindow.Bill
{
Address = addr, Asset = asset, Amount = qty, Summary = L.T("pay.otc", WalletWindow.Amt(qty), asset, Fmt.Credits(price)),
Execute = pw => Log.Guard("换币客户到账", () =>
{
pw.Spend(asset, qty, Session.Price);
ModHelper.AddCredit(price, true);
Session.Data.Otc.Record(price);
if (pendingBill == bill) pendingBill = null;
PlayerStore.Instance.AddNightLog(L.T("nightlog.otc.paid", WalletWindow.Amt(qty), asset, Fmt.Credits(price)));
Log.Info($"换币客户:{qty} {asset} 到账,收现金 {price}");
Session.Save();
}),
};
WalletWindow.Pending = pendingBill = bill;
line = L.T("otc.buy.accept", Fmt.Credits(price), ChainAddress.Short(addr));
Log.Info($"换币客户:第 {tier + 1} 档 {price} 成交,待转 {qty} {asset} 到 {addr}");
});
return line;
}
/// <summary>睡觉前:买币客户的账单还没付(仍是待付账单),作废。已经签名、还在确认中的照常到账。</summary>
private static void VoidUnpaid()
{
if (pendingBill == null || WalletWindow.Pending != pendingBill) return;
Log.Guard("换币账单作废", () =>
{
WalletWindow.Pending = null;
PlayerStore.Instance.AddNightLog(L.T("nightlog.otc.unpaid", WalletWindow.Amt(pendingBill.Amount), pendingBill.Asset), "cc7755");
Log.Info("换币客户的账单当天没付,作废");
});
pendingBill = null;
}
private static void Leave() => DialogUIManager.Instance.CurrentClientLeave();
}
}