diff --git a/src/Decentralized.Domain/CardLook.cs b/src/Decentralized.Domain/CardLook.cs
new file mode 100644
index 0000000..ebce2e1
--- /dev/null
+++ b/src/Decentralized.Domain/CardLook.cs
@@ -0,0 +1,86 @@
+using System;
+
+namespace Decentralized.Domain
+{
+ ///
+ /// 显卡的外观:5 个维度,每个 3 种取值,共 243 种组合。前三个画在正面,后两个画在背面(右键"切换"翻面)。
+ /// 老化类的维度(风扇、金手指、挡板划痕)随健康度出现,真卡也会有;破绽类的取值只会出现在假卡上,
+ /// 玩家要结合名字(标称型号)与背面的丝印、序列号贴纸判断。
+ ///
+ public struct CardLook
+ {
+ /// 风扇:0 干净,1 积灰,2 叶片缺角(老化)。
+ public int Fan;
+ /// 金手指:0 光亮,1 发暗,2 氧化发绿(老化)。
+ public int Fingers;
+ /// 挡板:0 完好,1 划痕(老化),2 接口数量不对(假卡)。
+ public int Bracket;
+ /// 背面丝印:0 与标称型号一致,1 印的是真实芯片的型号(假卡),2 重新印过、字歪(假卡)。
+ public int Print;
+ /// 背面贴纸与封条:0 印刷序列号、封条完好,1 手写序列号(假卡),2 封条缺失(旧卡或翻新卡)。
+ public int Sticker;
+
+ public const int Variants = 3;
+
+ /// 只在假卡上出现的取值:玩家一旦看到,就能肯定是假的。
+ public bool HasFakeSign => Bracket == 2 || Print != 0 || Sticker == 1;
+
+ ///
+ /// 生成外观。真卡:老化维度按健康度出现,封条缺失在二手卡上很常见,不会出现破绽类取值。
+ /// 假卡:在三类破绽(接口数、丝印、手写序列号)里随机挑 1–3 处,再叠加老化与封条缺失。
+ ///
+ 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
+ {
+ 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])
+ {
+ case 0: look.Bracket = 2; break;
+ case 1: look.Print = rng.Chance(0.6) ? 1 : 2; break;
+ default: look.Sticker = 1; break;
+ }
+ }
+ return look;
+ }
+
+ /// 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;
+ }
+
+ ///
+ /// 显卡的统一价值函数:实际售价和鉴定公会的"正确价值"是同一个数。按真实芯片和健康度算;
+ /// 假卡的标称价(贩子报的价)用标称型号算,二者之差就是被坑的钱。
+ ///
+ public static class CardValue
+ {
+ /// 价值 = 型号定价 × 健康度系数。健康 100 时满价,健康 0 时只剩 25%。
+ public static long Of(string model, double health)
+ {
+ var spec = Hardware.Specs[model];
+ double f = 0.25 + 0.75 * Math.Max(0, Math.Min(100, health)) / 100.0;
+ return Math.Max(1, (long)Math.Round(spec.Price * f));
+ }
+
+ /// 真实价值(芯片型号 + 健康度)。
+ public static long True(Unit u) => Of(u.FakeOf ?? u.Model, u.Health);
+
+ /// 标称价:贩子报的价,按标称型号算;二手卡也折旧,但只按贩子"自称"的健康度。
+ 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 ea92bf9..7bcc5d3 100644
--- a/src/Decentralized.Domain/Mining.cs
+++ b/src/Decentralized.Domain/Mining.cs
@@ -29,10 +29,10 @@ namespace Decentralized.Domain
public static readonly IReadOnlyDictionary Specs = new[]
{
- Gpu("RZX50", Probidia, 1.0, 4, 120), Gpu("RZX60", Probidia, 1.5, 5, 180), Gpu("RZX70", Probidia, 5.0, 12, 450),
- Gpu("RZX80Ti", Probidia, 8.0, 18, 750), Gpu("RZX90", Probidia, 20.0, 30, 2800),
- Gpu("RZ470", Pmd, 0.8, 2.5, 90), Gpu("RZ580", Pmd, 1.2, 3, 140), Gpu("RZ6800", Pmd, 4.0, 8, 380),
- Gpu("RZ7900", Pmd, 15.0, 20, 2200),
+ Gpu("RZX50", Probidia, 1.0, 4, 220), Gpu("RZX60", Probidia, 1.5, 5, 330), Gpu("RZX70", Probidia, 5.0, 12, 800),
+ Gpu("RZX80Ti", Probidia, 8.0, 18, 1300), Gpu("RZX90", Probidia, 20.0, 30, 5000),
+ Gpu("RZ470", Pmd, 0.8, 2.5, 170), Gpu("RZ580", Pmd, 1.2, 3, 250), Gpu("RZ6800", Pmd, 4.0, 8, 680),
+ Gpu("RZ7900", Pmd, 15.0, 20, 4000),
Asic("M1", 50, 60, 6000), Asic("M2", 120, 110, 15000), Asic("M3", 300, 220, 40000),
}.ToDictionary(s => s.Model);
diff --git a/tests/Decentralized.Domain.Tests/Program.cs b/tests/Decentralized.Domain.Tests/Program.cs
index 84555a2..0a3f51f 100644
--- a/tests/Decentralized.Domain.Tests/Program.cs
+++ b/tests/Decentralized.Domain.Tests/Program.cs
@@ -128,6 +128,37 @@ internal static class Program
Check(r.Refurbish() && r.Health == 80 && !r.Refurbish(), "翻新一次 +30,不能再翻新");
});
+ Test("显卡外观:5 个维度 243 种;真卡没有破绽类取值,假卡至少一处;老化随健康度;价值函数统一", () =>
+ {
+ var rng = new Rng(99);
+ int n = 6000;
+ int fakeNoSign = 0, realSign = 0, newWorn = 0, oldWorn = 0, fakeSigns = 0;
+ var seen = new System.Collections.Generic.HashSet();
+ for (int i = 0; i < n; i++)
+ {
+ var real = CardLook.Generate(rng, 100, false);
+ var oldReal = CardLook.Generate(rng, 30, false);
+ var fake = CardLook.Generate(rng, 60, true);
+ if (real.HasFakeSign || oldReal.HasFakeSign) realSign++;
+ if (!fake.HasFakeSign) fakeNoSign++;
+ fakeSigns += (fake.Bracket == 2 ? 1 : 0) + (fake.Print != 0 ? 1 : 0) + (fake.Sticker == 1 ? 1 : 0);
+ if (real.Fan > 0 || real.Fingers > 0) newWorn++;
+ if (oldReal.Fan > 0 || oldReal.Fingers > 0) oldWorn++;
+ seen.Add($"{fake.Fan}{fake.Fingers}{fake.Bracket}{fake.Print}{fake.Sticker}");
+ seen.Add($"{oldReal.Fan}{oldReal.Fingers}{oldReal.Bracket}{oldReal.Print}{oldReal.Sticker}");
+ }
+ Check(realSign == 0, "真卡从不出现破绽类取值(接口数不对、丝印不对、手写序列号)");
+ Check(fakeNoSign == 0, "假卡至少有一处破绽");
+ Check(Near(fakeSigns / (double)n, 1.65, 0.12), $"假卡平均 1.65 处破绽:{fakeSigns / (double)n:0.00}");
+ Check(oldWorn > newWorn * 1.8, $"低健康度的真卡明显更旧:{oldWorn / (double)n:P0} 对 {newWorn / (double)n:P0}");
+ Check(seen.Count >= 120, $"抽样见到 {seen.Count} 种不同组合(上限 243)");
+ Check(Hardware.Specs["RZX50"].Price == 220 && Hardware.Specs["RZX90"].Price == 5000 && Hardware.Specs["RZ7900"].Price == 4000, "价格已抬高到约 1.8 倍");
+ var u = new Unit { Model = "RZX90", FakeOf = "RZX60", Health = 80 };
+ Check(CardValue.True(u) == CardValue.Of("RZX60", 80) && CardValue.Quoted(u, 80) == CardValue.Of("RZX90", 80), "真实价值按芯片型号,标称价按标称型号");
+ Check(CardValue.Quoted(u, 80) > CardValue.True(u) * 8, $"假 RZX 90 的标称价 {CardValue.Quoted(u, 80)} 远高于真值 {CardValue.True(u)}");
+ Check(CardValue.Of("RZX70", 100) == 800 && CardValue.Of("RZX70", 0) == 200 && CardValue.Of("RZX70", 50) == 500, "健康 100 满价、0 剩 25%、50 为 62.5%");
+ });
+
Test("矿架槽位:默认 2 个,投入电子元件逐个升级到最多 5 个;只收下一级需要的;形状随槽数变化", () =>
{
int slots = RigSlots.Base, prog = 0;