换币客户改在交易终端成交:按比例部分成交、买币随机出价、弹出钱包

- 对话只剩"到终端上谈 / 这单我不做":交易终端打开柜台交易页(标签"柜台",只在有客户时出现),客户在柜台前等着
- 柜台交易页:客户来意、数量、市值、出价/要价、单价、地址(卖币可"查地址"进浏览器),25/50/75/100% 或手输数量部分成交(Otc.Part),成交 / 放弃
- 买币客户随机出价(OtcBidMin −5% … OtcBidMax +30%),去掉三档报价与成交概率(OtcPremium、OtcAccept);成交后弹出 PLEDGER,账单 Prefill 填好客户地址
- 卖币:成交后付现金、币进已连接的钱包;客户说最后一句离开(DialogUIManager.StartDialogue)
- 客户被打发或离开时交易作废;不再有"当晚未付作废"
- 增量翻译:只是和已删旧键同文的新键照常翻译
- 实测:买币客户 25% 部分成交(先在 Web3 换出 CRA),PLEDGER 预填地址签名,到账 +668,客户离开;涡轮增压器作用于矿架立刻挖一轮

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ZHU MINCHAOandClaude Opus 5.5 committed 2026-11-08 15:46:14 +08:00
1 parent 2badc45a5a
commit 9d95e1f760
16 files changed
+455 -223

No files matched your search

+120 -91
View File
@@ -5,16 +5,23 @@ using Decentralized.Domain;
namespace Decentralized
{
/// <summary>
/// 换币客户(游戏的对话顾客),由切到"换币客户"的小偷灯招来,见 <see cref="OtcLamp"/> 与 <see cref="Otc"/>。
/// 卖币:付现金,币进已连接的钱包(污点按比例、带上限),便衣会没收现金并记销赃罪证。
/// 买币:玩家选报价档,客户接受后离开,玩家在 PLEDGER 上把币转到客户地址,确认后现金到账;当晚没付的账单作废。
/// 换币客户(游戏的对话顾客),由切到"换币客户"的暗灯招来,见 <see cref="OtcLamp"/> 与 <see cref="Otc"/>。
/// 客户说明来意后选"去终端交易":打开交易终端的柜台交易页(<see cref="TerminalWindow"/>),客户留在柜台前等着。
/// 在终端里定成交数量(可以只成交一部分):卖币的客户收现金、币直接进已连接的钱包(便衣会没收现金并记销赃罪证);
/// 买币的客户弹出 PLEDGER,收款地址和数量已填好,签名确认后客户付现金。客户走了(被打发、没成交)就作废。
/// 原版取顾客名字时也会调用工厂:只有在生成顾客的那段时间里(普通顾客回调之间,原版在这里按展示柜生成顾客)才生成报价、写链上账本。
/// </summary>
public static class OtcClient
{
public const string Id = OtcLamp.Target;
private static bool spawning;
/// <summary>买币客户开出的账单:付清(确认到账)或作废之前一直记着。</summary>
/// <summary>正在柜台前、已经去终端交易的客户:报价、收款地址和顾客对象(用来判断他还在不在)。</summary>
public static OtcOffer Active;
public static string ActiveAddress;
private static StoreClient activeClient;
/// <summary>买币客户的账单:确认到账或作废之前一直记着。</summary>
private static WalletWindow.Bill pendingBill;
/// <summary>还没签名付款的换币账单:钱包没有终端账单时用它(见 <see cref="WalletWindow.Pending"/>);签名后清掉,没成交(挤出内存池)时放回来。</summary>
public static WalletWindow.Bill Standing;
@@ -24,7 +31,7 @@ namespace Decentralized
StoreClientListDict.storeClientDict[Id] = Create;
ModHook.OnGenerateCustomerNormal += Open;
ModHook.OnGenerateCustomerLate += Close;
ModHook.OnGoingSleepEarly += VoidUnpaid;
ModHook.OnGoingSleepEarly += Drop;
ModHelper.AddDebugFunction(ModInfo.Id, ModInfo.Name, "OTC client (test)", () => Log.Guard("换币客户(测试)", () =>
{
spawning = true;
@@ -41,7 +48,7 @@ namespace Decentralized
StoreClientListDict.storeClientDict.Remove(Id);
ModHook.OnGenerateCustomerNormal -= Open;
ModHook.OnGenerateCustomerLate -= Close;
ModHook.OnGoingSleepEarly -= VoidUnpaid;
ModHook.OnGoingSleepEarly -= Drop;
}
/// <summary>这张账单是不是换币客户开的(终端不让它独占 Web3、灰网页)。</summary>
@@ -89,12 +96,12 @@ namespace Decentralized
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);
if (o.Side == OtcSide.Sell) WriteHistory(d, o, addr);
Build(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}");
: $"换币客户(买币):{o.Qty:0.##} {o.Asset},市值 {o.Value:0},出价 {o.Cash}");
Session.Save();
return c;
}
@@ -109,125 +116,147 @@ namespace Decentralized
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)
/// <summary>对话:来意和报价,选"去终端交易"打开柜台交易页、客户留下等着;选"算了"客户走人。</summary>
private static void Build(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 trade = new Dialogue();
trade.SetText("PLAYER", L.T("otc.say.trade")).NextDialogue().SetText(c.displayName, L.T("otc.wait"));
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";
string intro = o.Side == OtcSide.Buy ? "otc.intro.buy" : o.TaintRatio >= 1 || o.Asset == Assets.Ponero ? "otc.intro." + o.Asset : "otc.intro.sell";
string offer = o.Side == OtcSide.Buy
? L.T("otc.buy.ask", Fmt.Num(o.Qty), o.Asset, Fmt.Credits(o.Value), Fmt.Credits(o.Cash))
: L.T("otc.offer", Fmt.Num(o.Qty), o.Asset, Fmt.Credits(o.Value), Fmt.Credits(o.Cash), ChainAddress.Short(addr));
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);
.SetText(c.displayName, offer)
.AddChoice(DirectoryMaster.Item("yes"), L.T("otc.say.trade"), "", trade, () => Begin(c, o, addr))
.AddChoice(DirectoryMaster.Item("cancel"), L.T("otc.say.no"), "", no, null);
}
/// <summary>收下:现金不够或没连钱包就作罢;便衣没收现金、记销赃罪证;否则付现金、币进钱包。返回客户回话的键。</summary>
private static string AcceptSell(OtcOffer o, string addr)
/// <summary>去终端交易:记下在场的客户,打开交易终端的柜台交易页。</summary>
private static void Begin(StoreClient c, OtcOffer o, string addr)
{
string line = "otc.refused";
Log.Guard("换币客户去终端交易", () =>
{
Active = o; ActiveAddress = addr; activeClient = c;
TerminalWindow.OpenOtc();
});
}
/// <summary>客户还在柜台前吗(没被打发、没走)。不在了就作废这笔交易。</summary>
public static bool Present()
{
if (Active == null) return false;
var cur = PlayerStore.Instance?.currentClientInstance?.GetClientBlueprint();
if (cur != null && cur == activeClient) return true;
Log.Info("换币客户已经离开,交易作废");
Drop();
return false;
}
/// <summary>
/// 终端里点"成交":qty 是成交的数量(不超过报价)。卖币:付现金、币进已连接的钱包;买币:开账单、弹出钱包(地址已填好)。
/// 返回终端显示的消息(null 表示弹出了钱包,等签名)。
/// </summary>
public static string Deal(double qty)
{
if (!Present()) return L.T("otc.msg.gone");
var part = Otc.Part(Active, qty);
if (part == null) return L.T("msg.amount");
return part.Side == OtcSide.Sell ? Take(part) : Give(part);
}
/// <summary>收下客户卖的币:没连钱包、现金不够就作罢;便衣没收现金、记销赃罪证;否则付现金、币进钱包。客户随后离开。</summary>
private static string Take(OtcOffer o)
{
var d = Session.Data;
var ps = PlayerStore.Instance;
if (!d.Connected) return L.T("web3.err.noconn");
if (ps.playerCash < o.Cash) return L.T("otc.msg.cash");
string msg = null;
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";
Say(L.T("otc.sting"));
msg = L.T("otc.msg.sting", Fmt.Credits(o.Cash));
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.Chain(ActiveAddress, 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";
msg = L.T("otc.msg.bought", Fmt.Num(o.Qty), o.Asset, Fmt.Credits(o.Cash)) + TaintCrime.Commit(over);
Say(L.T("otc.done"));
Log.Info($"换币客户:{o.Qty:0.##} {o.Asset} 进 {WalletWindow.NameOf(d.Hot)},付现金 {o.Cash},钱包污点 {d.Hot.Taint:0},超出 {over:0}");
}
Drop();
Session.Save();
});
return line;
return msg;
}
// ---------- 向玩家买币 ----------
private static void BuildBuy(StoreClient c, OtcOffer o, string addr)
/// <summary>卖币给客户:开账单(客户地址已填好),弹出钱包签名;确认后客户付现金、离开。</summary>
private static string Give(OtcOffer o)
{
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++)
double qty = WalletWindow.RoundUp(o.Qty);
string asset = o.Asset;
int cash = o.Cash;
WalletWindow.Bill bill = null;
bill = new WalletWindow.Bill
{
int t = tier, price = Otc.Quote(o, t);
var say = new Dialogue();
// 选项上显示的是玩家要说的这句话
string line = L.T("otc.buy.say", Fmt.Credits(price), (Tuning.OtcPremium[t] * 100).ToString("0"));
var reply = say.SetText("PLAYER", line).NextDialogue();
reply.SetText(c.displayName, L.T("otc.buy.reject")).SetEndAction(Leave);
ask.AddChoice(DirectoryMaster.Item("$"), line, "", say,
() => reply.SetText(c.displayName, AcceptBuy(o, addr, t, price)));
}
// 原版的对话最多三个选项(选项气泡只有三个),所以没有"不卖":不想卖就把客户打发走
}
/// <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 = ActiveAddress, Asset = asset, Amount = qty, Prefill = true, Summary = L.T("pay.otc", WalletWindow.Amt(qty), asset, Fmt.Credits(cash)),
Execute = pw => Log.Guard("换币客户到账", () =>
{
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)));
TerminalWindow.Refresh(L.T("nightlog.otc.paid", WalletWindow.Amt(qty), asset, Fmt.Credits(price)));
Log.Info($"换币客户:{qty} {asset} 到账,收现金 {price}");
Session.Save();
}),
};
pendingBill = Standing = bill;
line = L.T("otc.buy.accept", Fmt.Credits(price), ChainAddress.Short(addr));
Log.Info($"换币客户:第 {tier + 1} 档 {price} 成交,待转 {qty} {asset} 到 {addr}");
});
return line;
pw.Spend(asset, qty, Session.Price);
ModHelper.AddCredit(cash, true);
Session.Data.Otc.Record(cash);
if (pendingBill == bill) pendingBill = null;
if (Active != null && Present()) { Say(L.T("otc.paid")); Active = null; ActiveAddress = null; activeClient = null; }
TerminalWindow.Refresh(L.T("otc.msg.sold", WalletWindow.Amt(qty), asset, Fmt.Credits(cash)));
Log.Info($"换币客户:{qty} {asset} 到账,收现金 {cash}");
Session.Save();
}),
};
pendingBill = Standing = bill;
WalletWindow.OpenForBill(bill);
Log.Info($"换币客户:卖出 {qty} {asset},现金 {cash},等签名");
return null;
}
/// <summary>睡觉前:买币客户的账单还没签名付款,作废。已经签名、还在确认中的照常到账。</summary>
private static void VoidUnpaid()
/// <summary>客户说最后一句,然后离开。</summary>
private static void Say(string line)
{
if (Standing == null) return;
var bill = Standing;
Log.Guard("换币账单作废", () =>
{
PlayerStore.Instance.AddNightLog(L.T("nightlog.otc.unpaid", WalletWindow.Amt(bill.Amount), bill.Asset), "cc7755");
Log.Info("换币客户的账单当天没付,作废");
});
var c = activeClient;
if (c == null || DialogUIManager.Instance == null) return;
var dl = new Dialogue();
dl.SetText(c.displayName, line).SetEndAction(Leave);
DialogUIManager.Instance.StartDialogue(dl);
}
/// <summary>放弃(终端的"放弃"):客户说一句走人,交易作废。</summary>
public static void Cancel()
{
if (Present()) Say(L.T("otc.refused"));
Drop();
}
/// <summary>这位客户的交易结束(成交、作废、离开,或者睡觉前):还没签名的账单一起作废;已经签名、还在确认中的照常到账。</summary>
private static void Drop()
{
Active = null; ActiveAddress = null; activeClient = null;
Void();
}
/// <summary>作废换币账单(终端的"作废"、夜里没付、读档)。</summary>
/// <summary>作废换币账单(终端的"作废"、客户离开、读档)。</summary>
public static void Void()
{
if (Standing != null && WalletWindow.Pending == Standing) WalletWindow.Pending = null;
Standing = null;
pendingBill = null;
if (pendingBill != null && Standing == null) pendingBill = null;
}
private static void Leave() => DialogUIManager.Instance.CurrentClientLeave();