diff --git a/src/EDNexus.Core/State/StateTracker.cs b/src/EDNexus.Core/State/StateTracker.cs index 5f106e3..823ca83 100644 --- a/src/EDNexus.Core/State/StateTracker.cs +++ b/src/EDNexus.Core/State/StateTracker.cs @@ -1,6 +1,7 @@ using System.Collections.Concurrent; using System.Text.Json; using System.Text.RegularExpressions; +using EDNexus.Core.Engineering; using EDNexus.Core.Journal; namespace EDNexus.Core.State; @@ -32,6 +33,17 @@ public StateTracker(JournalEventBus bus, CommanderState state) bus.Subscribe("Cargo", OnCargo); bus.Subscribe("Materials", OnMaterials); bus.Subscribe("MaterialCollected", OnMaterialCollected); + // Everything that SPENDS or swaps materials. The game writes a full Materials snapshot only at + // launch and relog, so without these the inventory only ever goes up between snapshots. + bus.Subscribe("MaterialTrade", OnMaterialTrade); + bus.Subscribe("MaterialDiscarded", OnNamedMaterialSpent); + bus.Subscribe("EngineerCraft", OnEngineerCraft); + bus.Subscribe("EngineerLegacyConvert", OnEngineerLegacyConvert); + bus.Subscribe("Synthesis", OnSynthesis); + bus.Subscribe("TechnologyBroker", OnTechnologyBroker); + bus.Subscribe("EngineerContribution", OnEngineerContribution); + bus.Subscribe("ScientificResearch", OnNamedMaterialSpent); + bus.Subscribe("MissionCompleted", OnMissionCompleted); // the one thing that GIVES materials besides a pickup or a trade bus.Subscribe("ShipLocker", OnShipLocker); bus.Subscribe("BackpackChange", OnBackpackChange); bus.Subscribe("SuitLoadout", OnSuitLoadout); @@ -241,11 +253,165 @@ private void OnMaterialCollected(JournalEntry e) var name = e.GetString("Name"); if (name is null || category is null) return; - var count = (int)(e.GetInt64("Count") ?? 1); - TargetFor(category).AddOrUpdate(name, count, (_, v) => v + count); - _state.RaiseMaterialsChanged(); + if (Int(e.Raw, "Count", fallback: 1) is int count && Gain(TargetFor(category), name, count)) + _state.RaiseMaterialsChanged(); } + /// + /// A material trader swap: what was paid leaves, what was received arrives. Without this a commander + /// who trades sees the old stock and none of the new, until the next game launch. + /// + private void OnMaterialTrade(JournalEntry e) + { + var changed = false; + if (e.Raw.TryGetProperty("Paid", out var paid) && paid.ValueKind == JsonValueKind.Object) + changed |= Int(paid, "Quantity") is int quantity && Spend(Str(paid, "Material"), quantity, Str(paid, "Category")); + if (e.Raw.TryGetProperty("Received", out var received) && received.ValueKind == JsonValueKind.Object + && Str(received, "Material") is { } material && Str(received, "Category") is { } category) + changed |= Int(received, "Quantity") is int quantity && Gain(TargetFor(category), material, quantity); + if (changed) _state.RaiseMaterialsChanged(); + } + + /// + /// Discarding a material and Guardian/Thargoid research each name one { Name, Category, Count } + /// to take out of the inventory. + /// + private void OnNamedMaterialSpent(JournalEntry e) + { + if (Int(e.Raw, "Count", fallback: 1) is int count && Spend(e.GetString("Name"), count, e.GetString("Category"))) + _state.RaiseMaterialsChanged(); + } + + /// An engineer modification consumes its ingredients (Ingredients: Name/Count, no category). + private void OnEngineerCraft(JournalEntry e) => SpendAll(e, "Ingredients"); + + /// + /// Converting a legacy engineered module costs ingredients like a roll does, unless the event is only a + /// preview of that cost. + /// + private void OnEngineerLegacyConvert(JournalEntry e) + { + if (e.GetBool("IsPreview") == true) return; + SpendAll(e, "Ingredients"); + } + + /// Synthesis (ammo, repair, FSD boost…) consumes the listed materials. + private void OnSynthesis(JournalEntry e) => SpendAll(e, "Materials"); + + /// + /// Unlocking a technology broker module consumes materials (and commodities, which are cargo and not + /// handled here). + /// + private void OnTechnologyBroker(JournalEntry e) => SpendAll(e, "Materials"); + + /// + /// Handing materials to an engineer to unlock them. Read by what the event names rather than by its + /// Type: a commodity, credits, bond or bounty contribution carries no Material (commodities + /// are cargo, not this), so only a material contribution has anything to take out. + /// + private void OnEngineerContribution(JournalEntry e) + { + if (Int(e.Raw, "Quantity", fallback: 1) is int quantity && Spend(e.GetString("Material"), quantity, e.GetString("Category"))) + _state.RaiseMaterialsChanged(); + } + + /// + /// A mission can pay out ship materials (MaterialsReward: Name/Category/Count). Only the three ship + /// categories count: an Odyssey reward arrives as $MICRORESOURCE_CATEGORY_*, which is ship-locker + /// stock the ShipLocker snapshot keeps right, and must not land in an engineering bucket. + /// + private void OnMissionCompleted(JournalEntry e) + { + if (!e.Raw.TryGetProperty("MaterialsReward", out var rewards) || rewards.ValueKind != JsonValueKind.Array) return; + + var changed = false; + foreach (var reward in rewards.EnumerateArray()) + { + if (reward.ValueKind != JsonValueKind.Object) continue; + if (Str(reward, "Name") is not { } name || Str(reward, "Category") is not { } category) continue; + if (!IsShipMaterialCategory(category)) continue; + changed |= Int(reward, "Count", fallback: 1) is int count && Gain(TargetFor(category), name, count); + } + + if (changed) _state.RaiseMaterialsChanged(); + } + + private static bool IsShipMaterialCategory(string category) + => category.Equals("Raw", StringComparison.OrdinalIgnoreCase) + || category.Equals("Manufactured", StringComparison.OrdinalIgnoreCase) + || category.Equals("Encoded", StringComparison.OrdinalIgnoreCase); + + /// Spends every { Name, Count[, Category] } in the named array and raises one change. + private void SpendAll(JournalEntry e, string arrayProperty) + { + if (!e.Raw.TryGetProperty(arrayProperty, out var items) || items.ValueKind != JsonValueKind.Array) return; + + var changed = false; + foreach (var item in items.EnumerateArray()) + { + if (item.ValueKind != JsonValueKind.Object) continue; + changed |= Int(item, "Count", fallback: 1) is int count && Spend(Str(item, "Name"), count, Str(item, "Category")); + } + + if (changed) _state.RaiseMaterialsChanged(); + } + + /// + /// Adds to a material, up to its cap. The game logs the pickup but only + /// holds up to the cap (100 for a grade 5, 300 for a grade 1…), so counting it all would leave the + /// inventory above what the commander really has, and every later subtraction would be off by the same + /// amount. A material the catalogue does not know has no known cap and is not limited. Never lowers a + /// count that is already above the cap, since that would be the catalogue disagreeing with the game. + /// Returns whether the stored count changed (a pickup at the cap changes nothing). + /// + private static bool Gain(ConcurrentDictionary bucket, string name, int count) + { + if (count <= 0) return false; + var cap = EngineeringCatalog.Default.Material(name)?.Cap ?? int.MaxValue; + var had = bucket.TryGetValue(name, out var held); + var next = !had ? Math.Min(count, cap) + : held >= cap ? held + : (int)Math.Min((long)held + count, cap); + bucket[name] = next; + return !had || next != held; + } + + /// + /// Removes of a material, never below zero. Ingredients carry no category, + /// so unless one is given the material is looked for in all three buckets. A material that is not held + /// is ignored: spending what the tracker never saw (a log that starts mid-session) must not invent stock. + /// Returns whether the stored count changed (spending from zero changes nothing). + /// + private bool Spend(string? name, int count, string? category = null) + { + if (name is null || count <= 0) return false; + + var buckets = category is null + ? new[] { _state.Materials.Raw, _state.Materials.Manufactured, _state.Materials.Encoded } + : new[] { TargetFor(category), _state.Materials.Raw, _state.Materials.Manufactured, _state.Materials.Encoded }; + + foreach (var bucket in buckets) + { + if (!bucket.TryGetValue(name, out var held)) continue; + var next = Math.Max(0, held - count); + bucket[name] = next; + return next != held; + } + + return false; + } + + private static string? Str(JsonElement obj, string prop) + => obj.TryGetProperty(prop, out var v) && v.ValueKind == JsonValueKind.String ? v.GetString() : null; + + /// + /// A whole-number property: when it is absent, null when it is present but + /// not a usable integer — an invalid quantity is ignored, not read as "one". + /// + private static int? Int(JsonElement obj, string prop, int fallback = 0) + => !obj.TryGetProperty(prop, out var v) ? fallback + : v.ValueKind == JsonValueKind.Number && v.TryGetInt32(out var n) ? n : null; + private ConcurrentDictionary TargetFor(string category) => category.ToLowerInvariant() switch { "raw" => _state.Materials.Raw, diff --git a/tests/EDNexus.Tests/State/MaterialsTrackingTests.cs b/tests/EDNexus.Tests/State/MaterialsTrackingTests.cs new file mode 100644 index 0000000..afb7a91 --- /dev/null +++ b/tests/EDNexus.Tests/State/MaterialsTrackingTests.cs @@ -0,0 +1,502 @@ +using EDNexus.Core.Journal; +using EDNexus.Core.Materials; +using EDNexus.Core.State; +using Xunit; + +namespace EDNexus.Tests.State; + +/// +/// The ship materials inventory between full Materials snapshots. The game writes that snapshot +/// only at launch and relog; in between, the inventory has to follow the events that change it. Before, +/// only pickups were followed, so trading, engineering and synthesis left every later count wrong. +/// +public class MaterialsTrackingTests +{ + private static (JournalEventBus Bus, CommanderState State) Tracked() + { + var bus = new JournalEventBus(); + var state = new CommanderState(); + _ = new StateTracker(bus, state); + return (bus, state); + } + + private static void Publish(JournalEventBus bus, string json) + { + Assert.True(JournalEntry.TryParse(json, historical: false, out var entry), json); + bus.Publish(entry); + } + + private static string Snapshot(string raw = "", string manufactured = "", string encoded = "") + => $$"""{ "timestamp":"2026-10-21T23:47:18Z", "event":"Materials", "Raw":[{{raw}}], "Manufactured":[{{manufactured}}], "Encoded":[{{encoded}}] }"""; + + private static string Item(string name, int count) => $$"""{ "Name":"{{name}}", "Count":{{count}} }"""; + + private static int Held(CommanderState s, string category, string name) + => MaterialInventory.CountsFor(s, category).GetValueOrDefault(name); + + // --- the case that was reported: a material trader ------------------------------------------------ + + [Fact] + public void A_material_trade_takes_what_was_paid_and_adds_what_was_received() + { + var (bus, state) = Tracked(); + Publish(bus, Snapshot(manufactured: Item("imperialshielding", 74) + "," + Item("chemicalmanipulators", 0))); + + // Exactly as the game wrote it. + Publish(bus, """ + { "timestamp":"2026-10-21T23:50:04Z", "event":"MaterialTrade", "MarketID":3230665216, "TraderType":"manufactured", + "Paid":{ "Material":"imperialshielding", "Material_Localised":"Imperial Shielding", "Category":"Manufactured", "Quantity":74 }, + "Received":{ "Material":"chemicalmanipulators", "Material_Localised":"Chemical Manipulators", "Category":"Manufactured", "Quantity":37 } } + """); + + Assert.Equal(0, Held(state, "Manufactured", "imperialshielding")); + Assert.Equal(37, Held(state, "Manufactured", "chemicalmanipulators")); + } + + [Fact] + public void A_trade_for_a_material_not_held_yet_creates_it() + { + var (bus, state) = Tracked(); + Publish(bus, Snapshot(raw: Item("iron", 100))); + + Publish(bus, """ + { "timestamp":"2026-10-21T23:50:04Z", "event":"MaterialTrade", "TraderType":"raw", + "Paid":{ "Material":"iron", "Category":"Raw", "Quantity":30 }, + "Received":{ "Material":"vanadium", "Category":"Raw", "Quantity":6 } } + """); + + Assert.Equal(70, Held(state, "Raw", "iron")); + Assert.Equal(6, Held(state, "Raw", "vanadium")); + } + + [Fact] + public void A_trade_raises_one_materials_changed() + { + var (bus, state) = Tracked(); + Publish(bus, Snapshot(raw: Item("iron", 100))); + var raised = 0; + state.MaterialsChanged += () => raised++; + + Publish(bus, """ + { "timestamp":"2026-10-21T23:50:04Z", "event":"MaterialTrade", + "Paid":{ "Material":"iron", "Category":"Raw", "Quantity":30 }, + "Received":{ "Material":"vanadium", "Category":"Raw", "Quantity":6 } } + """); + + Assert.Equal(1, raised); // the card refreshes once, not once per half of the trade + } + + // --- engineering ---------------------------------------------------------------------------------- + + [Fact] + public void An_engineer_craft_spends_its_ingredients_which_carry_no_category() + { + var (bus, state) = Tracked(); + Publish(bus, Snapshot( + raw: Item("sulphur", 295) + "," + Item("iron", 181), + manufactured: Item("heatconductionwiring", 9))); + + Publish(bus, """ + { "timestamp":"2026-10-22T00:41:01Z", "event":"EngineerCraft", "Slot":"PowerPlant", "Module":"int_powerplant_size5_class5", + "Ingredients":[ { "Name":"sulphur", "Count":1 }, { "Name":"heatconductionwiring", "Count":2 } ], + "Engineer":"Hera Tani", "EngineerID":300090, "BlueprintName":"PowerPlant_Boosted", "Level":1, "Quality":0.3333 } + """); + + Assert.Equal(294, Held(state, "Raw", "sulphur")); // found in Raw + Assert.Equal(7, Held(state, "Manufactured", "heatconductionwiring")); // and in Manufactured + Assert.Equal(181, Held(state, "Raw", "iron")); // untouched + } + + [Fact] + public void A_craft_with_several_ingredients_raises_one_change() + { + var (bus, state) = Tracked(); + Publish(bus, Snapshot(raw: Item("sulphur", 10) + "," + Item("iron", 10) + "," + Item("carbon", 10))); + var raised = 0; + state.MaterialsChanged += () => raised++; + + Publish(bus, """{ "timestamp":"2026-10-22T00:41:01Z", "event":"EngineerCraft", "Ingredients":[ { "Name":"sulphur", "Count":1 }, { "Name":"iron", "Count":1 }, { "Name":"carbon", "Count":1 } ] }"""); + + Assert.Equal(1, raised); + } + + [Fact] + public void Material_names_match_whatever_the_case() + { + var (bus, state) = Tracked(); + Publish(bus, Snapshot(raw: Item("Sulphur", 10))); + + Publish(bus, """{ "timestamp":"2026-10-22T00:41:01Z", "event":"EngineerCraft", "Ingredients":[ { "Name":"SULPHUR", "Count":4 } ] }"""); + + Assert.Equal(6, Held(state, "Raw", "sulphur")); + } + + // --- the other ways materials are spent ---------------------------------------------------------- + + [Fact] + public void Discarding_synthesis_and_research_each_spend_what_they_name() + { + var (bus, state) = Tracked(); + Publish(bus, Snapshot( + raw: Item("iron", 50) + "," + Item("nickel", 50) + "," + Item("zinc", 50), + encoded: Item("shielddensityreports", 20))); + + Publish(bus, """{ "timestamp":"2026-10-22T00:50:00Z", "event":"MaterialDiscarded", "Category":"Raw", "Name":"iron", "Count":5 }"""); + Publish(bus, """{ "timestamp":"2026-10-22T00:51:00Z", "event":"Synthesis", "Name":"Repair Basic", "Materials":[ { "Name":"nickel", "Count":2 }, { "Name":"zinc", "Count":1 } ] }"""); + Publish(bus, """{ "timestamp":"2026-10-22T00:52:00Z", "event":"ScientificResearch", "Name":"shielddensityreports", "Category":"Encoded", "Count":3 }"""); + + Assert.Equal(45, Held(state, "Raw", "iron")); + Assert.Equal(48, Held(state, "Raw", "nickel")); + Assert.Equal(49, Held(state, "Raw", "zinc")); + Assert.Equal(17, Held(state, "Encoded", "shielddensityreports")); + } + + [Fact] + public void A_technology_broker_unlock_spends_its_materials_but_not_its_commodities() + { + var (bus, state) = Tracked(); + Publish(bus, Snapshot(manufactured: Item("chemicalmanipulators", 10))); + state.Cargo["Gold"] = 4; + + Publish(bus, """ + { "timestamp":"2026-10-22T01:00:00Z", "event":"TechnologyBroker", "BrokerType":"guardian", "MarketID":1, "ItemsUnlocked":[], + "Commodities":[ { "Name":"gold", "Count":4 } ], + "Materials":[ { "Name":"chemicalmanipulators", "Count":3, "Category":"Manufactured" } ] } + """); + + Assert.Equal(7, Held(state, "Manufactured", "chemicalmanipulators")); + Assert.Equal(4, state.Cargo["Gold"]); // cargo is the cargo card's business + } + + [Fact] + public void An_engineer_contribution_spends_materials_only() + { + var (bus, state) = Tracked(); + Publish(bus, Snapshot(raw: Item("iron", 40))); + + Publish(bus, """{ "timestamp":"2026-10-22T01:10:00Z", "event":"EngineerContribution", "Engineer":"Hera Tani", "Type":"Commodity", "Commodity":"kamitracigars", "Quantity":50, "TotalQuantity":50 }"""); + Assert.Equal(40, Held(state, "Raw", "iron")); // a commodity contribution changes no material + + Publish(bus, """{ "timestamp":"2026-10-22T01:11:00Z", "event":"EngineerContribution", "Engineer":"Hera Tani", "Type":"Material", "Material":"iron", "Category":"Raw", "Quantity":15, "TotalQuantity":15 }"""); + Assert.Equal(25, Held(state, "Raw", "iron")); + } + + // --- the cap: the game logs a pickup but only holds so much -------------------------------------- + + [Fact] + public void A_pickup_beyond_the_cap_is_not_counted() + { + var (bus, state) = Tracked(); + // Imperial Shielding is grade 5: the ship holds 100. + Publish(bus, Snapshot(manufactured: Item("imperialshielding", 98))); + + Publish(bus, """{ "timestamp":"2026-10-21T23:58:25Z", "event":"MaterialCollected", "Category":"Manufactured", "Name":"imperialshielding", "Count":4 }"""); + + Assert.Equal(100, Held(state, "Manufactured", "imperialshielding")); + } + + [Fact] + public void A_capped_pickup_does_not_make_a_later_spend_come_out_wrong() + { + // The real case from a journal: 102 counted, 100 held. Trading 26 away left the card on 76 while the + // game had 74, and every count after that was off by two. + var (bus, state) = Tracked(); + Publish(bus, Snapshot(manufactured: Item("imperialshielding", 98))); + Publish(bus, """{ "timestamp":"2026-10-21T23:58:25Z", "event":"MaterialCollected", "Category":"Manufactured", "Name":"imperialshielding", "Count":4 }"""); + + Publish(bus, """ + { "timestamp":"2026-10-22T00:09:17Z", "event":"MaterialTrade", "TraderType":"manufactured", + "Paid":{ "Material":"imperialshielding", "Category":"Manufactured", "Quantity":26 }, + "Received":{ "Material":"chemicalmanipulators", "Category":"Manufactured", "Quantity":13 } } + """); + + Assert.Equal(74, Held(state, "Manufactured", "imperialshielding")); + } + + [Fact] + public void Items_received_in_a_trade_are_capped_too() + { + var (bus, state) = Tracked(); + Publish(bus, Snapshot(raw: Item("iron", 100), manufactured: Item("imperialshielding", 90))); + + Publish(bus, """ + { "timestamp":"2026-10-22T00:09:17Z", "event":"MaterialTrade", "TraderType":"manufactured", + "Paid":{ "Material":"iron", "Category":"Raw", "Quantity":10 }, + "Received":{ "Material":"imperialshielding", "Category":"Manufactured", "Quantity":40 } } + """); + + Assert.Equal(100, Held(state, "Manufactured", "imperialshielding")); + } + + [Fact] + public void A_material_the_catalogue_does_not_know_is_never_capped_or_lowered() + { + var (bus, state) = Tracked(); + Publish(bus, Snapshot(raw: Item("some_future_material", 400))); + + Publish(bus, """{ "timestamp":"2026-10-22T00:00:00Z", "event":"MaterialCollected", "Category":"Raw", "Name":"some_future_material", "Count":50 }"""); + + Assert.Equal(450, Held(state, "Raw", "some_future_material")); + } + + // --- defensive parsing --------------------------------------------------------------------------- + + [Fact] + public void Spending_never_goes_below_zero_and_never_invents_stock() + { + var (bus, state) = Tracked(); + Publish(bus, Snapshot(raw: Item("iron", 2))); + + Publish(bus, """{ "timestamp":"2026-10-22T00:41:01Z", "event":"EngineerCraft", "Ingredients":[ { "Name":"iron", "Count":9 }, { "Name":"neverheld", "Count":3 } ] }"""); + + Assert.Equal(0, Held(state, "Raw", "iron")); + Assert.False(MaterialInventory.CountsFor(state, "Raw").ContainsKey("neverheld")); + } + + [Fact] + public void Malformed_material_events_never_throw_and_change_nothing() + { + var (bus, state) = Tracked(); + Publish(bus, Snapshot(raw: Item("iron", 10))); + + foreach (var json in new[] + { + """{ "timestamp":"2026-10-22T00:00:00Z", "event":"MaterialTrade" }""", + """{ "timestamp":"2026-10-22T00:00:00Z", "event":"MaterialTrade", "Paid":5, "Received":"x" }""", + """{ "timestamp":"2026-10-22T00:00:00Z", "event":"MaterialTrade", "Paid":{ "Material":"iron" }, "Received":{ "Material":"carbon" } }""", + """{ "timestamp":"2026-10-22T00:00:00Z", "event":"EngineerCraft" }""", + """{ "timestamp":"2026-10-22T00:00:00Z", "event":"EngineerCraft", "Ingredients":"lots" }""", + """{ "timestamp":"2026-10-22T00:00:00Z", "event":"EngineerCraft", "Ingredients":[ null, 5, { "Count":2 }, { "Name":7, "Count":"x" } ] }""", + """{ "timestamp":"2026-10-22T00:00:00Z", "event":"MaterialDiscarded", "Name":"iron", "Count":-4 }""", + """{ "timestamp":"2026-10-22T00:00:00Z", "event":"Synthesis", "Materials":null }""", + """{ "timestamp":"2026-10-22T00:00:00Z", "event":"TechnologyBroker" }""", + """{ "timestamp":"2026-10-22T00:00:00Z", "event":"EngineerContribution", "Type":"Material" }""", + """{ "timestamp":"2026-10-22T00:00:00Z", "event":"ScientificResearch" }""", + }) + { + Publish(bus, json); // the bus isolates handler errors, but a handler must not rely on that + } + + Assert.Equal(10, Held(state, "Raw", "iron")); + Assert.Equal(0, Held(state, "Raw", "carbon")); + } + + [Fact] + public void Nothing_to_change_raises_nothing() + { + var (bus, state) = Tracked(); + Publish(bus, Snapshot(raw: Item("iron", 10))); + var raised = 0; + state.MaterialsChanged += () => raised++; + + Publish(bus, """{ "timestamp":"2026-10-22T00:41:01Z", "event":"EngineerCraft", "Ingredients":[ { "Name":"neverheld", "Count":3 } ] }"""); + + Assert.Equal(0, raised); + } + + [Fact] + public void A_pickup_at_the_cap_changes_nothing_so_raises_nothing() + { + var (bus, state) = Tracked(); + Publish(bus, Snapshot(manufactured: Item("imperialshielding", 100))); // grade 5: already full + var raised = 0; + state.MaterialsChanged += () => raised++; + + Publish(bus, """{ "timestamp":"2026-10-22T00:41:01Z", "event":"MaterialCollected", "Category":"Manufactured", "Name":"imperialshielding", "Count":4 }"""); + Publish(bus, """ + { "timestamp":"2026-10-22T00:41:02Z", "event":"MaterialTrade", + "Paid":{ "Material":"neverheld", "Category":"Raw", "Quantity":5 }, + "Received":{ "Material":"imperialshielding", "Category":"Manufactured", "Quantity":9 } } + """); + + Assert.Equal(100, Held(state, "Manufactured", "imperialshielding")); + Assert.Equal(0, raised); + } + + [Fact] + public void Spending_a_material_held_at_zero_changes_nothing_so_raises_nothing() + { + var (bus, state) = Tracked(); + Publish(bus, Snapshot(manufactured: Item("chemicalmanipulators", 0))); // the game lists spent-out materials at 0 + var raised = 0; + state.MaterialsChanged += () => raised++; + + Publish(bus, """{ "timestamp":"2026-10-22T00:41:01Z", "event":"EngineerCraft", "Ingredients":[ { "Name":"chemicalmanipulators", "Count":2 } ] }"""); + + Assert.Equal(0, raised); + } + + [Fact] + public void A_missing_quantity_means_one_but_an_unusable_one_is_ignored() + { + var (bus, state) = Tracked(); + Publish(bus, Snapshot(raw: Item("iron", 10))); + + Publish(bus, """{ "timestamp":"2026-10-22T00:00:00Z", "event":"MaterialDiscarded", "Category":"Raw", "Name":"iron" }"""); + Assert.Equal(9, Held(state, "Raw", "iron")); // absent Count: the game's single-item default + + foreach (var count in new[] { "\"x\"", "null", "1.5", "99999999999" }) + { + Publish(bus, $$"""{ "timestamp":"2026-10-22T00:00:01Z", "event":"MaterialDiscarded", "Category":"Raw", "Name":"iron", "Count":{{count}} }"""); + Publish(bus, $$"""{ "timestamp":"2026-10-22T00:00:02Z", "event":"EngineerCraft", "Ingredients":[ { "Name":"iron", "Count":{{count}} } ] }"""); + Publish(bus, $$"""{ "timestamp":"2026-10-22T00:00:03Z", "event":"MaterialTrade", "Paid":{ "Material":"iron", "Category":"Raw", "Quantity":{{count}} }, "Received":{ "Material":"carbon", "Category":"Raw", "Quantity":{{count}} } }"""); + Publish(bus, $$"""{ "timestamp":"2026-10-22T00:00:04Z", "event":"MaterialCollected", "Category":"Raw", "Name":"iron", "Count":{{count}} }"""); + } + + Assert.Equal(9, Held(state, "Raw", "iron")); + Assert.Equal(0, Held(state, "Raw", "carbon")); + } + + // --- the other things that change the inventory ------------------------------------------------ + + [Fact] + public void A_mission_that_pays_ship_materials_adds_them_up_to_the_cap() + { + var (bus, state) = Tracked(); + Publish(bus, Snapshot(raw: Item("iron", 20), manufactured: Item("imperialshielding", 99))); + var raised = 0; + state.MaterialsChanged += () => raised++; + + // Fields as documented for MaterialsReward (Name / Category / Count). + Publish(bus, """ + { "timestamp":"2026-10-22T02:00:00Z", "event":"MissionCompleted", "MissionID":1, "Reward":100000, + "MaterialsReward":[ + { "Name":"iron", "Category":"Raw", "Count":5 }, + { "Name":"imperialshielding", "Category":"Manufactured", "Count":4 }, + { "Name":"vanadium", "Category":"Raw", "Count":3 } ] } + """); + + Assert.Equal(25, Held(state, "Raw", "iron")); + Assert.Equal(100, Held(state, "Manufactured", "imperialshielding")); // grade 5: capped + Assert.Equal(3, Held(state, "Raw", "vanadium")); // not held before + Assert.Equal(1, raised); // one change for the whole reward + } + + [Fact] + public void An_odyssey_micro_resource_reward_is_not_an_engineering_material() + { + var (bus, state) = Tracked(); + Publish(bus, Snapshot(raw: Item("iron", 20))); + var raised = 0; + state.MaterialsChanged += () => raised++; + + // As the game wrote it: on-foot stock, restored by ShipLocker, not one of the three ship categories. + Publish(bus, """ + { "timestamp":"2026-09-18T19:40:00Z", "event":"MissionCompleted", "MissionID":2, "Reward":50000, + "MaterialsReward":[ { "Name":"WeaponSchematic", "Name_Localised":"Weapon Schematic", + "Category":"$MICRORESOURCE_CATEGORY_Item;", "Category_Localised":"Item", "Count":10 } ] } + """); + + Assert.Empty(MaterialInventory.CountsFor(state, "Encoded")); + Assert.Equal(20, Held(state, "Raw", "iron")); + Assert.Equal(0, raised); + } + + [Fact] + public void A_mission_without_a_material_reward_changes_nothing() + { + var (bus, state) = Tracked(); + Publish(bus, Snapshot(raw: Item("iron", 20))); + + foreach (var json in new[] + { + """{ "timestamp":"2026-10-22T02:00:00Z", "event":"MissionCompleted", "MissionID":3, "Reward":1 }""", + """{ "timestamp":"2026-10-22T02:00:00Z", "event":"MissionCompleted", "MaterialsReward":"lots" }""", + """{ "timestamp":"2026-10-22T02:00:00Z", "event":"MissionCompleted", "MaterialsReward":[ null, 5, { "Name":"iron" }, { "Category":"Raw", "Count":2 }, { "Name":"iron", "Category":"Raw", "Count":"x" } ] }""", + }) + { + Publish(bus, json); + } + + Assert.Equal(20, Held(state, "Raw", "iron")); + } + + [Fact] + public void Converting_a_legacy_module_spends_its_ingredients_unless_it_is_only_a_preview() + { + var (bus, state) = Tracked(); + Publish(bus, Snapshot(raw: Item("iron", 50), manufactured: Item("heatconductionwiring", 9))); + + // Shape as the community reference trackers read it: the same Ingredients list as EngineerCraft. + Publish(bus, """{ "timestamp":"2026-10-22T02:10:00Z", "event":"EngineerLegacyConvert", "IsPreview":true, "Ingredients":[ { "Name":"iron", "Count":10 } ] }"""); + Assert.Equal(50, Held(state, "Raw", "iron")); + + Publish(bus, """{ "timestamp":"2026-10-22T02:10:30Z", "event":"EngineerLegacyConvert", "IsPreview":false, "Ingredients":[ { "Name":"iron", "Count":10 }, { "Name":"heatconductionwiring", "Count":2 } ] }"""); + Assert.Equal(40, Held(state, "Raw", "iron")); + Assert.Equal(7, Held(state, "Manufactured", "heatconductionwiring")); + } + + [Fact] + public void An_engineer_contribution_is_read_by_the_material_it_names_not_by_its_type_string() + { + var (bus, state) = Tracked(); + Publish(bus, Snapshot(raw: Item("iron", 40))); + + Publish(bus, """{ "timestamp":"2026-10-22T01:11:00Z", "event":"EngineerContribution", "Engineer":"Hera Tani", "Type":"Materials", "Material":"iron", "Quantity":15, "TotalQuantity":15 }"""); + Assert.Equal(25, Held(state, "Raw", "iron")); + + // Credits, bonds and bounties name no material, whatever else they carry. + Publish(bus, """{ "timestamp":"2026-10-22T01:12:00Z", "event":"EngineerContribution", "Engineer":"Hera Tani", "Type":"Credits", "Quantity":1000000, "TotalQuantity":1000000 }"""); + Assert.Equal(25, Held(state, "Raw", "iron")); + } + + [Fact] + public void A_wrong_category_on_an_event_still_finds_the_material_in_the_bucket_that_holds_it() + { + var (bus, state) = Tracked(); + Publish(bus, Snapshot(manufactured: Item("chemicalmanipulators", 10))); + + Publish(bus, """{ "timestamp":"2026-10-22T02:20:00Z", "event":"MaterialDiscarded", "Category":"Raw", "Name":"chemicalmanipulators", "Count":3 }"""); + + Assert.Equal(7, Held(state, "Manufactured", "chemicalmanipulators")); + Assert.False(MaterialInventory.CountsFor(state, "Raw").ContainsKey("chemicalmanipulators")); + } + + // --- the property that ties it together ---------------------------------------------------------- + + [Fact] + public void A_snapshot_plus_the_events_after_it_equals_the_next_snapshot() + { + // The game writes a full Materials snapshot at every launch and nothing changes while it is + // closed, so the tracked inventory at the end of a session has to equal the next snapshot. This is + // the same check that was run against a real commander's journals (15 consecutive snapshots, + // 179 items out before, none after), reduced to one session with a bit of everything. + var (bus, state) = Tracked(); + Publish(bus, Snapshot( + raw: Item("iron", 181) + "," + Item("sulphur", 295) + "," + Item("carbon", 278), + manufactured: Item("imperialshielding", 98) + "," + Item("fsdtelemetry", 26) + "," + Item("chemicalmanipulators", 0), + encoded: Item("hyperspacetrajectories", 16))); + + Publish(bus, """{ "timestamp":"2026-10-21T23:58:25Z", "event":"MaterialCollected", "Category":"Manufactured", "Name":"imperialshielding", "Count":4 }"""); // capped at 100 + Publish(bus, """{ "timestamp":"2026-10-21T23:59:00Z", "event":"MaterialCollected", "Category":"Raw", "Name":"carbon", "Count":30 }"""); // capped at 300 + Publish(bus, """{ "timestamp":"2026-10-22T00:09:17Z", "event":"MaterialTrade", "Paid":{ "Material":"imperialshielding", "Category":"Manufactured", "Quantity":26 }, "Received":{ "Material":"chemicalmanipulators", "Category":"Manufactured", "Quantity":13 } }"""); + Publish(bus, """{ "timestamp":"2026-10-22T00:41:01Z", "event":"EngineerCraft", "Ingredients":[ { "Name":"sulphur", "Count":1 }, { "Name":"fsdtelemetry", "Count":12 } ] }"""); + Publish(bus, """{ "timestamp":"2026-10-22T00:42:00Z", "event":"EngineerCraft", "Ingredients":[ { "Name":"hyperspacetrajectories", "Count":3 } ] }"""); + + // What the game would write at the next launch. + var expected = new Dictionary + { + ["iron"] = 181, ["sulphur"] = 294, ["carbon"] = 300, + ["imperialshielding"] = 74, ["fsdtelemetry"] = 14, ["chemicalmanipulators"] = 13, + ["hyperspacetrajectories"] = 13, + }; + + foreach (var (name, count) in expected) + { + var category = MaterialInventory.Categories.First(c => MaterialInventory.CountsFor(state, c).ContainsKey(name)); + Assert.True(count == Held(state, category, name), $"{name}: expected {count}, tracked {Held(state, category, name)}"); + } + } + + [Fact] + public void A_new_snapshot_replaces_whatever_the_events_had_made_of_the_inventory() + { + var (bus, state) = Tracked(); + Publish(bus, Snapshot(raw: Item("iron", 50))); + Publish(bus, """{ "timestamp":"2026-10-22T00:41:01Z", "event":"EngineerCraft", "Ingredients":[ { "Name":"iron", "Count":5 } ] }"""); + + Publish(bus, Snapshot(raw: Item("iron", 60))); + + Assert.Equal(60, Held(state, "Raw", "iron")); + } +}