From f93dbc6eecfa3cedee84a216823584723d2de0a3 Mon Sep 17 00:00:00 2001 From: ZHU MINCHAO <48154958+MinchaoZhu@users.noreply.github.com> Date: Tue, 3 Nov 2026 13:37:48 +0800 Subject: [PATCH] Cards: appearance follows wear, hash depends on fan and contacts, seller confrontation Fan, contact and scratch levels are recomputed from health and a per-card seed, so a card looks older as it wears. Fan and contacts scale hashrate. Each inspection-tray detail now maps to exactly one dimension value. Quoting dealers price the card as if its condition were fine; the player points out fan, contacts, bracket, print or sticker in the tray to take the matching discount or expose a fake (a wrong accusation costs reputation like the vanilla confrontation). Unowned cards show a coarse grade and rated specs. Co-Authored-By: Claude Sonnet 5.5 --- src/Decentralized.Domain/CardLook.cs | 97 ++++++++++++++----- src/Decentralized.Domain/Mining.cs | 8 +- src/Decentralized.Domain/UsedDealer.cs | 16 ++- src/Decentralized/GpuInspectView.cs | 78 +++++++++++++-- src/Decentralized/Items.cs | 67 ++++++++++--- .../ModFiles/Localization/en.csv | 13 ++- .../ModFiles/Localization/zh.csv | 13 ++- src/Decentralized/UsedDealerClient.cs | 22 +++-- tests/Decentralized.Domain.Tests/Program.cs | 41 ++++++-- 9 files changed, 279 insertions(+), 76 deletions(-) diff --git a/src/Decentralized.Domain/CardLook.cs b/src/Decentralized.Domain/CardLook.cs index e4efa64..d703c1b 100644 --- a/src/Decentralized.Domain/CardLook.cs +++ b/src/Decentralized.Domain/CardLook.cs @@ -22,6 +22,36 @@ namespace Decentralized.Domain public const int Variants = 3; + /// 每张卡自己的随机种子:老化类维度(风扇、金手指、挡板划痕)由它和当前健康度决定,每晚损耗后重新算, + /// 所以越旧的卡看着越旧。为 0 时表示外观是写死的(测试用)。 + public uint Seed; + + private static double U(uint seed, int salt) + { + uint x = seed * 2654435761u + (uint)salt * 40503u + 12345u; + x ^= x >> 15; x *= 2246822519u; x ^= x >> 13; x *= 3266489917u; x ^= x >> 16; + return (x >> 8) * (1.0 / (1 << 24)); + } + + /// 按当前健康度重新算出的外观:磨损度 = 1 − 健康度 / 100,再加上这张卡自己的偏差(±0.15)。 + /// 风扇磨损 ≥ 0.25 积灰、≥ 0.55 缺角;金手指 ≥ 0.30 发暗、≥ 0.60 氧化;挡板磨损 ≥ 0.45 出划痕(接口数不对是假卡的固定特征,不变)。 + public CardLook Appearance(double health) + { + if (Seed == 0) return this; + double worn = 1 - Math.Max(0, Math.Min(100, health)) / 100.0; + var l = this; + double fan = worn + (U(Seed, 1) - 0.5) * 0.3, fin = worn + (U(Seed, 2) - 0.5) * 0.3, brk = worn + (U(Seed, 3) - 0.5) * 0.3; + l.Fan = fan >= 0.55 ? 2 : fan >= 0.25 ? 1 : 0; + l.Fingers = fin >= 0.60 ? 2 : fin >= 0.30 ? 1 : 0; + if (Bracket != 2) l.Bracket = brk >= 0.45 ? 1 : 0; + return l; + } + + private static readonly double[] FanHash = { 1.00, 0.97, 0.92 }, FingerHash = { 1.00, 0.98, 0.94 }; + + /// 算力倍数:风扇积灰 ×0.97、缺角 ×0.92(降频);金手指发暗 ×0.98、氧化 ×0.94(接触不良掉速)。 + public double HashFactor => FanHash[Fan] * FingerHash[Fingers]; + /// 五位数字,存在物品标签上:风扇、金手指、挡板、丝印、贴纸。 public string Encode() => $"{Fan}{Fingers}{Bracket}{Print}{Sticker}"; @@ -37,7 +67,15 @@ namespace Decentralized.Domain private static readonly double[] FingerValue = { 1.00, 0.96, 0.88 }, FingerDeath = { 1.00, 1.10, 1.30 }; /// 外观对价值的折扣:风扇积灰 −4%、缺角 −12%;金手指发暗 −4%、氧化 −12%;挡板划痕 −2%;封条缺失(开过封)−6%。 - public double ValueFactor => FanValue[Fan] * FingerValue[Fingers] * (Bracket == 1 ? 0.98 : 1.0) * (Sticker == 2 ? 0.94 : 1.0); + public double ValueFactor => ValueFactorOf(AllDims); + + /// 外观折扣的维度位:1 风扇、2 金手指、4 挡板划痕、8 封条缺失。 + public const int DimFan = 1, DimFingers = 2, DimScratch = 4, DimSeal = 8, AllDims = 15; + + /// 只计入 mask 里的维度的折扣:贩子报价时先装作这些问题都没有,玩家指出一项才扣一项。 + public double ValueFactorOf(int mask) => + ((mask & DimFan) != 0 ? FanValue[Fan] : 1.0) * ((mask & DimFingers) != 0 ? FingerValue[Fingers] : 1.0) + * ((mask & DimScratch) != 0 && Bracket == 1 ? 0.98 : 1.0) * ((mask & DimSeal) != 0 && Sticker == 2 ? 0.94 : 1.0); /// 每晚损耗倍数:风扇积灰散热差 ×1.1,缺角 ×1.3。 public double WearFactor => FanWear[Fan]; @@ -54,36 +92,29 @@ namespace Decentralized.Domain /// public static CardLook Generate(Rng rng, double health, bool fake) { - double worn = 1 - Math.Max(0, Math.Min(100, health)) / 100.0; - var look = new CardLook + var look = new CardLook { Seed = rng.NextUInt() | 1, Sticker = rng.Chance(fake ? 0.35 : 0.15 + 0.30 * (1 - Math.Max(0, Math.Min(100, health)) / 100.0)) ? 2 : 0 }; + if (fake) { - Fan = Pick(rng, 0.10 + 0.55 * worn, 0.35 * worn), - Fingers = Pick(rng, 0.08 + 0.55 * worn, 0.40 * worn), - Bracket = rng.Chance(0.10 + 0.50 * worn) ? 1 : 0, - Sticker = rng.Chance(fake ? 0.35 : 0.15 + 0.30 * worn) ? 2 : 0, - }; - if (!fake) return look; - int signs = rng.Chance(0.55) ? 1 : rng.Chance(0.6) ? 2 : 3; - var order = new[] { 0, 1, 2 }; - for (int i = order.Length - 1; i > 0; i--) - { - int j = rng.Range(0, i); - (order[i], order[j]) = (order[j], order[i]); - } - for (int k = 0; k < signs; k++) - { - switch (order[k]) + int signs = rng.Chance(0.55) ? 1 : rng.Chance(0.6) ? 2 : 3; + var order = new[] { 0, 1, 2 }; + for (int i = order.Length - 1; i > 0; i--) { - case 0: look.Bracket = 2; break; - case 1: look.Print = rng.Chance(0.6) ? 1 : 2; break; - default: look.Sticker = 1; break; + int j = rng.Range(0, i); + (order[i], order[j]) = (order[j], order[i]); + } + for (int k = 0; k < signs; k++) + { + switch (order[k]) + { + case 0: look.Bracket = 2; break; + case 1: look.Print = rng.Chance(0.6) ? 1 : 2; break; + default: look.Sticker = 1; break; + } } } - return look; + return look.Appearance(health); } - /// p1 的概率取 1,再以 p2 的概率升到 2。 - private static int Pick(Rng rng, double p1, double p2) => rng.Chance(p1) ? (rng.Chance(p2 / Math.Max(p1, 1e-9)) ? 2 : 1) : 0; } /// @@ -101,7 +132,21 @@ namespace Decentralized.Domain } /// 真实价值(芯片型号 + 健康度 + 外观折扣)。 - public static long True(Unit u) => Math.Max(1, (long)Math.Round(Of(u.FakeOf ?? u.Model, u.Health) * u.Look.ValueFactor)); + public static long True(Unit u) => Math.Max(1, (long)Math.Round(Of(u.FakeOf ?? u.Model, u.Health) * u.Appearance.ValueFactor)); + + public const double ScamDiscount = 0.90; + + /// + /// 贩子的报价:装作成色没问题(外观折扣只计玩家已指出的维度),在此之上加价 markupPct%。 + /// 没拆穿的假卡按标称型号的价再打九折;拆穿之后按真实芯片算,和真卡一样。 + /// + public static long Quote(Unit u, int markupPct, int pointedDims, bool exposed) + { + bool fakeHidden = u.FakeOf != null && !exposed; + double v = Of(fakeHidden ? u.Model : (u.FakeOf ?? u.Model), u.Health) * u.Appearance.ValueFactorOf(pointedDims); + v *= fakeHidden ? ScamDiscount : 1 + markupPct / 100.0; + return Math.Max(1, (long)Math.Round(v)); + } /// 标称价:贩子报的价,按标称型号算;二手卡也折旧,但只按贩子"自称"的健康度。 public static long Quoted(Unit u, double claimedHealth) => Of(u.Model, claimedHealth); diff --git a/src/Decentralized.Domain/Mining.cs b/src/Decentralized.Domain/Mining.cs index 0c2c8d2..9b82b86 100644 --- a/src/Decentralized.Domain/Mining.cs +++ b/src/Decentralized.Domain/Mining.cs @@ -86,6 +86,8 @@ namespace Decentralized.Domain public bool Refurbished; /// 外观;新卡(正规渠道)没有记录,按全新画。 public CardLook Look; + /// 按当前健康度算出的外观。 + public CardLook Appearance => Look.Appearance(Health); public HardwareSpec Label => Hardware.Specs[Model]; public HardwareSpec Chip => Hardware.Specs[FakeOf ?? Model]; @@ -94,7 +96,7 @@ namespace Decentralized.Domain public double Hash(ModuleBonus b) { if (Dead) return 0; - return Chip.Hash * (Health >= 50 ? 1.0 : 0.6 + 0.4 * Health / 50) * b.Hash; + return Chip.Hash * (Health >= 50 ? 1.0 : 0.6 + 0.4 * Health / 50) * b.Hash * Appearance.HashFactor; } public double Kj(ModuleBonus b) => Dead ? 0 : Chip.Kj * b.Kj; @@ -103,14 +105,14 @@ namespace Decentralized.Domain public double DeathChance(ModuleBonus b) { double p = Tuning.DeathBase + Tuning.DeathWorn * Math.Pow(1 - Health / 100, 2); - return p * Label.DeathFactor * (FakeOf != null ? Tuning.FakeDeathFactor : 1) * b.Death * Look.DeathFactor; + return p * Label.DeathFactor * (FakeOf != null ? Tuning.FakeDeathFactor : 1) * b.Death * Appearance.DeathFactor; } /// 一夜的损耗与暴毙判定;返回是否在今晚暴毙。 public bool Night(ModuleBonus b, Rng rng) { if (Dead) return false; - Health = Math.Max(0, Health - Label.WearFactor * b.Wear * Look.WearFactor); + Health = Math.Max(0, Health - Label.WearFactor * b.Wear * Appearance.WearFactor); if (rng.Chance(DeathChance(b))) { Dead = true; diff --git a/src/Decentralized.Domain/UsedDealer.cs b/src/Decentralized.Domain/UsedDealer.cs index c015c5b..291282e 100644 --- a/src/Decentralized.Domain/UsedDealer.cs +++ b/src/Decentralized.Domain/UsedDealer.cs @@ -7,18 +7,20 @@ namespace Decentralized.Domain /// 二手显卡贩子的类型:老实人按真实价卖;中间商加价;骗子夹带假卡,按标称型号的价卖。 public enum DealerKind { Honest, Middleman, Scammer } - /// 贩子带来的一张卡: 是柜台上相对真实价值的加价百分比(骗子卖假卡时是正的,按标称型号报价再打九折)。 + /// 贩子带来的一张卡。老实人不报价(按真实价值卖);其他贩子 为真,按 报价, + /// 是加价百分比,玩家指出外观问题或假货迹象就按维度扣回去。 public sealed class UsedCard { public string Model; public string FakeOf; public double Health; public int MarkupPct; + public bool Quoted; } public static class UsedDealers { - public const double MiddlemanMarkup = 0.08, ScamDiscount = 0.90; + public const double MiddlemanMarkup = 0.08, ScamDiscount = CardValue.ScamDiscount; public const double FakeShareMiddleman = 0.10, FakeShareScammer = 0.45; /// 今天来的贩子的类型:骗子越往后越多。 @@ -49,13 +51,9 @@ namespace Decentralized.Domain var label = pool[rng.Range(0, pool.Length - 1)]; var card = new UsedCard { Model = label, Health = 25 + rng.NextDouble() * 60 }; var cheaper = Hardware.Specs.Values.Where(s => !s.Asic && s.Price * 2 <= Hardware.Specs[label].Price).Select(s => s.Model).ToArray(); - if (cheaper.Length > 0 && rng.Chance(share)) - { - card.FakeOf = cheaper[rng.Range(0, cheaper.Length - 1)]; - var u = new Unit { Model = card.Model, FakeOf = card.FakeOf, Health = card.Health }; - card.MarkupPct = (int)Math.Round((CardValue.Of(label, card.Health) * ScamDiscount / CardValue.True(u) - 1) * 100); - } - else if (kind == DealerKind.Middleman) card.MarkupPct = (int)Math.Round(MiddlemanMarkup * 100); + if (cheaper.Length > 0 && rng.Chance(share)) card.FakeOf = cheaper[rng.Range(0, cheaper.Length - 1)]; + card.Quoted = kind != DealerKind.Honest; + card.MarkupPct = kind == DealerKind.Middleman ? (int)Math.Round(MiddlemanMarkup * 100) : 0; cards.Add(card); } return cards; diff --git a/src/Decentralized/GpuInspectView.cs b/src/Decentralized/GpuInspectView.cs index f4499cd..f30c921 100644 --- a/src/Decentralized/GpuInspectView.cs +++ b/src/Decentralized/GpuInspectView.cs @@ -15,6 +15,10 @@ namespace Decentralized private static GpuInspectView instance; private RectTransform root, cardRoot; private Button flip; + private readonly Button[] point = new Button[5]; + private RectTransform pointRow; + private static readonly int[] PointBits = { Items.PointFan, Items.PointFingers, Items.PointBracket, Items.PointPrint, Items.PointSticker }; + private static readonly string[] PointKeys = { "point.fan", "point.fingers", "point.bracket", "point.print", "point.sticker" }; private TextMeshProUGUI flipText; private GameItem shown; private string shownSig; @@ -47,6 +51,7 @@ namespace Decentralized { if (root == null) Build(m); if (!root.gameObject.activeSelf) root.gameObject.SetActive(true); + UpdatePointButtons(item); var keys = new List(); string sig = Items.LookKeys(item, keys) + "|" + string.Join(",", keys); if (item != shown || sig != shownSig) @@ -64,24 +69,24 @@ namespace Decentralized var o = new List(); var u = Items.ToUnit(item); if (u.Dead) return o; - var l = u.Look; + var l = u.Appearance; string P = "gpu_big_ov_"; + // 每个维度的取值对应一种细节,一一对应,不靠组合触发 if (!Items.IsBack(item)) { - if (l.Fan == 1) o.Add(P + (l.Fingers > 0 ? "fan_dust2" : "fan_dust1")); - if (l.Fan == 2) { o.Add(P + "fan_chip"); if (l.Fingers == 2) o.Add(P + "fan_oil"); } + if (l.Fan == 1) o.Add(P + "fan_dust2"); + if (l.Fan == 2) { o.Add(P + "fan_dust1"); o.Add(P + "fan_chip"); } if (l.Fingers == 1) o.Add(P + "fin_dim"); if (l.Fingers == 2) o.Add(P + "fin_oxide"); - if (l.Bracket == 1) { o.Add(P + "brk_scratch"); if (l.Fan > 0) o.Add(P + "shroud_chip"); } + if (l.Bracket == 1) o.Add(P + "brk_scratch"); if (l.Bracket == 2) o.Add(P + "brk_port"); } else { string model = (l.Print == 1 && u.FakeOf != null ? u.FakeOf : u.Model).ToLowerInvariant(); o.Add(P + "print_" + (l.Print == 2 ? "skew_" : l.Print == 1 ? "stamp_" : "normal_") + model); - if (l.Print == 2) o.Add(P + "screw_opened"); // 重印过,说明拆开过 if (l.Sticker == 1) o.Add(P + "sticker_hand"); - if (l.Sticker == 2) o.Add(P + (l.Print != 0 ? "seal_reglued" : "seal_missing")); + if (l.Sticker == 2) o.Add(P + "seal_missing"); } return o; } @@ -94,14 +99,37 @@ namespace Decentralized root.localPosition = Vector3.zero; cardRoot = new GameObject("Card", typeof(RectTransform)).GetComponent(); cardRoot.SetParent(root, false); - cardRoot.localPosition = new Vector3(0, 40, 0); + cardRoot.localPosition = new Vector3(0, 0, 0); // 翻面按钮:复制鉴定面板里"发送"按钮的外观 var template = m.appraisalPanel.transform.Find("buttonSend"); if (template != null) { + pointRow = new GameObject("PointRow", typeof(RectTransform)).GetComponent(); + pointRow.SetParent(root, false); + pointRow.localPosition = new Vector3(0, 312, 0); + for (int i = 0; i < point.Length; i++) + { + int bit = PointBits[i]; + var pb = Instantiate(template.gameObject, pointRow, false); + pb.name = "Point" + i; + var prt = (RectTransform)pb.transform; + prt.sizeDelta = new Vector2(140, 40); + prt.localPosition = new Vector3((i - 2) * 148, 0, 0); + point[i] = pb.GetComponent