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"));
+ }
+}