2026-11-08 14:29:29 +08:00
|
|
|
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;
|
2026-11-08 15:10:03 +08:00
|
|
|
/// <summary>买币客户开出的账单:付清(确认到账)或作废之前一直记着。</summary>
|
2026-11-08 14:29:29 +08:00
|
|
|
private static WalletWindow.Bill pendingBill;
|
2026-11-08 15:10:03 +08:00
|
|
|
/// <summary>还没签名付款的换币账单:钱包没有终端账单时用它(见 <see cref="WalletWindow.Pending"/>);签名后清掉,没成交(挤出内存池)时放回来。</summary>
|
|
|
|
|
public static WalletWindow.Bill Standing;
|
2026-11-08 14:29:29 +08:00
|
|
|
|
|
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
|
2026-11-08 15:10:03 +08:00
|
|
|
/// <summary>这张账单是不是换币客户开的(终端不让它独占 Web3、灰网页)。</summary>
|
|
|
|
|
public static bool IsBill(WalletWindow.Bill bill) => bill != null && bill == pendingBill;
|
|
|
|
|
|
2026-11-08 14:29:29 +08:00
|
|
|
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();
|
2026-11-08 15:10:03 +08:00
|
|
|
// 选项上显示的是玩家要说的这句话
|
|
|
|
|
string line = L.T("otc.buy.say", Fmt.Credits(price), (Tuning.OtcPremium[t] * 100).ToString("0"));
|
|
|
|
|
var reply = say.SetText("PLAYER", line).NextDialogue();
|
2026-11-08 14:29:29 +08:00
|
|
|
reply.SetText(c.displayName, L.T("otc.buy.reject")).SetEndAction(Leave);
|
2026-11-08 15:10:03 +08:00
|
|
|
ask.AddChoice(DirectoryMaster.Item("$"), line, "", say,
|
2026-11-08 14:29:29 +08:00
|
|
|
() => reply.SetText(c.displayName, AcceptBuy(o, addr, t, price)));
|
|
|
|
|
}
|
2026-11-08 15:10:03 +08:00
|
|
|
// 原版的对话最多三个选项(选项气泡只有三个),所以没有"不卖":不想卖就把客户打发走
|
2026-11-08 14:29:29 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// <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)));
|
2026-11-08 15:10:03 +08:00
|
|
|
TerminalWindow.Refresh(L.T("nightlog.otc.paid", WalletWindow.Amt(qty), asset, Fmt.Credits(price)));
|
2026-11-08 14:29:29 +08:00
|
|
|
Log.Info($"换币客户:{qty} {asset} 到账,收现金 {price}");
|
|
|
|
|
Session.Save();
|
|
|
|
|
}),
|
|
|
|
|
};
|
2026-11-08 15:10:03 +08:00
|
|
|
pendingBill = Standing = bill;
|
2026-11-08 14:29:29 +08:00
|
|
|
line = L.T("otc.buy.accept", Fmt.Credits(price), ChainAddress.Short(addr));
|
|
|
|
|
Log.Info($"换币客户:第 {tier + 1} 档 {price} 成交,待转 {qty} {asset} 到 {addr}");
|
|
|
|
|
});
|
|
|
|
|
return line;
|
|
|
|
|
}
|
|
|
|
|
|
2026-11-08 15:10:03 +08:00
|
|
|
/// <summary>睡觉前:买币客户的账单还没签名付款,作废。已经签名、还在确认中的照常到账。</summary>
|
2026-11-08 14:29:29 +08:00
|
|
|
private static void VoidUnpaid()
|
|
|
|
|
{
|
2026-11-08 15:10:03 +08:00
|
|
|
if (Standing == null) return;
|
|
|
|
|
var bill = Standing;
|
2026-11-08 14:29:29 +08:00
|
|
|
Log.Guard("换币账单作废", () =>
|
|
|
|
|
{
|
2026-11-08 15:10:03 +08:00
|
|
|
PlayerStore.Instance.AddNightLog(L.T("nightlog.otc.unpaid", WalletWindow.Amt(bill.Amount), bill.Asset), "cc7755");
|
2026-11-08 14:29:29 +08:00
|
|
|
Log.Info("换币客户的账单当天没付,作废");
|
|
|
|
|
});
|
2026-11-08 15:10:03 +08:00
|
|
|
Void();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// <summary>作废换币账单(终端的"作废"、夜里没付、读档)。</summary>
|
|
|
|
|
public static void Void()
|
|
|
|
|
{
|
|
|
|
|
Standing = null;
|
2026-11-08 14:29:29 +08:00
|
|
|
pendingBill = null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private static void Leave() => DialogUIManager.Instance.CurrentClientLeave();
|
|
|
|
|
}
|
|
|
|
|
}
|