diff --git a/CHANGELOG.md b/CHANGELOG.md index f27ba51..0a95907 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,3 +4,5 @@ - 纯逻辑层:带种子的随机数、资产登记、地址账本(污点与成本随币走)、恒定乘积交易对、挖矿(显卡、矿机、全网算力、减半、损耗与暴毙)。 - 纯逻辑层:市场(价格锚、情绪、游戏事件冲击、NPC 大户、多交易对套利回归、当日均价),三种稳定币(CRC 脱锚与减记、CRT 脱锚与冻结概率、CRA 补贴池与死亡螺旋),合规出金与 AML(大额与累计阈值、污点比例、轻度与重度命中、只对收益计税)。 +- mod 本体(第一阶段的第一部分):人脉网络"矿业渠道"(300)交付交易终端、矿架和一张 RZX 50;矿架是带电池槽和 4 个显卡槽的机器,每晚从电池扣电挖 PSCoin;显卡按型号显示算力、耗电与健康度,每晚损耗并可能烧毁;交易终端有钱包、交易(四个交易对、存入信用点、提取 CRC 过合规审查)、报告三页;夜间结算写晨间日志;PSCoin 交易所开业与减半上报纸;状态存进游戏的 mod 存档数据。中英双语。 +- 物品图:art/sprites.py 生成显卡(Probidia、PMD、烧毁)、矿架、交易终端。 diff --git a/ProbablyDecentralized.sln b/ProbablyDecentralized.sln index 0e82993..e67363c 100644 --- a/ProbablyDecentralized.sln +++ b/ProbablyDecentralized.sln @@ -11,6 +11,8 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{BC589259 EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Decentralized.Domain.Tests", "tests\Decentralized.Domain.Tests\Decentralized.Domain.Tests.csproj", "{FCBED7E8-41D2-4474-99F3-C8EC5FF6BEF5}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Decentralized", "src\Decentralized\Decentralized.csproj", "{6174F3DC-7AB4-4302-9CBB-419B120533B7}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -28,9 +30,14 @@ Global {FCBED7E8-41D2-4474-99F3-C8EC5FF6BEF5}.Debug|Any CPU.Build.0 = Debug|Any CPU {FCBED7E8-41D2-4474-99F3-C8EC5FF6BEF5}.Release|Any CPU.ActiveCfg = Release|Any CPU {FCBED7E8-41D2-4474-99F3-C8EC5FF6BEF5}.Release|Any CPU.Build.0 = Release|Any CPU + {6174F3DC-7AB4-4302-9CBB-419B120533B7}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {6174F3DC-7AB4-4302-9CBB-419B120533B7}.Debug|Any CPU.Build.0 = Debug|Any CPU + {6174F3DC-7AB4-4302-9CBB-419B120533B7}.Release|Any CPU.ActiveCfg = Release|Any CPU + {6174F3DC-7AB4-4302-9CBB-419B120533B7}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(NestedProjects) = preSolution {9F515839-7902-4AB8-86D6-33755009DDDF} = {65D2EE76-8B79-48B0-BFF3-BF1B7FE5C096} {FCBED7E8-41D2-4474-99F3-C8EC5FF6BEF5} = {BC589259-3F05-4F25-81C9-183268862282} + {6174F3DC-7AB4-4302-9CBB-419B120533B7} = {65D2EE76-8B79-48B0-BFF3-BF1B7FE5C096} EndGlobalSection EndGlobal diff --git a/README.md b/README.md index cc707b9..ea8f052 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,9 @@ All coins, exchanges and events are fictional and have no real-world value. | Path | Content | |---|---| | `src/Decentralized.Domain` | Pure logic (assets and ledger, AMM pairs, market, mining). No game or Unity references. | +| `src/Decentralized` | The mod: items (trading terminal, mining rig, graphics cards), nightly settlement, terminal window, news. Builds into `/Mods/ProbablyDecentralized` (`dotnet build src/Decentralized -p:GameDir=...`). | | `tests/Decentralized.Domain.Tests` | Console test runner: `dotnet run --project tests/Decentralized.Domain.Tests` | +| `art/sprites.py` | Generates the item sprites into `src/Decentralized/ModFiles/Textures/Items`. | ## License diff --git a/art/sprites.py b/art/sprites.py new file mode 100644 index 0000000..277f8a9 --- /dev/null +++ b/art/sprites.py @@ -0,0 +1,168 @@ +#!/usr/bin/env python3 +"""物品像素图(游戏物品为 100 PPU,一格 16 像素;形状由不透明像素决定)。 + +用法:python3 art/sprites.py → 写出 src/Decentralized/ModFiles/Textures/Items/*.png + python3 art/sprites.py --preview DIR → 另存放大 6 倍、桌面底色的预览 + +画法沿用 Probably Automated 对讲机图:轮廓统一用 #292234,光从左上来,颜色取自游戏物品图集的调色板。 +""" +import os +import struct +import sys +import zlib + +C = { + 'O': (0x29, 0x22, 0x34), # 轮廓 + 'k': (0x37, 0x35, 0x41), # 最暗 + 'd': (0x44, 0x46, 0x4C), # 暗面 + 'm': (0x5F, 0x68, 0x68), # 中间 + 'b': (0x88, 0x94, 0x8A), # 亮面 + 'l': (0xA8, 0xAE, 0xA5), # 受光 + 'h': (0xC4, 0xC4, 0xB0), # 亮边 + 'g': (0x53, 0x58, 0x5B), # 散热片 + 's': (0x27, 0x56, 0x54), # 屏幕底 + 'S': (0x67, 0xAA, 0xA5), # 屏幕字 + 'T': (0x8A, 0xBF, 0xBB), # 屏幕高光 + 'G': (0x5E, 0x9C, 0x4E), # Probidia 绿 + 'g2': (0x3E, 0x6B, 0x36), + 'R': (0xB0, 0x4A, 0x3E), # PMD 红 + 'r2': (0x7A, 0x33, 0x2E), + 'Y': (0xC8, 0xA4, 0x4A), # 金手指 + 'c': (0x2E, 0x2A, 0x2A), # 焦黑 + 'e': (0x6B, 0x4A, 0x3A), # 焦痕 +} + + +class Canvas: + def __init__(self, w, h): + self.w, self.h = w, h + self.px = [[None] * w for _ in range(h)] + + def put(self, x, y, c): + if 0 <= x < self.w and 0 <= y < self.h: + self.px[y][x] = c + + def rect(self, x0, y0, x1, y1, c): + for y in range(y0, y1 + 1): + for x in range(x0, x1 + 1): + self.put(x, y, c) + + def box(self, x0, y0, x1, y1, fill, outline='O'): + self.rect(x0, y0, x1, y1, fill) + for x in range(x0, x1 + 1): + self.put(x, y0, outline); self.put(x, y1, outline) + for y in range(y0, y1 + 1): + self.put(x0, y, outline); self.put(x1, y, outline) + + def png(self, path, scale=1, bg=None): + raw = bytearray() + for row in self.px: + line = bytearray() + for c in row: + r, g, b, a = (0, 0, 0, 0) if c is None else C[c] + (255,) + if bg is not None: + r, g, b = (round(v * a / 255 + w * (255 - a) / 255) for v, w in zip((r, g, b), bg)) + a = 255 + line += bytes((r, g, b, a)) * scale + for _ in range(scale): + raw.append(0); raw += line + + def chunk(tag, data): + return struct.pack('>I', len(data)) + tag + data + struct.pack('>I', zlib.crc32(tag + data) & 0xFFFFFFFF) + + data = b'\x89PNG\r\n\x1a\n' + chunk(b'IHDR', struct.pack('>IIBBBBB', self.w * scale, self.h * scale, 8, 6, 0, 0, 0)) \ + + chunk(b'IDAT', zlib.compress(bytes(raw), 9)) + chunk(b'IEND', b'') + os.makedirs(os.path.dirname(os.path.abspath(path)), exist_ok=True) + with open(path, 'wb') as f: + f.write(data) + + +def gpu(accent, accent_dark, burnt=False): + """显卡(32×16,2×1 格):外壳、两个风扇、品牌色条、金手指。""" + c = Canvas(32, 16) + c.box(1, 2, 30, 12, 'm') + c.rect(2, 3, 29, 3, 'l') # 顶边受光 + c.rect(2, 11, 29, 11, 'd') # 底边暗面 + for cx in (9, 21): # 风扇 + for y in range(5, 11): + for x in range(cx - 4, cx + 5): + dx, dy = x - cx, y - 7.5 + if dx * dx + dy * dy <= 16: + c.put(x, y, 'k') + if 6 <= dx * dx + dy * dy <= 16: + c.put(x, y, 'g') + c.put(cx, 7, 'b'); c.put(cx, 8, 'b') + c.rect(2, 4, 29, 4, accent) # 品牌色条 + c.put(2, 5, accent_dark); c.put(29, 5, accent_dark) + c.rect(6, 13, 25, 13, 'Y') # 金手指 + for x in range(6, 26, 2): + c.put(x, 14, 'Y') + c.rect(30, 3, 30, 11, 'k') # 挡板 + if burnt: + for x, y in [(7, 5), (8, 6), (10, 9), (19, 6), (20, 7), (22, 9), (14, 8), (15, 7), (24, 5)]: + c.put(x, y, 'c') + c.rect(2, 4, 29, 4, 'e') + for x, y in [(12, 3), (13, 3), (17, 11), (26, 10)]: + c.put(x, y, 'c') + return c + + +def rig(): + """矿架(48×48,3×3 格):开放式机架,四层显卡位,左下电源。""" + c = Canvas(48, 48) + c.box(2, 3, 45, 44, None) + for y in range(4, 44): + c.put(3, y, 'b'); c.put(44, y, 'd') + for x in range(3, 45): + c.put(x, 4, 'l'); c.put(x, 43, 'k') + for i, y in enumerate((8, 16, 24, 32)): # 四层卡位 + c.rect(6, y, 41, y + 5, 'k') + c.rect(6, y, 41, y, 'g') + for x in range(8, 40, 3): + c.put(x, y + 2, 'g'); c.put(x, y + 3, 'g') + c.put(40, y + 2, 'G' if i % 2 == 0 else 'R') + c.box(6, 38, 17, 42, 'd') # 电源 + c.put(15, 40, 'S') + for x in range(20, 42, 2): # 底部走线 + c.put(x, 40, 'm') + c.rect(2, 45, 45, 46, 'k') # 脚 + return c + + +def terminal(): + """交易终端(32×32,2×2 格):小型显示器 + 键盘,屏幕上是 K 线。""" + c = Canvas(32, 32) + c.box(3, 2, 28, 21, 'm') + c.rect(4, 3, 27, 3, 'l') + c.box(5, 4, 26, 18, 's') + candles = [(8, 12, 15), (11, 10, 14), (14, 11, 13), (17, 7, 12), (20, 8, 11), (23, 5, 10)] + for x, top, bot in candles: + c.rect(x, top, x, bot, 'S') + c.put(x, top - 1, 'T') + c.rect(13, 22, 18, 23, 'd') # 支架 + c.box(2, 24, 29, 30, 'b') # 键盘 + for y in (26, 28): + for x in range(4, 28, 2): + c.put(x, y, 'm') + return c + + +SPRITES = { + 'gpu_probidia': lambda: gpu('G', 'g2'), + 'gpu_pmd': lambda: gpu('R', 'r2'), + 'gpu_burnt': lambda: gpu('e', 'c', burnt=True), + 'rig': rig, + 'terminal': terminal, +} + +if __name__ == '__main__': + here = os.path.dirname(os.path.abspath(__file__)) + if len(sys.argv) == 3 and sys.argv[1] == '--preview': + for name, make in SPRITES.items(): + make().png(os.path.join(sys.argv[2], name + '.png'), scale=6, bg=(0xA0, 0xA8, 0x9C)) + print(sys.argv[2]) + else: + out = os.path.normpath(os.path.join(here, '..', 'src', 'Decentralized', 'ModFiles', 'Textures', 'Items')) + for name, make in SPRITES.items(): + make().png(os.path.join(out, name + '.png')) + print(out) diff --git a/src/Decentralized/Core.cs b/src/Decentralized/Core.cs new file mode 100644 index 0000000..4c3652d --- /dev/null +++ b/src/Decentralized/Core.cs @@ -0,0 +1,37 @@ +using System; +using UnityEngine; + +namespace Decentralized +{ + public static class ModInfo + { + public const string Id = "probably_decentralized"; + public const string Name = "Probably Decentralized"; + public const string Version = "0.1.0"; + } + + /// 本地化:游戏的 mod 本地化表(ModFiles/Localization/*.csv),当前语言缺失时回退英文。 + public static class L + { + public static string T(string key, params object[] args) => ModHelper.GetLocalized(ModInfo.Id, key, args); + } + + public static class Log + { + public static void Info(string m) => Debug.Log($"[{ModInfo.Name}] {m}"); + public static void Warn(string m) => Debug.LogWarning($"[{ModInfo.Name}] {m}"); + public static void Error(string m) => Debug.LogError($"[{ModInfo.Name}] {m}"); + + public static void Guard(string what, Action a) + { + try { a(); } + catch (Exception ex) { Error($"{what}: {ex}"); } + } + } + + public static class Fmt + { + public static string Num(double v) => Math.Abs(v) >= 100 ? v.ToString("N0") : Math.Abs(v) >= 1 ? v.ToString("0.##") : v.ToString("0.####"); + public static string Credits(double v) => v.ToString("N0"); + } +} diff --git a/src/Decentralized/Decentralized.csproj b/src/Decentralized/Decentralized.csproj new file mode 100644 index 0000000..99111a9 --- /dev/null +++ b/src/Decentralized/Decentralized.csproj @@ -0,0 +1,44 @@ + + + + + netstandard2.1 + ProbablyDecentralized + Decentralized + latest + disable + disable + false + + C:/Program Files (x86)/Steam/steamapps/common/Probably Stolen + $(GameDir)/Probably Stolen_Data/Managed + $(GameDir)/Mods/ProbablyDecentralized + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Decentralized/Items.cs b/src/Decentralized/Items.cs new file mode 100644 index 0000000..cf03473 --- /dev/null +++ b/src/Decentralized/Items.cs @@ -0,0 +1,182 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text.RegularExpressions; +using Decentralized.Domain; +using UnityEngine; + +namespace Decentralized +{ + /// + /// mod 物品:交易终端、矿架、显卡(每个型号一个物品 id)。显卡的健康度、假卡、翻新、烧毁存在物品标签上,随存档保存。 + /// 读档时游戏按 id 重新调用工厂,再把存档里的标签与子物品(矿架里的显卡、电池)恢复回去。 + /// + public static class Items + { + public const string Terminal = "pd_terminal"; + public const string Rig = "pd_rig"; + public const string GpuPrefix = "pd_gpu_"; + + public const string HealthTag = "PD_HEALTH"; + public const string FakeTag = "PD_FAKE_OF"; + public const string RefurbTag = "PD_REFURBISHED"; + public const string DeadTag = "PD_DEAD"; + + public static Action OpenTerminal; + + public static void Subscribe() => ModHook.OnModItemDirectoryInit += Register; + public static void Unsubscribe() => ModHook.OnModItemDirectoryInit -= Register; + + public static string GpuId(string model) => GpuPrefix + model.ToLowerInvariant(); + + public static bool IsGpu(GameItem item) => item != null && item.identifier != null && item.identifier.StartsWith(GpuPrefix); + + private static void Register(ModItemDirectory dir) + { + dir.Add(Terminal, CreateTerminal); + dir.Add(Rig, CreateRig); + foreach (var spec in Hardware.Specs.Values.Where(s => !s.Asic)) + { + var s = spec; + dir.Add(GpuId(s.Model), () => CreateGpu(s)); + } + } + + // ---------- 交易终端 ---------- + + private static GameItem CreateTerminal() + { + var item = ItemDirectory.CreateEmptyItem(Terminal); + item.SetSpriteAndShapeFromMod(ModInfo.Id, "terminal"); + item.name = L.T("item.terminal.name"); + item.shortDescription = L.T("item.terminal.desc"); + item.unitValue = 0; + item.EnableTag(GeneralHelper.IMPORTANT_TAG); + item.EnableTag(TypeHelper.NOT_FOR_RESALE); + item.mayActivateSlotItemFunc = (_, _, _) => true; + item.canActivateSlotItemFunc = (_, _, _) => true; + item.onActivateSlotItemFunc = (_, _, _) => Log.Guard("打开交易终端", () => OpenTerminal?.Invoke()); + ModHelper.SetSource(item, ModInfo.Id, ModInfo.Name); + return item; + } + + // ---------- 显卡 ---------- + + /// RZX70 → "RZX 70",RZX80Ti → "RZX 80 Ti",RZ6800 → "RZ 6800"。 + public static string DisplayModel(string model) => Regex.Replace(Regex.Replace(model, @"(?<=[A-Za-z])(?=\d)", " "), @"(?<=\d)(?=[A-Z])", " "); + + private static GameItem CreateGpu(HardwareSpec spec) + { + var item = ItemDirectory.CreateEmptyItem(GpuId(spec.Model)); + item.SetSpriteAndShapeFromMod(ModInfo.Id, spec.Brand == Hardware.Probidia ? "gpu_probidia" : "gpu_pmd"); + item.SetGameItemType("ELECTRONIC"); + item.onLoaded = Refresh; + ModHelper.SetSource(item, ModInfo.Id, ModInfo.Name); + Refresh(item); + return item; + } + + public static Unit ToUnit(GameItem item) + { + string model = item.identifier.Substring(GpuPrefix.Length); + model = Hardware.Specs.Keys.First(k => k.ToLowerInvariant() == model); + var u = new Unit { Model = model, Health = 100 }; + if (item.IsTag(HealthTag)) u.Health = item.GetTagReadonly(HealthTag).GetDouble(); + if (item.IsTag(FakeTag)) u.FakeOf = item.GetTagReadonly(FakeTag).GetString(); + u.Refurbished = item.IsTag(RefurbTag); + u.Dead = item.IsTag(DeadTag); + return u; + } + + public static void Store(GameItem item, Unit u) + { + item.ModifyTag(HealthTag, s => s.Enable().SetDouble(u.Health)); + if (u.FakeOf != null) item.ModifyTag(FakeTag, s => s.Enable().SetString(u.FakeOf)); + if (u.Refurbished) item.EnableTag(RefurbTag); + if (u.Dead) item.EnableTag(DeadTag); + Refresh(item); + } + + /// 按标签更新名字、说明、价值和图:显示的是标签上的型号(假卡也显示被冒充的型号)。 + public static void Refresh(GameItem item) + { + var u = ToUnit(item); + var label = u.Label; + if (u.Dead) + { + item.name = L.T("item.gpu.burnt.name", label.Brand, DisplayModel(label.Model)); + item.shortDescription = L.T("item.gpu.burnt.desc"); + item.unitValue = Math.Max(1, label.Price / 20); + item.SetSpriteAndShapeFromMod(ModInfo.Id, "gpu_burnt"); + return; + } + item.name = L.T("item.gpu.name", label.Brand, DisplayModel(label.Model)); + item.shortDescription = L.T("item.gpu.desc", Fmt.Num(label.DisplayHash), Fmt.Num(label.Kj), Mathf.RoundToInt((float)u.Health)); + item.unitValue = Math.Max(1, (long)Math.Round(label.Price * u.Health / 100)); + } + + // ---------- 矿架 ---------- + + private static GameItem CreateRig() + { + var battery = new GameSlotInventory(); + var cards = new GameGridInventory(2, 4); + var window = MachineHelper.CreateMachineryWindow(true, + MachineHelper.PowerPanel(battery), + MachineHelper.Panel(L.T("rig.label.cards"), cards, "cards")); + var rig = ItemDirectory.CreateEmptyItem(Rig).SetContentWindow(window); + rig.SetSpriteAndShapeFromMod(ModInfo.Id, "rig"); + rig.name = L.T("item.rig.name"); + rig.unitValue = Tuning.RigPrice; + MachineHelper.SetupBatterySlot(battery, rig); + rig.EnableTag("CONTAINER_TAG"); + rig.EnableTag("STANDARD_MACHINE_TAG"); + MachineryHelper.InitMachinery(rig, Tuning.RigKj); + cards.mayInventoryAddItemFunc = (item, _) => GeneralHelper.IsItemOwned(item) && IsGpu(item) && !item.IsTag(DeadTag); + cards.onSlotAddItemEndFunc = (_, _, _) => UpdateRig(rig, cards); + cards.onSlotRemoveItemEndFunc = (_, _, _) => UpdateRig(rig, cards); + rig.onLoaded = _ => UpdateRig(rig, cards); + rig.onCycleEndEarlySlotItemFunc = (_, _, _) => Log.Guard("矿架扣电", () => NightDraw(rig, battery, cards)); + ModHelper.SetSource(rig, ModInfo.Id, ModInfo.Name); + UpdateRig(rig, cards); + return rig; + } + + public static List Cards(GameInventory cards) => cards.childItems.Where(IsGpu).ToList(); + + private static int RigKj(GameInventory cards) => + Tuning.RigKj + Mathf.CeilToInt((float)Cards(cards).Select(ToUnit).Sum(u => u.Kj(false))); + + private static void UpdateRig(GameItem rig, GameInventory cards) + { + int kj = RigKj(cards); + rig.ModifyTag(MachineryHelper.MACHINERY_DRAWN_POWER, s => s.Enable().SetInt(kj)); + double hash = Cards(cards).Select(ToUnit).Sum(u => u.Hash(false)); + rig.shortDescription = L.T("item.rig.desc", Fmt.Num(hash * 10), kj); + } + + /// 夜间早段:按矿架上的显卡总耗电从电池扣电;扣电成功,登记今晚参与挖矿的显卡。 + private static void NightDraw(GameItem rig, GameSlotInventory battery, GameInventory cards) + { + var list = Cards(cards).Where(c => !c.IsTag(DeadTag)).ToList(); + Session.TonightRigs++; + if (list.Count == 0) return; + int kj = RigKj(cards); + if (battery.childItems.Count == 0 || !PowerHelper.DrawPowerSource(battery.childItem, kj)) return; + Session.TonightPoweredRigs++; + Session.TonightKj += kj; + Session.TonightCards.AddRange(list); + UpdateRig(rig, cards); + } + + /// + /// 放进储藏区(游戏 EmporiumTryAdd 的默认目标,放不下时游戏自己找位置)。 + /// 不放柜台:测试中柜台上的物品过夜后会消失(未在正常流程中确认);ModHelper.MoveItemTo* 放满时会静默失败。 + /// + public static void Give(GameItem item) + { + GeneralHelper.SetItemOwned(item, true); + GraphUtils.EmporiumTryAdd(item); + } + } +} diff --git a/src/Decentralized/ModEntry.cs b/src/Decentralized/ModEntry.cs new file mode 100644 index 0000000..7ed25b1 --- /dev/null +++ b/src/Decentralized/ModEntry.cs @@ -0,0 +1,32 @@ +namespace Decentralized +{ + /// + /// mod 入口(游戏自带 mod 系统)。Init / OnEnable 在主菜单阶段调用:这里只订阅钩子、登记物品和人脉升级, + /// 不碰存档和店里的东西(那些在读档 / 新游戏的钩子里做)。 + /// + public class ModEntry : IMod + { + public void Init(ModManifest manifest) { } + + public void OnEnable() + { + Items.Subscribe(); + Items.OpenTerminal = TerminalWindow.Open; + Unlock.Register(); + Night.Subscribe(); + ModHook.OnGameLoadedLate += Session.Load; + ModHook.OnNewGameStarted += OnNewGame; + Log.Info($"{ModInfo.Name} {ModInfo.Version} 已启用"); + } + + public void OnDisable() + { + Items.Unsubscribe(); + Night.Unsubscribe(); + ModHook.OnGameLoadedLate -= Session.Load; + ModHook.OnNewGameStarted -= OnNewGame; + } + + private static void OnNewGame(string scenarioId) => Session.Load(); + } +} diff --git a/src/Decentralized/ModFiles/Localization/en.csv b/src/Decentralized/ModFiles/Localization/en.csv new file mode 100644 index 0000000..b7d905f --- /dev/null +++ b/src/Decentralized/ModFiles/Localization/en.csv @@ -0,0 +1,55 @@ +# Probably Decentralized localization table: English (en) +# Source language and fallback. Format: key,text (text may be quoted; \n is a line break). +upgrade.title,Mining Access +upgrade.card,Mining +upgrade.desc,Delivers a trading terminal and a mining rig to the back counter. Graphics cards installed in a rig mine PSCoin each night the rig has power. +item.terminal.name,Trading Terminal +item.terminal.desc,Wallet, exchange and the nightly mining report. +item.rig.name,Mining Rig +item.rig.desc,"Holds up to four graphics cards. Hashrate {0} MH/s. Draws {1} kJ per day." +rig.label.cards,Graphics cards +item.gpu.name,{0} {1} +item.gpu.desc,"Hashrate {0} MH/s. Draws {1} kJ per day. Condition {2}%." +item.gpu.burnt.name,Burnt {0} {1} +item.gpu.burnt.desc,This card no longer works. +term.title,Trading Terminal +tab.wallet,Wallet +tab.trade,Trade +tab.report,Report +wallet.line,"{0}: {1} (about {2} credits)" +wallet.empty,The wallet is empty. +wallet.total,Total value: {0} credits +wallet.taint,Tainted value: {0} credits +trade.amount,Amount +trade.hint,Buy spends the amount in the quote coin. Sell sells the amount of the traded coin. +trade.deposit,Deposit credits as CRC +trade.withdraw,Withdraw CRC as credits +trade.pair,"{0}/{1} ({2})" +trade.price,Price {0} +trade.buy,Buy +trade.sell,Sell +venue.Exchange,Exchange +venue.Dex,DEX +venue.Darknet,Darknet +msg.amount,Enter an amount. +msg.balance,Not enough balance. +msg.trade,"Paid {0} {1}, received {2} {3}." +msg.deposit,Deposited {0} credits as CRC. +msg.cashout,"Received {0} credits. Tax {1} credits." +msg.aml_light,Compliance review: {0} credits of tainted funds were withheld. +msg.aml_heavy,Compliance review: {0} credits of tainted funds were seized and reported to Security. +report.none,No mining report yet. +report.day,Night of day {0} +report.rigs,Rigs with power: {0} of {1} +report.hash,"Hashrate: {0} MH/s (network {1} MH/s)" +report.mined,"Mined: {0} PSC (about {1} credits)" +report.energy,Energy used: {0} kJ +report.burnt,Cards burnt out: {0} +report.price,PSCoin price: {0} credits +nightlog.mined,Mining rigs produced {0} PSC. +nightlog.burnt,A {0} burnt out. +nightlog.unpowered,{0} mining rig(s) had no power. +news.open.title,PSCoin Exchange Opens +news.open.desc,The Upper Level exchange now lists PSCoin against CRC. Graphics cards are in demand across the station. +news.halving.title,PSCoin Block Reward Halved +news.halving.desc,Miners now receive half as many coins per night. diff --git a/src/Decentralized/ModFiles/Localization/zh.csv b/src/Decentralized/ModFiles/Localization/zh.csv new file mode 100644 index 0000000..75deaf7 --- /dev/null +++ b/src/Decentralized/ModFiles/Localization/zh.csv @@ -0,0 +1,54 @@ +# Probably Decentralized 本地化表:简体中文(zh) +upgrade.title,矿业渠道 +upgrade.card,挖矿 +upgrade.desc,交付一台交易终端和一个矿架,放在后柜台。装进矿架的显卡在有电的夜晚挖出 PSCoin。 +item.terminal.name,交易终端 +item.terminal.desc,钱包、交易所与夜间挖矿报告。 +item.rig.name,矿架 +item.rig.desc,可装 4 张显卡。算力 {0} MH/s。耗电 {1} kJ/天。 +rig.label.cards,显卡 +item.gpu.name,{0} {1} +item.gpu.desc,算力 {0} MH/s。耗电 {1} kJ/天。健康度 {2}%。 +item.gpu.burnt.name,烧毁的 {0} {1} +item.gpu.burnt.desc,此显卡已无法工作。 +term.title,交易终端 +tab.wallet,钱包 +tab.trade,交易 +tab.report,报告 +wallet.line,{0}:{1}(约 {2} 信用点) +wallet.empty,钱包为空。 +wallet.total,总值:{0} 信用点 +wallet.taint,污点:{0} 信用点 +trade.amount,数量 +trade.hint,买入按计价币的数量支付;卖出按所交易币的数量卖出。 +trade.deposit,存入信用点(换成 CRC) +trade.withdraw,提取 CRC(换成信用点) +trade.pair,{0}/{1}({2}) +trade.price,价格 {0} +trade.buy,买入 +trade.sell,卖出 +venue.Exchange,交易所 +venue.Dex,DEX +venue.Darknet,暗网 +msg.amount,请输入数量。 +msg.balance,余额不足。 +msg.trade,支付 {0} {1},得到 {2} {3}。 +msg.deposit,已存入 {0} 信用点,换成 CRC。 +msg.cashout,到账 {0} 信用点。税 {1} 信用点。 +msg.aml_light,合规审查:{0} 信用点的污点资金被扣留。 +msg.aml_heavy,合规审查:{0} 信用点的污点资金被没收,并已上报安保部。 +report.none,暂无挖矿报告。 +report.day,第 {0} 天夜间 +report.rigs,有电的矿架:{0} / {1} +report.hash,算力:{0} MH/s(全网 {1} MH/s) +report.mined,挖出:{0} PSC(约 {1} 信用点) +report.energy,耗电:{0} kJ +report.burnt,烧毁的显卡:{0} +report.price,PSCoin 价格:{0} 信用点 +nightlog.mined,矿架挖出 {0} PSC。 +nightlog.burnt,一张 {0} 烧毁了。 +nightlog.unpowered,{0} 个矿架没有电。 +news.open.title,PSCoin 交易所开业 +news.open.desc,上层交易所上架 PSCoin/CRC 交易对。全站显卡需求上升。 +news.halving.title,PSCoin 出块奖励减半 +news.halving.desc,矿工每晚获得的币减半。 diff --git a/src/Decentralized/ModFiles/Textures/Items/gpu_burnt.png b/src/Decentralized/ModFiles/Textures/Items/gpu_burnt.png new file mode 100644 index 0000000..c50a4e6 Binary files /dev/null and b/src/Decentralized/ModFiles/Textures/Items/gpu_burnt.png differ diff --git a/src/Decentralized/ModFiles/Textures/Items/gpu_pmd.png b/src/Decentralized/ModFiles/Textures/Items/gpu_pmd.png new file mode 100644 index 0000000..9eaf842 Binary files /dev/null and b/src/Decentralized/ModFiles/Textures/Items/gpu_pmd.png differ diff --git a/src/Decentralized/ModFiles/Textures/Items/gpu_probidia.png b/src/Decentralized/ModFiles/Textures/Items/gpu_probidia.png new file mode 100644 index 0000000..ba456e0 Binary files /dev/null and b/src/Decentralized/ModFiles/Textures/Items/gpu_probidia.png differ diff --git a/src/Decentralized/ModFiles/Textures/Items/rig.png b/src/Decentralized/ModFiles/Textures/Items/rig.png new file mode 100644 index 0000000..05cc25f Binary files /dev/null and b/src/Decentralized/ModFiles/Textures/Items/rig.png differ diff --git a/src/Decentralized/ModFiles/Textures/Items/terminal.png b/src/Decentralized/ModFiles/Textures/Items/terminal.png new file mode 100644 index 0000000..3fbea8f Binary files /dev/null and b/src/Decentralized/ModFiles/Textures/Items/terminal.png differ diff --git a/src/Decentralized/ModFiles/manifest.xml b/src/Decentralized/ModFiles/manifest.xml new file mode 100644 index 0000000..9f35327 --- /dev/null +++ b/src/Decentralized/ModFiles/manifest.xml @@ -0,0 +1,17 @@ + + + probably_decentralized + Probably Decentralized + edward + 0.1.0 + Mine PSCoin with graphics cards, trade it on a fictional exchange, and cash it out past the compliance desk. Work in progress. All coins, exchanges and events are fictional and have no real-world value. + + 0.46 + + + + ProbablyDecentralized.dll + ProbablyDecentralized.Domain.dll + + + diff --git a/src/Decentralized/Night.cs b/src/Decentralized/Night.cs new file mode 100644 index 0000000..245e922 --- /dev/null +++ b/src/Decentralized/Night.cs @@ -0,0 +1,140 @@ +using System.Collections.Generic; +using System.Linq; +using Decentralized.Domain; + +namespace Decentralized +{ + /// + /// 每天的推进:开门时日内波动和 NPC 大户;睡觉时(游戏 EndNight 的最后)挖矿、损耗与暴毙、全网、稳定币、行情结算,写晨间日志,存档。 + /// 矿架自己在夜间早段扣电并登记显卡(Items.NightDraw),这里只结算。 + /// + public static class Night + { + public static void Subscribe() + { + ModHook.OnShutterOpenedLate += OnOpen; + ModHook.OnGoingSleepEarly += OnSleepEarly; + ModHook.OnGoingSleepLate += OnSleepLate; + } + + public static void Unsubscribe() + { + ModHook.OnShutterOpenedLate -= OnOpen; + ModHook.OnGoingSleepEarly -= OnSleepEarly; + ModHook.OnGoingSleepLate -= OnSleepLate; + } + + private static bool Active => Session.Data != null && Unlock.IsUnlocked; + + /// 游戏新闻事件与派系行动 → 行情事件。 + public static List ActiveEvents() + { + var list = new List(); + var mgr = StoreStation.Instance?.storeEventManager; + if (mgr == null) return list; + foreach (var e in mgr.GetActiveEvents()) + { + switch (e.identifier) + { + case "stationWideBlackout": case "powerShortage": case "powerPlantMaintenance": list.Add(MarketEvent.Blackout); break; + case "crackdown": case "sec_martial_law": list.Add(MarketEvent.Crackdown); break; + case "bm_gang_war": list.Add(MarketEvent.GangWar); break; + case "rev_raid_upper_level": list.Add(MarketEvent.RevRaidUpper); break; + case "ll_riot": case "ll_worker_strike": list.Add(MarketEvent.LowerUnrest); break; + } + } + return list.Distinct().ToList(); + } + + private static void OnOpen() + { + if (!Active) return; + Log.Guard("开门行情", () => + { + var d = Session.Data; + int day = StoreStation.GetDayCounter(); + d.Market.Open(ActiveEvents(), d.Rng, day); + d.Market.Whale(d.Rng); + if (!d.OpeningNewsShown) + { + News.Show("pd_news_open", L.T("news.open.title"), L.T("news.open.desc"), 2); + d.OpeningNewsShown = true; + } + Session.Save(); + }); + } + + private static void OnSleepEarly() + { + Session.TonightCards.Clear(); + Session.TonightRigs = Session.TonightPoweredRigs = 0; + Session.TonightKj = 0; + } + + private static void OnSleepLate() + { + if (!Active) return; + Log.Guard("夜间结算", Settle); + } + + private static void Settle() + { + var d = Session.Data; + int day = StoreStation.GetDayCounter(); + var events = ActiveEvents(); + foreach (var ev in events.Where(e => !d.ActiveEvents.Contains(e))) d.Stable.OnEventStart(ev, day, d.Rng); + d.ActiveEvents = events; + d.Network.Availability = (events.Contains(MarketEvent.Blackout) ? 0.8 : 1) * (events.Contains(MarketEvent.LowerUnrest) ? 0.9 : 1); + + var units = Session.TonightCards.Select(c => (item: c, unit: Items.ToUnit(c))).ToList(); + double hash = units.Sum(x => x.unit.Hash(false)); + double mined = d.Network.Mine(hash); + if (mined > 0) d.Hot.Add(Assets.PSC, mined); + + var report = new NightReport + { + Day = day, Rigs = Session.TonightRigs, PoweredRigs = Session.TonightPoweredRigs, Kj = Session.TonightKj, + Hash = hash, NetworkHash = d.Network.NpcHash * d.Network.Availability + hash, Mined = mined, + }; + foreach (var (item, unit) in units) + { + bool died = unit.Night(false, d.Rng); + Items.Store(item, unit); + if (died) report.Burnt.Add(item.name); + } + + double rewardBefore = d.Network.Reward; + d.Network.Night(day, d.Market.Price, hash); + if (d.Network.Reward < rewardBefore) News.Show("pd_news_halving_" + day, L.T("news.halving.title"), L.T("news.halving.desc"), 2); + double crcFactor = d.Stable.Night(day, d.Rng, 0); + if (crcFactor < 1) d.Hot.Balances[Assets.CRC] = d.Hot.Get(Assets.CRC) * crcFactor; + d.Market.Night(events, d.Rng, day, Session.Price); + report.Price = d.Market.Price; + d.LastNight = report; + d.Day = day; + + var ps = PlayerStore.Instance; + if (mined > 0) ps.AddNightLog(L.T("nightlog.mined", Fmt.Num(mined))); + foreach (var name in report.Burnt) ps.AddNightLog(L.T("nightlog.burnt", name), "cc7755"); + int unpowered = report.Rigs - report.PoweredRigs; + if (unpowered > 0 && units.Count + unpowered > 0) ps.AddNightLog(L.T("nightlog.unpowered", unpowered), "cc7755"); + Session.Save(); + } + } + + /// mod 新闻:不带价格修正的新闻事件,和原版新闻一样出现在报纸上。 + public static class News + { + public static void Show(string id, string title, string text, int days) + { + var mgr = StoreStation.Instance?.storeEventManager; + if (mgr == null || mgr.IsEventActive(id)) return; + mgr.ActivateEvent(new StoreEvent + { + identifier = id, newsName = title, displayName = title, newsDescription = text, + duration = days, eventType = StoreEvent.EventType.COSMETIC, eventArea = StoreEvent.EventArea.ALL, + importance = 2, cooldown = 0, negociationDatas = new List(), + }); + } + } +} diff --git a/src/Decentralized/Session.cs b/src/Decentralized/Session.cs new file mode 100644 index 0000000..c53a5f0 --- /dev/null +++ b/src/Decentralized/Session.cs @@ -0,0 +1,76 @@ +using System; +using System.Collections.Generic; +using Decentralized.Domain; +using Newtonsoft.Json; + +namespace Decentralized +{ + /// 上一晚的挖矿报告(终端"报告"页和晨间日志)。 + public sealed class NightReport + { + public int Day; + public int Rigs, PoweredRigs; + public double Hash, NetworkHash, Mined, Price, Kj; + public List Burnt = new List(); + } + + /// 一局的 mod 状态。整体序列化成 JSON 存进游戏的 mod 存档数据;显卡的健康度等存在显卡物品的标签上。 + public sealed class SaveData + { + public int Version = 1; + public int Day; + public Rng Rng = new Rng((ulong)DateTime.UtcNow.Ticks); + public Market Market; + public Stablecoins Stable = new Stablecoins(); + public Network Network = new Network(); + public Wallet Hot = new Wallet { Address = "hot" }; + public Exchange Exchange = new Exchange(); + public List ActiveEvents = new List(); + public NightReport LastNight; + public bool OpeningNewsShown; + } + + /// 当前这一局的状态:读档 / 新游戏时载入,睡觉结算后写回。 + public static class Session + { + private const string Key = "state"; + public static SaveData Data; + + /// 今晚有电的矿架上的显卡(矿架在夜间早段扣电时登记,结算时用)。 + public static readonly List TonightCards = new List(); + public static int TonightRigs, TonightPoweredRigs; + public static double TonightKj; + + public static void Load() + { + Data = null; + try + { + string json = ModHelper.GetModData(ModInfo.Id, Key); + if (!string.IsNullOrEmpty(json)) Data = JsonConvert.DeserializeObject(json); + } + catch (Exception ex) { Log.Error($"读取存档数据失败,重新开始:{ex.Message}"); } + if (Data == null) Data = new SaveData(); + if (Data.Market == null) Data.Market = Market.CreateDefault(StoreStation.GetDayCounter()); + Log.Info($"状态已载入(第 {Data.Day} 天,PSCoin {Data.Market.Price:0.00})"); + } + + public static void Save() + { + if (Data == null) return; + try { ModHelper.SetModData(ModInfo.Id, Key, JsonConvert.SerializeObject(Data)); } + catch (Exception ex) { Log.Error($"写入存档数据失败:{ex.Message}"); } + } + + /// 链上资产的信用点价格。 + public static double Price(string asset) + { + if (asset == Assets.PSC) return Data.Market.Price; + var spec = Assets.Get(asset); + if (spec != null && spec.Stable) return Data.Stable.Price(asset); + return 0; + } + + public static double Rent() => PlayerStore.Instance != null ? PlayerStore.Instance.rentValue : 200; + } +} diff --git a/src/Decentralized/TerminalWindow.cs b/src/Decentralized/TerminalWindow.cs new file mode 100644 index 0000000..f31cb7c --- /dev/null +++ b/src/Decentralized/TerminalWindow.cs @@ -0,0 +1,151 @@ +using System; +using System.Globalization; +using System.Linq; +using Decentralized.Domain; + +namespace Decentralized +{ + /// 交易终端窗口:钱包、交易、报告三页。每次操作后按当前页重建窗口。 + public static class TerminalWindow + { + public const string Id = "pd_terminal"; + private enum Page { Wallet, Trade, Report } + + private static Page _page = Page.Wallet; + private static string _amount = ""; + private static string _message; + + public static void Open() + { + if (Session.Data == null) return; + var b = CustomUIManager.Window(Id, L.T("term.title")); + if (b == null) return; + b.SetSize(620, 560).SetDraggable(true).SetCloseOnEscape(true).UseMachineryStyle(); + b.BeginRow(); + foreach (Page p in Enum.GetValues(typeof(Page))) + { + var page = p; + string name = L.T("tab." + p.ToString().ToLowerInvariant()); + b.AddButton(page == _page ? $"[{name}]" : name, () => { _page = page; _message = null; Open(); }); + } + b.End(); + b.AddSeparator(); + b.BeginScroll(440); + switch (_page) + { + case Page.Wallet: WalletPage(b); break; + case Page.Trade: TradePage(b); break; + default: ReportPage(b); break; + } + b.End(); + if (!string.IsNullOrEmpty(_message)) b.AddLabel(_message); + b.Show(); + } + + private static void WalletPage(CustomUIBuilder b) + { + var w = Session.Data.Hot; + if (w.Balances.Count == 0) b.AddLabel(L.T("wallet.empty")); + foreach (var kv in w.Balances.OrderBy(k => k.Key)) + b.AddLabel(L.T("wallet.line", kv.Key, Fmt.Num(kv.Value), Fmt.Credits(kv.Value * Session.Price(kv.Key)))); + b.AddSeparator(); + b.AddLabel(L.T("wallet.total", Fmt.Credits(w.Value(Session.Price)))); + if (w.Taint > 0.5) b.AddLabel(L.T("wallet.taint", Fmt.Credits(w.Taint))); + } + + private static void TradePage(CustomUIBuilder b) + { + b.AddLabel(L.T("trade.hint")); + b.BeginRow(); + b.AddLabel(L.T("trade.amount")); + b.AddInput("0", _amount, s => _amount = s, "amount", s => _amount = s); + b.End(); + b.BeginRow(); + b.AddButton(L.T("trade.deposit"), Deposit); + b.AddButton(L.T("trade.withdraw"), Withdraw); + b.End(); + b.AddSeparator(); + foreach (var pool in Session.Data.Market.Pools) + { + 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(); + b.AddButton(L.T("trade.buy"), () => Trade(p, buy: true)); + b.AddButton(L.T("trade.sell"), () => Trade(p, buy: false)); + b.End(); + } + } + + private static void ReportPage(CustomUIBuilder b) + { + var r = Session.Data.LastNight; + if (r == null) { b.AddLabel(L.T("report.none")); return; } + b.AddHeader(L.T("report.day", r.Day)); + b.AddLabel(L.T("report.rigs", r.PoweredRigs, r.Rigs)); + b.AddLabel(L.T("report.hash", Fmt.Num(r.Hash * 10), Fmt.Num(r.NetworkHash * 10))); + 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))); + b.AddLabel(L.T("report.burnt", r.Burnt.Count)); + foreach (var n in r.Burnt) b.AddLabel(" " + n); + b.AddLabel(L.T("report.price", Fmt.Num(r.Price))); + } + + 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.Hot; + w.Add(Assets.CRC, credits / Session.Data.Stable.Crc); + 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.Hot.Get(Assets.CRC) + 1e-9 < v) { _message = L.T("msg.balance"); Open(); return; } + var r = d.Exchange.Cashout(d.Hot, v, Session.Price, StoreStation.GetDayCounter(), Session.Rent(), false, 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(); + } + + private static void Trade(Pool p, bool buy) + { + if (!ReadAmount(out double v)) return; + var w = Session.Data.Hot; + string pay = buy ? p.Quote : p.Base, get = buy ? p.Base : p.Quote; + if (w.Get(pay) + 1e-9 < v) { _message = L.T("msg.balance"); Open(); return; } + double got = buy ? p.BuyBase(v) : p.SellBase(v); + w.Add(pay, -v); + w.Add(get, got); + _message = L.T("msg.trade", Fmt.Num(v), pay, Fmt.Num(got), get); + Session.Save(); + Open(); + } + } +} diff --git a/src/Decentralized/Unlock.cs b/src/Decentralized/Unlock.cs new file mode 100644 index 0000000..841bbe9 --- /dev/null +++ b/src/Decentralized/Unlock.cs @@ -0,0 +1,37 @@ +using System; + +namespace Decentralized +{ + /// 人脉网络里的"矿业渠道":买下后交付交易终端和一个矿架,并开始每天的行情与挖矿结算。 + public static class Unlock + { + public const string Id = "PD_MINING_ACCESS"; + public const int Price = 300; + + public static bool IsUnlocked => PlayerStore.Instance != null && ModHelper.IsNetworkUpgradeUnlocked(Id); + + public static void Register() + { + try + { + // 文字都用回调:OnEnable 时 mod 本地化表还没加载 + ModHelper.AddNetworkUpgrade(ModInfo.Id, Id, Price, "", + () => L.T("upgrade.title"), () => L.T("upgrade.desc"), OnPurchased, + iconKey: ModInfo.Id + ":rig", subtitleLabel: () => L.T("upgrade.card")); + } + catch (NullReferenceException) + { + // 登记定义后游戏会访问 PlayerStore.Instance,主菜单阶段为空会抛异常;定义已登记,读档时游戏会把升级补进这一局。 + Log.Info("人脉升级已登记(主菜单阶段访问 PlayerStore 失败,已忽略)"); + } + } + + private static void OnPurchased() + { + Items.Give(ModHelper.SpawnItem(Items.Terminal)); + Items.Give(ModHelper.SpawnItem(Items.Rig)); + Items.Give(ModHelper.SpawnItem(Items.GpuId("RZX50"))); + Log.Info("已解锁矿业渠道"); + } + } +} diff --git a/tests/Decentralized.Domain.Tests/Program.cs b/tests/Decentralized.Domain.Tests/Program.cs index 2a4de91..3151ae4 100644 --- a/tests/Decentralized.Domain.Tests/Program.cs +++ b/tests/Decentralized.Domain.Tests/Program.cs @@ -276,7 +276,43 @@ internal static class Program Check(Near(dr.Received, 900 * (1 - Tuning.ExchangeFee), 1e-6), "CRC 脱锚时按市价兑出"); }); + Test("存档:市场、稳定币、全网、钱包、交易所、随机数序列化后读回,之后的结果一致", () => + { + var m = Market.CreateDefault(3); + var st = new Stablecoins(); + var net = new Network(); + var w = new Wallet { Address = "hot", Taint = 12, Basis = 34 }; + w.Add(Assets.PSC, 5); + var ex = new Exchange(); + ex.History.Add(new KeyValuePair(2, 100)); + var rng = new Rng(77); + for (int d = 1; d <= 5; d++) { m.Night(new MarketEvent[0], rng, d, a => 1); st.Night(d, rng, 0); net.Night(d, m.Price, 10); } + var bundle = new SaveBundle { Market = m, Stable = st, Network = net, Hot = w, Exchange = ex, Rng = rng }; + string json = Newtonsoft.Json.JsonConvert.SerializeObject(bundle); + var back = Newtonsoft.Json.JsonConvert.DeserializeObject(json); + Check(Near(back.Market.Price, m.Price, 1e-9) && back.Market.Pools.Count == m.Pools.Count && back.Market.Pools[1].Id == m.Pools[1].Id, "交易对与价格"); + Check(back.Hot.Get(Assets.PSC) == 5 && back.Hot.Taint == 12 && back.Hot.Basis == 34, "钱包余额、污点、成本"); + Check(back.Exchange.History.Count == 1 && back.Exchange.History[0].Value == 100, "出金记录"); + Check(Near(back.Network.NpcHash, net.NpcHash, 1e-9) && Near(back.Stable.CraSubsidy, st.CraSubsidy, 1e-9), "全网算力、CRA 补贴池"); + for (int d = 6; d <= 10; d++) + { + m.Night(new MarketEvent[0], rng, d, a => 1); + back.Market.Night(new MarketEvent[0], back.Rng, d, a => 1); + } + Check(Near(back.Market.Price, m.Price, 1e-9), "读回后继续推进,行情与原来完全相同"); + }); + Console.WriteLine($"\n{_passed} 通过,{_failed} 失败"); return _failed == 0 ? 0 : 1; } } + +internal sealed class SaveBundle +{ + public Market Market; + public Stablecoins Stable; + public Network Network; + public Wallet Hot; + public Exchange Exchange; + public Rng Rng; +}