From c9c28c604a0c111073ef6d452d964304842ac850 Mon Sep 17 00:00:00 2001 From: Dennis Westermann Date: Sun, 9 Aug 2026 19:27:52 +0200 Subject: [PATCH 1/2] feat(economy): derived storage ceiling caps the AE account (#53) Sprint 16.4, D-024/D-096. The account gains an upper bound DERIVED from the living building stock on every read - never stored (a stored cap would be a state field and a format break): a completed HQ provides the 2.000 AE base per HQ, every completed Storage adds 2.000, sites hold nothing (via the bound site lookup). - DepositCapped(playerId, amount) is now the only income path: harvest deposits, production cancel, construction cancel and sell refunds all clamp at the ceiling - overflow is forfeit ("Ueberschuss verfaellt"). - An existing balance above the ceiling decays by 25% of the excess once per second (tick % 10, integer floor, minimum 1 AE). The decay IS the D-024 "25% loss on destruction" carried without an event: a destroyed or sold storage drops the ceiling and the decay is the loss. Stateless and restore-safe by construction. - The destruction-rule wording is an owner decision (decay over a slot-bound one-time loss): the slot of a destroyed storage is not reconstructible inside the hard bounds (despawned entity, ownerless PlacementState, KillUnit in the units track). This branch also replicates the 16.3 site-lookup mechanism (BindSiteLookup / IsActiveSite) VERBATIM from PR #71: the capacity scan must exclude sites once sites carry their definition role. Identical hunks at identical locations - whichever PR merges first, the other merges clean. Expected: golden-byte baselines move (the Determinism10000 opening starts 1.000 AE over the HQ ceiling and decays; deposits clamp). Baseline reset lands in a SEPARATE PR per the standing rule and needs an SDK-8 environment. --- .../EditMode/Simulation/EconomySystemTests.cs | 124 ++++++++++++++++ .../Construction/ConstructionSystem.cs | 22 ++- .../Simulation/Economy/EconomySystem.cs | 135 +++++++++++++++++- .../Simulation/Economy/PlayerEconomyState.cs | 7 +- .../Simulation/Production/ProductionSystem.cs | 4 +- CHANGELOG.md | 12 ++ .../EconomySystemTests.cs | 124 ++++++++++++++++ 7 files changed, 420 insertions(+), 8 deletions(-) diff --git a/Assets/Tests/EditMode/Simulation/EconomySystemTests.cs b/Assets/Tests/EditMode/Simulation/EconomySystemTests.cs index ad8650e..adfea7e 100644 --- a/Assets/Tests/EditMode/Simulation/EconomySystemTests.cs +++ b/Assets/Tests/EditMode/Simulation/EconomySystemTests.cs @@ -163,6 +163,11 @@ public void HarvestCycle_GathersExactRate_AndDepositRaisesCreditsExactly() kernel.Start(); Assert.That(economy.TryAddField(1, new GridPos2D(10, 10), 9000), Is.True); + // 16.4: deposits obey the derived storage ceiling — a completed + // HQ provides the 2.000 AE base. Far away, so no reach rule here + // is touched. + entities.SpawnUnit(0, new Transform2D(SimFixed.FromInt(60), SimFixed.FromInt(60)), SimFixed.Zero, role: UnitRole.HQ); + EntityId harvester = SpawnHarvester(entities, 0, 10, 10); entities.GetUnitRef(harvester).HarvestFieldId = 1; @@ -208,6 +213,11 @@ public void ReturnOrder_RefineryFootprintEdgeInReach_DepositsWithCentreTwoCellsA 0, SimDefinitions.ToDefinitionId(FactionId.Alliance, UnitRole.Refinery), 8, 4); Assert.That(refinery.IsValid, Is.True); + // 16.4: the deposit obeys the derived ceiling — completed HQ, + // far away so no reach rule here is touched. + Assert.That(construction.PlaceCompletedBuilding( + 0, SimDefinitions.ToDefinitionId(FactionId.Alliance, UnitRole.HQ), 40, 40).IsValid, Is.True); + // Adjacent to the footprint's west edge cell (8,6), Chebyshev 2 // from the centre (9,5). EntityId harvester = SpawnHarvester(entities, 0, 7, 6); @@ -239,6 +249,10 @@ public void AutoCycle_CanonicalOpeningDistances_CompletesRoundTripAndResumes() Assert.That(economy.TryAddField(1, new GridPos2D(7, 7), 9000), Is.True); construction.PlaceCompletedBuilding( 0, SimDefinitions.ToDefinitionId(FactionId.Alliance, UnitRole.Refinery), 8, 4); + // 16.4: deposits obey the derived ceiling — completed HQ, far + // away so the opening geometry under test is untouched. + Assert.That(construction.PlaceCompletedBuilding( + 0, SimDefinitions.ToDefinitionId(FactionId.Alliance, UnitRole.HQ), 40, 40).IsValid, Is.True); EntityId harvester = SpawnHarvester(entities, 0, 7, 6); entities.GetUnitRef(harvester).HarvestFieldId = 1; @@ -426,6 +440,116 @@ public void TryAddField_ValidatesIdentityAndReserve() Assert.That(economy.FieldCount, Is.EqualTo(1)); } + // ------------------------------------------------------------------ + // 16.4 (#53, D-024/D-096): the derived AE ceiling + // ------------------------------------------------------------------ + + [Test] + public void DepositCapped_ClampsAtTheDerivedCeiling_OverflowIsForfeit() + { + EntityManager entities = CreateEntities(); + var kernel = new SimulationKernel(new SimRandom(42UL)); + var economy = new EconomySystem(entities); + var construction = new ConstructionSystem(entities, economy); + kernel.RegisterSystem(economy); + kernel.Start(); + Assert.That(construction.PlaceCompletedBuilding( + 0, SimDefinitions.ToDefinitionId(FactionId.Alliance, UnitRole.HQ), 40, 40).IsValid, Is.True); + + Assert.That(economy.CapacityFor(0), Is.EqualTo(EconomySystem.HqBaseCapacityAE), "one completed HQ: the 2.000 AE base"); + + Assert.That(economy.DepositCapped(0, 1500), Is.EqualTo(1000L), + "only what fits under the ceiling lands"); + Assert.That(economy.GetPlayerEconomy(0).AetheriumCredits, Is.EqualTo(2000L), + "1000 start + 1000 that fit — the remaining 500 are forfeit"); + Assert.That(economy.DepositCapped(0, 500), Is.EqualTo(0L), "at the ceiling nothing more lands"); + Assert.That(economy.GetPlayerEconomy(0).AetheriumCredits, Is.EqualTo(2000L)); + Assert.That(economy.CapacityFor(1), Is.EqualTo(0L), "no buildings, no ceiling — the other slot is unaffected"); + } + + [Test] + public void CapacityFor_CountsCompletedStorage_AndExcludesSites() + { + EntityManager entities = CreateEntities(); + var kernel = new SimulationKernel(new SimRandom(42UL)); + var economy = new EconomySystem(entities, startingCredits: 3000); + var construction = new ConstructionSystem(entities, economy); + kernel.RegisterSystem(economy); + kernel.Start(); + Assert.That(construction.PlaceCompletedBuilding( + 0, SimDefinitions.ToDefinitionId(FactionId.Alliance, UnitRole.HQ), 40, 40).IsValid, Is.True); + kernel.StepTick(); // commit the grid (30 provided) for the placement power rule + + // A storage SITE holds nothing yet. + Assert.That(construction.TryPlaceBuilding( + 0, SimDefinitions.ToDefinitionId(FactionId.Alliance, UnitRole.Storage), 20, 20), Is.True, + "storage site placed (cost fits the 3.000 start)"); + Assert.That(economy.CapacityFor(0), Is.EqualTo(EconomySystem.HqBaseCapacityAE), + "an unfinished silo holds nothing"); + + // A COMPLETED storage adds its 2.000. + Assert.That(construction.PlaceCompletedBuilding( + 0, SimDefinitions.ToDefinitionId(FactionId.Alliance, UnitRole.Storage), 50, 50).IsValid, Is.True); + Assert.That(economy.CapacityFor(0), Is.EqualTo(EconomySystem.HqBaseCapacityAE + EconomySystem.StorageCapacityBonusAE), + "HQ base + one completed storage"); + } + + [Test] + public void DecayExcessBalance_QuarterPerSecond_ConvergesBelowTheCeiling() + { + EntityManager entities = CreateEntities(); + var kernel = new SimulationKernel(new SimRandom(42UL)); + var economy = new EconomySystem(entities); + var construction = new ConstructionSystem(entities, economy); + kernel.RegisterSystem(economy); + kernel.Start(); + Assert.That(construction.PlaceCompletedBuilding( + 0, SimDefinitions.ToDefinitionId(FactionId.Alliance, UnitRole.HQ), 40, 40).IsValid, Is.True); + + economy.GetPlayerEconomy(0).AddCredits(2000); // raw write: 3.000 total, 1.000 over the 2.000 ceiling + for (int i = 0; i < 9; i++) kernel.StepTick(); + Assert.That(economy.GetPlayerEconomy(0).AetheriumCredits, Is.EqualTo(3000L), + "no decay between the per-second decay ticks"); + + kernel.StepTick(); // tick 10: first decay — 25% of the 1.000 excess + Assert.That(economy.GetPlayerEconomy(0).AetheriumCredits, Is.EqualTo(2750L)); + + for (int i = 0; i < 10; i++) kernel.StepTick(); // tick 20: 25% of 750 (floor 187) + Assert.That(economy.GetPlayerEconomy(0).AetheriumCredits, Is.EqualTo(2563L), + "integer floor decay, once per second"); + + for (int i = 0; i < 80; i++) kernel.StepTick(); // tick 100: converging, minimum-1-AE steps + Assert.That(economy.GetPlayerEconomy(0).AetheriumCredits, Is.EqualTo(2058L)); + } + + [Test] + public void DecayExcessBalance_NeverTouchesBalancesAtOrBelowTheCeiling() + { + EntityManager entities = CreateEntities(); + var kernel = new SimulationKernel(new SimRandom(42UL)); + var economy = new EconomySystem(entities); + var construction = new ConstructionSystem(entities, economy); + kernel.RegisterSystem(economy); + kernel.Start(); + Assert.That(construction.PlaceCompletedBuilding( + 0, SimDefinitions.ToDefinitionId(FactionId.Alliance, UnitRole.HQ), 40, 40).IsValid, Is.True); + + for (int i = 0; i < 25; i++) kernel.StepTick(); + Assert.That(economy.GetPlayerEconomy(0).AetheriumCredits, Is.EqualTo(1000L), + "1.000 under the 2.000 ceiling: the decay never runs"); + + // Without any building the ceiling is zero and even the start + // stock decays — the destruction path of D-024. + var lone = new EconomySystem(CreateEntities()); + var loneKernel = new SimulationKernel(new SimRandom(42UL)); + loneKernel.RegisterSystem(lone); + loneKernel.Start(); + Assert.That(lone.DepositCapped(0, 500), Is.EqualTo(0L), "no ceiling, no deposit"); + for (int i = 0; i < 10; i++) loneKernel.StepTick(); + Assert.That(lone.GetPlayerEconomy(0).AetheriumCredits, Is.EqualTo(750L), + "no HQ and no storage: the 1.000 start decays (excess 1.000 over ceiling 0)"); + } + private static byte[] SerializeBlock(EconomySystem economy) { var writer = new SnapshotBlockWriter(); diff --git a/Assets/_Project/Scripts/Simulation/Construction/ConstructionSystem.cs b/Assets/_Project/Scripts/Simulation/Construction/ConstructionSystem.cs index 1495ef7..557bf95 100644 --- a/Assets/_Project/Scripts/Simulation/Construction/ConstructionSystem.cs +++ b/Assets/_Project/Scripts/Simulation/Construction/ConstructionSystem.cs @@ -213,6 +213,10 @@ public ConstructionSystem(EntityManager entityManager, EconomySystem economy, Co _t2Unlocked = new bool[EconomySystem.MaxPlayers]; _occupied = new byte[GridSize * GridSize]; _costField = costField; + // 16.3 (#44): a site carries its definition role, so the power + // recompute can no longer skip sites by role — it skips them via + // this register instead. Bound here so no host can forget it. + _economy.BindSiteLookup(IsActiveSite); } public void Initialize(SimulationKernel kernel) @@ -287,6 +291,18 @@ public bool IsCompletedPlacement(uint rawEntityId) return IndexOfBuilding(rawEntityId) >= 0; } + /// + /// True while the entity is an unfinished site (16.3, #44: sites now + /// carry their definition role, so role alone no longer tells a site + /// apart). Bound into the economy's power recompute via + /// ; also the read the + /// presentation layer needs to keep the site look until completion. + /// + public bool IsActiveSite(EntityId id) + { + return IndexOfSite(UnitCommandStateView.ToRawEntityId(id)) >= 0; + } + /// True when the slot owns a COMPLETED building of the given role (prerequisite scans). public bool HasFinishedBuilding(byte playerSlot, UnitRole role) { @@ -493,7 +509,8 @@ public bool CancelConstruction(uint rawEntityId) EntityId id = UnitCommandStateView.ToEntityId(rawEntityId); if (_entityManager.TryGetUnit(id, out UnitState unit)) { - _economy.GetPlayerEconomy(unit.PlayerId).AddCredits((long)def.CostAE * CancelRefundPercent / 100); + // 16.4: refunds obey the derived ceiling too — overflow is forfeit. + _economy.DepositCapped(unit.PlayerId, (long)def.CostAE * CancelRefundPercent / 100); } _entityManager.DespawnUnit(id); FreeFootprint(site.OriginX, site.OriginY); @@ -517,7 +534,8 @@ public bool SellBuilding(uint rawEntityId) EntityId id = UnitCommandStateView.ToEntityId(rawEntityId); if (_entityManager.TryGetUnit(id, out UnitState unit)) { - _economy.GetPlayerEconomy(unit.PlayerId).AddCredits((long)def.CostAE * SellRefundPercent / 100); + // 16.4: refunds obey the derived ceiling too — overflow is forfeit. + _economy.DepositCapped(unit.PlayerId, (long)def.CostAE * SellRefundPercent / 100); } _entityManager.DespawnUnit(id); FreeFootprint(placement.OriginX, placement.OriginY); diff --git a/Assets/_Project/Scripts/Simulation/Economy/EconomySystem.cs b/Assets/_Project/Scripts/Simulation/Economy/EconomySystem.cs index 2e62ef2..5f42799 100644 --- a/Assets/_Project/Scripts/Simulation/Economy/EconomySystem.cs +++ b/Assets/_Project/Scripts/Simulation/Economy/EconomySystem.cs @@ -47,7 +47,22 @@ namespace Nova.Simulation.Economy /// closing the distance is Movement's concern. A harvester with a /// standing order deposits its /// full cargo at an own refinery in reach (same Chebyshev rule): credits - /// rise by exactly the cargo amount and the return leg resolves. + /// rise by the cargo amount THAT FITS under the storage ceiling (16.4 — + /// overflow is forfeit) and the return leg resolves. + /// + /// + /// Storage ceiling (16.4, #53, D-024/D-096): the AE account has a derived + /// upper bound — a completed HQ provides the 2.000 AE base (per HQ), + /// every completed Storage adds 2.000, scanned from the living building + /// stock on every read and NEVER stored (a stored cap would be a state + /// field and a format break). All income and refunds route through + /// and clamp at the ceiling ("Überschuss + /// verfällt"); an EXISTING balance above it decays by 25% of the excess + /// once per second (tick % , integer + /// floor, minimum 1 AE). The decay IS the D-024 "25% loss on + /// destruction", carried without an event: a destroyed or sold storage + /// drops the ceiling and the decay is the loss. Keyed to the tick number + /// — stateless and restore-safe. /// /// /// Auto-cycle (harvest -> return -> harvest, Q-040 resolution): the @@ -127,6 +142,24 @@ public sealed class EconomySystem : IStatefulSimSystem, ISlotFactionLookup /// public const long CanonicalMatchStartingCreditsAE = 3000L; + /// + /// 16.4 (#53, D-024/D-096): AE capacity base per completed HQ. + /// Deliberately below the canonical start balance (3.000 AE, D-077): + /// the start stock stays (existing balances only decay), but fresh + /// income forfeits until the player builds storage — the D-024 silo + /// pressure from the first minute. + /// + public const long HqBaseCapacityAE = 2000L; + + /// 16.4 (#53, D-024): AE capacity bonus per completed Storage. + public const long StorageCapacityBonusAE = 2000L; + + /// 16.4 (#53, D-024): excess balance decay in percent of the excess per decay tick (integer floor, minimum 1 AE). + public const int ExcessDecayPercent = 25; + + /// 16.4 (#53): excess decay cadence — once per second on the canonical 10 Hz clock, keyed to the tick number (stateless, restore-safe). + public const int ExcessDecayIntervalTicks = 10; + /// /// Faction-resolved harvester cargo capacities, indexed by raw /// and resolved once from @@ -147,6 +180,15 @@ public sealed class EconomySystem : IStatefulSimSystem, ISlotFactionLookup private int _fieldCount; private SimulationKernel _kernel; + /// + /// Construction-site lookup bound by the ConstructionSystem + /// constructor (16.3, #44): a site entity carries its definition role + /// now, so the power recompute needs the site's own register to tell + /// "unfinished" from "completed". Null in a rig without construction + /// — every building-role entity then counts, the pre-16.3 behaviour. + /// + private Func _isSiteLookup; + public string Name => "EconomySystem"; public ushort StateBlockId => SnapshotBlockIds.Economy; @@ -178,6 +220,70 @@ public void Initialize(SimulationKernel kernel) $"[{Name}] Initialized canonical economy ({MaxPlayers} slots, harvest rate {HarvestRateAE} AE/tick)."); } + /// + /// Binds the construction site's own register as the "is this entity + /// an unfinished site" lookup (16.3, #44). Called ONCE by the + /// ConstructionSystem constructor — hosts never wire this themselves. + /// The lookup is read-only against the site table and moves no state + /// into the economy, so the snapshot layout is untouched. + /// + public void BindSiteLookup(Func isSiteLookup) + { + _isSiteLookup = isSiteLookup; + } + + /// + /// 16.4 (#53, D-024/D-096): the slot's AE ceiling, DERIVED from the + /// living building stock on every read — never stored (a stored cap + /// would be a state-field and format break). A completed HQ provides + /// the 2.000 AE base (per HQ), every completed Storage adds 2.000. + /// Sites are excluded via the bound lookup: a half-built silo holds + /// nothing. Without the lookup (construction-free rigs) every + /// building-role entity counts. Integer scan in ascending entity + /// order — deterministic, restore-safe. + /// + public long CapacityFor(byte playerId) + { + long capacity = 0; + UnitState[] units = _entityManager.RawUnits; + int count = _entityManager.Capacity; + for (int i = 0; i < count; i++) + { + ref readonly UnitState unit = ref units[i]; + if (!unit.IsActive || unit.PlayerId != playerId) continue; + if (unit.Role == UnitRole.HQ) + { + if (_isSiteLookup != null && _isSiteLookup(unit.Id)) continue; + capacity += HqBaseCapacityAE; + } + else if (unit.Role == UnitRole.Storage) + { + if (_isSiteLookup != null && _isSiteLookup(unit.Id)) continue; + capacity += StorageCapacityBonusAE; + } + } + return capacity; + } + + /// + /// 16.4 (#53, D-024): the capped deposit — the ONLY way income and + /// refunds should land. What does not fit under + /// is forfeit ("Überschuss verfällt"); an existing balance above the + /// ceiling is NOT touched here (it decays per second, see ExecuteTick). + /// Returns the amount actually deposited (0 when the account is at or + /// above the ceiling). + /// + public long DepositCapped(byte playerId, long amount) + { + if (amount <= 0 || playerId >= MaxPlayers) return 0; + ref PlayerEconomyState eco = ref _players[playerId]; + long room = CapacityFor(playerId) - eco.AetheriumCredits; + if (room <= 0) return 0; + long deposited = Math.Min(amount, room); + eco.AddCredits(deposited); + return deposited; + } + /// Mutable access to one slot's economy state (slot must be in [0, MaxPlayers)). public ref PlayerEconomyState GetPlayerEconomy(byte playerId) { @@ -284,12 +390,35 @@ public void SetSlotFaction(byte playerId, FactionId faction) /// Phases 2 and 3 of the canonical tick (SimulationCore.md section /// 2): power recompute, then the harvest cycle — both in strict /// ascending entity-index order, before movement runs (registration - /// order; see class remarks). + /// order; see class remarks). Once per second the excess-balance + /// decay runs (16.4, #53, D-024): a balance above the derived + /// ceiling loses 25% of the excess per decay tick (integer floor, + /// minimum 1 AE, so it always converges). This IS the "25% loss on + /// destruction", carried without an event: a destroyed (or sold) + /// storage drops the ceiling and the decay is the loss. Keyed to the + /// tick number — stateless and restore-safe, no remembered event. /// public void ExecuteTick(Tick tick) { RecomputePower(); ExecuteHarvest(); + if (tick.Value % ExcessDecayIntervalTicks == 0) + { + DecayExcessBalances(); + } + } + + /// The per-second excess decay (16.4): every slot above its derived ceiling loses a quarter of the excess. + private void DecayExcessBalances() + { + for (byte p = 0; p < MaxPlayers; p++) + { + ref PlayerEconomyState eco = ref _players[p]; + long excess = eco.AetheriumCredits - CapacityFor(p); + if (excess <= 0) continue; + long loss = Math.Max(1L, excess * ExcessDecayPercent / 100L); + eco.AetheriumCredits -= loss; + } } public void Shutdown() @@ -441,7 +570,7 @@ private void ExecuteReturnOrder(ref UnitState unit) if (!HasOwnRefineryInReach(in unit)) return; // held, not dropped - _players[unit.PlayerId].AddCredits(unit.CargoAE); + DepositCapped(unit.PlayerId, unit.CargoAE); // 16.4: capped at the derived ceiling — overflow is forfeit unit.CargoAE = 0; unit.IsReturningCargo = false; } diff --git a/Assets/_Project/Scripts/Simulation/Economy/PlayerEconomyState.cs b/Assets/_Project/Scripts/Simulation/Economy/PlayerEconomyState.cs index e32590c..f702d38 100644 --- a/Assets/_Project/Scripts/Simulation/Economy/PlayerEconomyState.cs +++ b/Assets/_Project/Scripts/Simulation/Economy/PlayerEconomyState.cs @@ -74,7 +74,12 @@ public PlayerEconomyState(byte playerId, long startingCredits = 1000, FactionId PowerRequired = 0; } - /// Adds a non-negative amount of credits (harvest deposits of this slice). + /// + /// Adds a non-negative amount of credits (raw write). Callers route + /// through instead (16.4, + /// #53, D-024): income and refunds obey the derived storage ceiling — + /// only the ceiling rule itself and tests touch this directly. + /// public void AddCredits(long amount) { if (amount > 0) diff --git a/Assets/_Project/Scripts/Simulation/Production/ProductionSystem.cs b/Assets/_Project/Scripts/Simulation/Production/ProductionSystem.cs index 29762c1..2f93930 100644 --- a/Assets/_Project/Scripts/Simulation/Production/ProductionSystem.cs +++ b/Assets/_Project/Scripts/Simulation/Production/ProductionSystem.cs @@ -336,8 +336,8 @@ public bool CancelProduction(uint buildingRaw, int queueIndex) EntityId id = UnitCommandStateView.ToEntityId(buildingRaw); if (_entityManager.TryGetUnit(id, out UnitState building)) { - _economy.GetPlayerEconomy(building.PlayerId) - .AddCredits((long)def.CostAE * row.Entries[queueIndex].RemainingCount); + // 16.4: refunds obey the derived ceiling too — overflow is forfeit. + _economy.DepositCapped(building.PlayerId, (long)def.CostAE * row.Entries[queueIndex].RemainingCount); } RemoveEntry(row, queueIndex); return true; diff --git a/CHANGELOG.md b/CHANGELOG.md index d61365d..75bff89 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,18 @@ die Versionierung folgt (in der aktuellen Doku-Phase) dem Dokumentationsstand de > erzeugt; MS-0 und MS-1 bleiben offen. ### Behoben +- **#53: Das Lager begrenzt das Konto (D-024/D-096)** — das Aetherium-Konto hat + jetzt eine aus dem Gebäudebestand abgeleitete Obergrenze, nichts wird + gespeichert (kein Zustandsfeld, kein Formatbruch): ein fertiges HQ gibt die + Basis 2.000 AE, jedes fertige Lager +2.000, Baustellen zählen nicht. Jede + Einzahlung (Ernte, Rückerstattungen bei Streichung, Abbruch, Verkauf) läuft + über `EconomySystem.DepositCapped` und deckelt hart — Überschuss verfällt. + Ein Bestand über der Grenze zerfällt einmal pro Sekunde um 25 % des + Überschusses (getaktet über den Sim-Tick, zustandslos, restore-sicher): das + ist der „25 % Verlust bei Zerstörung" ohne Ereignis getragen — ein + zerstörtes oder verkauftes Lager senkt die Grenze, der Zerfall ist der + Verlust. Ausformung der Zerstörungsregel durch Inhaberentscheidung + (Zerfall statt Slot-gebundenem Einmalverlust) - **#49: Auswahlrahmen und Füllung entschärft** — `GroundMarkerVisuals`: Rand von 6/64 auf 2/64 der Quad-Kante, Füll-Alpha von 0.28 auf 0.10; wirkt auf Auswahl-, Platzierungs-, Sammelpunkt- und Baustellenmarker zugleich und nimmt #50 (Einheit im Pulk nicht auffindbar) die verdeckende Füllung ab - **Die drei Laborschalter greifen nicht mehr in einer Netzpartie und nicht mehr im ausgelieferten Build:** `FogRevealDebug` und `MatchSpeedDebug` kamen aus dem diff --git a/tools/Nova.SimRunner.Tests/EconomySystemTests.cs b/tools/Nova.SimRunner.Tests/EconomySystemTests.cs index 7118c59..5c0f42c 100644 --- a/tools/Nova.SimRunner.Tests/EconomySystemTests.cs +++ b/tools/Nova.SimRunner.Tests/EconomySystemTests.cs @@ -163,6 +163,11 @@ public void HarvestCycle_GathersExactRate_AndDepositRaisesCreditsExactly() kernel.Start(); Assert.That(economy.TryAddField(1, new GridPos2D(10, 10), 9000), Is.True); + // 16.4: deposits obey the derived storage ceiling — a completed + // HQ provides the 2.000 AE base. Far away, so no reach rule here + // is touched. + entities.SpawnUnit(0, new Transform2D(SimFixed.FromInt(60), SimFixed.FromInt(60)), SimFixed.Zero, role: UnitRole.HQ); + EntityId harvester = SpawnHarvester(entities, 0, 10, 10); entities.GetUnitRef(harvester).HarvestFieldId = 1; @@ -208,6 +213,11 @@ public void ReturnOrder_RefineryFootprintEdgeInReach_DepositsWithCentreTwoCellsA 0, SimDefinitions.ToDefinitionId(FactionId.Alliance, UnitRole.Refinery), 8, 4); Assert.That(refinery.IsValid, Is.True); + // 16.4: the deposit obeys the derived ceiling — completed HQ, + // far away so no reach rule here is touched. + Assert.That(construction.PlaceCompletedBuilding( + 0, SimDefinitions.ToDefinitionId(FactionId.Alliance, UnitRole.HQ), 40, 40).IsValid, Is.True); + // Adjacent to the footprint's west edge cell (8,6), Chebyshev 2 // from the centre (9,5). EntityId harvester = SpawnHarvester(entities, 0, 7, 6); @@ -239,6 +249,10 @@ public void AutoCycle_CanonicalOpeningDistances_CompletesRoundTripAndResumes() Assert.That(economy.TryAddField(1, new GridPos2D(7, 7), 9000), Is.True); construction.PlaceCompletedBuilding( 0, SimDefinitions.ToDefinitionId(FactionId.Alliance, UnitRole.Refinery), 8, 4); + // 16.4: deposits obey the derived ceiling — completed HQ, far + // away so the opening geometry under test is untouched. + Assert.That(construction.PlaceCompletedBuilding( + 0, SimDefinitions.ToDefinitionId(FactionId.Alliance, UnitRole.HQ), 40, 40).IsValid, Is.True); EntityId harvester = SpawnHarvester(entities, 0, 7, 6); entities.GetUnitRef(harvester).HarvestFieldId = 1; @@ -426,6 +440,116 @@ public void TryAddField_ValidatesIdentityAndReserve() Assert.That(economy.FieldCount, Is.EqualTo(1)); } + // ------------------------------------------------------------------ + // 16.4 (#53, D-024/D-096): the derived AE ceiling + // ------------------------------------------------------------------ + + [Test] + public void DepositCapped_ClampsAtTheDerivedCeiling_OverflowIsForfeit() + { + EntityManager entities = CreateEntities(); + var kernel = new SimulationKernel(new SimRandom(42UL)); + var economy = new EconomySystem(entities); + var construction = new ConstructionSystem(entities, economy); + kernel.RegisterSystem(economy); + kernel.Start(); + Assert.That(construction.PlaceCompletedBuilding( + 0, SimDefinitions.ToDefinitionId(FactionId.Alliance, UnitRole.HQ), 40, 40).IsValid, Is.True); + + Assert.That(economy.CapacityFor(0), Is.EqualTo(EconomySystem.HqBaseCapacityAE), "one completed HQ: the 2.000 AE base"); + + Assert.That(economy.DepositCapped(0, 1500), Is.EqualTo(1000L), + "only what fits under the ceiling lands"); + Assert.That(economy.GetPlayerEconomy(0).AetheriumCredits, Is.EqualTo(2000L), + "1000 start + 1000 that fit — the remaining 500 are forfeit"); + Assert.That(economy.DepositCapped(0, 500), Is.EqualTo(0L), "at the ceiling nothing more lands"); + Assert.That(economy.GetPlayerEconomy(0).AetheriumCredits, Is.EqualTo(2000L)); + Assert.That(economy.CapacityFor(1), Is.EqualTo(0L), "no buildings, no ceiling — the other slot is unaffected"); + } + + [Test] + public void CapacityFor_CountsCompletedStorage_AndExcludesSites() + { + EntityManager entities = CreateEntities(); + var kernel = new SimulationKernel(new SimRandom(42UL)); + var economy = new EconomySystem(entities, startingCredits: 3000); + var construction = new ConstructionSystem(entities, economy); + kernel.RegisterSystem(economy); + kernel.Start(); + Assert.That(construction.PlaceCompletedBuilding( + 0, SimDefinitions.ToDefinitionId(FactionId.Alliance, UnitRole.HQ), 40, 40).IsValid, Is.True); + kernel.StepTick(); // commit the grid (30 provided) for the placement power rule + + // A storage SITE holds nothing yet. + Assert.That(construction.TryPlaceBuilding( + 0, SimDefinitions.ToDefinitionId(FactionId.Alliance, UnitRole.Storage), 20, 20), Is.True, + "storage site placed (cost fits the 3.000 start)"); + Assert.That(economy.CapacityFor(0), Is.EqualTo(EconomySystem.HqBaseCapacityAE), + "an unfinished silo holds nothing"); + + // A COMPLETED storage adds its 2.000. + Assert.That(construction.PlaceCompletedBuilding( + 0, SimDefinitions.ToDefinitionId(FactionId.Alliance, UnitRole.Storage), 50, 50).IsValid, Is.True); + Assert.That(economy.CapacityFor(0), Is.EqualTo(EconomySystem.HqBaseCapacityAE + EconomySystem.StorageCapacityBonusAE), + "HQ base + one completed storage"); + } + + [Test] + public void DecayExcessBalance_QuarterPerSecond_ConvergesBelowTheCeiling() + { + EntityManager entities = CreateEntities(); + var kernel = new SimulationKernel(new SimRandom(42UL)); + var economy = new EconomySystem(entities); + var construction = new ConstructionSystem(entities, economy); + kernel.RegisterSystem(economy); + kernel.Start(); + Assert.That(construction.PlaceCompletedBuilding( + 0, SimDefinitions.ToDefinitionId(FactionId.Alliance, UnitRole.HQ), 40, 40).IsValid, Is.True); + + economy.GetPlayerEconomy(0).AddCredits(2000); // raw write: 3.000 total, 1.000 over the 2.000 ceiling + for (int i = 0; i < 9; i++) kernel.StepTick(); + Assert.That(economy.GetPlayerEconomy(0).AetheriumCredits, Is.EqualTo(3000L), + "no decay between the per-second decay ticks"); + + kernel.StepTick(); // tick 10: first decay — 25% of the 1.000 excess + Assert.That(economy.GetPlayerEconomy(0).AetheriumCredits, Is.EqualTo(2750L)); + + for (int i = 0; i < 10; i++) kernel.StepTick(); // tick 20: 25% of 750 (floor 187) + Assert.That(economy.GetPlayerEconomy(0).AetheriumCredits, Is.EqualTo(2563L), + "integer floor decay, once per second"); + + for (int i = 0; i < 80; i++) kernel.StepTick(); // tick 100: converging, minimum-1-AE steps + Assert.That(economy.GetPlayerEconomy(0).AetheriumCredits, Is.EqualTo(2058L)); + } + + [Test] + public void DecayExcessBalance_NeverTouchesBalancesAtOrBelowTheCeiling() + { + EntityManager entities = CreateEntities(); + var kernel = new SimulationKernel(new SimRandom(42UL)); + var economy = new EconomySystem(entities); + var construction = new ConstructionSystem(entities, economy); + kernel.RegisterSystem(economy); + kernel.Start(); + Assert.That(construction.PlaceCompletedBuilding( + 0, SimDefinitions.ToDefinitionId(FactionId.Alliance, UnitRole.HQ), 40, 40).IsValid, Is.True); + + for (int i = 0; i < 25; i++) kernel.StepTick(); + Assert.That(economy.GetPlayerEconomy(0).AetheriumCredits, Is.EqualTo(1000L), + "1.000 under the 2.000 ceiling: the decay never runs"); + + // Without any building the ceiling is zero and even the start + // stock decays — the destruction path of D-024. + var lone = new EconomySystem(CreateEntities()); + var loneKernel = new SimulationKernel(new SimRandom(42UL)); + loneKernel.RegisterSystem(lone); + loneKernel.Start(); + Assert.That(lone.DepositCapped(0, 500), Is.EqualTo(0L), "no ceiling, no deposit"); + for (int i = 0; i < 10; i++) loneKernel.StepTick(); + Assert.That(lone.GetPlayerEconomy(0).AetheriumCredits, Is.EqualTo(750L), + "no HQ and no storage: the 1.000 start decays (excess 1.000 over ceiling 0)"); + } + private static byte[] SerializeBlock(EconomySystem economy) { var writer = new SnapshotBlockWriter(); From f618a2785d9ceac1e2782c2df9f740b7c25d5a03 Mon Sep 17 00:00:00 2001 From: Dennis Westermann Date: Sun, 9 Aug 2026 22:30:36 +0200 Subject: [PATCH 2/2] test(economy): cover capped deposits through real call paths --- .../Simulation/ConstructionSystemTests.cs | 46 +++++++++++++++++-- .../Simulation/EconomyIntegrationTests.cs | 5 ++ .../EditMode/Simulation/EconomySystemTests.cs | 27 +++++++++++ .../Simulation/HarvesterAutoCycleTests.cs | 11 ++++- .../Simulation/ProductionSystemTests.cs | 21 +++++++-- .../Construction/ConstructionSystem.cs | 18 ++++---- .../Simulation/Economy/EconomySystem.cs | 21 +++++---- CHANGELOG.md | 5 +- .../CanonicalAiOutcomeTests.cs | 10 ++-- .../ConstructionSystemTests.cs | 46 +++++++++++++++++-- .../EconomyIntegrationTests.cs | 5 ++ .../EconomySystemTests.cs | 27 +++++++++++ .../HarvesterAutoCycleTests.cs | 11 ++++- .../LockstepNetworkTests.cs | 5 +- .../ProductionSystemTests.cs | 21 +++++++-- 15 files changed, 239 insertions(+), 40 deletions(-) diff --git a/Assets/Tests/EditMode/Simulation/ConstructionSystemTests.cs b/Assets/Tests/EditMode/Simulation/ConstructionSystemTests.cs index e6b6b74..0d50fe3 100644 --- a/Assets/Tests/EditMode/Simulation/ConstructionSystemTests.cs +++ b/Assets/Tests/EditMode/Simulation/ConstructionSystemTests.cs @@ -634,7 +634,8 @@ public void PlaceCompletedBuilding_Refinery_GrantsNothing_MatchStartIsUnchanged( public void CancelConstruction_Refunds75Percent_AndFreesFootprint() { var f = new Fixture(); - Assert.That(f.Construction.PlaceCompletedBuilding(0, 5, 40, 40).IsValid, Is.True, "power provider"); + Assert.That(f.Construction.PlaceCompletedBuilding(0, 3, 40, 40).IsValid, Is.True, + "HQ provides power and 2,000 AE capacity"); f.SpawnBuilder(0, 19, 20); f.Step(1); // commit the balance Assert.That(f.Construction.TryPlaceBuilding(0, 7, 20, 20), Is.True); // 500 spent @@ -657,7 +658,8 @@ public void CancelConstruction_Refunds75Percent_AndFreesFootprint() public void Sell_CompletedBuilding_Refunds50Percent_SiteIsNotSellable() { var f = new Fixture(); - Assert.That(f.Construction.PlaceCompletedBuilding(0, 5, 40, 40).IsValid, Is.True, "power provider"); + Assert.That(f.Construction.PlaceCompletedBuilding(0, 3, 40, 40).IsValid, Is.True, + "HQ provides power and 2,000 AE capacity"); EntityId barracks = f.Construction.PlaceCompletedBuilding(0, 7, 20, 20); uint raw = UnitCommandStateView.ToRawEntityId(barracks); @@ -668,13 +670,51 @@ public void Sell_CompletedBuilding_Refunds50Percent_SiteIsNotSellable() Assert.That(f.Construction.IsCellFree(20, 20), Is.True); f.SpawnBuilder(0, 19, 20); - f.Step(1); // commit the balance (100 provided, 0 required) + f.Step(1); // commit the balance (30 provided, 0 required) Assert.That(f.Construction.TryPlaceBuilding(0, 7, 20, 20), Is.True); uint siteRaw = UnitCommandStateView.ToRawEntityId(SiteEntity(f)); Assert.That(f.Construction.ValidateSell(0, siteRaw), Is.EqualTo(CommandResultCode.RejectedInvalidTarget), "a site is cancelled, not sold"); } + [Test] + public void CancelConstruction_RefundIsCappedAtStorageCeiling() + { + var f = new Fixture(startingCredits: EconomySystem.HqBaseCapacityAE); + Assert.That(f.Construction.PlaceCompletedBuilding(0, 3, 40, 40).IsValid, Is.True, + "HQ provides power and the 2,000 AE ceiling"); + f.SpawnBuilder(0, 19, 20); + f.Step(1); + Assert.That(f.Construction.TryPlaceBuilding(0, 7, 20, 20), Is.True); // 2.000 - 500 = 1.500 + uint siteRaw = UnitCommandStateView.ToRawEntityId(SiteEntity(f)); + f.Economy.GetPlayerEconomy(0).AddCredits(495); // raw fixture setup: 1.995 + + Assert.That(f.Construction.CancelConstruction(siteRaw), Is.True); + Assert.That(f.Economy.GetPlayerEconomy(0).AetheriumCredits, + Is.EqualTo(EconomySystem.HqBaseCapacityAE), + "only 5 of the 375 AE refund fit; the overflow is forfeit"); + } + + [Test] + public void SellStorage_CapsRefundThenLoweredCapacityDrivesExcessDecay() + { + var f = new Fixture(startingCredits: 3900); + Assert.That(f.Construction.PlaceCompletedBuilding(0, 3, 40, 40).IsValid, Is.True); + EntityId storage = f.Construction.PlaceCompletedBuilding(0, 6, 20, 20); + Assert.That(f.Economy.CapacityFor(0), + Is.EqualTo(EconomySystem.HqBaseCapacityAE + EconomySystem.StorageCapacityBonusAE)); + + Assert.That(f.Construction.SellBuilding(UnitCommandStateView.ToRawEntityId(storage)), Is.True); + Assert.That(f.Economy.GetPlayerEconomy(0).AetheriumCredits, Is.EqualTo(4000L), + "only 100 of the 150 AE sale refund fit before the Storage leaves the stock"); + Assert.That(f.Economy.CapacityFor(0), Is.EqualTo(EconomySystem.HqBaseCapacityAE), + "selling the Storage immediately lowers the derived ceiling"); + + f.Step(EconomySystem.ExcessDecayIntervalTicks); + Assert.That(f.Economy.GetPlayerEconomy(0).AetheriumCredits, Is.EqualTo(3500L), + "tick 10 removes 25% of the 2,000 AE excess"); + } + [Test] public void Repair_BuilderRestoresHp_InReachOnly_AndResolvesAtFull() { diff --git a/Assets/Tests/EditMode/Simulation/EconomyIntegrationTests.cs b/Assets/Tests/EditMode/Simulation/EconomyIntegrationTests.cs index 9a7ea07..b0beca1 100644 --- a/Assets/Tests/EditMode/Simulation/EconomyIntegrationTests.cs +++ b/Assets/Tests/EditMode/Simulation/EconomyIntegrationTests.cs @@ -117,6 +117,11 @@ public void RestoreSessionTick() new Transform2D(SimFixed.FromInt(11), SimFixed.FromInt(10)), SimFixed.Zero, role: UnitRole.Refinery); + Entities.SpawnUnit( + owner, + new Transform2D(SimFixed.FromInt(60), SimFixed.FromInt(60)), + SimFixed.Zero, + role: UnitRole.HQ); return (UnitCommandStateView.ToRawEntityId(harvester), harvester); } diff --git a/Assets/Tests/EditMode/Simulation/EconomySystemTests.cs b/Assets/Tests/EditMode/Simulation/EconomySystemTests.cs index adfea7e..9c1acef 100644 --- a/Assets/Tests/EditMode/Simulation/EconomySystemTests.cs +++ b/Assets/Tests/EditMode/Simulation/EconomySystemTests.cs @@ -191,6 +191,33 @@ public void HarvestCycle_GathersExactRate_AndDepositRaisesCreditsExactly() "credits rise by exactly the cargo"); } + [Test] + public void HarvesterDeposit_OverflowIsForfeitAtTheStorageCeiling() + { + EntityManager entities = CreateEntities(); + var kernel = new SimulationKernel(new SimRandom(42UL)); + var economy = new EconomySystem(entities, startingCredits: 1995); + kernel.RegisterSystem(economy); + kernel.Start(); + + entities.SpawnUnit(0, new Transform2D(SimFixed.FromInt(60), SimFixed.FromInt(60)), SimFixed.Zero, + role: UnitRole.HQ); + entities.SpawnUnit(0, new Transform2D(SimFixed.FromInt(11), SimFixed.FromInt(10)), SimFixed.Zero, + role: UnitRole.Refinery); + EntityId harvester = SpawnHarvester(entities, 0, 10, 10); + ref UnitState unit = ref entities.GetUnitRef(harvester); + unit.CargoAE = 10; + unit.IsReturningCargo = true; + + kernel.StepTick(); + + Assert.That(economy.GetPlayerEconomy(0).AetheriumCredits, Is.EqualTo(EconomySystem.HqBaseCapacityAE), + "only 5 of the 10 AE cargo fit below the HQ ceiling"); + Assert.That(entities.GetUnitRef(harvester).CargoAE, Is.EqualTo(0), + "overflow is forfeit, so the full cargo leaves the Harvester"); + Assert.That(entities.GetUnitRef(harvester).IsReturningCargo, Is.False); + } + [Test] public void ReturnOrder_RefineryFootprintEdgeInReach_DepositsWithCentreTwoCellsAway() { diff --git a/Assets/Tests/EditMode/Simulation/HarvesterAutoCycleTests.cs b/Assets/Tests/EditMode/Simulation/HarvesterAutoCycleTests.cs index a77f4fd..16f3b3f 100644 --- a/Assets/Tests/EditMode/Simulation/HarvesterAutoCycleTests.cs +++ b/Assets/Tests/EditMode/Simulation/HarvesterAutoCycleTests.cs @@ -22,7 +22,16 @@ namespace Nova.Simulation.Tests [TestFixture] public class HarvesterAutoCycleTests { - private static EntityManager CreateEntities() => new EntityManager(64); + private static EntityManager CreateEntities() + { + var entities = new EntityManager(64); + entities.SpawnUnit( + 0, + new Transform2D(SimFixed.FromInt(60), SimFixed.FromInt(60)), + SimFixed.Zero, + role: UnitRole.HQ); + return entities; + } private static EntityId SpawnHarvester(EntityManager entities, byte player, int x, int y) { diff --git a/Assets/Tests/EditMode/Simulation/ProductionSystemTests.cs b/Assets/Tests/EditMode/Simulation/ProductionSystemTests.cs index 8b6d0fd..58cc4ec 100644 --- a/Assets/Tests/EditMode/Simulation/ProductionSystemTests.cs +++ b/Assets/Tests/EditMode/Simulation/ProductionSystemTests.cs @@ -51,15 +51,16 @@ public Fixture(long startingCredits = 1000, int capacity = 64, System.Action /// Places a completed Barracks at (10,10) and returns its raw wire - /// id. Also places a completed Power plant at (40,40) unless + /// id. Also places a completed HQ at (40,40) unless /// is false — a Barracks draws 15, - /// so a powered grid keeps production at full speed. + /// so the HQ keeps production at full speed and provides the + /// canonical 2,000 AE storage capacity used by refund tests. /// public uint SpawnBarracks(byte slot, bool withPower = true) { if (withPower) { - Assert.That(Construction.PlaceCompletedBuilding(slot, 5, 40, 40).IsValid, Is.True); + Assert.That(Construction.PlaceCompletedBuilding(slot, 3, 40, 40).IsValid, Is.True); } EntityId id = Construction.PlaceCompletedBuilding(slot, 7, 10, 10); Assert.That(id.IsValid, Is.True); @@ -308,6 +309,20 @@ public void CancelProduction_QueuedEntry_FullRefund_RunningEntryUntouched() Assert.That(remaining, Is.EqualTo((ushort)1), "the running entry is untouched"); } + [Test] + public void CancelProduction_RefundIsCappedAtStorageCeiling() + { + var f = new Fixture(startingCredits: EconomySystem.HqBaseCapacityAE); + uint barracks = f.SpawnBarracks(0); + Assert.That(f.Production.TryQueueUnit(0, barracks, 12, 1), Is.True); // 2.000 - 120 = 1.880 + f.Economy.GetPlayerEconomy(0).AddCredits(115); // raw fixture setup: 1.995 + + Assert.That(f.Production.CancelProduction(barracks, 0), Is.True); + Assert.That(f.Economy.GetPlayerEconomy(0).AetheriumCredits, + Is.EqualTo(EconomySystem.HqBaseCapacityAE), + "only 5 of the 120 AE refund fit; the overflow is forfeit"); + } + [Test] public void EntityStoreFull_QueuePauses_ResumesAfterSpace() { diff --git a/Assets/_Project/Scripts/Simulation/Construction/ConstructionSystem.cs b/Assets/_Project/Scripts/Simulation/Construction/ConstructionSystem.cs index 087d073..11e6540 100644 --- a/Assets/_Project/Scripts/Simulation/Construction/ConstructionSystem.cs +++ b/Assets/_Project/Scripts/Simulation/Construction/ConstructionSystem.cs @@ -214,9 +214,10 @@ public ConstructionSystem(EntityManager entityManager, EconomySystem economy, Co _t2Unlocked = new bool[EconomySystem.MaxPlayers]; _occupied = new byte[GridSize * GridSize]; _costField = costField; - // 16.3 (#44): a site carries its definition role, so the power - // recompute can no longer skip sites by role — it skips them via - // this register instead. Bound here so no host can forget it. + // Bind the authoritative site register once so capacity scans + // exclude unfinished sites today and remain correct when #44 + // changes sites from Unit to their definition role. Keeping the + // binding here means hosts cannot forget the dependency. _economy.BindSiteLookup(IsActiveSite); } @@ -294,11 +295,12 @@ public bool IsCompletedPlacement(uint rawEntityId) } /// - /// True while the entity is an unfinished site (16.3, #44: sites now - /// carry their definition role, so role alone no longer tells a site - /// apart). Bound into the economy's power recompute via - /// ; also the read the - /// presentation layer needs to keep the site look until completion. + /// True while the entity is an unfinished site. Bound into the + /// economy's capacity scan via + /// so sites never provide storage, including after #44 changes them + /// from to their definition role. It is + /// also the read the presentation layer needs to keep the site look + /// until completion. /// public bool IsActiveSite(EntityId id) { diff --git a/Assets/_Project/Scripts/Simulation/Economy/EconomySystem.cs b/Assets/_Project/Scripts/Simulation/Economy/EconomySystem.cs index 9d1cbaf..88933bb 100644 --- a/Assets/_Project/Scripts/Simulation/Economy/EconomySystem.cs +++ b/Assets/_Project/Scripts/Simulation/Economy/EconomySystem.cs @@ -182,10 +182,10 @@ public sealed class EconomySystem : IStatefulSimSystem, ISlotFactionLookup /// /// Construction-site lookup bound by the ConstructionSystem - /// constructor (16.3, #44): a site entity carries its definition role - /// now, so the power recompute needs the site's own register to tell - /// "unfinished" from "completed". Null in a rig without construction - /// — every building-role entity then counts, the pre-16.3 behaviour. + /// constructor. It keeps the capacity scan authoritative today and + /// after #44 changes site entities from Unit to their definition role. + /// Null in a rig without construction — every building-role entity + /// then counts. /// private Func _isSiteLookup; @@ -222,10 +222,10 @@ public void Initialize(SimulationKernel kernel) /// /// Binds the construction site's own register as the "is this entity - /// an unfinished site" lookup (16.3, #44). Called ONCE by the - /// ConstructionSystem constructor — hosts never wire this themselves. - /// The lookup is read-only against the site table and moves no state - /// into the economy, so the snapshot layout is untouched. + /// an unfinished site" lookup. Called once by the ConstructionSystem + /// constructor — hosts never wire this themselves. The lookup is + /// read-only against the site table and moves no state into the + /// economy, so the snapshot layout is untouched. /// public void BindSiteLookup(Func isSiteLookup) { @@ -585,8 +585,9 @@ private void ExecuteHarvestOrder(ref UnitState unit) } /// - /// One return order: deposits the full cargo at an own refinery in - /// reach (credits rise by exactly the cargo); holds out of reach. + /// One return order: empties the full cargo at an own refinery in + /// reach; credits rise only by the amount that fits below the derived + /// storage ceiling and overflow is forfeit. Holds out of reach. /// Clearing the returning flag alone resumes an auto-cycle, because /// the retained is picked up by /// the harvest branch on the next tick. A command-issued return diff --git a/CHANGELOG.md b/CHANGELOG.md index 093d9c0..0613af9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -64,7 +64,10 @@ die Versionierung folgt (in der aktuellen Doku-Phase) dem Dokumentationsstand de ist der „25 % Verlust bei Zerstörung" ohne Ereignis getragen — ein zerstörtes oder verkauftes Lager senkt die Grenze, der Zerfall ist der Verlust. Ausformung der Zerstörungsregel durch Inhaberentscheidung - (Zerfall statt Slot-gebundenem Einmalverlust) + (Zerfall statt Slot-gebundenem Einmalverlust). Der kanonische KI-Endzustand + bleibt bei Tick 2.546 entschieden und bewegt sich durch diese Wirtschaftsregel + von `0x9F93097AD526B6F7` auf `0xE784E6184AD16081`; die KI-Kennung bleibt + unverändert `r6.E34435F9` - **#54: Das Radar wird ein Gebäude (C3/D-096)** — die Minimap ist jetzt eine Radar-Funktion: `MinimapHud` zeichnet (Panel und Trefferfläche) nur noch, solange der lokale Slot ein fertiges Radar besitzt; der Bauknopf sagt es im diff --git a/tools/Nova.SimRunner.Tests/CanonicalAiOutcomeTests.cs b/tools/Nova.SimRunner.Tests/CanonicalAiOutcomeTests.cs index 7908432..ad99ff1 100644 --- a/tools/Nova.SimRunner.Tests/CanonicalAiOutcomeTests.cs +++ b/tools/Nova.SimRunner.Tests/CanonicalAiOutcomeTests.cs @@ -48,12 +48,12 @@ public sealed class CanonicalAiOutcomeTests /// /// End-state hash of the canonical AI match, last moved by: Sprint 16 - /// package 16.2 (#46) — produced units spawn at the building footprint - /// and walk to their rally point. The AI itself is unchanged: - /// AiBehaviorId stayed r5.779A1B5B. - /// Previous value: 0x8C0B54F31F2986B7 (Sprint 16.1). + /// package 16.4 (#53) — storage capacity and excess decay change the + /// economy the AI plays in. The AI itself is unchanged: + /// AiBehaviorId stays r6.E34435F9. + /// Previous value: 0x9F93097AD526B6F7 (Sprint 16.2). /// - private const string PinnedEndState = "0x9F93097AD526B6F7"; + private const string PinnedEndState = "0xE784E6184AD16081"; [Test] public void CanonicalAiMatch_DecidesOnThePinnedTick_WithThePinnedEndState() diff --git a/tools/Nova.SimRunner.Tests/ConstructionSystemTests.cs b/tools/Nova.SimRunner.Tests/ConstructionSystemTests.cs index e1c380a..a9a8e93 100644 --- a/tools/Nova.SimRunner.Tests/ConstructionSystemTests.cs +++ b/tools/Nova.SimRunner.Tests/ConstructionSystemTests.cs @@ -634,7 +634,8 @@ public void PlaceCompletedBuilding_Refinery_GrantsNothing_MatchStartIsUnchanged( public void CancelConstruction_Refunds75Percent_AndFreesFootprint() { var f = new Fixture(); - Assert.That(f.Construction.PlaceCompletedBuilding(0, 5, 40, 40).IsValid, Is.True, "power provider"); + Assert.That(f.Construction.PlaceCompletedBuilding(0, 3, 40, 40).IsValid, Is.True, + "HQ provides power and 2,000 AE capacity"); f.SpawnBuilder(0, 19, 20); f.Step(1); // commit the balance Assert.That(f.Construction.TryPlaceBuilding(0, 7, 20, 20), Is.True); // 500 spent @@ -657,7 +658,8 @@ public void CancelConstruction_Refunds75Percent_AndFreesFootprint() public void Sell_CompletedBuilding_Refunds50Percent_SiteIsNotSellable() { var f = new Fixture(); - Assert.That(f.Construction.PlaceCompletedBuilding(0, 5, 40, 40).IsValid, Is.True, "power provider"); + Assert.That(f.Construction.PlaceCompletedBuilding(0, 3, 40, 40).IsValid, Is.True, + "HQ provides power and 2,000 AE capacity"); EntityId barracks = f.Construction.PlaceCompletedBuilding(0, 7, 20, 20); uint raw = UnitCommandStateView.ToRawEntityId(barracks); @@ -668,13 +670,51 @@ public void Sell_CompletedBuilding_Refunds50Percent_SiteIsNotSellable() Assert.That(f.Construction.IsCellFree(20, 20), Is.True); f.SpawnBuilder(0, 19, 20); - f.Step(1); // commit the balance (100 provided, 0 required) + f.Step(1); // commit the balance (30 provided, 0 required) Assert.That(f.Construction.TryPlaceBuilding(0, 7, 20, 20), Is.True); uint siteRaw = UnitCommandStateView.ToRawEntityId(SiteEntity(f)); Assert.That(f.Construction.ValidateSell(0, siteRaw), Is.EqualTo(CommandResultCode.RejectedInvalidTarget), "a site is cancelled, not sold"); } + [Test] + public void CancelConstruction_RefundIsCappedAtStorageCeiling() + { + var f = new Fixture(startingCredits: EconomySystem.HqBaseCapacityAE); + Assert.That(f.Construction.PlaceCompletedBuilding(0, 3, 40, 40).IsValid, Is.True, + "HQ provides power and the 2,000 AE ceiling"); + f.SpawnBuilder(0, 19, 20); + f.Step(1); + Assert.That(f.Construction.TryPlaceBuilding(0, 7, 20, 20), Is.True); // 2.000 - 500 = 1.500 + uint siteRaw = UnitCommandStateView.ToRawEntityId(SiteEntity(f)); + f.Economy.GetPlayerEconomy(0).AddCredits(495); // raw fixture setup: 1.995 + + Assert.That(f.Construction.CancelConstruction(siteRaw), Is.True); + Assert.That(f.Economy.GetPlayerEconomy(0).AetheriumCredits, + Is.EqualTo(EconomySystem.HqBaseCapacityAE), + "only 5 of the 375 AE refund fit; the overflow is forfeit"); + } + + [Test] + public void SellStorage_CapsRefundThenLoweredCapacityDrivesExcessDecay() + { + var f = new Fixture(startingCredits: 3900); + Assert.That(f.Construction.PlaceCompletedBuilding(0, 3, 40, 40).IsValid, Is.True); + EntityId storage = f.Construction.PlaceCompletedBuilding(0, 6, 20, 20); + Assert.That(f.Economy.CapacityFor(0), + Is.EqualTo(EconomySystem.HqBaseCapacityAE + EconomySystem.StorageCapacityBonusAE)); + + Assert.That(f.Construction.SellBuilding(UnitCommandStateView.ToRawEntityId(storage)), Is.True); + Assert.That(f.Economy.GetPlayerEconomy(0).AetheriumCredits, Is.EqualTo(4000L), + "only 100 of the 150 AE sale refund fit before the Storage leaves the stock"); + Assert.That(f.Economy.CapacityFor(0), Is.EqualTo(EconomySystem.HqBaseCapacityAE), + "selling the Storage immediately lowers the derived ceiling"); + + f.Step(EconomySystem.ExcessDecayIntervalTicks); + Assert.That(f.Economy.GetPlayerEconomy(0).AetheriumCredits, Is.EqualTo(3500L), + "tick 10 removes 25% of the 2,000 AE excess"); + } + [Test] public void Repair_BuilderRestoresHp_InReachOnly_AndResolvesAtFull() { diff --git a/tools/Nova.SimRunner.Tests/EconomyIntegrationTests.cs b/tools/Nova.SimRunner.Tests/EconomyIntegrationTests.cs index 9dbea90..9b414f4 100644 --- a/tools/Nova.SimRunner.Tests/EconomyIntegrationTests.cs +++ b/tools/Nova.SimRunner.Tests/EconomyIntegrationTests.cs @@ -117,6 +117,11 @@ public void RestoreSessionTick() new Transform2D(SimFixed.FromInt(11), SimFixed.FromInt(10)), SimFixed.Zero, role: UnitRole.Refinery); + Entities.SpawnUnit( + owner, + new Transform2D(SimFixed.FromInt(60), SimFixed.FromInt(60)), + SimFixed.Zero, + role: UnitRole.HQ); return (UnitCommandStateView.ToRawEntityId(harvester), harvester); } diff --git a/tools/Nova.SimRunner.Tests/EconomySystemTests.cs b/tools/Nova.SimRunner.Tests/EconomySystemTests.cs index 5c0f42c..da46704 100644 --- a/tools/Nova.SimRunner.Tests/EconomySystemTests.cs +++ b/tools/Nova.SimRunner.Tests/EconomySystemTests.cs @@ -191,6 +191,33 @@ public void HarvestCycle_GathersExactRate_AndDepositRaisesCreditsExactly() "credits rise by exactly the cargo"); } + [Test] + public void HarvesterDeposit_OverflowIsForfeitAtTheStorageCeiling() + { + EntityManager entities = CreateEntities(); + var kernel = new SimulationKernel(new SimRandom(42UL)); + var economy = new EconomySystem(entities, startingCredits: 1995); + kernel.RegisterSystem(economy); + kernel.Start(); + + entities.SpawnUnit(0, new Transform2D(SimFixed.FromInt(60), SimFixed.FromInt(60)), SimFixed.Zero, + role: UnitRole.HQ); + entities.SpawnUnit(0, new Transform2D(SimFixed.FromInt(11), SimFixed.FromInt(10)), SimFixed.Zero, + role: UnitRole.Refinery); + EntityId harvester = SpawnHarvester(entities, 0, 10, 10); + ref UnitState unit = ref entities.GetUnitRef(harvester); + unit.CargoAE = 10; + unit.IsReturningCargo = true; + + kernel.StepTick(); + + Assert.That(economy.GetPlayerEconomy(0).AetheriumCredits, Is.EqualTo(EconomySystem.HqBaseCapacityAE), + "only 5 of the 10 AE cargo fit below the HQ ceiling"); + Assert.That(entities.GetUnitRef(harvester).CargoAE, Is.EqualTo(0), + "overflow is forfeit, so the full cargo leaves the Harvester"); + Assert.That(entities.GetUnitRef(harvester).IsReturningCargo, Is.False); + } + [Test] public void ReturnOrder_RefineryFootprintEdgeInReach_DepositsWithCentreTwoCellsAway() { diff --git a/tools/Nova.SimRunner.Tests/HarvesterAutoCycleTests.cs b/tools/Nova.SimRunner.Tests/HarvesterAutoCycleTests.cs index 07dfae9..441ac83 100644 --- a/tools/Nova.SimRunner.Tests/HarvesterAutoCycleTests.cs +++ b/tools/Nova.SimRunner.Tests/HarvesterAutoCycleTests.cs @@ -22,7 +22,16 @@ namespace Nova.SimRunner.Tests [TestFixture] public sealed class HarvesterAutoCycleTests { - private static EntityManager CreateEntities() => new EntityManager(64); + private static EntityManager CreateEntities() + { + var entities = new EntityManager(64); + entities.SpawnUnit( + 0, + new Transform2D(SimFixed.FromInt(60), SimFixed.FromInt(60)), + SimFixed.Zero, + role: UnitRole.HQ); + return entities; + } private static EntityId SpawnHarvester(EntityManager entities, byte player, int x, int y) { diff --git a/tools/Nova.SimRunner.Tests/LockstepNetworkTests.cs b/tools/Nova.SimRunner.Tests/LockstepNetworkTests.cs index bd2690c..560cb9d 100644 --- a/tools/Nova.SimRunner.Tests/LockstepNetworkTests.cs +++ b/tools/Nova.SimRunner.Tests/LockstepNetworkTests.cs @@ -1964,8 +1964,9 @@ public void Desync_WritesOneParseableSnapshotAndRecordStreamPerClient() // drive helper permits the normal input-delay pipeline lead, so // aiming at 49 could already have crossed tick 50 on one end. Drive(server, clientA, clientB, hostA, hostB, 25); - ref PlayerEconomyState divergentEconomy = ref hostB.Economy.GetPlayerEconomy(0); - divergentEconomy.AddCredits(1); + ref UnitState divergentBuilder = ref hostB.Entities.GetUnitRef( + UnitCommandStateView.ToEntityId(hostB.BuilderRaw)); + divergentBuilder.CurrentHealth -= 1; Drive(server, clientA, clientB, hostA, hostB, 50); PumpUntil(server, clientA, clientB, () => clientA.Phase == RelayClientPhase.Ended && clientB.Phase == RelayClientPhase.Ended, diff --git a/tools/Nova.SimRunner.Tests/ProductionSystemTests.cs b/tools/Nova.SimRunner.Tests/ProductionSystemTests.cs index 228c555..f25f2e8 100644 --- a/tools/Nova.SimRunner.Tests/ProductionSystemTests.cs +++ b/tools/Nova.SimRunner.Tests/ProductionSystemTests.cs @@ -51,15 +51,16 @@ public Fixture(long startingCredits = 1000, int capacity = 64, System.Action /// Places a completed Barracks at (10,10) and returns its raw wire - /// id. Also places a completed Power plant at (40,40) unless + /// id. Also places a completed HQ at (40,40) unless /// is false — a Barracks draws 15, - /// so a powered grid keeps production at full speed. + /// so the HQ keeps production at full speed and provides the + /// canonical 2,000 AE storage capacity used by refund tests. /// public uint SpawnBarracks(byte slot, bool withPower = true) { if (withPower) { - Assert.That(Construction.PlaceCompletedBuilding(slot, 5, 40, 40).IsValid, Is.True); + Assert.That(Construction.PlaceCompletedBuilding(slot, 3, 40, 40).IsValid, Is.True); } EntityId id = Construction.PlaceCompletedBuilding(slot, 7, 10, 10); Assert.That(id.IsValid, Is.True); @@ -308,6 +309,20 @@ public void CancelProduction_QueuedEntry_FullRefund_RunningEntryUntouched() Assert.That(remaining, Is.EqualTo((ushort)1), "the running entry is untouched"); } + [Test] + public void CancelProduction_RefundIsCappedAtStorageCeiling() + { + var f = new Fixture(startingCredits: EconomySystem.HqBaseCapacityAE); + uint barracks = f.SpawnBarracks(0); + Assert.That(f.Production.TryQueueUnit(0, barracks, 12, 1), Is.True); // 2.000 - 120 = 1.880 + f.Economy.GetPlayerEconomy(0).AddCredits(115); // raw fixture setup: 1.995 + + Assert.That(f.Production.CancelProduction(barracks, 0), Is.True); + Assert.That(f.Economy.GetPlayerEconomy(0).AetheriumCredits, + Is.EqualTo(EconomySystem.HqBaseCapacityAE), + "only 5 of the 120 AE refund fit; the overflow is forfeit"); + } + [Test] public void EntityStoreFull_QueuePauses_ResumesAfterSpace() {