Files
probably-decentralized/src/Decentralized/TerminalWindow.cs
T

2246 lines
120 KiB
C#
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using UnityEngine;
using UnityEngine.UI;
using TMPro;
using Decentralized.Domain;
namespace Decentralized
{
/// <summary>
/// 交易终端窗口:首页三个入口(银行、Poinbase、Web3)加报告。银行是真实的信用点(存取、房租);
/// Poinbase 是合规交易所;Web3 是去中心化交易和链上钱包。每次操作后按当前页重建窗口。
/// </summary>
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<GameObject> _picked = new List<GameObject>();
private static readonly List<Image> _clear = new List<Image>();
private static readonly List<PledgerSkin.RowDef> _rows = new List<PledgerSkin.RowDef>();
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(); }
/// <summary>钱包那边交易落地后刷新终端(开着的话),带上结果消息。</summary>
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 ? "<b>" + text + "</b>" : 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);
/// <summary>左右分栏:左边是账户与持仓表格,右边是操作区。</summary>
private static void Columns(CustomUIBuilder b, Action<CustomUIBuilder> left, Action<CustomUIBuilder> 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();
}
/// <summary>银行:真实的信用点。现金、房租,以及信用点与 Poinbase 账户(CRC)之间的存取。</summary>
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);
});
}
/// <summary>提取前的合规审查提示:仅保留账户污点比例(额度与命中概率按设计不公开)。</summary>
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");
}
/// <summary>稳定币行情:价格与状态(脱锚、预兆)。</summary>
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<string, bool> 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");
}
/// <summary>Poinbase:合规交易所。左边账户持仓,右边交易;信用点的存取在银行。</summary>
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;
/// <summary>永续页:保证金账户、仓位、资金费与保险基金;开仓、追加保证金、平仓。Poinbase 是托管账户,不用签名。</summary>
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 };
/// <summary>第三方收益池:日息、储备、时间锁、审计、我的存款,存入 / 取出(合约调用,签名)。是不是骗局看不见。</summary>
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();
},
});
}
/// <summary>空投页:可领取的空投(名字、奖励、要求的授权)和当前钱包的授权(可撤销)。领取和撤销都是合约调用,签名时 PLEDGER 上会写明授权范围。</summary>
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;
/// <summary>理财页:两个子页。"存钱"是 NPC 开的第三方收益池,你存进去吃利息(可能是骗局);"自己发"是你当庄开高息理财(庞氏),下层的人往你这儿存。</summary>
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;
/// <summary>理财页:开一个高息理财产品,下层的人往里存钱;池子随时可以卷走,但每晚都有被举报的风险。开张和卷走是链上合约调用,用当前钱包,要签名;之后每晚垫付的利息从发行人钱包出。</summary>
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);
/// <summary>预测页:盘口(左边列表与最近结果,右边选中盘口的价格、线索、买卖)和情报(情报贩子)。下注和卖出是链上合约调用,用当前钱包的 CRC,要在钱包上签名;持仓记在签名的钱包名下。</summary>
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));
});
}
/// <summary>情报页:局势简报,以及对派系行动、矿机时代盘口的内幕消息。付款是 CRT 的合约调用(销毁),要签名。</summary>
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();
}
/// <summary>Poinbase 提到当前钱包:托管账户直接转,不用签名。污点按比例随币走。</summary>
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();
}
/// <summary>钱包页:全部硬件钱包,连接(设为当前)、打开实体钱包。</summary>
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);
}
/// <summary>表格行里的操作键:固定 24 高,垂直居中(行高 32)。</summary>
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<LayoutElement>() ?? go.AddComponent<LayoutElement>();
le.minHeight = 26f; le.preferredHeight = 26f; le.flexibleHeight = 0f;
}
private static string _destroyPick, _renamePick, _renameText = "";
/// <summary>改名:表格下方一行输入框;留空恢复默认名。</summary>
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();
}
/// <summary>销毁钱包(主钱包不能销毁):要先把币转走;点两次确认。之后回到这个地址的退款、回款、混币提回改进当前钱包。</summary>
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();
}
/// <summary>硬件钱包拖到终端上:添加一个钱包(被投毒的钱包看起来一样)。</summary>
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();
}
/// <summary>Web3:链上钱包与去中心化交易。</summary>
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<string, Sprite> IconCache = new System.Collections.Generic.Dictionary<string, Sprite>();
/// <summary>货架上的商品图:游戏物品自己的贴图(本 mod 的显卡用本 mod 的贴图)。</summary>
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;
}
/// <summary>Web3 页里的黑市:黑市派系的链上实体。货架式商城(现货、订单)、我的订单与情报、Ponero 兑换处。</summary>
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);
}
/// <summary>
/// 买货:左栏是卖家列表(第一个是平台现货,立即到货但贵,其余是商家,便宜但要等),右栏是这家的货架。
/// 点"购买"后右栏换成付款面板:选数量(和付款方式)、付款币种;商家不收手里的币时可以用 Web3 内置的自动换币(多付滑点)。论坛情报在最下面。
/// </summary>
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<string> { 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<BmGood>().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);
}
// ——— 付款面板 ———
/// <summary>一次付款的草稿:买什么、收款地址、商家收哪些币、可选数量与付款方式、合计,以及付款成功后做的事。</summary>
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<int, PayMode, double> Credits;
public Action<string, int, PayMode, Wallet> 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 : "");
/// <summary>付款面板:数量、付款方式、合计、付款币种(商家收的币)、生成账单;下面是自动换币。</summary>
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(); });
}
/// <summary>自动换币并付款:一次合约调用,先把别的币按滑点换成商家收的币,再下单。</summary>
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;
/// <summary>出售:储藏区里能上架的货做成货架,选标价倍率后上架。</summary>
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<BmGood, GameItem> SellCards = new System.Collections.Generic.Dictionary<BmGood, GameItem>();
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<CartLine> _cart = new List<CartLine>();
private static bool _checkout;
private static readonly Dictionary<int, string> _ckSwap = new Dictionary<int, string>();
private static readonly Dictionary<int, PayMode> _ckMode = new Dictionary<int, PayMode>();
private static readonly Dictionary<int, string> _ckGroupCoin = new Dictionary<int, string>();
/// <summary>读档或换局时清空购物车(货架对象已经不是同一批了)。</summary>
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;
/// <summary>丢掉已经下架的货,数量压到库存以内。</summary>
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));
/// <summary>悬浮购物车:商店页右下角一个常驻的小按钮,显示件数,点开进结算页。</summary>
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<LayoutElement>().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<Image>();
PledgerSkin.KeyShape(img, CartCount() > 0 ? PledgerSkin.Ink : PledgerSkin.Key);
var btn = go.GetComponent<Button>();
btn.transition = Selectable.Transition.None;
btn.onClick.AddListener(() => { _checkout = true; _message = null; Open(); });
var tgo = new GameObject("label", typeof(RectTransform), typeof(CanvasRenderer), typeof(TextMeshProUGUI));
tgo.transform.SetParent(go.transform, false);
var trt = (RectTransform)tgo.transform;
trt.anchorMin = Vector2.zero; trt.anchorMax = Vector2.one; trt.offsetMin = trt.offsetMax = Vector2.zero;
var tmp = tgo.GetComponent<TextMeshProUGUI>();
if (win.frame?.titleText != null) { tmp.font = win.frame.titleText.font; tmp.fontSharedMaterial = win.frame.titleText.fontSharedMaterial; }
tmp.text = L.T("cart.fab", CartCount());
tmp.fontSize = 15f;
tmp.fontStyle = FontStyles.Bold;
tmp.alignment = TextAlignmentOptions.Center;
tmp.color = PledgerSkin.KeyText;
tmp.raycastTarget = false;
go.transform.SetAsLastSibling();
}
/// <summary>右栏最前面:商家信息(成交、好评、到货天数、收的币、是否接受提前放款)。</summary>
private static void ShopInfo(CustomUIBuilder b, SaveData d, BmSeller seller)
{
if (seller == null)
{
b.AddHeader(L.T("merchant.Platform"));
b.AddLabel(L.T("black.platform.line"), "shop_info_dim");
b.AddLabel(L.T("black.info.accepts", string.Join(" / ", Merchants.PlatformAccepts)), "shop_accepts_dim");
}
else
{
b.AddHeader(MerchantLabel(seller));
b.AddLabel(L.T("black.seller", "", seller.Trades, seller.Rating) + " " + L.T("black.days", seller.Days), "shop_info_dim");
b.AddLabel(L.T("black.info.accepts", string.Join(" / ", Merchants.Accepts(seller.Type))) + " " + L.T(seller.Advance ? "black.info.advance.yes" : "black.info.advance.no"), "shop_accepts_dim");
}
b.AddSeparator();
}
/// <summary>结算页:左栏是购物车详情(按商家分组,可增减数量、删除),右栏是付款:每家商家收款地址不同,所以逐家付款。</summary>
private static void CheckoutPage(CustomUIBuilder b, SaveData d)
{
CartValidate(d);
var w = d.Hot;
var groups = _cart.GroupBy(l => l.Seller).OrderBy(g => g.Key).ToList();
Columns(b, l =>
{
l.AddHeader(L.T("cart.h"));
if (_cart.Count == 0) { l.AddLabel(L.T("cart.empty"), "cart_empty_dim"); return; }
foreach (var g in groups)
{
int seller = g.Key;
l.AddLabel("<b>" + ShopName(d, seller) + "</b>", "cart_shop");
if (seller >= 0 && d.Black.Sellers[seller].Advance)
{
l.BeginRow(4f);
foreach (PayMode m in Enum.GetValues(typeof(PayMode)))
{
var mode = m;
PledgerSkin.Pick(l, L.T("black.mode." + m), ModeOf(d, seller) == mode, () => { _ckMode[seller] = mode; Open(); }, _picked);
}
l.End();
}
foreach (var line in g.ToList())
{
var ln = line;
l.BeginRow(4f, TextAnchor.MiddleLeft);
Cell(l, 150f, ItemName(ln.Good.Item), false, TextAnchor.MiddleLeft);
RowKey(l, "-", () => { ln.Qty--; if (ln.Qty < 1) _cart.Remove(ln); Open(); });
Cell(l, 26f, ln.Qty.ToString(), false, TextAnchor.MiddleCenter);
RowKey(l, "+", () => { ln.Qty = Math.Min(StockOf(ln.Good), ln.Qty + 1); Open(); });
Cell(l, 58f, Fmt.Credits(LineCredits(d, ln)), false, TextAnchor.MiddleRight);
RowKey(l, "×", () => { _cart.Remove(ln); Open(); });
l.End();
}
}
l.AddSeparator();
l.AddLabel(L.T("bpay.total", Fmt.Credits(_cart.Sum(x => LineCredits(d, x)))), "cart_total");
}, r =>
{
r.AddHeader(L.T("bpay.h"));
if (_cart.Count == 0) { r.AddButton(L.T("cart.back"), () => { _checkout = false; Open(); }); return; }
foreach (var g in groups) GroupPay(r, d, w, g.Key);
r.AddSpace(4f);
r.AddButton(L.T("cart.back"), () => { _checkout = false; _message = null; Open(); });
});
}
/// <summary>逐家付款:这家商家收的币里选一种,生成账单在钱包里转账;也可以用钱包里别的币自动换币(多付滑点)一次签名付清。</summary>
private static void GroupPay(CustomUIBuilder r, SaveData d, Wallet w, int seller)
{
var acc = AcceptsOf(d, seller);
if (!_ckGroupCoin.TryGetValue(seller, out var coin) || !acc.Contains(coin)) coin = _ckGroupCoin[seller] = acc[0];
double credits = GroupCredits(d, seller);
r.AddSeparator();
r.AddLabel(ShopName(d, seller) + " " + Fmt.Credits(credits), "cart_shop");
r.BeginRow(4f);
foreach (var a in acc) { string asset = a; PledgerSkin.Pick(r, asset, asset == coin, () => { _ckGroupCoin[seller] = asset; Open(); }, _picked); }
r.End();
r.AddLabel(L.T("bpay.need", WalletWindow.Amt(WalletWindow.RoundUp(credits / Session.Price(coin))), coin, Fmt.Num(w.Avail(coin))), "bpay_need_dim");
r.AddButton(L.T("bpay.invoice"), () =>
{
var lines = _cart.Where(l => l.Seller == seller).ToList();
var mode = ModeOf(d, seller);
string summary = ShopName(d, seller) + " ×" + lines.Sum(l => l.Qty);
_checkout = false;
BeginPay(AddressOf(d, seller), summary, coin, credits, pw => { var r2 = ExecGroup(seller, lines, mode, coin, pw); Finish(CartMessage(r2.items, r2.failed)); });
});
double slipped = credits * (1 + Tuning.BmAutoSwapSlip);
var sources = w.Balances.Keys.Where(a => !acc.Contains(a) && a != Assets.Ponero && Session.Price(a) > 0 && w.Avail(a) * Session.Price(a) >= slipped).ToList();
if (sources.Count == 0) return;
if (!_ckSwap.TryGetValue(seller, out var src) || !sources.Contains(src)) src = _ckSwap[seller] = sources[0];
r.AddLabel(L.T("bpay.swap.h", coin), "bpay_swap_dim");
r.BeginRow(4f);
foreach (var s in sources) { string sc = s; PledgerSkin.Pick(r, sc, sc == src, () => { _ckSwap[seller] = sc; Open(); }, _picked); }
r.End();
double needSrc = WalletWindow.RoundUp(slipped / Session.Price(src));
r.AddLabel(L.T("bpay.swap.need", WalletWindow.Amt(needSrc), src, (Tuning.BmAutoSwapSlip * 100).ToString("0", CultureInfo.InvariantCulture)), "bpay_swap_need_dim");
r.AddButton(L.T("bpay.swap.btn"), () => SwapGroup(seller, coin, src, credits, needSrc));
}
/// <summary>自动换币并结算一家商家:一次合约调用,先把别的币按滑点换成这家收的币,再下单。</summary>
private static void SwapGroup(int seller, string coin, string src, double credits, double needSrc)
{
var d = Session.Data;
var lines = _cart.Where(l => l.Seller == seller).ToList();
var mode = ModeOf(d, seller);
string summary = ShopName(d, seller) + " ×" + lines.Sum(l => l.Qty);
_checkout = false;
_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, src, WalletWindow.Amt(needSrc), coin),
Execute = pw =>
{
if (pw.Avail(src) + 1e-9 < needSrc) { Finish(L.T("msg.balance")); return; }
double got = credits / Session.Price(coin) * 1.002;
pw.Add(src, -needSrc); pw.Add(coin, got); pw.RecordBuy(coin, got, credits);
CandleChart.Trade(src, credits); CandleChart.Trade(coin, credits);
Log.Info($"自动换币:{needSrc:0.####} {src} → {got:0.####} {coin}({credits:0} 信用点等值)");
var r2 = ExecGroup(seller, lines, mode, coin, pw);
Finish(CartMessage(r2.items, r2.failed));
},
});
}
private static void Finish(string message)
{
_message = message;
Session.Save();
Open();
}
/// <summary>结算一家商家的购物车行:平台现货直接买、商家下订单,成功的行从购物车里拿掉。返回结果消息。</summary>
private static (int items, int failed) ExecGroup(int seller, List<CartLine> lines, PayMode mode, string asset, Wallet pw)
{
var d = Session.Data;
int items = 0, failed = 0;
foreach (var line in lines)
{
if (line.Good is BmOffer o)
{
int n = BuyOfferCore(o, line.Qty, asset, pw);
items += n;
if (n < line.Qty) failed += line.Qty - n;
}
else
{
var ord = d.Black.Place((BmListing)line.Good, line.Qty, mode, pw, asset, StoreStation.GetDayCounter(), Session.Price);
if (ord == null) failed += line.Qty; else items += ord.Count;
}
_cart.Remove(line);
}
return (items, failed);
}
private static string CartMessage(int items, int failed) => failed == 0 ? L.T("cart.done", items) : L.T("cart.partial", items, failed);
private static string MerchantLabel(BmSeller s, bool shortName = false) => L.T("merchant." + s.Type) + " " + (shortName ? "·" + s.Name.Substring(Math.Max(0, s.Name.Length - 4)) : s.Name);
private static int _merchant = -1;
private static readonly int[] Qtys = { 1, 5, 10 };
/// <summary>商家货架:这家的成交数、好评和到货天数,下面是这周的货;点"购买"进付款面板。</summary>
private static void MerchantShelf(CustomUIBuilder b, SaveData d)
{
var sellers = d.Black.Sellers;
var seller = sellers[_merchant];
int merchant = _merchant;
var goods = d.Black.Catalog.Where(l => l.Seller == merchant).Cast<BmGood>().ToList();
if (goods.Count == 0) { b.AddLabel(L.T("black.empty"), "shelf_empty_dim"); return; }
Shelf(b, goods, (c, g) =>
{
var listing = (BmListing)g;
GoodHead(c, g, L.T("black.stock", listing.Qty));
c.AddLabel(L.T("black.price", BlackMarket.Pay(BlackMarket.OrderTotal(listing.Price, 1), PayMode.Escrow)));
c.AddFlexibleSpace();
c.AddButton(L.T("black.buy"), () => AddToCart(merchant, listing));
}, 3);
}
private const int ShelfCols = 4;
private const float CardW = 172f, CardH = 268f;
/// <summary>货架:一行 4 张商品卡。</summary>
private static void Shelf(CustomUIBuilder b, System.Collections.Generic.List<BmGood> goods, Action<CustomUIBuilder, BmGood> card, int cols = ShelfCols)
{
for (int i = 0; i < goods.Count; i += cols)
{
b.BeginRow(6f);
for (int k = i; k < Math.Min(goods.Count, i + cols); k++)
{
var g = goods[k];
b.BeginPanel(CardW, CardH, TextAnchor.UpperCenter, 6f, 3f);
if (b.Last != null && b.Last.image != null)
{
b.Last.image.sprite = MachineryStyle.White;
b.Last.image.color = PledgerSkin.RowNormal;
}
card(b, g);
b.End();
}
b.End();
}
}
private static void GoodHead(CustomUIBuilder c, BmGood g, string line2)
{
var sprite = Icon(g.Item);
if (sprite != null) c.AddImage(sprite, 64f, 64f); else c.AddSpace(64f);
c.AddLabel("<b>" + ItemName(g.Item) + "</b>");
bool asic = g.Kind == GoodKind.Gpu && g.Model != null && Hardware.Specs.TryGetValue(g.Model, out var gs) && gs.Asic;
c.AddLabel(asic ? (g.Health >= 100 ? L.T("black.kind.asicnew") : L.T("black.kind.asic", (int)Math.Round(g.Health))) : g.Kind == GoodKind.Gpu ? (g.Health >= 100 ? L.T("black.kind.gpunew") : L.T("black.kind.gpu", (int)Math.Round(g.Health))) : L.T("black.kind." + g.Kind + (g.Kind == GoodKind.Contraband ? "." + g.Level : "")), "good_kind_dim");
if (!string.IsNullOrEmpty(line2)) c.AddLabel(line2, "good_line2_dim");
}
private static string ItemName(string id) => BlackDelivery.ItemName(id);
/// <summary>开账单:收款地址、币种、金额(按信用点等值折成币),付款在硬件钱包里做。</summary>
private static void BeginPay(string address, string summary, string asset, double credits, Action<Wallet> execute) =>
BillFor(address, summary, asset, credits / Session.Price(asset), execute);
private static void BillFor(string address, string summary, string asset, double amount, Action<Wallet> execute)
{
WalletWindow.Pending = new WalletWindow.Bill { Address = address, Summary = summary, Asset = asset, Amount = WalletWindow.RoundUp(amount), Execute = execute };
_message = null;
Open();
}
/// <summary>待付账单:收款地址和金额,去钱包里转账;也可以作废。</summary>
private static void PayPage(CustomUIBuilder b)
{
var p = WalletWindow.Pending;
b.AddHeader(L.T("black.pay.confirm"));
b.AddLabel(p.Summary);
b.BeginRow(8f, TextAnchor.MiddleLeft);
b.AddLabel(L.T("black.pay.to", p.Address), "pay_to_dim");
b.AddButton(L.T("hw.copy"), () => { Clipboard.Copy(p.Address); _message = L.T("hw.copied.addr", p.Address); Open(); });
b.End();
b.AddLabel(L.T("black.pay.amount", WalletWindow.Amt(p.Amount), p.Asset, Fmt.Credits(p.Amount * Session.Price(p.Asset))));
b.BeginRow(6f);
b.AddButton(L.T("black.pay.wallet"), () => WalletWindow.OpenForBill(p));
b.AddButton(L.T("black.pay.cancel"), () => { WalletWindow.Pending = null; _message = null; Open(); });
b.End();
}
private static void BuyOffer(BmOffer o, int count, string asset, Wallet pw)
{
int bought = BuyOfferCore(o, count, asset, pw);
if (bought == 0) { _message = L.T("msg.balance"); Open(); return; }
_message = L.T("black.bought", ItemName(o.Item) + (bought > 1 ? " ×" + bought : ""));
Session.Save();
Open();
}
/// <summary>平台现货买 count 件:每件从钱包扣币,货进储藏区。返回实际买到的件数。</summary>
private static int BuyOfferCore(BmOffer o, int count, string asset, Wallet pw)
{
var d = Session.Data;
string item = o.Item, model = o.Model, fake = o.FakeOf;
double health = o.Health;
int bought = 0;
for (int i = 0; i < count; i++)
{
if (!d.Black.Buy(o, pw, asset, Session.Price)) break;
Items.Give(BlackDelivery.Make(item, model, fake, health));
bought++;
}
return bought;
}
private static void PlaceOrder(BmListing l, int count, string asset, PayMode mode, Wallet pw)
{
var d = Session.Data;
var o = d.Black.Place(l, count, mode, pw, asset, StoreStation.GetDayCounter(), Session.Price);
if (o == null) { _message = L.T("msg.balance"); Open(); return; }
_message = L.T("black.ordered", ItemName(o.Item) + (o.Count > 1 ? " ×" + o.Count : ""), o.DueDay);
Session.Save();
Open();
}
private static void BeginEdit(EditPrice e, int initial)
{
_edit = e;
_editPrice = initial.ToString(CultureInfo.InvariantCulture);
_message = null;
Open();
}
private static int MinPrice(long refValue) => (int)Math.Ceiling(Tuning.BmSellMin * refValue - 1e-9);
private static int MaxPrice(long refValue) => (int)Math.Floor(Tuning.BmSellMax * refValue + 1e-9);
/// <summary>设定标价:显示参考价,档位按钮只是帮忙填价格,价格也可以手输;按 标价 / 参考价 的比例算成交和污点。</summary>
private static void EditPage(CustomUIBuilder b)
{
var e = _edit;
b.AddHeader(ItemName(e.Id));
b.AddLabel(L.T("black.edit.ref", e.Ref), "edit_ref_dim");
b.BeginPanel(RightW, -1f, TextAnchor.MiddleCenter, 0f, 0f);
if (b.Last != null && b.Last.image != null) _clear.Add(b.Last.image);
b.AddInput(L.T("black.edit.input"), _editPrice, s => _editPrice = s, "price", s => _editPrice = s);
b.End();
b.BeginRow(4f);
foreach (var m in Tuning.BmSellMults)
{
double mult = m;
string label = "×" + m.ToString("0.0", CultureInfo.InvariantCulture);
int targetPrice = BlackMarket.ListPriceOf(e.Ref, mult);
bool sel = int.TryParse((_editPrice ?? "").Trim(), NumberStyles.Integer, CultureInfo.InvariantCulture, out int curP) && curP == targetPrice;
PledgerSkin.Pick(b, label, sel, () => { _editPrice = targetPrice.ToString(CultureInfo.InvariantCulture); Open(); }, _picked);
}
b.End();
if (int.TryParse((_editPrice ?? "").Trim(), NumberStyles.Integer, CultureInfo.InvariantCulture, out int p) && p > 0)
b.AddLabel(L.T("black.edit.price", p, (p / (double)e.Ref).ToString("0.00", CultureInfo.InvariantCulture)));
b.BeginRow(6f);
b.AddButton(L.T("black.edit.ok"), () =>
{
if (!int.TryParse((_editPrice ?? "").Trim(), NumberStyles.Integer, CultureInfo.InvariantCulture, out int price) || price < MinPrice(e.Ref) || price > MaxPrice(e.Ref))
{ _message = L.T("black.edit.range", MinPrice(e.Ref), MaxPrice(e.Ref)); Open(); return; }
double mult = price / (double)e.Ref;
var d = Session.Data;
int fee = e.Sale != null ? BlackMarket.RepriceFee(price) : BlackMarket.ListFee(price);
_edit = null;
OpenPay(new PayDraft
{
Title = ItemName(e.Id) + L.T(e.Sale != null ? "pay.repricefee" : "pay.listfee", mult.ToString("0.00", CultureInfo.InvariantCulture)), Address = d.Address("platform"), Accepted = Merchants.PlatformAccepts,
Credits = (q, m) => fee,
Execute = (asset, q, m, pw) => { if (e.Sale != null) RepriceSale(e.Sale, mult, asset, pw); else ListItem(e.Item, mult, asset, pw); },
});
});
b.AddButton(L.T("black.edit.cancel"), () => { _edit = null; _message = null; Open(); });
b.End();
}
private static void RepriceSale(BmSale s, double mult, string asset, Wallet pw)
{
var d = Session.Data;
if (!d.Black.Reprice(s, mult, pw, asset, StoreStation.GetDayCounter(), Session.Price)) { _message = L.T("msg.balance"); Open(); return; }
_message = L.T("black.sell.repriced", ItemName(s.Item), s.ListPrice);
Session.Save();
Open();
}
private static void Delist(BmSale s)
{
var d = Session.Data;
d.Black.Delist(s);
Items.Give(BlackDelivery.Make(s.Item, s.Model, s.FakeOf, s.Health));
Session.Save();
Open();
}
/// <summary>我的订单与论坛情报。</summary>
private static void OrdersPage(CustomUIBuilder b, int day)
{
var d = Session.Data;
b.BeginRow(8f, TextAnchor.UpperLeft);
b.BeginPanel(SellerW + 8f + 150f, -1f, TextAnchor.UpperLeft, 4f, 4f);
_clear.Add(b.Last.image);
b.AddHeader(L.T("black.orders.in"));
if (d.Black.Orders.Count == 0) b.AddLabel(L.T("black.mine.none"), "orders_in_none_dim");
foreach (var o in d.Black.Orders.OrderByDescending(x => x.PlacedDay))
b.AddLabel(L.T("black.mine.line", ItemName(o.Item) + (o.Count > 1 ? " ×" + o.Count : ""), BlackDelivery.DisplayName(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));
b.End();
b.BeginPanel(ShelfW - 150f, -1f, TextAnchor.UpperLeft, 4f, 4f);
_clear.Add(b.Last.image);
b.AddHeader(L.T("black.orders.out"));
if (d.Black.Sales.Count == 0) b.AddLabel(L.T("black.mine.none"), "orders_out_none_dim");
foreach (var sl in d.Black.Sales.Where(x => x.State != SaleState.Delisted || x.Why == "fake").OrderByDescending(x => x.ListedDay))
{
var sale = sl;
string st = sale.State == SaleState.Sold ? L.T("black.sale.Sold", sale.PayDay) : L.T("black.sale." + sale.State + (sale.State == SaleState.Delisted && sale.Why != null ? "." + sale.Why : ""));
b.AddLabel(L.T("black.mine.sale", ItemName(sale.Item), sale.Ref, sale.ListPrice, sale.Mult.ToString("0.00", CultureInfo.InvariantCulture), st));
if (sale.State == SaleState.Listed)
{
b.BeginRow(6f);
b.AddButton(L.T("black.sell.reprice"), () => BeginEdit(new EditPrice { Sale = sale, Id = sale.Item, Ref = sale.Ref }, sale.ListPrice));
b.AddButton(L.T("black.sell.delist"), () => Delist(sale));
b.End();
}
}
b.End();
b.End();
}
/// <summary>隐私币兑换:Web3 交易页里的一个通用兑换块,带"隐私币"标签。要黑市声望,有每日额度;兑入时这部分价值的污点被清除。</summary>
private static void PoneroBlock(CustomUIBuilder r)
{
var d = Session.Data;
var w = d.Hot;
int day = StoreStation.GetDayCounter();
r.AddSeparator();
r.AddLabel(L.T("black.desk.pair", Fmt.Num(Session.Price(Assets.Ponero))));
r.AddLabel(L.T("black.desk.left", Fmt.Credits(d.Black.DeskLeft(day)), Fmt.Credits(Tuning.PoneroDailyCap)), "desk_left_dim");
r.BeginRow(4f);
foreach (var a in new[] { Assets.PSC, Assets.CRT, Assets.CRA })
{
var asset = a;
PledgerSkin.Pick(r, asset, asset == _deskAsset, () => { _deskAsset = asset; Open(); }, _picked);
}
r.End();
r.BeginRow(6f);
r.AddButton(L.T("black.desk.in"), () => { _deskIn = true; Swap(); });
r.AddButton(L.T("black.desk.out"), () => { _deskIn = false; Swap(); });
r.End();
void Swap()
{
if (!ReadAmount(out double dv)) return;
bool into = _deskIn; string other = _deskAsset;
string asset = into ? other : Assets.Ponero;
_web3Tab = Web3Tab.Trade;
BillFor(d.Address("desk"), L.T("pay.swap", Fmt.Num(dv), asset), asset, dv, pw => DeskSwap(dv, into, other, pw));
}
}
private static void DeskSwap(double v, bool into, string other, Wallet pw)
{
var d = Session.Data;
_deskIn = into; _deskAsset = other;
int day = StoreStation.GetDayCounter();
double got = _deskIn ? d.Black.ToPonero(pw, _deskAsset, v, day, Session.Price) : d.Black.FromPonero(pw, _deskAsset, v, day, Session.Price);
if (got < 0) { _message = L.T("black.desk.fail"); Open(); return; }
{ double cr = v * Session.Price(_deskIn ? _deskAsset : Assets.Ponero); CandleChart.Trade(Assets.Ponero, cr); CandleChart.Trade(_deskAsset, cr); }
// 从 Ponero 出来的币来源不明:出金审查按"来自混币器"加重
if (!_deskIn) d.Mixer.LastReturnDay = day;
_message = _deskIn ? L.T("msg.trade", Fmt.Num(v), _deskAsset, Fmt.Num(got), Assets.Ponero) : L.T("msg.trade", Fmt.Num(v), Assets.Ponero, Fmt.Num(got), _deskAsset);
Session.Save();
Open();
}
private enum Web3Tab { Trade, Stake, Airdrop, Predict, Ponzi, Mining, Mixer, Black }
private static Web3Tab _web3Tab = Web3Tab.Trade;
private static string _mixAsset = Assets.PSC;
private static int _mixRounds = 2;
private static readonly float[] MixW = { 70f, 100f, 100f, 80f };
private static readonly TextAnchor[] MixAligns = { TextAnchor.MiddleLeft, TextAnchor.MiddleRight, TextAnchor.MiddleRight, TextAnchor.MiddleRight };
/// <summary>混币器:把币存进去,几轮之后提回同一个地址,污点降低,每轮扣手续费。</summary>
private static void MixerPage(CustomUIBuilder b)
{
var d = Session.Data;
var w = d.Hot;
int day = StoreStation.GetDayCounter();
Columns(b, l =>
{
l.AddHeader(L.T("mixer.h"));
l.AddLabel(d.Story.MixerClosed ? L.T("mixer.status.closed") : d.Mixer.Sanctioned(day) ? L.T("mixer.sanctioned", d.Mixer.SanctionedUntil - day + 1) : L.T("mixer.ok"), d.Story.MixerClosed || d.Mixer.Sanctioned(day) ? "mixer_status_note" : "mixer_status_dim");
if (w.Taint > 0.5) l.AddLabel(L.T("wallet.taint", Fmt.Credits(w.Taint)), "taint_note");
l.AddSeparator();
l.AddHeader(L.T("mixer.pending"));
if (d.Mixer.Pending.Count == 0) l.AddLabel(L.T("mixer.none"), "mixer_none_dim");
else
{
TableHeader(l, MixW.Sum() + 8f, MixW, MixAligns, L.T("col.asset"), L.T("col.amount"), L.T("mixer.col.taint"), L.T("mixer.col.ready"));
foreach (var p in d.Mixer.Pending)
TableRow(l, MixW.Sum() + 8f, MixW, MixAligns, new[] { p.Asset, Fmt.Num(p.Amount), Fmt.Credits(p.Taint), L.T("mixer.day", p.ReadyDay) });
}
}, r =>
{
r.AddHeader(L.T("mixer.deposit"));
if (d.Story.MixerClosed) { r.AddLabel(L.T("mixer.closed"), "mixer_closed_note"); return; }
r.BeginRow(4f);
foreach (var a in new[] { Assets.PSC, Assets.CRC, Assets.CRT, Assets.CRA })
{
var asset = a;
PledgerSkin.Pick(r, asset, asset == _mixAsset, () => { _mixAsset = asset; Open(); }, _picked);
}
r.End();
r.AddLabel(L.T("mixer.balance", _mixAsset, Fmt.Num(w.Avail(_mixAsset))));
AmountRow(r);
r.AddLabel(L.T("mixer.rounds"));
r.BeginRow(4f);
for (int n = 1; n <= Tuning.MixerMaxRounds; n++)
{
int rounds = n;
PledgerSkin.Pick(r, rounds.ToString(), rounds == _mixRounds, () => { _mixRounds = rounds; Open(); }, _picked);
}
r.End();
if (double.TryParse((_amount ?? "").Trim(), NumberStyles.Float, CultureInfo.InvariantCulture, out double v) && v > 0)
{
double rate = Mixer.RoundRate(v * Session.Price(_mixAsset));
r.AddLabel(L.T("mixer.preview", Fmt.Num(v * Math.Pow(1 - Mixer.Fee, _mixRounds)), _mixAsset, (int)Math.Round((1 - Math.Pow(1 - rate, _mixRounds)) * 100), day + _mixRounds), "mixer_preview_dim");
}
r.AddButton(L.T("mixer.go"), () =>
{
if (!ReadAmount(out double mv)) return;
string asset = _mixAsset; int rounds = _mixRounds;
BillFor(d.Address("mixer"), L.T("pay.mix", Fmt.Num(mv), asset), asset, mv, pw => MixDeposit(mv, asset, rounds, pw));
});
});
}
private static void MixDeposit(double v, string asset, int rounds, Wallet pw)
{
var d = Session.Data;
int day = StoreStation.GetDayCounter();
if (pw.Avail(asset) + 1e-9 < v) { _message = L.T("msg.balance"); Open(); return; }
var dep = d.Mixer.Deposit(pw, asset, v, rounds, day, Session.Price);
_message = dep == null ? L.T("mixer.min", Tuning.MixerMin) : L.T("mixer.done", Fmt.Num(v), asset, dep.ReadyDay);
Session.Save();
Open();
}
private static readonly float[] StakeW = { 110f, 90f, 200f };
private static readonly TextAnchor[] StakeAligns = { TextAnchor.MiddleLeft, TextAnchor.MiddleRight, TextAnchor.MiddleRight };
/// <summary>质押页:革命军 CRA 质押池(日息、解锁排队)和平衡代币 SPARK 的铸销。都是合约调用,要在钱包上签名。</summary>
private static void StakePage(CustomUIBuilder b)
{
var d = Session.Data;
var st = d.Stable;
var w = d.Hot;
int day = StoreStation.GetDayCounter();
Columns(b, l =>
{
l.AddHeader(L.T("stake.h"));
l.AddLabel(L.T("stake.rate", (st.CraRate * 100).ToString("0.0", CultureInfo.InvariantCulture), Tuning.CraUnlockDays) + (st.CraSubsidyLow ? " " + L.T("stake.low") : ""), st.CraSubsidyLow ? "stake_low_note" : "stake_rate_dim");
l.AddLabel(L.T("stake.cra", Fmt.Num(st.Cra), StableStatus(st, Assets.CRA, day)));
l.AddSeparator();
if (d.Staking.Lots.Count == 0) l.AddLabel(L.T("stake.none"), "stake_none_dim");
else
{
TableHeader(l, StakeW.Sum() + 8f, StakeW, StakeAligns, L.T("stake.col.wallet"), L.T("stake.col.amount"), L.T("stake.col.queue"));
foreach (var lot in d.Staking.Lots)
{
var ow = d.Own(lot.Wallet);
string queue = lot.Unlocking.Count == 0 ? "—" : string.Join(" ", lot.Unlocking.Select(u => Fmt.Num(u.Value) + L.T("stake.day", u.Key)));
TableRow(l, StakeW.Sum() + 8f, StakeW, StakeAligns,
new[] { ow != null ? WalletWindow.NameOf(ow) : lot.Wallet, Fmt.Num(lot.Amount), queue });
}
}
l.AddSeparator();
l.AddLabel(L.T("spark.price", Fmt.Num(st.Spark)));
}, r =>
{
r.AddHeader(L.T("stake.ops", WalletWindow.NameOf(w)));
r.AddLabel(L.T("stake.avail", Fmt.Num(w.Avail(Assets.CRA)), Fmt.Num(d.Staking.Of(w.Address)?.Amount ?? 0)), "stake_avail_dim");
r.AddLabel(L.T("stake.avail2", Fmt.Num(w.Avail(Assets.SPARK))), "stake_avail2_dim");
AmountRow(r);
r.AddSpace(4f);
r.BeginRow(6f);
r.AddButton(L.T("stake.do"), () => StakeCall(true));
r.AddButton(L.T("stake.undo"), () => StakeCall(false));
r.End();
r.AddSeparator();
r.AddHeader(L.T("spark.h"));
r.BeginRow(6f);
r.AddButton(L.T("spark.mint"), () => SparkCall(true));
r.AddButton(L.T("spark.burn"), () => SparkCall(false));
r.End();
});
}
/// <summary>手续费用钱包里价值最高的那种币付。</summary>
private static string BestFeeAsset(Wallet w) =>
w.Balances.Where(k => k.Value > 1e-9 && Session.Price(k.Key) > 0).OrderByDescending(k => k.Value * Session.Price(k.Key)).Select(k => k.Key).FirstOrDefault() ?? Assets.PSC;
private static void StakeCall(bool into)
{
if (!ReadAmount(out double v)) return;
var d = Session.Data;
var w = d.Hot;
if (into ? w.Avail(Assets.CRA) + 1e-9 < v : (d.Staking.Of(w.Address)?.Amount ?? 0) + 1e-9 < v) { _message = L.T("msg.balance"); Open(); return; }
_message = L.T("hw.wait");
Open();
WalletWindow.OpenForBill(new WalletWindow.Bill
{
Contract = true, Asset = Assets.CRA, Amount = v, Spend = into ? v : 0, FeeAsset = into ? null : BestFeeAsset(w),
Summary = L.T(into ? "stake.sum.in" : "stake.sum.out", Fmt.Num(v), Tuning.CraUnlockDays),
Execute = pw =>
{
int day = StoreStation.GetDayCounter();
bool ok = into ? d.Staking.Stake(pw, v, Session.Price) : d.Staking.RequestUnstake(pw.Address, v, day);
_message = ok ? L.T(into ? "stake.done.in" : "stake.done.out", Fmt.Num(v), day + Tuning.CraUnlockDays) : L.T("msg.balance");
Session.Save();
Open();
},
});
}
private static void SparkCall(bool mint)
{
if (!ReadAmount(out double v)) return;
var d = Session.Data;
var w = d.Hot;
string pay = mint ? Assets.CRA : Assets.SPARK, get = mint ? Assets.SPARK : Assets.CRA;
if (w.Avail(pay) + 1e-9 < v) { _message = L.T("msg.balance"); Open(); return; }
double quote = mint ? v * d.Stable.Cra / d.Stable.Spark * (1 - Tuning.SparkFee) : v * d.Stable.Spark / d.Stable.Cra * (1 - Tuning.SparkFee);
_message = L.T("hw.wait");
Open();
WalletWindow.OpenForBill(new WalletWindow.Bill
{
Contract = true, Asset = pay, Amount = v, Summary = L.T("hw.swap", Fmt.Num(v), pay, Fmt.Num(quote), get),
Execute = pw =>
{
double got = mint ? d.Stable.Mint(pw, v) : d.Stable.Burn(pw, v);
if (got >= 0) { double cr = v * Session.Price(pay); CandleChart.Trade(Assets.SPARK, cr); CandleChart.Trade(Assets.CRA, cr); }
_message = got < 0 ? L.T("msg.balance") : L.T("msg.trade", Fmt.Num(v), pay, Fmt.Num(got), get);
Session.Save();
Open();
},
});
}
private static bool frozenPay(Wallet w, string asset) => w.Frozen.ContainsKey(asset);
private static string AvgText(Wallet w, string asset) => w.AvgBuy(asset) is double a ? Fmt.Num(a) : L.T("avg.none");
private static void ReportPage(CustomUIBuilder b)
{
var r = Session.Data.LastNight;
if (r == null) { b.AddLabel(L.T("report.none"), "report_none_dim"); return; }
b.AddHeader(L.T("report.day", r.Day));
b.AddLabel(L.T("report.rigs", r.PoweredRigs, r.Rigs), "report_dim");
b.AddLabel(L.T("report.hash", Fmt.Num(r.Hash * 10), Fmt.Num(r.NetworkHash * 10)), "report_dim");
b.AddLabel(L.T("report.mined", Fmt.Num(r.Mined), Fmt.Credits(r.Mined * r.Price)));
b.AddLabel(L.T("report.energy", Fmt.Num(r.Kj)), "report_dim");
b.AddLabel(L.T("report.burnt", r.Burnt.Count), r.Burnt.Count > 0 ? "report_burnt_note" : "report_burnt_dim");
foreach (var n in r.Burnt) b.AddLabel(" " + n, "report_burnt_note");
b.AddLabel(L.T("report.price", Fmt.Num(r.Price)), "report_dim");
}
private static bool ReadAmount(out double v)
{
if (!double.TryParse((_amount ?? "").Trim(), NumberStyles.Float, CultureInfo.InvariantCulture, out v) || v <= 0)
{
_message = L.T("msg.amount");
Open();
return false;
}
return true;
}
private static void Deposit()
{
if (!ReadAmount(out double v)) return;
int credits = (int)Math.Floor(v);
if (credits <= 0 || PlayerStore.Instance.playerCash < credits) { _message = L.T("msg.balance"); Open(); return; }
ModHelper.AddCredit(-credits, false);
var w = Session.Data.Poinbase;
w.Add(Assets.CRC, credits / Session.Data.Stable.Crc);
w.RecordBuy(Assets.CRC, credits / Session.Data.Stable.Crc, credits);
w.Basis += credits;
_message = L.T("msg.deposit", credits);
Session.Save();
Open();
}
private static void Withdraw()
{
if (!ReadAmount(out double v)) return;
var d = Session.Data;
if (d.Perps.Debt > 0) { _message = L.T("perp.debt.block"); Open(); return; }
if (d.Poinbase.Avail(Assets.CRC) + 1e-9 < v) { _message = L.T("msg.balance"); Open(); return; }
var r = d.Exchange.Cashout(d.Poinbase, v, Session.Price, StoreStation.GetDayCounter(), Session.Rent(), d.Mixer.RecentlyMixed(StoreStation.GetDayCounter()), d.Rng);
int got = (int)Math.Floor(r.Received - r.Tax);
ModHelper.AddCredit(got, true);
_message = L.T("msg.cashout", got, Fmt.Credits(r.Tax));
if (r.Outcome == AmlOutcome.Light) _message += "\n" + L.T("msg.aml_light", Fmt.Credits(r.Confiscated));
if (r.Outcome == AmlOutcome.Heavy)
{
_message += "\n" + L.T("msg.aml_heavy", Fmt.Credits(r.Confiscated));
PlayerStore.Instance.secData.CommitCrime("FENCING", (int)Math.Round(r.Crime));
}
Session.Save();
Open();
}
/// <summary>交易:Poinbase 是托管账户,直接成交;DEX 是合约调用,要在硬件钱包上签名。</summary>
private static void Trade(Pool p, bool buy)
{
if (!ReadAmount(out double v)) return;
var w = p.Venue == Venue.Dex ? Session.Data.Hot : Session.Data.Poinbase;
string pay = buy ? p.Quote : p.Base, get = buy ? p.Base : p.Quote;
if (w.Avail(pay) + 1e-9 < v) { _message = L.T(frozenPay(w, pay) ? "msg.frozen" : "msg.balance"); Open(); return; }
if (p.Venue != Venue.Dex) { DoTrade(p, buy, v, w); return; }
double quote = buy ? v / p.Price : v * p.Price;
_message = L.T("hw.wait");
Open();
WalletWindow.OpenForBill(new WalletWindow.Bill { Contract = true, Asset = pay, Amount = v, Summary = L.T("hw.swap", Fmt.Num(v), pay, Fmt.Num(quote), get), Execute = pw => DoTrade(p, buy, v, pw) });
}
private static void DoTrade(Pool p, bool buy, double v, Wallet w)
{
string pay = buy ? p.Quote : p.Base, get = buy ? p.Base : p.Quote;
if (w.Avail(pay) + 1e-9 < v) { _message = L.T(frozenPay(w, pay) ? "msg.frozen" : "msg.balance"); Open(); return; }
double before = p.Price;
double got = buy ? p.BuyBase(v) : p.SellBase(v);
if (p == Session.Data.Market.Main) { Session.Data.Market.PlayerPush += Math.Log(p.Price / before); Log.Info($"现货推价:累计 {Session.Data.Market.PlayerPush:0.000}"); }
double spent = v * Session.Price(pay);
CandleChart.Trade(pay, spent); CandleChart.Trade(get, spent);
w.Add(pay, -v);
w.Add(get, got);
w.RecordBuy(get, got, spent);
_message = L.T("msg.trade", Fmt.Num(v), pay, Fmt.Num(got), get);
Session.Save();
Open();
}
}
}