using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using UnityEngine;
using UnityEngine.UI;
using TMPro;
using Decentralized.Domain;
namespace Decentralized
{
///
/// 交易终端窗口:首页三个入口(银行、Poinbase、Web3)加报告。银行是真实的信用点(存取、房租);
/// Poinbase 是合规交易所;Web3 是去中心化交易和链上钱包。每次操作后按当前页重建窗口。
///
public static class TerminalWindow
{
public const string Id = "pd_terminal";
private const float WinW = 828f, WinH = 680f, ScreenW = 768f, ScreenH = 578f, Bezel = 6f;
private const float LeftW = 420f, RightW = 310f;
private enum Page { Home, Bank, Poinbase, Web3, Wallets }
private static Page _page = Page.Home;
private sealed class EditPrice { public GameItem Item; public BmSale Sale; public string Id; public long Ref; }
private static EditPrice _edit;
private static string _editPrice = "";
private static string _amount = "";
private static string _message;
private static readonly List _picked = new List();
private static readonly List _clear = new List();
private static readonly List _rows = new List();
public static void Open()
{
if (Session.Data == null) return;
var b = CustomUIManager.Window(Id, L.T("term.title"));
if (b == null) return;
_picked.Clear(); _clear.Clear(); _rows.Clear();
b.SetSize(WinW, WinH).SetDraggable(true).SetCloseOnEscape(true).UseMachineryStyle();
// 黑色外边框包裹墨水屏
b.BeginPanel(ScreenW + Bezel * 2, ScreenH + Bezel * 2, TextAnchor.MiddleCenter, Bezel + 3f, 0f);
var bezel = b.Last.image;
b.BeginPanel(ScreenW, ScreenH, TextAnchor.UpperLeft, 12f, 4f);
var screen = b.CurrentContainer;
var paper = b.Last.image;
b.BeginRow(4f, TextAnchor.MiddleLeft);
foreach (Page p in Enum.GetValues(typeof(Page)))
{
var page = p;
string name = L.T("tab." + p.ToString().ToLowerInvariant());
PledgerSkin.Pick(b, name, page == _page, () => { _page = page; _message = null; _edit = null; Open(); }, _picked);
}
b.End();
b.AddSeparator();
b.BeginScroll(ScreenH - 52f);
_clear.Add(b.Last.image);
switch (_page)
{
case Page.Home: HomePage(b); break;
case Page.Bank: BankPage(b); break;
case Page.Poinbase: ExchangePage(b, Venue.Exchange); break;
case Page.Web3: Web3Page(b); break;
case Page.Wallets: WalletsPage(b); break;
default: break;
}
b.End();
if (!string.IsNullOrEmpty(_message)) b.AddLabel(_message, "term_note");
b.End();
b.End();
var win = b.Show();
Log.Guard("交易终端外观", () => PledgerSkin.Apply(win, screen, bezel, paper, _picked, _clear, _rows));
Log.Guard("购物车按钮", () => CartFab(win));
}
private static void Go(Page p) { _page = p; _message = null; _edit = null; Open(); }
/// 钱包那边交易落地后刷新终端(开着的话),带上结果消息。
public static void Refresh(string message)
{
if (message != null) _message = message;
if (CustomUIManager.Instance != null && CustomUIManager.Instance.IsOpen(Id)) Open();
}
private static void Entry(CustomUIBuilder b, string icon, string name, string line1, string line2, Page target)
{
b.BeginPanel(230f, 250f, TextAnchor.UpperCenter, 10f, 6f);
var cardImg = b.Last.image;
cardImg.sprite = MachineryStyle.White;
cardImg.color = PledgerSkin.RowNormal;
b.AddImage(ModInfo.Id + ":" + icon, 96f, 96f);
b.AddHeader(name);
b.AddLabel(line1);
if (line2 != null) b.AddLabel(line2, "entry_l2_dim");
b.AddFlexibleSpace();
b.AddButton(L.T("home.enter"), () => Go(target));
b.End();
}
private static void HomePage(CustomUIBuilder b)
{
var d = Session.Data;
double chain = d.ChainValue(Session.Price);
b.AddLabel(L.T("home.summary", PlayerStore.Instance.playerCash, Fmt.Credits(chain)));
b.AddSpace(6f);
b.BeginRow(8f, TextAnchor.UpperCenter);
Entry(b, "ui_bank", L.T("entry.bank"), L.T("entry.bank.l1"), L.T("entry.bank.l2", PlayerStore.Instance.playerCash), Page.Bank);
Entry(b, "ui_poinbase", L.T("entry.poinbase"), L.T("entry.poinbase.l1"), null, Page.Poinbase);
Entry(b, "ui_web3", L.T("entry.web3"), L.T("entry.web3.l1"), L.T("entry.web3.l2", Fmt.Credits(chain)), Page.Web3);
b.End();
b.AddSpace(8f);
b.AddSeparator();
var r = d.LastNight;
b.AddLabel(r == null ? L.T("report.none") : L.T("home.lastnight", r.Day, Fmt.Num(r.Mined), r.PoweredRigs, r.Rigs), "report_dim");
}
private static void Cell(CustomUIBuilder b, float w, string text, bool header = false, TextAnchor align = TextAnchor.MiddleLeft)
{
b.BeginPanel(w, 26f, align, 0f, 0f);
_clear.Add(b.Last.image);
b.AddLabel(header ? "" + text + "" : text);
b.End();
}
private static void TableRow(CustomUIBuilder b, float totalW, float[] widths, TextAnchor[] aligns, string[] cells, bool selected = false, Action onClick = null)
{
b.BeginPanel(totalW, 28f, TextAnchor.MiddleLeft, 4f, 0f);
_rows.Add(new PledgerSkin.RowDef { Root = b.Last.root, Image = b.Last.image, Selected = selected, OnClick = onClick });
b.BeginRow(0f, TextAnchor.MiddleLeft);
for (int i = 0; i < widths.Length; i++)
{
var align = (aligns != null && i < aligns.Length) ? aligns[i] : (i == 0 ? TextAnchor.MiddleLeft : TextAnchor.MiddleRight);
Cell(b, widths[i], i < cells.Length ? cells[i] : "", false, align);
}
b.End();
b.End();
}
private static void TableRow(CustomUIBuilder b, float[] widths, params string[] cells) =>
TableRow(b, widths.Sum() + 8f, widths, null, cells, false, null);
private static void TableHeader(CustomUIBuilder b, float totalW, float[] widths, TextAnchor[] aligns, params string[] cells)
{
b.BeginPanel(totalW, 26f, TextAnchor.MiddleLeft, 4f, 0f);
_clear.Add(b.Last.image);
b.BeginRow(0f, TextAnchor.MiddleLeft);
for (int i = 0; i < widths.Length; i++)
{
var align = (aligns != null && i < aligns.Length) ? aligns[i] : (i == 0 ? TextAnchor.MiddleLeft : TextAnchor.MiddleRight);
Cell(b, widths[i], i < cells.Length ? cells[i] : "", true, align);
}
b.End();
b.End();
}
private static void TableHeader(CustomUIBuilder b, float[] widths, params string[] cells) =>
TableHeader(b, widths.Sum() + 8f, widths, null, cells);
/// 左右分栏:左边是账户与持仓表格,右边是操作区。
private static void Columns(CustomUIBuilder b, Action left, Action right)
{
b.BeginRow(10f);
b.BeginPanel(LeftW, -1f, TextAnchor.UpperLeft, 4f, 4f);
_clear.Add(b.Last.image);
left(b);
b.End();
b.BeginPanel(RightW, -1f, TextAnchor.UpperLeft, 4f, 6f);
_clear.Add(b.Last.image);
right(b);
b.End();
b.End();
}
private static void AmountRow(CustomUIBuilder b)
{
b.AddLabel(L.T("trade.amount"));
b.BeginPanel(RightW - 30f, -1f, TextAnchor.MiddleCenter, 0f, 0f);
_clear.Add(b.Last.image);
b.AddInput("0", _amount, s => _amount = s, "amount", s => _amount = s);
b.End();
}
/// 银行:真实的信用点。现金、房租,以及信用点与 Poinbase 账户(CRC)之间的存取。
private static void BankPage(CustomUIBuilder b)
{
var ps = PlayerStore.Instance;
var w = new[] { 180f, 220f };
Columns(b, l =>
{
l.AddHeader(L.T("entry.bank"));
TableRow(l, w, L.T("bank.cash.k"), ps.playerCash.ToString());
TableRow(l, w, L.T("bank.rent.k"), L.T("bank.rent.v", Session.Rent(), ps.GetRentDayCounter()));
TableRow(l, w, L.T("bank.crc.k"), Fmt.Num(Session.Data.Poinbase.Get(Assets.CRC)));
}, r =>
{
r.AddHeader(L.T("bank.ops"));
AmountRow(r);
AmlPreview(r);
r.AddSpace(6f);
r.AddButton(L.T("trade.deposit"), Deposit);
r.Last?.SetPreferredSize(RightW - 30f, 28f);
r.AddSpace(4f);
r.AddButton(L.T("trade.withdraw"), Withdraw);
r.Last?.SetPreferredSize(RightW - 30f, 28f);
});
}
/// 提取前的合规审查提示:仅保留账户污点比例(额度与命中概率按设计不公开)。
private static void AmlPreview(CustomUIBuilder b)
{
var pb = Session.Data.Poinbase;
double total = pb.Value(Session.Price);
double taintRatio = total > 0 ? pb.Taint / total : 0;
b.AddSeparator();
b.AddLabel(L.T("aml.taint", (int)Math.Round(taintRatio * 100)), taintRatio > 0.05 ? "aml_note" : "aml_dim");
}
private static readonly float[] StableW = { 80f, 100f, 220f };
private static string StableStatus(Stablecoins st, string asset, int day)
{
double p = st.Price(asset);
if (asset == Assets.CRA && st.CraWarning(day)) return L.T("stable.warn");
if (asset == Assets.CRA && p < 0.97) return L.T("stable.spiral");
return p < 0.99 ? L.T("stable.depeg") : L.T("stable.ok");
}
/// 稳定币行情:价格与状态(脱锚、预兆)。
private static void StableTable(CustomUIBuilder b)
{
var st = Session.Data.Stable;
int day = StoreStation.GetDayCounter();
b.AddSeparator();
TableHeader(b, StableW, L.T("col.asset"), L.T("col.peg"), L.T("col.status"));
foreach (var a in new[] { Assets.CRC, Assets.CRT, Assets.CRA })
TableRow(b, StableW, a, Fmt.Num(st.Price(a)), StableStatus(st, a, day));
}
private static void TradeRows(CustomUIBuilder b, Venue venue)
{
foreach (var pool in Session.Data.Market.Pools.Where(x => x.Venue == venue))
{
var p = pool;
b.AddLabel(L.T("trade.pair", p.Base, p.Quote, L.T("venue." + p.Venue)) + " " + L.T("trade.price", Fmt.Num(p.Price)));
b.BeginRow(4f);
b.AddButton(L.T("trade.buy"), () => Trade(p, buy: true));
b.AddButton(L.T("trade.sell"), () => Trade(p, buy: false));
b.End();
}
}
private static readonly float[] HoldW = { 80f, 100f, 110f, 110f };
private static readonly TextAnchor[] HoldAligns = { TextAnchor.MiddleLeft, TextAnchor.MiddleRight, TextAnchor.MiddleRight, TextAnchor.MiddleRight };
private static void Holdings(CustomUIBuilder b, Wallet w, Func show)
{
TableHeader(b, HoldW.Sum() + 8f, HoldW, HoldAligns, L.T("col.asset"), L.T("col.amount"), L.T("col.value"), L.T("col.avg"));
bool any = false;
foreach (var kv in w.Balances.OrderBy(k => k.Key).Where(k => show(k.Key)))
{
any = true;
string asset = kv.Key;
bool selected = (_page == Page.Poinbase && asset == _poinAsset);
TableRow(b, HoldW.Sum() + 8f, HoldW, HoldAligns,
new[] { kv.Key, Fmt.Num(kv.Value), Fmt.Credits(kv.Value * Session.Price(kv.Key)), AvgText(w, kv.Key) },
selected,
() => { if (_page == Page.Poinbase) { _poinAsset = asset; Open(); } });
}
if (!any) b.AddLabel(L.T(w == Session.Data.Poinbase ? "poin.empty" : "wallet.empty"), "hold_empty_dim");
foreach (var kv in w.Frozen.OrderBy(k => k.Key).Where(k => show(k.Key)))
b.AddLabel(L.T("wallet.frozen", kv.Key, Fmt.Num(kv.Value), w.FrozenUntil.TryGetValue(kv.Key, out var u) ? u : 0), "hold_frozen_note");
}
/// Poinbase:合规交易所。左边账户持仓,右边交易;信用点的存取在银行。
private enum PoinTab { Spot, Perp }
private static PoinTab _poinTab = PoinTab.Spot;
private static void ExchangePage(CustomUIBuilder b, Venue venue)
{
var d = Session.Data;
var w = d.Poinbase;
b.BeginRow(4f);
foreach (PoinTab t in Enum.GetValues(typeof(PoinTab)))
{
var tab = t;
string name = L.T("poin.sub." + t.ToString().ToLowerInvariant());
PledgerSkin.Pick(b, name, tab == _poinTab, () => { _poinTab = tab; _message = null; Open(); }, _picked);
}
b.End();
if (_poinTab == PoinTab.Perp) { PerpPage(b); return; }
Columns(b, l =>
{
CandleChart.AddPicker(l, "poin", Assets.PSC, Open, _picked);
l.AddSeparator();
l.AddHeader(L.T("poin.account.h"));
Holdings(l, w, a => true);
if (w.Taint > 0.5) l.AddLabel(L.T("wallet.taint", Fmt.Credits(w.Taint)), "taint_note");
l.AddSeparator();
l.BeginRow(8f, TextAnchor.MiddleLeft);
l.AddLabel(L.T("poin.deposit", d.Address("poinbase")), "deposit_dim");
l.AddButton(L.T("hw.copy"), () => { Clipboard.Copy(d.Address("poinbase")); _message = L.T("hw.copied.addr", d.Address("poinbase")); Open(); });
l.End();
}, r =>
{
r.AddHeader(L.T("trade.h"));
AmountRow(r);
TradeRows(r, venue);
r.AddSeparator();
r.BeginRow(4f);
foreach (var a in new[] { Assets.CRC, Assets.PSC })
{
var asset = a;
PledgerSkin.Pick(r, asset, asset == _poinAsset, () => { _poinAsset = asset; Open(); }, _picked);
}
r.End();
r.AddButton(L.T("poin.out", WalletWindow.NameOf(d.Hot)), PoinbaseOut);
});
}
private static string _poinAsset = Assets.CRC;
private static string _perpProduct = "PSC";
private static bool _perpLong = true;
private static int _perpLev = 3;
private static readonly float[] PerpW = { 80f, 70f, 110f, 60f, 70f };
private static readonly TextAnchor[] PerpAligns = { TextAnchor.MiddleLeft, TextAnchor.MiddleRight, TextAnchor.MiddleRight, TextAnchor.MiddleRight, TextAnchor.MiddleRight };
private static double PerpSpot(string p) => p == "SPARK" ? Session.Data.Stable.Spark : Session.Data.Market.Price;
/// 永续页:保证金账户、仓位、资金费与保险基金;开仓、追加保证金、平仓。Poinbase 是托管账户,不用签名。
private static void PerpPage(CustomUIBuilder b)
{
var d = Session.Data;
var bk = d.Perps;
var acct = d.Poinbase;
int day = StoreStation.GetDayCounter();
if (_perpLev > PerpBook.MaxLeverage(_perpProduct)) _perpLev = PerpBook.MaxLeverage(_perpProduct);
Columns(b, l =>
{
l.AddHeader(L.T("perp.h"));
l.AddLabel(L.T("perp.acct", Fmt.Num(acct.Avail(Assets.CRC))));
if (bk.Debt > 0) l.AddLabel(L.T("perp.debt", Fmt.Credits(bk.Debt), Math.Max(0, Tuning.PerpDebtDays - (day - bk.DebtSince))), "perp_debt_note");
if (bk.Banned(day)) l.AddLabel(L.T("perp.banned", bk.BannedUntil - day + 1), "perp_banned_note");
l.AddSeparator();
if (bk.Positions.Count == 0) l.AddLabel(L.T("perp.none"), "perp_none_dim");
else
{
TableHeader(l, PerpW.Sum() + 8f, PerpW, PerpAligns, L.T("perp.col.pos"), L.T("perp.col.notional"), L.T("perp.col.price"), L.T("perp.col.pnl"), L.T("perp.col.liq"));
foreach (var p in bk.Positions)
{
var pos = p;
double spot = PerpSpot(pos.Product);
bool selPos = pos.Product == _perpProduct;
TableRow(l, PerpW.Sum() + 8f, PerpW, PerpAligns,
new[] {
pos.Product + " " + L.T(pos.Long ? "perp.long" : "perp.short") + " " + Math.Round(pos.Leverage, 1) + "×",
Fmt.Credits(pos.Notional(spot)),
Fmt.Num(pos.Entry) + " → " + Fmt.Num(spot),
Fmt.Credits(pos.Pnl(spot)),
Fmt.Num(pos.LiqPrice)
},
selPos,
() => { _perpProduct = pos.Product; Open(); });
}
}
l.AddSeparator();
foreach (var p in PerpBook.Products)
l.AddLabel(L.T("perp.rate", p, Fmt.Num(PerpSpot(p)), (bk.Rate(p) * 100).ToString("+0.00;-0.00;0.00", CultureInfo.InvariantCulture)), "perp_rate_dim");
l.AddLabel(L.T("perp.fund", Fmt.Credits(bk.InsuranceFund)), "perp_fund_dim");
l.AddSeparator();
CandleChart.Add(l, "perp", _perpProduct);
}, r =>
{
r.AddHeader(L.T("perp.open"));
r.BeginRow(4f);
foreach (var p in PerpBook.Products)
{
var product = p;
PledgerSkin.Pick(r, product, product == _perpProduct, () => { _perpProduct = product; Open(); }, _picked);
}
r.End();
r.BeginRow(4f);
PledgerSkin.Pick(r, L.T("perp.long"), _perpLong, () => { _perpLong = true; Open(); }, _picked);
PledgerSkin.Pick(r, L.T("perp.short"), !_perpLong, () => { _perpLong = false; Open(); }, _picked);
r.End();
r.BeginRow(4f);
foreach (int lev in new[] { 1, 2, 3, 5 })
{
if (lev > PerpBook.MaxLeverage(_perpProduct)) continue;
int lv = lev;
PledgerSkin.Pick(r, lv + "×", lv == _perpLev, () => { _perpLev = lv; Open(); }, _picked);
}
r.End();
r.AddLabel(L.T("perp.margin"));
AmountRow(r);
if (double.TryParse((_amount ?? "").Trim(), NumberStyles.Float, CultureInfo.InvariantCulture, out double m) && m > 0)
{
double notional = m * _perpLev, spot = PerpSpot(_perpProduct), exec = bk.Exec(spot, notional, _perpLong, day);
var ghost = new PerpPosition { Long = _perpLong, Entry = exec, Size = notional / exec, Margin = m };
r.AddLabel(L.T("perp.preview", Fmt.Credits(notional), Fmt.Num(exec)), "perp_preview_dim");
r.AddLabel(L.T("perp.preview2", Fmt.Num(notional * Tuning.PerpFee), Fmt.Num(ghost.LiqPrice)), "perp_preview_dim");
}
r.BeginRow(6f);
r.AddButton(L.T("perp.do.open"), PerpOpen);
r.AddButton(L.T("perp.do.add"), PerpAdd);
r.AddButton(L.T("perp.do.close"), PerpClose);
r.End();
});
}
private static readonly float[] PoolW = { 110f, 50f, 64f, 64f, 50f, 78f };
private static readonly TextAnchor[] PoolAligns = { TextAnchor.MiddleLeft, TextAnchor.MiddleRight, TextAnchor.MiddleRight, TextAnchor.MiddleRight, TextAnchor.MiddleRight, TextAnchor.MiddleRight };
/// 第三方收益池:日息、储备、时间锁、审计、我的存款,存入 / 取出(合约调用,签名)。是不是骗局看不见。
private static void YieldPools(CustomUIBuilder l, Wallet w, int day)
{
var sc = Session.Data.Scams;
var pools = sc.Pools.Where(p => !p.Closed || p.Pending.Any(q => ChainAddress.Same(q.Wallet, w.Address))).ToList();
l.AddHeader(L.T("pool.h"));
if (pools.Count == 0) { l.AddLabel(L.T("pool.none"), "pool_none_dim"); return; }
TableHeader(l, PoolW.Sum() + 8f, PoolW, PoolAligns, L.T("pool.col.name"), L.T("pool.col.rate"), L.T("pool.col.reserve"), L.T("pool.col.lock"), L.T("pool.col.audit"), L.T("pool.col.mine"));
foreach (var p in pools)
{
var pp = p;
string mine = Fmt.Num(p.Of(w.Address)) + (p.Pending.Any(q => ChainAddress.Same(q.Wallet, w.Address)) ? "+" + L.T("pool.pending") : "");
TableRow(l, PoolW.Sum() + 8f, PoolW, PoolAligns,
new[] {
p.Name + (p.Closed ? " " + L.T("pool.closed") : ""),
(p.Rate * 100).ToString("0", CultureInfo.InvariantCulture) + "%",
Fmt.Num(p.Reserve),
L.T(p.Timelock ? "pool.yes" : "pool.no"),
L.T(p.Audited ? "pool.yes" : "pool.no"),
mine
});
if (p.Closed) continue;
l.BeginRow(4f);
l.AddButton(L.T("pool.deposit"), () => PoolCall(pp, true));
if (p.Of(w.Address) > 0) l.AddButton(L.T(p.Timelock ? "pool.withdraw.lock" : "pool.withdraw", Tuning.YieldLockDays), () => PoolCall(pp, false));
l.End();
l.AddSpace(2f);
}
}
private static void PoolCall(YieldPool p, bool deposit)
{
var d = Session.Data;
var w = d.Hot;
double v = 0;
if (deposit)
{
if (!ReadAmount(out v)) return;
if (w.Avail(Assets.CRC) + 1e-9 < v) { _message = L.T("msg.balance"); Open(); return; }
if (p.Total + v > p.Cap + 1e-9) { _message = L.T("pool.err.cap", Fmt.Num(Math.Max(0, p.Cap - p.Total))); Open(); return; }
}
_message = L.T("hw.wait");
Open();
WalletWindow.OpenForBill(new WalletWindow.Bill
{
Contract = true, Asset = Assets.CRC, Amount = v, Spend = deposit ? v : 0, FeeAsset = deposit ? null : BestFeeAsset(w),
Summary = L.T(deposit ? "pool.sum.in" : "pool.sum.out", p.Name, Fmt.Num(deposit ? v : p.Of(w.Address))),
Execute = pw =>
{
int day = StoreStation.GetDayCounter();
if (deposit) _message = d.Scams.Deposit(p, pw, v, Session.Price) ? L.T("pool.done.in", Fmt.Num(v), p.Name) : L.T("msg.balance");
else
{
double amt = p.Of(pw.Address), got = d.Scams.Withdraw(p, pw, day);
_message = amt <= 0 ? L.T("pool.err.none") : got > 0 ? L.T("pool.done.out", Fmt.Num(got)) : L.T("pool.done.lock", Fmt.Num(amt), day + Tuning.YieldLockDays);
}
Session.Save();
Open();
},
});
}
/// 空投页:可领取的空投(名字、奖励、要求的授权)和当前钱包的授权(可撤销)。领取和撤销都是合约调用,签名时 PLEDGER 上会写明授权范围。
private static void AirdropPage(CustomUIBuilder b)
{
var d = Session.Data;
var w = d.Hot;
int day = StoreStation.GetDayCounter();
Columns(b, l =>
{
l.AddHeader(L.T("air.h"));
var list = d.Scams.Airdrops.Where(a => !a.Claimed && a.Expire >= day).ToList();
if (list.Count == 0) l.AddLabel(L.T("air.none"), "air_none_dim");
foreach (var a in list)
{
var ad = a;
l.AddLabel(L.T("air.row", a.Name, Fmt.Num(a.Reward), ApproveText(a.Approve, a.Asset), a.Expire));
l.AddButton(L.T("air.claim"), () => ClaimAirdrop(ad));
l.AddSpace(4f);
}
}, r =>
{
r.AddHeader(L.T("air.approvals", WalletWindow.NameOf(w)));
var aps = d.Scams.Approvals.Where(x => ChainAddress.Same(x.Wallet, w.Address)).ToList();
if (aps.Count == 0) r.AddLabel(L.T("air.approvals.none"), "air_app_none_dim");
foreach (var ap in aps)
{
int id = ap.Id;
r.AddLabel(L.T("air.approval", ap.Spender, ApproveText(ap.Limit, ap.Asset)));
r.AddButton(L.T("air.revoke"), () => RevokeApproval(id));
r.AddSpace(4f);
}
});
}
private static string ApproveText(double limit, string asset) =>
limit <= 0 ? L.T("air.noapprove") : double.IsPositiveInfinity(limit) ? L.T("air.unlimited", asset) : L.T("air.limited", asset, Fmt.Num(limit));
private static void ClaimAirdrop(Airdrop a)
{
var d = Session.Data;
_message = L.T("hw.wait");
Open();
var w = d.Hot;
WalletWindow.OpenForBill(new WalletWindow.Bill
{
Contract = true, Asset = Assets.CRC, Amount = 0, Spend = 0, FeeAsset = BestFeeAsset(w),
Summary = L.T("air.sum", a.Name, ApproveText(a.Approve, a.Asset)),
Execute = pw =>
{
var ap = d.Scams.Claim(a, pw, StoreStation.GetDayCounter(), d.Rng, out double got);
_message = L.T("air.claimed", Fmt.Num(got));
Log.Info($"领取空投 {a.Name}:得 {got},授权 {(ap == null ? "无" : ap.Asset + " " + ap.Limit)},骗局 {a.Scam}");
Session.Save();
Open();
},
});
}
private static void RevokeApproval(int id)
{
var d = Session.Data;
var ap = d.Scams.Approvals.Find(x => x.Id == id);
if (ap == null) return;
var w = d.Own(ap.Wallet) ?? d.Hot;
_message = L.T("hw.wait");
Open();
WalletWindow.OpenForBill(new WalletWindow.Bill
{
Contract = true, Asset = Assets.CRC, Amount = 0, Spend = 0, FeeAsset = BestFeeAsset(w),
Summary = L.T("air.revoke.sum", ap.Spender, ap.Asset),
Execute = pw =>
{
_message = ChainAddress.Same(pw.Address, ap.Wallet) && d.Scams.Revoke(id) ? L.T("air.revoked") : L.T("air.err.wallet");
Session.Save();
Open();
},
});
}
private enum YieldTab { Pools, Mine }
private static YieldTab _yieldTab = YieldTab.Pools;
/// 理财页:两个子页。"存钱"是 NPC 开的第三方收益池,你存进去吃利息(可能是骗局);"自己发"是你当庄开高息理财(庞氏),下层的人往你这儿存。
private static void YieldPage(CustomUIBuilder b)
{
b.BeginRow(4f);
foreach (YieldTab t in Enum.GetValues(typeof(YieldTab)))
{
var tab = t;
string name = L.T("yield.sub." + t.ToString().ToLowerInvariant());
PledgerSkin.Pick(b, name, tab == _yieldTab, () => { _yieldTab = tab; _message = null; Open(); }, _picked);
}
b.End();
if (_yieldTab == YieldTab.Mine) { PonziPage(b); return; }
var d = Session.Data;
var w = d.Hot;
int day = StoreStation.GetDayCounter();
Columns(b, l => YieldPools(l, w, day), r =>
{
r.AddHeader(L.T("pool.side.h"));
r.AddLabel(L.T("predict.acct", WalletWindow.NameOf(w), Fmt.Num(w.Avail(Assets.CRC))));
AmountRow(r);
});
}
private static double _ponziRate = 0.04;
private static bool _ponziConfirm;
private static int LowerRep() => StoreReputation.GetStoreReputation(StoreReputation.FACTION_LOWER_LEVEL)?.GetReputation() ?? 0;
/// 理财页:开一个高息理财产品,下层的人往里存钱;池子随时可以卷走,但每晚都有被举报的风险。开张和卷走是链上合约调用,用当前钱包,要签名;之后每晚垫付的利息从发行人钱包出。
private static void PonziPage(CustomUIBuilder b)
{
var d = Session.Data;
var pz = d.Ponzi;
int day = StoreStation.GetDayCounter();
Columns(b, l =>
{
l.AddHeader(L.T("ponzi.h"));
var issuer = pz.Open ? d.Own(pz.Wallet) ?? d.Hot : d.Hot;
l.AddLabel(L.T("ponzi.acct", WalletWindow.NameOf(issuer), Fmt.Num(issuer.Avail(Assets.CRC))));
l.AddSeparator();
if (pz.Open)
{
l.AddLabel(L.T("ponzi.rate", (pz.Rate * 100).ToString("0", CultureInfo.InvariantCulture)));
l.AddLabel(L.T("ponzi.pool", Fmt.Num(pz.Pool), Fmt.Num(Ponzi.Cap(day))));
l.AddLabel(L.T("ponzi.inflow", Fmt.Num(pz.Inflow)));
l.AddLabel(L.T("ponzi.interest", Fmt.Num(pz.Pool * pz.Rate)));
l.AddLabel(L.T("ponzi.own", Fmt.Num(pz.OwnPaid)));
double risk = Ponzi.ReportChance(pz.Rate, pz.Pool, day);
l.AddLabel(L.T("ponzi.wind", L.T(risk < 0.05 ? "ponzi.wind.low" : risk < 0.10 ? "ponzi.wind.mid" : "ponzi.wind.high")), risk >= 0.10 ? "ponzi_risk_note" : risk >= 0.05 ? "ponzi_risk_dim" : "ponzi_risk_ok");
}
else l.AddLabel(pz.Done ? L.T("ponzi.done") : day < Tuning.PonziMinDay ? L.T("ponzi.early", Tuning.PonziMinDay) : L.T("ponzi.idle"), "ponzi_status_dim");
}, r =>
{
if (pz.Open)
{
r.AddHeader(L.T("ponzi.ops"));
r.AddButton(_ponziConfirm ? L.T("ponzi.rug.confirm", Fmt.Num(pz.Pool)) : L.T("ponzi.rug", Fmt.Num(pz.Pool)), () =>
{
if (!_ponziConfirm) { _ponziConfirm = true; Open(); return; }
_ponziConfirm = false;
var iw = d.Own(pz.Wallet) ?? d.Hot;
_message = L.T("hw.wait");
Open();
WalletWindow.OpenForBill(new WalletWindow.Bill
{
Contract = true, Asset = Assets.CRC, Amount = 0, Spend = 0, FeeAsset = BestFeeAsset(iw),
Summary = L.T("ponzi.rug.sum", Fmt.Num(pz.Pool)),
Execute = pw =>
{
if (!pz.Open || !ChainAddress.Same(pw.Address, pz.Wallet)) { _message = L.T("ponzi.err.wallet"); Open(); return; }
double take = pz.Rug(pw);
PlayerStore.Instance.secData.CommitCrime("FRAUD", (int)Tuning.PonziCrime);
StoreReputation.GetStoreReputation(StoreReputation.FACTION_LOWER_LEVEL)?.SetReputation(0);
News.Show("pd_news_ponzi_rug_" + day, L.T("news.ponzi.rug.title"), L.T("news.ponzi.rug.desc"), 4);
Log.Info($"卷走理财池 {take:0},自己垫的利息 {pz.OwnPaid:0},钱包 {pw.Address}");
_message = L.T("ponzi.rugged", Fmt.Num(take));
Session.Save();
Open();
},
});
});
return;
}
if (pz.Done || day < Tuning.PonziMinDay) return;
r.AddHeader(L.T("ponzi.open"));
r.BeginRow(4f);
foreach (double rate in Tuning.PonziRates)
{
double rt = rate;
string t = (rate * 100).ToString("0", CultureInfo.InvariantCulture) + "%";
PledgerSkin.Pick(r, t, Math.Abs(rt - _ponziRate) < 1e-9, () => { _ponziRate = rt; Open(); }, _picked);
}
r.End();
r.AddLabel(L.T("ponzi.ad", Fmt.Num(Tuning.PonziAdCost)), "ponzi_ad_dim");
r.AddButton(L.T("ponzi.start"), () =>
{
if (d.Hot.Avail(Assets.CRC) + 1e-9 < Tuning.PonziAdCost) { _message = L.T("ponzi.err"); Open(); return; }
double rate = _ponziRate;
_message = L.T("hw.wait");
Open();
WalletWindow.OpenForBill(new WalletWindow.Bill
{
Contract = true, Asset = Assets.CRC, Amount = Tuning.PonziAdCost,
Summary = L.T("ponzi.start.sum", (rate * 100).ToString("0", CultureInfo.InvariantCulture), Fmt.Num(Tuning.PonziAdCost)),
Execute = pw =>
{
_message = pz.Start(pw, rate, StoreStation.GetDayCounter()) ? L.T("ponzi.started") : L.T("ponzi.err");
Session.Save();
Open();
},
});
});
});
}
private static int _predPick;
private enum PredTab { Markets, Intel }
private static PredTab _predTab = PredTab.Markets;
private static string P2(double x) => x.ToString("0.00", CultureInfo.InvariantCulture);
/// 预测页:盘口(左边列表与最近结果,右边选中盘口的价格、线索、买卖)和情报(情报贩子)。下注和卖出是链上合约调用,用当前钱包的 CRC,要在钱包上签名;持仓记在签名的钱包名下。
private static void PredictPage(CustomUIBuilder b)
{
if (_predTab == PredTab.Intel && !Unlock.BlackUnlocked) _predTab = PredTab.Markets;
if (Unlock.BlackUnlocked)
{
b.BeginRow(4f);
foreach (PredTab t in Enum.GetValues(typeof(PredTab)))
{
var tab = t;
string name = L.T("predict.tab." + t.ToString().ToLowerInvariant());
PledgerSkin.Pick(b, name, tab == _predTab, () => { _predTab = tab; _message = null; Open(); }, _picked);
}
b.End();
}
if (_predTab == PredTab.Intel) { IntelPage(b); return; }
var d = Session.Data;
var bk = d.Predict;
var w = d.Hot;
int day = StoreStation.GetDayCounter();
var open = bk.Markets.Where(m => m.Outcome < 0).OrderBy(m => m.IsOpen(day) ? 0 : 1).ThenBy(m => m.FromDay).ThenBy(m => m.Id).ToList();
var sel = open.Find(m => m.Id == _predPick) ?? open.FirstOrDefault();
_predPick = sel?.Id ?? 0;
Columns(b, l =>
{
l.AddHeader(L.T("predict.h"));
l.AddLabel(L.T("predict.acct", WalletWindow.NameOf(w), Fmt.Num(w.Avail(Assets.CRC))));
l.AddSeparator();
// 选中 PSC 价格盘时,在列表上方画日 K 和阈值线
if (sel != null && sel.Kind == PredictKind.Price) { CandleChart.Add(l, "predict", Assets.PSC, sel.Param); l.AddSeparator(); }
if (open.Count == 0) l.AddLabel(L.T("predict.none"), "pred_none_dim");
foreach (var m in open)
{
var mk = m;
var all = bk.PosAll(m.Id);
string q = PredictGame.Question(m);
l.BeginRow(4f);
PledgerSkin.Pick(l, q, m.Id == _predPick, () => { _predPick = mk.Id; _message = null; Open(); }, _picked);
l.End();
string state = m.IsOpen(day) ? "" : " " + L.T("predict.closed");
string move = m.PrevP >= 0 && Math.Abs(m.PYes - m.PrevP) >= 0.02 ? L.T("predict.row.move", P2(m.PrevP), m.PYes > m.PrevP ? "↑" : "↓") : "";
l.AddLabel(L.T("predict.row", P2(m.PYes), P2(1 - m.PYes)) + move + (all.Count > 0 ? " " + L.T("predict.held", L.T(all[0].Yes ? "predict.yes" : "predict.no"), Fmt.Num(all.Sum(x => x.Shares)), Fmt.Num(all.Sum(x => x.Cost))) : "") + state, "pred_row_dim");
}
if (bk.Stats.Count > 0)
{
l.AddSeparator();
l.AddLabel(L.T("predict.stats.h"));
foreach (var kv in bk.Stats.OrderByDescending(k => k.Value.Count))
l.AddLabel(L.T("predict.stats.row", L.T("predict.kind." + kv.Key), kv.Value.Count, P2(kv.Value.AvgP0), (kv.Value.HitRate * 100).ToString("0", CultureInfo.InvariantCulture)), "pred_stat_dim");
}
if (bk.Recent.Count > 0)
{
l.AddSeparator();
foreach (var r in Enumerable.Reverse(bk.Recent))
l.AddLabel(L.T("predict.result", r.Day, r.Title, r.Outcome == 2 ? L.T("predict.void") : L.T(r.Outcome == 1 ? "predict.yes" : "predict.no"), r.Held ? (r.Pnl >= 0 ? "+" : "") + Fmt.Num(r.Pnl) : "—"), "pred_res_dim");
}
}, r =>
{
if (sel == null) { r.AddHeader(L.T("predict.h")); return; }
r.AddHeader(L.T("predict.pick"));
r.AddLabel(PredictGame.Question(sel));
r.AddLabel(L.T("predict.price", P2(sel.PYes), P2(1 - sel.PYes)));
foreach (var clue in PredictGame.Clues(sel)) r.AddLabel("· " + clue, "pred_clue_dim");
foreach (var pos in bk.PosAll(sel.Id))
{
var ow = d.Own(pos.Wallet);
r.AddLabel(L.T("predict.pos", ow != null ? WalletWindow.NameOf(ow) : pos.Wallet, L.T(pos.Yes ? "predict.yes" : "predict.no"), Fmt.Num(pos.Shares)));
if (pos.Wallet == w.Address)
{
r.AddLabel(L.T("predict.pos3", P2(pos.Cost / pos.Shares), P2(sel.PriceOf(pos.Yes))), "pred_pos_dim");
r.AddLabel(L.T("predict.pos2", Fmt.Num(pos.Cost), Fmt.Num(pos.Shares)), "pred_pos_dim");
r.AddLabel(L.T("predict.pos4", Fmt.Num(sel.SellValue(pos.Yes, pos.Shares) * (1 - Tuning.PredictFee))), "pred_pos_dim");
}
}
if (!sel.IsOpen(day)) { r.AddLabel(L.T("predict.closed.note"), "pred_closed_note"); return; }
AmountRow(r);
if (double.TryParse((_amount ?? "").Trim(), NumberStyles.Float, CultureInfo.InvariantCulture, out double v) && v > 0 && v < 1e7)
{
foreach (bool yes in new[] { true, false })
{
double shares = sel.SharesFor(yes, v / (1 + Tuning.PredictFee));
r.AddLabel(L.T("predict.preview", L.T(yes ? "predict.yes" : "predict.no"), Fmt.Num(shares), P2(v / shares), Fmt.Num(shares - v)), "pred_prev_dim");
}
}
r.BeginRow(6f);
r.AddButton(L.T("predict.buy.yes"), () => PredictBuy(sel, true));
r.AddButton(L.T("predict.buy.no"), () => PredictBuy(sel, false));
r.End();
r.AddSpace(4f);
r.AddButton(L.T("predict.sell"), () => PredictSell(sel));
});
}
/// 情报页:局势简报,以及对派系行动、矿机时代盘口的内幕消息。付款是 CRT 的合约调用(销毁),要签名。
private static void IntelPage(CustomUIBuilder b)
{
var d = Session.Data;
var w = d.Hot;
int day = StoreStation.GetDayCounter();
Columns(b, l =>
{
l.AddHeader(L.T("intel.h"));
if (!PredictGame.BrokerOpen) { l.AddLabel(L.T("intel.locked", Tuning.IntelRep), "intel_locked_note"); return; }
l.AddLabel(L.T("predict.acct.crt", WalletWindow.NameOf(w), Fmt.Num(w.Avail(Assets.CRT))));
l.AddButton(L.T("intel.brief.buy", Fmt.Num(Tuning.IntelBriefPrice)), IntelBuyBrief);
l.AddSeparator();
if (d.Brief.Count == 0) l.AddLabel(L.T("intel.brief.none"), "intel_brief_dim");
else
{
l.AddLabel(L.T("intel.brief.day", d.BriefDay), "intel_day_dim");
foreach (var line in d.Brief) l.AddLabel(line, "intel_line_dim");
}
}, r =>
{
r.AddHeader(L.T("intel.tips.h"));
if (!PredictGame.BrokerOpen) return;
var list = d.Predict.Markets.Where(m => PredictGame.Tippable(m, day)).OrderBy(m => m.FromDay).ToList();
if (list.Count == 0) r.AddLabel(L.T("intel.tips.none"), "intel_tips_none_dim");
foreach (var m in list)
{
var mk = m;
r.AddLabel(PredictGame.Question(m));
var tip = d.Predict.TipFor(m.Id);
if (tip != null) { r.AddLabel(L.T("predict.clue.tip", L.T("predict.tier." + tip.Tier), L.T(tip.Claim ? "predict.yes" : "predict.no")), "intel_tip_dim"); continue; }
for (int t = 0; t < 3; t++)
{
int tier = t;
r.AddButton(L.T("intel.tip.buy", (int)Math.Round(Tuning.IntelTipAcc[t] * 100), Fmt.Num(Tuning.IntelTipPrice[t] * m.MaxLoss)), () => IntelBuyTip(mk, tier));
r.Last?.SetPreferredSize(RightW - 30f, 28f);
if (t < 2) r.AddSpace(2f);
}
}
});
}
private static void IntelBuyBrief()
{
var d = Session.Data;
double price = Tuning.IntelBriefPrice;
if (d.Hot.Avail(Assets.CRT) + 1e-9 < price) { _message = L.T("intel.err.funds"); Open(); return; }
_message = L.T("hw.wait");
Open();
WalletWindow.OpenForBill(new WalletWindow.Bill
{
Contract = true, Asset = Assets.CRT, Amount = price, Summary = L.T("intel.brief.sum", Fmt.Num(price)),
Execute = pw =>
{
if (pw.Avail(Assets.CRT) + 1e-9 < price) { _message = L.T("intel.err.funds"); Open(); return; }
pw.Spend(Assets.CRT, price, Session.Price);
int day = StoreStation.GetDayCounter();
bool fake = d.Rng.Chance(Tuning.IntelFake);
d.Brief = PredictGame.MakeBrief(fake, day); d.BriefDay = day;
Log.Info($"局势简报:{(fake ? "假情报" : "真")},{string.Join(" | ", d.Brief)}");
_message = L.T("intel.brief.done");
Session.Save();
Open();
},
});
}
private static void IntelBuyTip(PredictMarket m, int tier)
{
var d = Session.Data;
double price = Tuning.IntelTipPrice[tier] * m.MaxLoss;
if (d.Hot.Avail(Assets.CRT) + 1e-9 < price) { _message = L.T("intel.err.funds"); Open(); return; }
_message = L.T("hw.wait");
Open();
WalletWindow.OpenForBill(new WalletWindow.Bill
{
Contract = true, Asset = Assets.CRT, Amount = price, Summary = L.T("intel.tip.sum", Fmt.Num(price), PredictGame.Question(m)),
Execute = pw =>
{
int day = StoreStation.GetDayCounter();
var truth = PredictGame.Truth(m);
if (pw.Avail(Assets.CRT) + 1e-9 < price || truth == null || !m.IsOpen(day) || d.Predict.TipFor(m.Id) != null) { _message = L.T("intel.err.funds"); Open(); return; }
pw.Spend(Assets.CRT, price, Session.Price);
var tip = PredictBook.MakeTip(m, truth.Value, tier, d.Rng);
d.Predict.Tips.Add(tip);
Log.Info($"内幕消息 #{m.Id} 第 {tier + 1} 档:说{(tip.Claim ? "是" : "否")},真相{(truth.Value ? "是" : "否")}");
_message = L.T("intel.tip.done", L.T(tip.Claim ? "predict.yes" : "predict.no"));
Session.Save();
Open();
},
});
}
private static void PredictBuy(PredictMarket m, bool yes)
{
if (!ReadAmount(out double v)) return;
var d = Session.Data;
var w = d.Hot;
if (w.Avail(Assets.CRC) + 1e-9 < v) { _message = L.T("predict.err.Funds"); Open(); return; }
if (d.Predict.PosAll(m.Id).Any(x => x.Yes != yes)) { _message = L.T("predict.err.Opposite"); Open(); return; }
string side = L.T(yes ? "predict.yes" : "predict.no");
_message = L.T("hw.wait");
Open();
WalletWindow.OpenForBill(new WalletWindow.Bill
{
Contract = true, Asset = Assets.CRC, Amount = v,
Summary = L.T("predict.sum.buy", PredictGame.Question(m), side, Fmt.Num(v)),
Execute = pw =>
{
int day = StoreStation.GetDayCounter();
bool insider = PredictGame.Known(m, yes);
var res = d.Predict.Buy(pw, m, yes, v, day, Session.Price, insider, out double shares);
_message = res == PredictResult.Ok ? L.T("predict.bought", side, Fmt.Num(shares), Fmt.Num(v)) : L.T("predict.err." + res);
if (res == PredictResult.Ok) { Log.Info($"预测下注 #{m.Id} {(yes ? "是" : "否")} {shares:0.0} 份,花 {v:0.##},钱包 {pw.Address},内幕 {insider}"); Session.Save(); }
Open();
},
});
}
private static void PredictSell(PredictMarket m)
{
var d = Session.Data;
var w = d.Hot;
if (d.Predict.PosOf(m.Id, w.Address) == null) { _message = L.T("predict.err.NoPosition"); Open(); return; }
_message = L.T("hw.wait");
Open();
WalletWindow.OpenForBill(new WalletWindow.Bill
{
Contract = true, Asset = Assets.CRC, Amount = 0, Spend = 0, FeeAsset = BestFeeAsset(w),
Summary = L.T("predict.sum.sell", PredictGame.Question(m)),
Execute = pw =>
{
var res = d.Predict.SellAll(pw, m, StoreStation.GetDayCounter(), d.Rng, out double got, out double fraud);
_message = res == PredictResult.Ok ? L.T("predict.sold", Fmt.Num(got)) : L.T("predict.err." + res);
if (fraud > 0)
{
PlayerStore.Instance.secData.CommitCrime("FRAUD", (int)Math.Round(fraud));
_message = L.T("predict.sold.caught", Fmt.Num(got));
Log.Info($"内幕卖出被查:只退本金 {got:0},罪证 {fraud:0}");
}
if (res == PredictResult.Ok) Session.Save();
Open();
},
});
}
private static string PerpErr(PerpResult res) => L.T("perp.err." + res);
private static void PerpOpen()
{
if (!ReadAmount(out double v)) return;
var d = Session.Data;
var res = d.Perps.Open(d.Poinbase, _perpProduct, _perpLong, v, _perpLev, PerpSpot(_perpProduct), StoreStation.GetDayCounter(), Session.Price);
if (res == PerpResult.Ok) CandleChart.Trade(_perpProduct, v * _perpLev);
_message = res == PerpResult.Ok ? L.T("perp.opened", _perpProduct, L.T(_perpLong ? "perp.long" : "perp.short"), Fmt.Num(v), _perpLev) : PerpErr(res);
if (res == PerpResult.Ok) Session.Save();
Open();
}
private static void PerpAdd()
{
if (!ReadAmount(out double v)) return;
var d = Session.Data;
var res = d.Perps.AddMargin(d.Poinbase, _perpProduct, v, Session.Price);
_message = res == PerpResult.Ok ? L.T("perp.added", Fmt.Num(v)) : PerpErr(res);
if (res == PerpResult.Ok) Session.Save();
Open();
}
private static void PerpClose()
{
var d = Session.Data;
var pos = d.Perps.Of(_perpProduct);
if (pos == null) { _message = PerpErr(PerpResult.NoPosition); Open(); return; }
double margin = pos.Margin;
CandleChart.Trade(_perpProduct, pos.Notional(PerpSpot(_perpProduct)));
double paid = d.Perps.Close(d.Poinbase, _perpProduct, PerpSpot(_perpProduct), StoreStation.GetDayCounter(), out double deficit);
// 操纵:平仓盈利,而且盈利方向和玩家自己推价的方向一致,按推价幅度被查:盈利没收,记诈骗罪证,永续账户冻结几天
double push = d.Market.PlayerPush, profit = paid - margin;
bool aligned = _perpProduct == "PSC" && profit > 0 && (pos.Long ? push > 0 : push < 0);
if (aligned && d.Rng.Chance(Math.Min(1, Market.ManipChance(push) * StoryMods.Current.Enforce(StoreStation.GetDayCounter()))))
{
int day = StoreStation.GetDayCounter();
d.Poinbase.Add(Assets.CRC, -Math.Min(profit, d.Poinbase.Avail(Assets.CRC)));
d.Perps.BannedUntil = day + Tuning.ManipFreezeDays;
PlayerStore.Instance.secData.CommitCrime("FRAUD", (int)Math.Round(Math.Min(Tuning.PredictFraudCap, profit * Tuning.PredictFraudShare)));
News.Show("pd_news_manip_" + day, L.T("news.manip.title"), L.T("news.manip.desc"), 3);
_message = L.T("perp.manip", Fmt.Credits(profit), Tuning.ManipFreezeDays);
Log.Info($"永续操纵被查:盈利 {profit:0} 没收,推价 {push:0.000}");
Session.Save();
Open();
return;
}
_message = L.T("perp.closed", Fmt.Num(paid), (paid - margin >= 0 ? "+" : "") + Fmt.Num(paid - margin)) + (deficit > 0 ? "\n" + L.T("perp.deficit", Fmt.Credits(deficit)) : "");
Session.Save();
Open();
}
/// Poinbase 提到当前钱包:托管账户直接转,不用签名。污点按比例随币走。
private static void PoinbaseOut()
{
if (!ReadAmount(out double v)) return;
var d = Session.Data;
if (d.Poinbase.Avail(_poinAsset) + 1e-9 < v) { _message = L.T("msg.balance"); Open(); return; }
Wallet.Transfer(d.Poinbase, d.Hot, _poinAsset, v, Session.Price);
_message = L.T("poin.out.done", Fmt.Num(v), _poinAsset, WalletWindow.NameOf(d.Hot));
Session.Save();
WalletWindow.RefreshDevice(d.Hot);
Open();
}
/// 钱包页:全部硬件钱包,连接(设为当前)、打开实体钱包。
private static void WalletsPage(CustomUIBuilder b)
{
var d = Session.Data;
b.AddLabel(L.T("wallets.count", d.Wallets.Count, Tuning.WalletMax), "wallets_count_dim");
var cols = new[] { 100f, 110f, 85f, 70f, 375f };
var aligns = new[] { TextAnchor.MiddleLeft, TextAnchor.MiddleLeft, TextAnchor.MiddleRight, TextAnchor.MiddleRight, TextAnchor.MiddleLeft };
TableHeader(b, 740f, cols, aligns, L.T("wallets.col.name"), L.T("wallets.col.addr"), L.T("col.value"), L.T("wallets.col.taint"), "");
for (int i = 0; i < d.Wallets.Count; i++)
{
var w = d.Wallets[i];
int idx = i;
bool isCurrent = idx == d.Current;
// 一行三块:数据格(整行反色的是这一块)、操作键(和顶栏页签一样的黑色触控键,放在纸面上)、连接状态靠右
b.BeginRow(4f, TextAnchor.MiddleLeft);
b.BeginPanel(cols[0] + cols[1] + cols[2] + cols[3] + 8f, 32f, TextAnchor.MiddleLeft, 4f, 0f);
_rows.Add(new PledgerSkin.RowDef
{
Root = b.Last.root,
Image = b.Last.image,
Selected = isCurrent,
OnClick = () => { d.Current = idx; Session.Save(); Open(); }
});
b.BeginRow(0f, TextAnchor.MiddleLeft);
Cell(b, cols[0], (isCurrent ? "● " : "") + WalletWindow.NameOf(w), false, TextAnchor.MiddleLeft);
Cell(b, cols[1], w.Address, false, TextAnchor.MiddleLeft);
Cell(b, cols[2], Fmt.Credits(w.Value(Session.Price)), false, TextAnchor.MiddleRight);
Cell(b, cols[3], Fmt.Credits(w.Taint), false, TextAnchor.MiddleRight);
b.End();
b.End();
b.BeginPanel(cols[4] - 150f, 32f, TextAnchor.MiddleLeft, 0f, 0f);
_clear.Add(b.Last.image);
b.BeginRow(4f, TextAnchor.MiddleLeft);
RowKey(b, L.T("wallets.open"), () => WalletWindow.Open(w));
RowKey(b, L.T("hw.copy"), () => { Clipboard.Copy(w.Address); _message = L.T("hw.copied.addr", w.Address); Open(); });
RowKey(b, L.T("wallets.rename"), () => { _renamePick = w.Address; _renameText = w.Label ?? ""; _message = null; Open(); });
if (idx > 0) RowKey(b, _destroyPick == w.Address ? L.T("wallets.destroy.sure") : L.T("wallets.destroy"), () => DestroyWallet(w));
b.End();
b.End();
b.BeginPanel(84f, 32f, TextAnchor.MiddleRight, 0f, 0f);
_clear.Add(b.Last.image);
b.BeginRow(0f, TextAnchor.MiddleRight);
if (idx != d.Current) RowKey(b, L.T("wallets.use"), () => { d.Current = idx; Session.Save(); Open(); });
else b.AddLabel(L.T("wallets.current"), "wallets_cur_dim");
b.End();
b.End();
b.End();
}
if (_renamePick != null) RenameRow(b);
}
/// 表格行里的操作键:固定 24 高,垂直居中(行高 32)。
private static void RowKey(CustomUIBuilder b, string text, Action onClick)
{
b.AddButton(text, onClick);
var go = b.Last.root;
if (go == null) return;
var le = go.GetComponent() ?? go.AddComponent();
le.minHeight = 26f; le.preferredHeight = 26f; le.flexibleHeight = 0f;
}
private static string _destroyPick, _renamePick, _renameText = "";
/// 改名:表格下方一行输入框;留空恢复默认名。
private static void RenameRow(CustomUIBuilder b)
{
var w = Session.Data.Own(_renamePick);
if (w == null) { _renamePick = null; return; }
b.AddSeparator();
b.BeginRow(8f, TextAnchor.MiddleLeft);
b.AddLabel(L.T("wallets.rename.h", w.Address));
b.BeginPanel(220f, -1f, TextAnchor.MiddleLeft, 0f, 0f);
_clear.Add(b.Last.image);
b.AddInput(WalletWindow.NameOf(w), _renameText, s => _renameText = s, "rename", s => _renameText = s);
b.End();
b.AddButton(L.T("wallets.rename.ok"), () =>
{
string name = (_renameText ?? "").Trim();
w.Label = name.Length == 0 ? null : name.Length > 16 ? name.Substring(0, 16) : name;
_renamePick = null;
Session.Save();
WalletWindow.RefreshDevice(w, true);
Open();
});
b.AddButton(L.T("black.edit.cancel"), () => { _renamePick = null; Open(); });
b.End();
}
/// 销毁钱包(主钱包不能销毁):要先把币转走;点两次确认。之后回到这个地址的退款、回款、混币提回改进当前钱包。
private static void DestroyWallet(Wallet w)
{
var d = Session.Data;
int idx = d.Wallets.IndexOf(w);
if (idx <= 0) return;
if (w.Balances.Values.Any(v => v > 1e-9) || w.Frozen.Values.Any(v => v > 1e-9)) { _destroyPick = null; _message = L.T("wallets.destroy.notempty", WalletWindow.NameOf(w)); Open(); return; }
if (_destroyPick != w.Address) { _destroyPick = w.Address; _message = null; Open(); return; }
_destroyPick = null;
string name = WalletWindow.NameOf(w);
WalletWindow.Close(w);
var current = d.Hot;
d.Wallets.RemoveAt(idx);
d.Current = Math.Max(0, d.Wallets.IndexOf(current));
Log.Info($"销毁硬件钱包 {w.Address}{(w.Poisoned ? "(被投毒)" : "")}");
Session.Save();
_message = L.T("wallets.destroyed", name, w.Address);
Open();
}
/// 硬件钱包拖到终端上:添加一个钱包(被投毒的钱包看起来一样)。
public static bool CanAddWallet() => Session.Data != null && Session.Data.Wallets.Count < Tuning.WalletMax;
public static void AddWallet(bool poisoned)
{
var w = Session.Data.AddWallet(poisoned);
Log.Info($"添加硬件钱包 {w.Address}{(poisoned ? "(被投毒)" : "")}");
Session.Save();
_page = Page.Wallets;
_message = L.T("wallets.added", WalletWindow.NameOf(w), w.Address);
Open();
}
/// Web3:链上钱包与去中心化交易。
private static void Web3Page(CustomUIBuilder b)
{
var w = Session.Data.Hot;
if (WalletWindow.Pending != null && !WalletWindow.Pending.Contract) { PayPage(b); return; }
{
if (_web3Tab == Web3Tab.Mixer && !Unlock.MixerUnlocked) _web3Tab = Web3Tab.Trade;
if (_web3Tab == Web3Tab.Black && !Unlock.BlackUnlocked) _web3Tab = Web3Tab.Trade;
if (_web3Tab == Web3Tab.Predict && !PredictGame.Available(StoreStation.GetDayCounter())) _web3Tab = Web3Tab.Trade;
b.BeginRow(4f);
foreach (Web3Tab t in Enum.GetValues(typeof(Web3Tab)))
{
if ((t == Web3Tab.Mixer && !Unlock.MixerUnlocked) || (t == Web3Tab.Black && !Unlock.BlackUnlocked) || (t == Web3Tab.Predict && !PredictGame.Available(StoreStation.GetDayCounter()))) continue;
var tab = t;
string name = L.T("web3.sub." + t.ToString().ToLowerInvariant());
PledgerSkin.Pick(b, name, tab == _web3Tab, () => { _web3Tab = tab; _message = null; _edit = null; Open(); }, _picked);
}
b.End();
if (_web3Tab == Web3Tab.Stake) { StakePage(b); return; }
if (_web3Tab == Web3Tab.Predict) { PredictPage(b); return; }
if (_web3Tab == Web3Tab.Ponzi) { YieldPage(b); return; }
if (_web3Tab == Web3Tab.Airdrop) { AirdropPage(b); return; }
if (_web3Tab == Web3Tab.Mining) { ReportPage(b); return; }
if (_web3Tab == Web3Tab.Mixer) { MixerPage(b); return; }
if (_web3Tab == Web3Tab.Black) { BlackPage(b); return; }
}
Columns(b, l =>
{
l.AddHeader(L.T("web3.wallet.h") + " " + WalletWindow.NameOf(w));
Holdings(l, w, a => true);
l.AddSeparator();
l.AddLabel(L.T("wallet.total", Fmt.Credits(w.Value(Session.Price))));
if (w.Taint > 0.5) l.AddLabel(L.T("wallet.taint", Fmt.Credits(w.Taint)), "taint_note");
StableTable(l);
l.AddSeparator();
CandleChart.AddPicker(l, "web3", Assets.PSC, Open, _picked);
}, r =>
{
r.AddHeader(L.T("trade.h"));
AmountRow(r);
TradeRows(r, Venue.Dex);
if (Unlock.BlackUnlocked && BlackMarket.DeskOpen(StoreReputation.GetBMReputation())) PoneroBlock(r);
});
}
private enum BlackTab { Buy, Sell, Orders }
private static BlackTab _blackTab = BlackTab.Buy;
private static PayMode _mode = PayMode.Escrow;
private static string _deskAsset = Assets.PSC;
private static bool _deskIn = true;
private static readonly System.Collections.Generic.Dictionary IconCache = new System.Collections.Generic.Dictionary();
/// 货架上的商品图:游戏物品自己的贴图(本 mod 的显卡用本 mod 的贴图)。
private static Sprite Icon(string id)
{
if (IconCache.TryGetValue(id, out var cached)) return cached;
Sprite sprite = null;
var it = ModHelper.SpawnItem(BlackDelivery.GameId(id));
if (it != null)
{
try { sprite = it.useSpriteFromMod ? Items.ModSprite(it.spritePath) : RenderHandler.LoadFromAtlas(it.spriteAtlasPath, it.spritePath); }
catch (Exception ex) { Log.Info($"商品图读取失败 {id}:{ex.Message}"); }
it.Destroy();
}
IconCache[id] = sprite;
return sprite;
}
/// Web3 页里的黑市:黑市派系的链上实体。货架式商城(现货、订单)、我的订单与情报、Ponero 兑换处。
private static void BlackPage(CustomUIBuilder b)
{
int rep = StoreReputation.GetBMReputation();
int day = StoreStation.GetDayCounter();
var d = Session.Data;
if (Night.ActiveEvents().Contains(MarketEvent.Crackdown)) { b.AddLabel(L.T("black.offline"), "black_offline_note"); return; }
if (!BlackMarket.Open(rep)) { b.AddLabel(L.T("black.closed", Tuning.BmOpenRep, rep), "black_closed_note"); return; }
b.BeginRow(4f);
foreach (BlackTab t in Enum.GetValues(typeof(BlackTab)))
{
var tab = t;
string name = L.T("black.sub." + t.ToString().ToLowerInvariant());
PledgerSkin.Pick(b, name, tab == _blackTab, () => { _blackTab = tab; _message = null; _edit = null; _pay = null; _checkout = false; Open(); }, _picked);
}
b.End();
if (d.Black.Closed(day)) { b.AddLabel(L.T("black.rugged", d.Black.ClosedUntil - day + 1), "black_rugged_note"); return; }
if (_checkout) { CheckoutPage(b, d); return; }
if (_pay != null && _blackTab != BlackTab.Buy) { PayPanel(b); return; }
if (_edit != null) { EditPage(b); return; }
if (_blackTab == BlackTab.Orders) { OrdersPage(b, day); return; }
if (_blackTab == BlackTab.Sell) { SellPage(b, day); return; }
BuyPage(b, d, rep);
}
///
/// 买货:左栏是卖家列表(第一个是平台现货,立即到货但贵,其余是商家,便宜但要等),右栏是这家的货架。
/// 点"购买"后右栏换成付款面板:选数量(和付款方式)、付款币种;商家不收手里的币时可以用 Web3 内置的自动换币(多付滑点)。论坛情报在最下面。
///
private static void BuyPage(CustomUIBuilder b, SaveData d, int rep)
{
var w = d.Hot;
var sellers = d.Black.Sellers;
if (_merchant >= sellers.Count) _merchant = -1;
bool platform = _merchant < 0;
b.AddLabel(L.T("black.rep", rep, BlackMarket.MaxLevel(rep)) + (w.Taint > 0.5 ? " " + L.T("wallet.taint", Fmt.Credits(w.Taint)) : ""), "black_rep_dim");
b.BeginRow(8f, TextAnchor.UpperLeft);
b.BeginPanel(SellerW + 8f, -1f, TextAnchor.UpperLeft, 0f, 2f);
_clear.Add(b.Last.image);
var names = new System.Collections.Generic.List { L.T("merchant.Platform") };
for (int k = 0; k < sellers.Count; k++) names.Add(MerchantLabel(sellers[k], true));
for (int i = 0; i < names.Count; i++)
{
int idx = i - 1;
TableRow(b, SellerW, new[] { SellerW - 8f }, new[] { TextAnchor.MiddleLeft }, new[] { names[i] }, idx == _merchant, () => { _merchant = idx; _message = null; Open(); });
}
b.End();
b.BeginPanel(ShelfW, -1f, TextAnchor.UpperLeft, 0f, 4f);
_clear.Add(b.Last.image);
ShopInfo(b, d, platform ? null : sellers[_merchant]);
if (platform) PlatformShelf(b, d);
else MerchantShelf(b, d);
b.End();
b.End();
b.AddSeparator();
b.AddHeader(L.T("black.forum.h"));
if (d.Black.Forum.Count == 0) b.AddLabel(L.T("black.forum.quiet"), "forum_quiet_dim");
foreach (var f in d.Black.Forum)
b.AddLabel(f.StartsWith("phish:") ? L.T("black.forum.phish", f.Substring(6)) : f.StartsWith("scam:") ? L.T("black.forum.scam", BlackDelivery.DisplayName(f.Substring(5))) : f.StartsWith("busted:") ? L.T("black.forum.busted", f.Substring(7)) : L.T("black.forum." + f), "forum_dim");
}
private const float SellerW = 176f, ShelfW = 560f;
private static void PlatformShelf(CustomUIBuilder b, SaveData d)
{
if (d.Black.Offers.Count == 0) { b.AddLabel(L.T("black.empty"), "platform_empty_dim"); return; }
Shelf(b, d.Black.Offers.Cast().ToList(), (c, g) =>
{
var offer = (BmOffer)g;
GoodHead(c, g, L.T("black.stock", offer.Qty));
c.AddLabel(L.T("black.price", offer.Price));
c.AddFlexibleSpace();
c.AddButton(L.T("black.buy"), () => AddToCart(-1, offer));
}, 3);
}
// ——— 付款面板 ———
/// 一次付款的草稿:买什么、收款地址、商家收哪些币、可选数量与付款方式、合计,以及付款成功后做的事。
private sealed class PayDraft
{
public string Title, Address, Asset, SwapFrom;
public string[] Accepted;
public int MaxQty = 1, Qty = 1;
public bool Modes;
public PayMode Mode = PayMode.Escrow;
public Func Credits;
public Action Execute;
}
private static PayDraft _pay;
private static void OpenPay(PayDraft p)
{
var w = Session.Data.Hot;
p.Asset = p.Accepted.FirstOrDefault(a => w.Avail(a) * Session.Price(a) >= p.Credits(1, p.Mode)) ?? p.Accepted[0];
p.SwapFrom = null;
_pay = p;
_message = null;
Open();
}
private static string Summary(PayDraft p) => p.Title + (p.Qty > 1 ? " ×" + p.Qty : "");
/// 付款面板:数量、付款方式、合计、付款币种(商家收的币)、生成账单;下面是自动换币。
private static void PayPanel(CustomUIBuilder b)
{
var d = Session.Data;
var w = d.Hot;
var p = _pay;
if (p.Qty > p.MaxQty) p.Qty = p.MaxQty;
b.AddHeader(L.T("bpay.h"));
b.AddLabel(p.Title, "bpay_title");
if (p.MaxQty > 1)
{
b.BeginRow(4f);
b.AddLabel(L.T("bpay.qty"));
foreach (var q in Qtys)
{
if (q > p.MaxQty && q != Qtys[0]) continue;
int qq = q;
PledgerSkin.Pick(b, "×" + qq, qq == p.Qty, () => { p.Qty = qq; Open(); }, _picked);
}
b.End();
}
if (p.Modes)
{
b.BeginRow(4f);
foreach (PayMode m in Enum.GetValues(typeof(PayMode)))
{
var mode = m;
PledgerSkin.Pick(b, L.T("black.mode." + m), mode == p.Mode, () => { p.Mode = mode; Open(); }, _picked);
}
b.End();
}
double credits = p.Credits(p.Qty, p.Mode);
b.AddLabel(L.T("bpay.total", Fmt.Credits(credits)), "bpay_total");
b.AddSeparator();
b.AddLabel(L.T("bpay.coin"), "bpay_coin_dim");
b.BeginRow(4f);
foreach (var a in p.Accepted)
{
string asset = a;
PledgerSkin.Pick(b, asset, asset == p.Asset, () => { p.Asset = asset; Open(); }, _picked);
}
b.End();
double need = WalletWindow.RoundUp(credits / Session.Price(p.Asset));
b.AddLabel(L.T("bpay.need", WalletWindow.Amt(need), p.Asset, Fmt.Num(w.Avail(p.Asset))), "bpay_need_dim");
b.AddButton(L.T("bpay.invoice"), () =>
{
string asset = p.Asset; int q = p.Qty; var mode = p.Mode;
string summary = Summary(p);
_pay = null;
BeginPay(p.Address, summary, asset, credits, pw => p.Execute(asset, q, mode, pw));
});
b.AddSeparator();
// 自动换币:从钱包里别的币里换出商家收的币,多付滑点;是合约调用,签名后一步完成
b.AddLabel(L.T("bpay.swap.h", p.Asset), "bpay_swap_dim");
double slipped = credits * (1 + Tuning.BmAutoSwapSlip);
var sources = w.Balances.Keys.Where(a => !p.Accepted.Contains(a) && a != Assets.Ponero && Session.Price(a) > 0 && w.Avail(a) * Session.Price(a) >= slipped).ToList();
if (sources.Count == 0) b.AddLabel(L.T("bpay.swap.none"), "bpay_swap_none_dim");
else
{
if (p.SwapFrom == null || !sources.Contains(p.SwapFrom)) p.SwapFrom = sources[0];
b.BeginRow(4f);
foreach (var s in sources)
{
string src = s;
PledgerSkin.Pick(b, src, src == p.SwapFrom, () => { p.SwapFrom = src; Open(); }, _picked);
}
b.End();
double needSrc = WalletWindow.RoundUp(slipped / Session.Price(p.SwapFrom));
b.AddLabel(L.T("bpay.swap.need", WalletWindow.Amt(needSrc), p.SwapFrom, (Tuning.BmAutoSwapSlip * 100).ToString("0", CultureInfo.InvariantCulture)), "bpay_swap_need_dim");
b.AddButton(L.T("bpay.swap.btn"), () => SwapAndPay(p, credits, needSrc));
}
b.AddSpace(4f);
b.AddButton(L.T("bpay.back"), () => { _pay = null; _message = null; Open(); });
}
/// 自动换币并付款:一次合约调用,先把别的币按滑点换成商家收的币,再下单。
private static void SwapAndPay(PayDraft p, double credits, double needSrc)
{
var d = Session.Data;
string src = p.SwapFrom, asset = p.Asset; int q = p.Qty; var mode = p.Mode;
_pay = null;
_message = L.T("hw.wait");
Open();
WalletWindow.OpenForBill(new WalletWindow.Bill
{
Contract = true, Asset = src, Amount = needSrc, Spend = needSrc,
Summary = L.T("bpay.swap.sum", Summary(p), src, WalletWindow.Amt(needSrc), asset),
Execute = pw =>
{
if (pw.Avail(src) + 1e-9 < needSrc) { _message = L.T("msg.balance"); Open(); return; }
double got = credits / Session.Price(asset) * 1.002;
pw.Add(src, -needSrc);
pw.Add(asset, got);
pw.RecordBuy(asset, got, credits);
CandleChart.Trade(src, credits); CandleChart.Trade(asset, credits);
Log.Info($"自动换币:{needSrc:0.####} {src} → {got:0.####} {asset}({credits:0} 信用点等值)");
p.Execute(asset, q, mode, pw);
},
});
}
private static double _sellMult = 1.0;
/// 出售:储藏区里能上架的货做成货架,选标价倍率后上架。
private static void SellPage(CustomUIBuilder b, int day)
{
var d = Session.Data;
var items = BlackDelivery.Sellable();
b.BeginRow(8f, TextAnchor.UpperLeft);
b.BeginPanel(SellerW + 8f, -1f, TextAnchor.UpperLeft, 0f, 2f);
_clear.Add(b.Last.image);
b.AddLabel(L.T("black.sell.mult"), "sell_mult_dim");
foreach (var m in Tuning.BmSellMults)
{
double mult = m;
TableRow(b, SellerW, new[] { 70f, SellerW - 78f }, new[] { TextAnchor.MiddleLeft, TextAnchor.MiddleRight },
new[] { "×" + m.ToString("0.0", CultureInfo.InvariantCulture), L.T("black.sell.days", Math.Round(BlackMarket.ExpectedDays(m))) },
Math.Abs(mult - _sellMult) < 1e-9, () => { _sellMult = mult; Open(); });
}
b.AddSpace(4f);
b.AddLabel(L.T("black.sell.rating", d.Black.Rating), "sell_status_dim");
b.AddLabel(L.T("black.sell.slots", d.Black.ActiveListings, Tuning.BmMaxListings), "sell_status_dim");
b.AddSpace(4f);
b.AddButton(L.T("black.sell.all"), () =>
{
double mult = _sellMult;
double fee = BlackDelivery.Sellable().Sum(it => BlackMarket.ListFee(BlackMarket.ListPriceOf(BlackDelivery.RefValue(it), mult)));
OpenPay(new PayDraft
{
Title = L.T("black.sell.all") + L.T("pay.listfee", mult.ToString("0.0", CultureInfo.InvariantCulture)), Address = d.Address("platform"), Accepted = Merchants.PlatformAccepts,
Credits = (q, m) => fee, Execute = (asset, q, m, pw) => ListAll(asset, mult, pw),
});
});
b.End();
b.BeginPanel(ShelfW, -1f, TextAnchor.UpperLeft, 0f, 4f);
_clear.Add(b.Last.image);
if (items.Count == 0) b.AddLabel(L.T("black.sell.none"), "sell_none_dim");
else Shelf(b, items.Select(it =>
{
string id = BlackDelivery.SellId(it);
var kind = BlackDelivery.KindOf(id);
var g = new BmGood { Item = id, Kind = kind, Level = kind == GoodKind.Gpu ? 2 : Merchants.Info(id).Level };
if (kind == GoodKind.Gpu) { var uu = Items.ToUnit(it); g.Health = uu.Health; g.Model = uu.Model; }
g.Price = BlackMarket.ListPriceOf(BlackDelivery.RefValue(it), _sellMult);
return new { g, it };
}).Select(x => { SellCards[x.g] = x.it; return x.g; }).ToList(), (c, g) =>
{
var it = SellCards[g];
GoodHead(c, g, "");
c.AddLabel(L.T("black.price", g.Price));
c.AddFlexibleSpace();
c.AddButton(L.T("black.sell.list"), () => BeginEdit(new EditPrice { Item = it, Id = g.Item, Ref = BlackDelivery.RefValue(it) }, g.Price));
}, 3);
b.End();
b.End();
}
private static readonly System.Collections.Generic.Dictionary SellCards = new System.Collections.Generic.Dictionary();
private static void ListAll(string asset, double mult, Wallet pw)
{
var d = Session.Data;
int n = 0;
foreach (var it in BlackDelivery.Sellable())
{
string id = BlackDelivery.SellId(it);
if (id == null) continue;
var u = Items.IsGpu(it) ? Items.ToUnit(it) : null;
var s = d.Black.List(id, u?.Model, u?.FakeOf, u?.Health ?? 0, BlackDelivery.RefValue(it), mult, pw, asset, StoreStation.GetDayCounter(), Session.Price);
if (s == null) break;
it.Destroy();
n++;
}
_message = L.T("black.sell.listedall", n);
Session.Save();
Open();
}
private static void ListItem(GameItem it, double mult, string asset, Wallet pw)
{
var d = Session.Data;
string id = BlackDelivery.SellId(it);
if (id == null) { Open(); return; }
var u = Items.IsGpu(it) ? Items.ToUnit(it) : null;
var s = d.Black.List(id, u?.Model, u?.FakeOf, u?.Health ?? 0, BlackDelivery.RefValue(it), mult, pw, asset, StoreStation.GetDayCounter(), Session.Price);
if (s == null) { _message = d.Black.ActiveListings >= Tuning.BmMaxListings ? L.T("black.sell.full", Tuning.BmMaxListings) : L.T("msg.balance"); Open(); return; }
it.Destroy();
_message = L.T("black.sell.listed", ItemName(id), s.ListPrice);
Session.Save();
Open();
}
// ——— 购物车 ———
private sealed class CartLine { public int Seller; public BmGood Good; public int Qty; }
private static readonly List _cart = new List();
private static bool _checkout;
private static readonly Dictionary _ckSwap = new Dictionary();
private static readonly Dictionary _ckMode = new Dictionary();
private static readonly Dictionary _ckGroupCoin = new Dictionary();
/// 读档或换局时清空购物车(货架对象已经不是同一批了)。
public static void ClearCart() { _cart.Clear(); _checkout = false; _ckMode.Clear(); _ckGroupCoin.Clear(); _ckSwap.Clear(); }
private static int StockOf(BmGood g) => g is BmOffer o ? o.Qty : ((BmListing)g).Qty;
/// 丢掉已经下架的货,数量压到库存以内。
private static void CartValidate(SaveData d)
{
_cart.RemoveAll(l => l.Good is BmOffer o ? !d.Black.Offers.Contains(o) : !d.Black.Catalog.Contains((BmListing)l.Good) || l.Seller >= d.Black.Sellers.Count);
foreach (var l in _cart) l.Qty = Math.Max(1, Math.Min(l.Qty, StockOf(l.Good)));
}
private static int CartCount() => _cart.Sum(l => l.Qty);
private static void AddToCart(int seller, BmGood g)
{
var line = _cart.Find(l => l.Good == g);
if (line == null) _cart.Add(new CartLine { Seller = seller, Good = g, Qty = 1 });
else line.Qty = Math.Min(StockOf(g), line.Qty + 1);
_message = L.T("cart.added", ItemName(g.Item));
Open();
}
private static string[] AcceptsOf(SaveData d, int seller) => seller < 0 ? Merchants.PlatformAccepts : Merchants.Accepts(d.Black.Sellers[seller].Type);
private static string AddressOf(SaveData d, int seller) => seller < 0 ? d.Address("platform") : d.Black.Sellers[seller].Name;
private static string ShopName(SaveData d, int seller) => seller < 0 ? L.T("merchant.Platform") : MerchantLabel(d.Black.Sellers[seller], true);
private static PayMode ModeOf(SaveData d, int seller) => seller >= 0 && d.Black.Sellers[seller].Advance && _ckMode.TryGetValue(seller, out var m) ? m : PayMode.Escrow;
private static double LineCredits(SaveData d, CartLine l) =>
l.Good is BmOffer o ? o.Price * l.Qty : BlackMarket.Pay(BlackMarket.OrderTotal(((BmListing)l.Good).Price, l.Qty), ModeOf(d, l.Seller));
private static double GroupCredits(SaveData d, int seller) => _cart.Where(l => l.Seller == seller).Sum(l => LineCredits(d, l));
/// 悬浮购物车:商店页右下角一个常驻的小按钮,显示件数,点开进结算页。
private static void CartFab(CustomUIWindow win)
{
if (win?.root == null) return;
var old = win.root.transform.Find("pd_cart_fab");
bool show = _page == Page.Web3 && _web3Tab == Web3Tab.Black && !_checkout && WalletWindow.Pending == null && Unlock.BlackUnlocked;
if (!show) { if (old != null) UnityEngine.Object.Destroy(old.gameObject); return; }
if (old != null) UnityEngine.Object.DestroyImmediate(old.gameObject);
var go = new GameObject("pd_cart_fab", typeof(RectTransform), typeof(CanvasRenderer), typeof(Image), typeof(Button), typeof(LayoutElement));
go.transform.SetParent(win.root.transform, false);
go.GetComponent().ignoreLayout = true;
var rt = (RectTransform)go.transform;
rt.anchorMin = rt.anchorMax = new Vector2(1f, 0f);
rt.pivot = new Vector2(1f, 0f);
rt.sizeDelta = new Vector2(132f, 36f);
rt.anchoredPosition = new Vector2(-34f, 34f);
var img = go.GetComponent();
PledgerSkin.KeyShape(img, CartCount() > 0 ? PledgerSkin.Ink : PledgerSkin.Key);
var btn = go.GetComponent