From bf26d02ee08ca21a729253eb5792eb90f8bf2ce6 Mon Sep 17 00:00:00 2001 From: Kevin Dethlefs Date: Wed, 21 Oct 2026 20:02:28 -0500 Subject: [PATCH 1/3] Track materials spent, traded and engineered, not only collected The game writes a full Materials snapshot only at launch and relog. Between snapshots only MaterialCollected (which only adds) was followed, so trading, engineering, synthesis, discarding and research left every later count wrong and the Engineering card stale after a material trader. StateTracker now follows MaterialTrade, MaterialDiscarded, EngineerCraft, Synthesis, TechnologyBroker (materials only), EngineerContribution (materials only) and ScientificResearch. Ingredients carry no category, so they are looked up across the three buckets. Pickups and trade receipts are capped at the material's grade cap, since the game logs a pickup but only holds up to the cap (Imperial Shielding, grade 5: 100); counting it all left a 2 item error that carried through every later spend. Spending never goes below zero or invents stock; malformed events change nothing. Found by reconciling against real journals (previous Materials snapshot plus the events after it must equal the next snapshot): the old tracker was off by 179 items across 15 snapshot transitions; this one is off by 0. 14 of the 18 new tests fail on the old tracker. Co-Authored-By: Claude Sonnet 5.5 --- src/EDNexus.Core/State/StateTracker.cs | 123 ++++++- .../State/MaterialsTrackingTests.cs | 344 ++++++++++++++++++ 2 files changed, 466 insertions(+), 1 deletion(-) create mode 100644 tests/EDNexus.Tests/State/MaterialsTrackingTests.cs diff --git a/src/EDNexus.Core/State/StateTracker.cs b/src/EDNexus.Core/State/StateTracker.cs index 5f106e3..54a3bb9 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,15 @@ 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", OnMaterialDiscarded); + bus.Subscribe("EngineerCraft", OnEngineerCraft); + bus.Subscribe("Synthesis", OnSynthesis); + bus.Subscribe("TechnologyBroker", OnTechnologyBroker); + bus.Subscribe("EngineerContribution", OnEngineerContribution); + bus.Subscribe("ScientificResearch", OnScientificResearch); bus.Subscribe("ShipLocker", OnShipLocker); bus.Subscribe("BackpackChange", OnBackpackChange); bus.Subscribe("SuitLoadout", OnSuitLoadout); @@ -242,10 +252,121 @@ private void OnMaterialCollected(JournalEntry e) if (name is null || category is null) return; var count = (int)(e.GetInt64("Count") ?? 1); - TargetFor(category).AddOrUpdate(name, count, (_, v) => v + 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 |= Spend(Str(paid, "Material"), Int(paid, "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 |= Gain(TargetFor(category), material, Int(received, "Quantity")); + if (changed) _state.RaiseMaterialsChanged(); + } + + private void OnMaterialDiscarded(JournalEntry e) + { + if (Spend(e.GetString("Name"), (int)(e.GetInt64("Count") ?? 1), e.GetString("Category"))) + _state.RaiseMaterialsChanged(); + } + + /// An engineer modification consumes its ingredients (Ingredients: Name/Count, no category). + private void OnEngineerCraft(JournalEntry e) => 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. Commodity contributions are cargo, not this. + private void OnEngineerContribution(JournalEntry e) + { + if (!string.Equals(e.GetString("Type"), "Material", StringComparison.OrdinalIgnoreCase)) return; + var name = e.GetString("Material") ?? e.GetString("Name"); + if (Spend(name, (int)(e.GetInt64("Quantity") ?? 1), e.GetString("Category"))) + _state.RaiseMaterialsChanged(); + } + + /// Guardian/Thargoid research consumes materials. + private void OnScientificResearch(JournalEntry e) + { + if (Spend(e.GetString("Name"), (int)(e.GetInt64("Count") ?? 1), e.GetString("Category"))) + _state.RaiseMaterialsChanged(); + } + + /// 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 |= Spend(Str(item, "Name"), Int(item, "Count", fallback: 1), 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. + /// + private static bool Gain(ConcurrentDictionary bucket, string name, int count) + { + if (count <= 0) return false; + var cap = EngineeringCatalog.Default.Material(name)?.Cap ?? int.MaxValue; + bucket.AddOrUpdate( + name, + Math.Min(count, cap), + (_, held) => held >= cap ? held : (int)Math.Min((long)held + count, cap)); + return true; + } + + /// + /// 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. + /// + 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; + bucket[name] = Math.Max(0, held - count); + return true; + } + + return false; + } + + private static string? Str(JsonElement obj, string prop) + => obj.TryGetProperty(prop, out var v) && v.ValueKind == JsonValueKind.String ? v.GetString() : null; + + private static int Int(JsonElement obj, string prop, int fallback = 0) + => obj.TryGetProperty(prop, out var v) && v.ValueKind == JsonValueKind.Number && v.TryGetInt32(out var n) ? n : fallback; + 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..f2dcd1f --- /dev/null +++ b/tests/EDNexus.Tests/State/MaterialsTrackingTests.cs @@ -0,0 +1,344 @@ +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); + } + + // --- 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")); + } +} From 79b63816b54f36c3581458b74c4745345967e9ac Mon Sep 17 00:00:00 2001 From: Kevin Dethlefs Date: Wed, 21 Oct 2026 20:13:11 -0500 Subject: [PATCH 2/3] Materials: only report a change when a count really changed; ignore unusable quantities - Gain/Spend now return whether the stored count changed, so a pickup at the cap or a spend from zero no longer raises MaterialsChanged. MaterialCollected uses the result. - Int() distinguishes an absent property (default, e.g. 1) from a present but unusable one (string, null, fractional, out of int range), which is now ignored instead of being read as one item. All quantity readers use it. Co-Authored-By: Claude Sonnet 5.5 --- src/EDNexus.Core/State/StateTracker.cs | 44 ++++++++------- .../State/MaterialsTrackingTests.cs | 53 +++++++++++++++++++ 2 files changed, 79 insertions(+), 18 deletions(-) diff --git a/src/EDNexus.Core/State/StateTracker.cs b/src/EDNexus.Core/State/StateTracker.cs index 54a3bb9..cf32174 100644 --- a/src/EDNexus.Core/State/StateTracker.cs +++ b/src/EDNexus.Core/State/StateTracker.cs @@ -251,9 +251,8 @@ 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); - Gain(TargetFor(category), name, count); - _state.RaiseMaterialsChanged(); + if (Int(e.Raw, "Count", fallback: 1) is int count && Gain(TargetFor(category), name, count)) + _state.RaiseMaterialsChanged(); } /// @@ -264,16 +263,16 @@ private void OnMaterialTrade(JournalEntry e) { var changed = false; if (e.Raw.TryGetProperty("Paid", out var paid) && paid.ValueKind == JsonValueKind.Object) - changed |= Spend(Str(paid, "Material"), Int(paid, "Quantity"), Str(paid, "Category")); + 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 |= Gain(TargetFor(category), material, Int(received, "Quantity")); + changed |= Int(received, "Quantity") is int quantity && Gain(TargetFor(category), material, quantity); if (changed) _state.RaiseMaterialsChanged(); } private void OnMaterialDiscarded(JournalEntry e) { - if (Spend(e.GetString("Name"), (int)(e.GetInt64("Count") ?? 1), e.GetString("Category"))) + if (Int(e.Raw, "Count", fallback: 1) is int count && Spend(e.GetString("Name"), count, e.GetString("Category"))) _state.RaiseMaterialsChanged(); } @@ -294,14 +293,14 @@ private void OnEngineerContribution(JournalEntry e) { if (!string.Equals(e.GetString("Type"), "Material", StringComparison.OrdinalIgnoreCase)) return; var name = e.GetString("Material") ?? e.GetString("Name"); - if (Spend(name, (int)(e.GetInt64("Quantity") ?? 1), e.GetString("Category"))) + if (Int(e.Raw, "Quantity", fallback: 1) is int quantity && Spend(name, quantity, e.GetString("Category"))) _state.RaiseMaterialsChanged(); } /// Guardian/Thargoid research consumes materials. private void OnScientificResearch(JournalEntry e) { - if (Spend(e.GetString("Name"), (int)(e.GetInt64("Count") ?? 1), e.GetString("Category"))) + if (Int(e.Raw, "Count", fallback: 1) is int count && Spend(e.GetString("Name"), count, e.GetString("Category"))) _state.RaiseMaterialsChanged(); } @@ -314,7 +313,7 @@ private void SpendAll(JournalEntry e, string arrayProperty) foreach (var item in items.EnumerateArray()) { if (item.ValueKind != JsonValueKind.Object) continue; - changed |= Spend(Str(item, "Name"), Int(item, "Count", fallback: 1), Str(item, "Category")); + changed |= Int(item, "Count", fallback: 1) is int count && Spend(Str(item, "Name"), count, Str(item, "Category")); } if (changed) _state.RaiseMaterialsChanged(); @@ -326,22 +325,25 @@ private void SpendAll(JournalEntry e, string arrayProperty) /// 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; - bucket.AddOrUpdate( - name, - Math.Min(count, cap), - (_, held) => held >= cap ? held : (int)Math.Min((long)held + count, cap)); - return true; + 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) { @@ -354,8 +356,9 @@ private bool Spend(string? name, int count, string? category = null) foreach (var bucket in buckets) { if (!bucket.TryGetValue(name, out var held)) continue; - bucket[name] = Math.Max(0, held - count); - return true; + var next = Math.Max(0, held - count); + bucket[name] = next; + return next != held; } return false; @@ -364,8 +367,13 @@ private bool Spend(string? name, int count, string? category = null) private static string? Str(JsonElement obj, string prop) => obj.TryGetProperty(prop, out var v) && v.ValueKind == JsonValueKind.String ? v.GetString() : null; - private static int Int(JsonElement obj, string prop, int fallback = 0) - => obj.TryGetProperty(prop, out var v) && v.ValueKind == JsonValueKind.Number && v.TryGetInt32(out var n) ? n : fallback; + /// + /// 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 { diff --git a/tests/EDNexus.Tests/State/MaterialsTrackingTests.cs b/tests/EDNexus.Tests/State/MaterialsTrackingTests.cs index f2dcd1f..025fe59 100644 --- a/tests/EDNexus.Tests/State/MaterialsTrackingTests.cs +++ b/tests/EDNexus.Tests/State/MaterialsTrackingTests.cs @@ -294,6 +294,59 @@ public void Nothing_to_change_raises_nothing() 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 property that ties it together ---------------------------------------------------------- [Fact] From 6ed7e5814e169e9b4cfead75f28ba9b6a3838421 Mon Sep 17 00:00:00 2001 From: Kevin Dethlefs Date: Wed, 21 Oct 2026 23:21:09 -0500 Subject: [PATCH 3/3] Materials: track mission rewards and legacy converts; read contributions by material Review follow-ups on the materials tracking: - MissionCompleted.MaterialsReward adds ship materials (Raw/Manufactured/Encoded only; Odyssey $MICRORESOURCE_* rewards are ignored). EngineerLegacyConvert spends its Ingredients unless IsPreview. - EngineerContribution is read by the Material it names rather than an exact Type string. - MaterialDiscarded and ScientificResearch share one handler. - Tests for each, plus Spend's wrong-category fallback. Co-Authored-By: Claude Sonnet 5.5 --- src/EDNexus.Core/State/StateTracker.cs | 59 ++++++++-- .../State/MaterialsTrackingTests.cs | 105 ++++++++++++++++++ 2 files changed, 153 insertions(+), 11 deletions(-) diff --git a/src/EDNexus.Core/State/StateTracker.cs b/src/EDNexus.Core/State/StateTracker.cs index cf32174..823ca83 100644 --- a/src/EDNexus.Core/State/StateTracker.cs +++ b/src/EDNexus.Core/State/StateTracker.cs @@ -36,12 +36,14 @@ public StateTracker(JournalEventBus bus, CommanderState state) // 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", OnMaterialDiscarded); + 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", OnScientificResearch); + 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); @@ -270,7 +272,11 @@ private void OnMaterialTrade(JournalEntry e) if (changed) _state.RaiseMaterialsChanged(); } - private void OnMaterialDiscarded(JournalEntry e) + /// + /// 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(); @@ -279,6 +285,16 @@ private void OnMaterialDiscarded(JournalEntry e) /// 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"); @@ -288,22 +304,43 @@ private void OnMaterialDiscarded(JournalEntry e) /// private void OnTechnologyBroker(JournalEntry e) => SpendAll(e, "Materials"); - /// Handing materials to an engineer to unlock them. Commodity contributions are cargo, not this. + /// + /// 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 (!string.Equals(e.GetString("Type"), "Material", StringComparison.OrdinalIgnoreCase)) return; - var name = e.GetString("Material") ?? e.GetString("Name"); - if (Int(e.Raw, "Quantity", fallback: 1) is int quantity && Spend(name, quantity, e.GetString("Category"))) + if (Int(e.Raw, "Quantity", fallback: 1) is int quantity && Spend(e.GetString("Material"), quantity, e.GetString("Category"))) _state.RaiseMaterialsChanged(); } - /// Guardian/Thargoid research consumes materials. - private void OnScientificResearch(JournalEntry e) + /// + /// 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 (Int(e.Raw, "Count", fallback: 1) is int count && Spend(e.GetString("Name"), count, e.GetString("Category"))) - _state.RaiseMaterialsChanged(); + 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) { diff --git a/tests/EDNexus.Tests/State/MaterialsTrackingTests.cs b/tests/EDNexus.Tests/State/MaterialsTrackingTests.cs index 025fe59..afb7a91 100644 --- a/tests/EDNexus.Tests/State/MaterialsTrackingTests.cs +++ b/tests/EDNexus.Tests/State/MaterialsTrackingTests.cs @@ -347,6 +347,111 @@ public void A_missing_quantity_means_one_but_an_unusable_one_is_ignored() 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]