From 1bc5cdac1e9b39552e720d7515a2b16eda29f788 Mon Sep 17 00:00:00 2001 From: Dennis Westermann Date: Mon, 31 Aug 2026 10:27:10 +0200 Subject: [PATCH] feat(construction): passive Reparaturzone an Fahrzeugfabrik und Kaserne (#55) --- .../Simulation/PassiveRepairZoneTests.cs | 350 +++++++++++++ .../Simulation/PassiveRepairZoneTests.cs.meta | 2 + .../Construction/ConstructionSystem.cs | 267 +++++++++- CHANGELOG.md | 16 + reports/v8.6.0/sprint-23/13-kimi-reparatur.md | 478 ++++++++++++++++++ .../PassiveRepairZoneTests.cs | 350 +++++++++++++ 6 files changed, 1458 insertions(+), 5 deletions(-) create mode 100644 Assets/Tests/EditMode/Simulation/PassiveRepairZoneTests.cs create mode 100644 Assets/Tests/EditMode/Simulation/PassiveRepairZoneTests.cs.meta create mode 100644 reports/v8.6.0/sprint-23/13-kimi-reparatur.md create mode 100644 tools/Nova.SimRunner.Tests/PassiveRepairZoneTests.cs diff --git a/Assets/Tests/EditMode/Simulation/PassiveRepairZoneTests.cs b/Assets/Tests/EditMode/Simulation/PassiveRepairZoneTests.cs new file mode 100644 index 0000000..6d77c35 --- /dev/null +++ b/Assets/Tests/EditMode/Simulation/PassiveRepairZoneTests.cs @@ -0,0 +1,350 @@ +using NUnit.Framework; +using Nova.Core; +using Nova.Simulation; +using Nova.Simulation.Construction; +using Nova.Simulation.Definitions; +using Nova.Simulation.Economy; +using Nova.Simulation.Pathfinding; +using Nova.Simulation.State; + +namespace Nova.Simulation.Tests +{ + /// + /// Passive producer repair zone suite (EditMode lane), Issue #55 / + /// owner decision E-3 (2026-08-31): a completed Barracks or + /// VehicleFactory heals its OWNER's damaged units of the roles it can + /// produce, inside a footprint-aware Chebyshev radius of + /// , at + /// HP per + /// tick, free of charge, without stacking, with the exact low-power + /// even-tick halving — and never beyond MaxHealth, never the dead, + /// never the under-construction (target or anchor), never buildings, + /// never enemies. + /// Mirror of the .NET lane PassiveRepairZoneTests. + /// + [TestFixture] + public sealed class PassiveRepairZoneTests + { + /// + /// The same minimal host the construction suite's repair tests use: + /// economy (phases 2/3) then construction (phase 4) — no movement, + /// no combat, so the zone is the only actor that can move unit hit + /// points. No Aetherium field: the command-path placement checks of + /// the site tests keep their field spacing trivially satisfied. + /// + private sealed class Fixture + { + public EntityManager Entities { get; } + public EconomySystem Economy { get; } + public ConstructionSystem Construction { get; } + public SimulationKernel Kernel { get; } + + public Fixture(long startingCredits = 1000, System.Action configure = null) + { + Entities = new EntityManager(64); + Economy = new EconomySystem(Entities, startingCredits); + var costField = new CostField(ConstructionSystem.GridSize, ConstructionSystem.GridSize); + Construction = new ConstructionSystem(Entities, Economy, costField); + Kernel = new SimulationKernel(new SimRandom(42UL)); + Kernel.RegisterSystem(Economy); + Kernel.RegisterSystem(Construction); + // Pre-start configuration hook (e.g. slot factions): the + // SetSlotFaction guard locks the assignment at Kernel.Start(). + configure?.Invoke(Economy); + Kernel.Start(); + } + + public EntityId SpawnUnit(byte slot, int x, int y, UnitRole role, int maxHealth, int currentHealth) + { + EntityId id = Entities.SpawnUnit( + slot, + new Transform2D(SimFixed.FromInt(x), SimFixed.FromInt(y)), + SimFixed.FromInt(3), + maxHealth: maxHealth, + role: role); + Entities.GetUnitRef(id).CurrentHealth = currentHealth; + return id; + } + + public int HealthOf(EntityId id) + { + return Entities.GetUnitRef(id).CurrentHealth; + } + + public void Step(int ticks) + { + for (int i = 0; i < ticks; i++) Kernel.StepTick(); + } + } + + /// Full-power base: HQ (capacity + 30 power), a Power plant and the named producer. + private static EntityId PlaceFullPowerProducer(Fixture f, ushort producerDefId, int originX, int originY, int anchorY) + { + Assert.That(f.Construction.PlaceCompletedBuilding(0, 3, 30, anchorY).IsValid, Is.True, + "HQ keeps the starting credits inside the D-106 capacity"); + Assert.That(f.Construction.PlaceCompletedBuilding(0, 5, 26, anchorY).IsValid, Is.True, + "Power plant: 130 provided, so the producer never sees low power"); + EntityId producer = f.Construction.PlaceCompletedBuilding(0, producerDefId, originX, originY); + Assert.That(producer.IsValid, Is.True, "producer placed completed"); + return producer; + } + + private static UnitRoleMask MaskOf(UnitRole role) + { + return (UnitRoleMask)(1u << (int)role); + } + + [Test] + public void Mapping_IsDerivedFromTheProducerAssignment_ContentPinned() + { + // E-3 restated as content (D-077 producer assignment, both + // factions identical): the Barracks heals the two infantry + // roles, the VehicleFactory the four vehicle roles. A producer + // reassignment in SimDefinitions moves this pin deliberately — + // the mapping must follow the table, never a second list. + Assert.That(ConstructionSystem.GetPassiveRepairableRoles(UnitRole.Barracks), + Is.EqualTo(MaskOf(UnitRole.BasicInfantry) | MaskOf(UnitRole.AntiArmorInfantry))); + Assert.That(ConstructionSystem.GetPassiveRepairableRoles(UnitRole.VehicleFactory), + Is.EqualTo(MaskOf(UnitRole.ScoutVehicle) | MaskOf(UnitRole.LightTank) + | MaskOf(UnitRole.BattleTank) | MaskOf(UnitRole.Artillery))); + + // Every other role projects no zone: the issue scope is the two + // combat-unit producers — not the HQ (Builder), not the + // Refinery (Harvester), and never a non-producer. + foreach (UnitRole role in new[] + { + UnitRole.Unit, UnitRole.Builder, UnitRole.Harvester, + UnitRole.HQ, UnitRole.Refinery, UnitRole.Power, UnitRole.Storage, + UnitRole.ResearchLab, UnitRole.Radar, UnitRole.DefensePlatform, + UnitRole.BasicInfantry, UnitRole.Artillery, + }) + { + Assert.That(ConstructionSystem.GetPassiveRepairableRoles(role), Is.EqualTo(UnitRoleMask.None), + $"{role} projects no passive repair zone (Issue #55 scope)"); + } + } + + [Test] + public void VehicleFactory_HealsOwnDamagedVehicle_EveryTick_ForFree() + { + var f = new Fixture(); + PlaceFullPowerProducer(f, 8, 20, 20, anchorY: 20); + EntityId tank = f.SpawnUnit(0, 25, 21, UnitRole.LightTank, maxHealth: 550, currentHealth: 100); + + f.Step(10); + + Assert.That(f.HealthOf(tank), Is.EqualTo(110), + "1 HP per tick — deliberately far below the active repair's 10 (heals between engagements)"); + Assert.That(f.Economy.GetPlayerEconomy(0).AetheriumCredits, Is.EqualTo(1000L), + "the zone is free: not one AE is debited, whatever the account holds"); + } + + [Test] + public void ZoneRadius_IsFootprintAwareChebyshev_BoundaryPinned() + { + var f = new Fixture(); + PlaceFullPowerProducer(f, 8, 20, 20, anchorY: 20); + // Footprint x/y 20..22; radius 3 covers the cells 17..25 per axis. + Assert.That(f.Construction.IsCellInsidePassiveRepairZone(0, 17, 17), Is.True, "near corner, distance 3"); + Assert.That(f.Construction.IsCellInsidePassiveRepairZone(0, 25, 25), Is.True, "far corner, Chebyshev 3"); + Assert.That(f.Construction.IsCellInsidePassiveRepairZone(0, 25, 21), Is.True, "edge cell, distance 3"); + Assert.That(f.Construction.IsCellInsidePassiveRepairZone(0, 16, 21), Is.False, "distance 4 is outside"); + Assert.That(f.Construction.IsCellInsidePassiveRepairZone(0, 21, 26), Is.False, "distance 4 is outside"); + Assert.That(f.Construction.IsCellInsidePassiveRepairZone(0, 26, 25), Is.False, "off the corner, Chebyshev 4"); + Assert.That(f.Construction.IsCellInsidePassiveRepairZone(0, 21, 21), Is.True, + "the footprint itself reads as distance 0 — consistent for an overlay; no unit can stand there"); + Assert.That(f.Construction.IsCellInsidePassiveRepairZone(1, 25, 21), Is.False, + "the zone answer is per owner: slot 1 owns no factory here"); + + // The same boundary as behavior, not only as a query. + EntityId inside = f.SpawnUnit(0, 25, 25, UnitRole.LightTank, maxHealth: 550, currentHealth: 100); + EntityId outside = f.SpawnUnit(0, 26, 25, UnitRole.LightTank, maxHealth: 550, currentHealth: 100); + f.Step(10); + Assert.That(f.HealthOf(inside), Is.EqualTo(110), "Chebyshev 3 from the footprint heals"); + Assert.That(f.HealthOf(outside), Is.EqualTo(100), "Chebyshev 4 does not"); + } + + [Test] + public void E3_BarracksHealsInfantryNotVehicles_VehicleFactoryHealsVehiclesNotInfantry() + { + var f = new Fixture(); + PlaceFullPowerProducer(f, 7, 20, 20, anchorY: 20); + Assert.That(f.Construction.PlaceCompletedBuilding(0, 8, 20, 40).IsValid, Is.True, "VehicleFactory"); + EntityId infantry = f.SpawnUnit(0, 23, 21, UnitRole.BasicInfantry, maxHealth: 90, currentHealth: 10); + EntityId tankAtBarracks = f.SpawnUnit(0, 24, 21, UnitRole.LightTank, maxHealth: 550, currentHealth: 100); + EntityId tank = f.SpawnUnit(0, 23, 41, UnitRole.LightTank, maxHealth: 550, currentHealth: 100); + EntityId infantryAtFactory = f.SpawnUnit(0, 24, 41, UnitRole.BasicInfantry, maxHealth: 90, currentHealth: 10); + + f.Step(10); + + Assert.That(f.HealthOf(infantry), Is.EqualTo(20), "the Barracks heals what it produces"); + Assert.That(f.HealthOf(tankAtBarracks), Is.EqualTo(100), + "a Barracks repairing tanks is illogical (E-3) — the building choice keeps its meaning"); + Assert.That(f.HealthOf(tank), Is.EqualTo(110), "the VehicleFactory heals what it produces"); + Assert.That(f.HealthOf(infantryAtFactory), Is.EqualTo(10), "the VehicleFactory does not heal infantry"); + } + + [Test] + public void ZoneScope_HqAndRefineryProjectNoZone() + { + // The derivation would yield Builder/Harvester for HQ/Refinery + // from the same table — the ISSUE SCOPE grants the zone only to + // the two combat-unit producers. Pin the scope so extending it + // is a deliberate decision, never a silent side effect. + var f = new Fixture(); + Assert.That(f.Construction.PlaceCompletedBuilding(0, 3, 20, 20).IsValid, Is.True, "HQ"); + Assert.That(f.Construction.PlaceCompletedBuilding(0, 4, 40, 20).IsValid, Is.True, + "Refinery (20 required, HQ provides 30: full power)"); + EntityId builder = f.SpawnUnit(0, 23, 21, UnitRole.Builder, maxHealth: 350, currentHealth: 100); + EntityId harvester = f.SpawnUnit(0, 43, 21, UnitRole.Harvester, maxHealth: 800, currentHealth: 100); + + f.Step(10); + + Assert.That(f.HealthOf(builder), Is.EqualTo(100), "the HQ projects no zone (issue scope)"); + Assert.That(f.HealthOf(harvester), Is.EqualTo(100), "the Refinery projects no zone (issue scope)"); + Assert.That(f.Construction.IsCellInsidePassiveRepairZone(0, 23, 21), Is.False); + Assert.That(f.Construction.IsCellInsidePassiveRepairZone(0, 43, 21), Is.False); + } + + [Test] + public void NoStacking_TwoCoveringFactories_HealOncePerTick() + { + var f = new Fixture(); + PlaceFullPowerProducer(f, 8, 20, 20, anchorY: 20); + Assert.That(f.Construction.PlaceCompletedBuilding(0, 8, 20, 24).IsValid, Is.True, "second VehicleFactory"); + // Cell (21,23): distance 1 to BOTH footprints (20..22 and y 24..26). + EntityId tank = f.SpawnUnit(0, 21, 23, UnitRole.LightTank, maxHealth: 550, currentHealth: 100); + Assert.That(f.Construction.IsCellInsidePassiveRepairZone(0, 21, 23), Is.True, "doubly covered cell"); + + f.Step(10); + + Assert.That(f.HealthOf(tank), Is.EqualTo(110), + "two covering zones heal once, not twice — the building count must not buy healing"); + } + + [Test] + public void NoOverheal_CapsAtMaxHealth_AndFullUnitsAreSkipped() + { + var f = new Fixture(); + PlaceFullPowerProducer(f, 8, 20, 20, anchorY: 20); + EntityId almostFull = f.SpawnUnit(0, 25, 21, UnitRole.LightTank, maxHealth: 550, currentHealth: 549); + EntityId full = f.SpawnUnit(0, 24, 21, UnitRole.LightTank, maxHealth: 550, currentHealth: 550); + + f.Step(5); + + Assert.That(f.HealthOf(almostFull), Is.EqualTo(550), "healing caps at MaxHealth, never beyond"); + Assert.That(f.HealthOf(full), Is.EqualTo(550), "a full unit is skipped, not re-topped"); + } + + [Test] + public void NoHealing_ForTheDead_ForSites_ForSiteAnchors_AndForBuildings() + { + var f = new Fixture(); + EntityId factory = PlaceFullPowerProducer(f, 8, 20, 60, anchorY: 60); + Assert.That(f.Construction.PlaceCompletedBuilding(0, 8, 20, 64).IsValid, Is.True, + "second VehicleFactory, its zone covering the first (distance 2)"); + f.Step(1); // commit the power balance: the rule-path placements below read it + + // (a) The dead: a despawned tank is a store slot, not a patient. + EntityId dead = f.SpawnUnit(0, 25, 61, UnitRole.LightTank, maxHealth: 550, currentHealth: 100); + Assert.That(f.Entities.DespawnUnit(dead), Is.True); + + // (c) Buildings are never patients: the first factory itself, + // damaged, standing inside the second factory's zone. + f.Entities.GetUnitRef(factory).CurrentHealth = 100; + + // (b) The under-construction TARGET: a paused Barracks site at + // 1 HP inside the zone (distance 2 to the factory footprint, no + // Builder alive to progress it). + Assert.That(f.Construction.TryPlaceBuilding(0, 7, 20, 56), Is.True, "site placed through the rule path"); + EntityId site = EntityId.Invalid; + UnitState[] units = f.Entities.RawUnits; + for (int i = 0; i < f.Entities.Capacity; i++) + { + if (units[i].IsActive && units[i].Role == UnitRole.Barracks) site = units[i].Id; + } + Assert.That(site.IsValid, Is.True, "the site entity exists (16.3: it carries its definition role)"); + Assert.That(f.HealthOf(site), Is.EqualTo(1), "a fresh site sits at 1 HP"); + + // (d) The under-construction ANCHOR: an unfinished Barracks + // site projects no zone for the infantry standing beside it + // (distance 1 — inside where a COMPLETED Barracks would heal). + Assert.That(f.Construction.TryPlaceBuilding(0, 7, 26, 56), Is.True, "paused Barracks site as anchor"); + EntityId infantry = f.SpawnUnit(0, 29, 57, UnitRole.BasicInfantry, maxHealth: 90, currentHealth: 10); + Assert.That(f.Construction.IsCellInsidePassiveRepairZone(0, 29, 57), Is.False, + "the site is no zone anchor — the query says so too"); + + f.Step(10); + + Assert.That(f.Entities.IsValid(dead), Is.False, "the dead stay dead — no resurrection, no crash"); + Assert.That(f.HealthOf(site), Is.EqualTo(1), + "units under construction are never healed (a site is a 1 HP entity of a building role)"); + Assert.That(f.HealthOf(factory), Is.EqualTo(100), + "buildings are never healed by zones — building repair stays the Builder's job"); + Assert.That(f.HealthOf(infantry), Is.EqualTo(10), + "a site projects no zone: only COMPLETED placements heal"); + } + + [Test] + public void OwnOnly_EnemyUnitsInsideTheZone_DoNotHeal() + { + var f = new Fixture(); + PlaceFullPowerProducer(f, 8, 20, 20, anchorY: 20); + EntityId own = f.SpawnUnit(0, 25, 21, UnitRole.LightTank, maxHealth: 550, currentHealth: 100); + EntityId enemy = f.SpawnUnit(1, 24, 21, UnitRole.LightTank, maxHealth: 480, currentHealth: 100); + + f.Step(10); + + Assert.That(f.HealthOf(own), Is.EqualTo(110), "the owner's units heal"); + Assert.That(f.HealthOf(enemy), Is.EqualTo(100), "an enemy standing in the same cells gains nothing"); + } + + [Test] + public void LowPower_HealsOnEvenTicksOnly_ExactHalving() + { + // 45 required (Refinery 20 + VehicleFactory 25) against the HQ's + // 30 provided: low power from the first recompute on. + var f = new Fixture(); + Assert.That(f.Construction.PlaceCompletedBuilding(0, 3, 30, 20).IsValid, Is.True, + "HQ keeps the starting credits inside the D-106 capacity"); + Assert.That(f.Construction.PlaceCompletedBuilding(0, 4, 40, 40).IsValid, Is.True, "Refinery"); + Assert.That(f.Construction.PlaceCompletedBuilding(0, 8, 20, 20).IsValid, Is.True, "VehicleFactory"); + EntityId tank = f.SpawnUnit(0, 25, 21, UnitRole.LightTank, maxHealth: 550, currentHealth: 100); + + f.Step(1); // tick 1 (odd): commits the balance, no heal + Assert.That(f.Economy.GetPlayerEconomy(0).IsLowPower, Is.True, "45 required vs 30 provided"); + Assert.That(f.HealthOf(tank), Is.EqualTo(100), "odd tick: the zone is silent"); + + f.Step(10); // ticks 2..11: the five even ticks heal + Assert.That(f.HealthOf(tank), Is.EqualTo(105), + "exactly half rate under low power: one heal per two ticks, no rounding (C4 precedent)"); + + f.Step(1); // tick 12 (even) + Assert.That(f.HealthOf(tank), Is.EqualTo(106), "even tick: the zone heals"); + f.Step(1); // tick 13 (odd) + Assert.That(f.HealthOf(tank), Is.EqualTo(106), "odd tick: the zone is silent again"); + } + + [Test] + public void Deterministic_IdenticalFixtures_IdenticalStateHash() + { + ulong first = RunZoneScenario(); + ulong second = RunZoneScenario(); + Assert.That(second, Is.EqualTo(first), + "the zone scan is ascending-index and parity-gated: identical setups hash identically"); + } + + private static ulong RunZoneScenario() + { + var f = new Fixture(); + PlaceFullPowerProducer(f, 8, 20, 20, anchorY: 20); + Assert.That(f.Construction.PlaceCompletedBuilding(0, 7, 40, 40).IsValid, Is.True, "Barracks"); + f.SpawnUnit(0, 25, 21, UnitRole.LightTank, maxHealth: 550, currentHealth: 100); + f.SpawnUnit(0, 24, 22, UnitRole.BattleTank, maxHealth: 1100, currentHealth: 700); + f.SpawnUnit(0, 43, 41, UnitRole.BasicInfantry, maxHealth: 90, currentHealth: 10); + f.SpawnUnit(1, 25, 25, UnitRole.LightTank, maxHealth: 480, currentHealth: 100); // enemy: untouched + f.Step(50); + return f.Kernel.CalculateStateHash(); + } + } +} diff --git a/Assets/Tests/EditMode/Simulation/PassiveRepairZoneTests.cs.meta b/Assets/Tests/EditMode/Simulation/PassiveRepairZoneTests.cs.meta new file mode 100644 index 0000000..87dda5e --- /dev/null +++ b/Assets/Tests/EditMode/Simulation/PassiveRepairZoneTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 9bff71a4fcdc4bcb977575c84c23fd11 diff --git a/Assets/_Project/Scripts/Simulation/Construction/ConstructionSystem.cs b/Assets/_Project/Scripts/Simulation/Construction/ConstructionSystem.cs index 5c976ab..448180a 100644 --- a/Assets/_Project/Scripts/Simulation/Construction/ConstructionSystem.cs +++ b/Assets/_Project/Scripts/Simulation/Construction/ConstructionSystem.cs @@ -14,8 +14,8 @@ namespace Nova.Simulation.Construction /// slice (docs/tech/SimulationCore.md section 2, phases 4 and 5): /// building placement with state-dependent validation, construction /// sites progressed by an assigned Builder, completion into building-role - /// entities, cancel/sell refunds, repair orders and the per-slot T2 - /// unlock. Deterministic, pure integer/fixed-point, zero engine + /// entities, cancel/sell refunds, repair orders, the passive producer + /// repair zones (Issue #55) and the per-slot T2 unlock. Deterministic, pure integer/fixed-point, zero engine /// dependencies. Replaces the unregistered prototype scaffolding /// (pre-G1 reset; the retired ConstructionGrid is folded into this /// system as a derived occupancy cache). @@ -116,7 +116,12 @@ namespace Nova.Simulation.Construction /// under low power, and pays an /// exact cumulative integer share of 30% of its new price. At most one /// reachable Builder may repair a target per tick; out-of-reach orders are - /// HELD, never dropped, and Stop clears them. + /// HELD, never dropped, and Stop clears them. Complementing the active + /// order, completed Barracks and VehicleFactory buildings passively heal + /// matching own units inside a small zone around their footprint + /// (Issue #55, owner decision E-3; the four rule decisions — rate, + /// radius, cost, stacking — are documented on + /// ). /// /// /// State (snapshot block , @@ -198,6 +203,26 @@ public sealed class ConstructionSystem : IStatefulSimSystem /// Repair rate in HP per tick while the owner's grid is in LOW POWER (C4, Sprint 16.6). public const int LowPowerRepairRateHpPerTick = 5; + /// + /// Passive producer-zone repair rate in HP per tick (Issue #55, owner + /// decision E-3 of 2026-08-31). Deliberately far below the active + /// repair ( = 10, low power + /// = 5): the zone heals + /// BETWEEN engagements, not during one — the full rationale lives on + /// . + /// + public const int PassiveRepairRateHpPerTick = 1; + + /// + /// Footprint-aware Chebyshev radius of the passive producer repair + /// zone in cells (Issue #55): a unit's grid cell heals while within + /// this distance of a matching zone building's 3x3 footprint (the + /// D-104 rectangle-distance convention). Deliberately far under + /// (8) — the zone is the + /// building's own yard, not its territory. + /// + public const int PassiveRepairRadiusCells = 3; + private struct SiteState { public bool IsActive; @@ -233,6 +258,26 @@ private struct RepairOrderState private readonly bool[] _t2Unlocked; private INovaLogger _logger = NullNovaLogger.Instance; + /// + /// E-3 derivation table of the passive repair zone (Issue #55): per + /// building role the mask of unit roles its zone repairs — exactly + /// the unit roles the definition table assigns to that building's + /// production (), filled + /// only for the two zone-building roles of the issue scope + /// (Barracks, VehicleFactory). Derived from + /// , the same source the + /// production executor's producer check reads — a producer + /// reassignment (D-077 precedent) moves the zone with the table + /// instead of stranding a second list. Full-domain (byte enum) so + /// lookups need no bounds check. + /// + private static readonly UnitRoleMask[] PassiveRepairableByBuildingRole = BuildPassiveRepairableTable(); + + /// Union of every zone-repairable unit role; prunes the per-tick unit scan before any placement is read. + private static readonly UnitRoleMask PassiveRepairableUnion = + PassiveRepairableByBuildingRole[(int)UnitRole.Barracks] + | PassiveRepairableByBuildingRole[(int)UnitRole.VehicleFactory]; + // Derived occupancy cache (rebuilt from the placements on restore). private readonly byte[] _occupied; @@ -382,6 +427,53 @@ public bool HasFinishedBuilding(byte playerSlot, UnitRole role) return roleMask != UnitRoleMask.None && HasFinishedBuildings(playerSlot, roleMask); } + /// + /// The E-3 mapping of the passive repair zone (Issue #55): the unit + /// roles a completed building of + /// heals inside its zone — exactly the unit roles the definition + /// table assigns to that building's production + /// (), restricted to the + /// two zone-building roles of the issue scope (Barracks, + /// VehicleFactory). for every other + /// role: no zone, nothing repairable. Pure read of the derived + /// table, no mutation. + /// + public static UnitRoleMask GetPassiveRepairableRoles(UnitRole buildingRole) + { + return PassiveRepairableByBuildingRole[(int)buildingRole]; + } + + /// + /// True when the grid cell is covered by the passive repair zone of + /// an own, living, COMPLETED zone building (footprint-aware + /// Chebyshev <= ). + /// Role-agnostic on purpose: which units a zone actually heals is + /// plus the own/damaged + /// rules of the tick. THIS METHOD IS THE RULE, and it is public so + /// that a future repair-zone overlay can ASK it per cell instead of + /// re-deriving the radius or the anchor set on its own (the + /// precedent). Pure read, no + /// mutation. + /// + public bool IsCellInsidePassiveRepairZone(byte playerSlot, int cellX, int cellY) + { + for (int i = 0; i < MaxBuildings; i++) + { + ref readonly PlacementState placement = ref _buildings[i]; + if (!placement.IsActive) continue; + if (!SimDefinitions.TryGetBuilding(placement.BuildingDefId, out SimBuildingDefinition def)) continue; + if (PassiveRepairableByBuildingRole[(int)def.Role] == UnitRoleMask.None) continue; + + EntityId id = UnitCommandStateView.ToEntityId(placement.RawEntityId); + if (!_entityManager.TryGetUnit(id, out UnitState unit) || unit.PlayerId != playerSlot) continue; + if (PointToFootprintDistance(cellX, cellY, placement.OriginX, placement.OriginY) <= PassiveRepairRadiusCells) + { + return true; + } + } + return false; + } + private static UnitRoleMask RoleMask(UnitRole role) { int bit = (int)role; @@ -654,14 +746,16 @@ public void ClearRepairOrder(uint builderRaw) /// Phase 4: sweeps placements whose entity died (sites abort without /// refund, completed placements free their footprint), then progresses /// every site with an in-reach Builder by the owner's exact Q16.16 - /// speed multiplier, then processes standing repair orders — all in - /// strict ascending table order. + /// speed multiplier, then processes standing repair orders, then + /// heals own damaged units inside the passive producer repair zones + /// — all in strict ascending table order. /// public void ExecuteTick(Tick tick) { SweepDeadPlacements(); ProgressSites(); ProcessRepairOrders(); + ProcessPassiveRepairZones(tick); } private void SweepDeadPlacements() @@ -998,6 +1092,169 @@ private static long RepairCostAtHealth(long fullRepairCost, int health, int maxH return fullRepairCost * health / maxHealth; } + /// + /// Passive repair zones of the producer buildings (Issue #55, owner + /// decision E-3 of 2026-08-31): a completed Barracks or + /// VehicleFactory projects a zone around its footprint in which its + /// OWNER's damaged units of the roles it can PRODUCE regain + /// HP per tick. Runs in + /// phase 4 as the last step of this system's tick, directly after + /// : the active Builder repair and + /// the passive zone are the two repair behaviors of the + /// construction domain, both read tick-start positions (movement is + /// phase 6, so a unit walking into the zone during tick T is first + /// healed in tick T+1), and both heal before combat (phase 8) can + /// re-damage in the same tick — the same documented ordering the + /// building repair already lives with. The target sets are disjoint + /// (building roles vs. unit roles), so the relative order of the + /// two repair passes cannot interact. + /// + /// THE FOUR RULE DECISIONS (Q-040 candidates, stated here so the + /// next balance pass finds them at the code): + /// RATE — 1 HP/tick at full power, deliberately far below the + /// active repair ( = 10, + /// = 5): the zone heals + /// BETWEEN engagements, not during one. 1 HP/tick loses against + /// every weapon in the table (the weakest attacker, BasicInfantry, + /// deals 10 damage per 9 ticks), so a unit under fire inside the + /// zone still loses its fight, while a half-dead Light Tank is full + /// again in roughly half a minute at the 10 Hz tick rate — and the + /// active, priced Builder repair keeps its role as the fast option. + /// Under LOW POWER the zone follows the Sprint-16.6 C4 halving + /// precedent exactly: it heals on even ticks only — 0.5 HP per tick + /// on average with no rounding, the same "one tick of progress per + /// two ticks" idiom the exact Q16.16 0.5 multiplier encodes for + /// sites and queues. + /// RADIUS — = 3 cells, + /// footprint-aware (the D-104 rectangle-Chebyshev convention, unit + /// cell to footprint rectangle). Deliberately far under + /// (8): the zone is the + /// building's own yard, not its territory — one footprint-width of + /// breathing room around a 3x3 building (72 free cells), enough for + /// a battered control group, too small to park a whole army; a zone + /// an entire army fits in is not a zone anymore. + /// COST — free. No AE is debited, ever, and an empty account heals + /// exactly like a full one. The zone exists to remove the per-unit + /// repair micromanagement the beta report complained about; a + /// credit drip would re-introduce exactly that friction (shuttling + /// units out of the zone while saving). The zone's price is already + /// paid in the building's cost and power draw and in the + /// opportunity cost of units standing still in a small yard instead + /// of fighting or harvesting; the active repair keeps its 30% price + /// because it buys speed (10x) and map-wide reach. The economy + /// coupling the zone DOES carry is the power grid (the low-power + /// halving above) — the same coupling every other passive building + /// behavior has. + /// STACKING — a unit heals at most once per tick no matter how many + /// zones cover it: the first matching zone in ascending placement + /// order wins and the scan stops. Overlapping auras must not reward + /// duplicate producers (the building CHOICE keeps its meaning, not + /// the building COUNT), and the cap keeps the maximum inbound heal + /// per unit readable in combat math. The active repair answers the + /// same question the same way (at most one reachable Builder per + /// target per tick). + /// + /// + /// MAPPING (E-3): a building heals exactly the unit roles the + /// definition table's producer assignment names for it — derived + /// from + /// (), the same source + /// the production executor and the input layer's + /// ProducerBuildingRoles read, so a producer reassignment (D-077 + /// precedent) moves the zone with the table instead of stranding a + /// second list. Issue #55 scopes the zone to the two combat-unit + /// producers (Barracks, VehicleFactory); the HQ and Refinery rows + /// of the same derivation would yield Builder/Harvester if a future + /// decision extends the zone set — the derivation is ready for it, + /// the zone set is the decision. + /// + /// + /// Determinism: units are scanned in ascending entity-store index, + /// placements in ascending table order; the low-power gate reads + /// the tick parity; no PRNG, no allocation, pure integer math. The + /// zone keeps NO state of its own — it is re-derived from the + /// placements and the entities every tick (founding-Harvester + /// precedent), so the snapshot block layout is untouched. Guards: + /// dead units (inactive slots, or a not-yet-swept zero-HP entity) + /// are never healed; sites and completed buildings are never healed + /// (their roles appear in no repairable mask — building repair + /// stays the Builder's job); healing never exceeds MaxHealth; only + /// OWN units heal. + /// + /// + private void ProcessPassiveRepairZones(Tick tick) + { + UnitState[] units = _entityManager.RawUnits; + int capacity = _entityManager.Capacity; + for (int i = 0; i < capacity; i++) + { + ref UnitState unit = ref units[i]; + if (!unit.IsActive || unit.CurrentHealth <= 0) continue; // dead or not yet swept + if (unit.CurrentHealth >= unit.MaxHealth) continue; // nothing to heal — also the overheal guard + if ((PassiveRepairableUnion & RoleMask(unit.Role)) == UnitRoleMask.None) continue; // no zone repairs this role + + int ux = Math.Max(0, SimFixed.WorldToGrid(unit.Transform.PositionX)); + int uy = Math.Max(0, SimFixed.WorldToGrid(unit.Transform.PositionY)); + for (int b = 0; b < MaxBuildings; b++) + { + ref readonly PlacementState placement = ref _buildings[b]; + if (!placement.IsActive) continue; + if (!SimDefinitions.TryGetBuilding(placement.BuildingDefId, out SimBuildingDefinition def)) continue; + if ((PassiveRepairableByBuildingRole[(int)def.Role] & RoleMask(unit.Role)) == UnitRoleMask.None) + { + continue; // E-3: the building heals only what it can produce + } + + EntityId buildingId = UnitCommandStateView.ToEntityId(placement.RawEntityId); + if (!_entityManager.TryGetUnit(buildingId, out UnitState building) || building.PlayerId != unit.PlayerId) + { + continue; // own, living, completed placements only (sites are never in _buildings) + } + if (PointToFootprintDistance(ux, uy, placement.OriginX, placement.OriginY) > PassiveRepairRadiusCells) + { + continue; + } + + // The first matching zone wins and the scan stops: no + // stacking. The low-power gate reads the OWNER's grid — + // every matching zone has the same owner, so the verdict + // never depends on which placement matched first. Exact + // halving by tick parity (Sprint-16.6 C4 precedent): + // under low power the zone heals on even ticks only. + ref readonly PlayerEconomyState eco = ref _economy.GetPlayerEconomy(building.PlayerId); + if (!eco.IsLowPower || (tick.Value & 1u) == 0u) + { + unit.CurrentHealth = Math.Min(unit.MaxHealth, unit.CurrentHealth + PassiveRepairRateHpPerTick); + } + break; + } + } + } + + /// + /// Builds the E-3 derivation table from the definition table: for + /// the two zone-building roles of the Issue-#55 scope (Barracks, + /// VehicleFactory) the mask of the unit roles their production + /// covers; every other entry stays . + /// Both faction rows iterate the same D-077 assignment, so the OR + /// is idempotent. + /// + private static UnitRoleMask[] BuildPassiveRepairableTable() + { + var table = new UnitRoleMask[256]; + ReadOnlySpan units = SimDefinitions.AllUnits; + for (int i = 0; i < units.Length; i++) + { + UnitRole producer = units[i].ProducerRole; + if (producer != UnitRole.Barracks && producer != UnitRole.VehicleFactory) + { + continue; // Issue #55 zone scope: the two combat-unit producers + } + table[(int)producer] |= RoleMask(units[i].Role); + } + return table; + } + // ------------------------------------------------------------------ // Internals // ------------------------------------------------------------------ diff --git a/CHANGELOG.md b/CHANGELOG.md index 9ecbb7e..e8616d9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -73,6 +73,22 @@ die Versionierung folgt (in der aktuellen Doku-Phase) dem Dokumentationsstand de spielerisch abgenommen und kein Meilenstein-Nachweis ### Hinzugefügt +- **Fahrzeugfabrik und Kaserne reparieren jetzt passiv, was sie bauen können (#55).** + Der Wunsch stammt aus dem Betatest vom 09.08.2026 und wurde am 31.08.2026 + erneut vermisst: beschädigte Panzerung war bis hierher faktisch dauerhaft — + man schickte jede Einheit einzeln mit einem Pionier los oder verlor sie im + nächsten Gefecht. Nach Inhaberentscheidung heilt jedes der beiden Gebäude + **nur, was es auch produzieren kann** (die Zuordnung wird aus + `SimDefinitions` abgeleitet, es gibt keine zweite Liste): eine Kaserne + repariert keine Panzer. Die vier offenen Zahlen sind gegen den Bestand + hergeleitet und im Docstring begründet, damit die nächste Balance-Runde sie + am Code findet — **Radius 3** Zellen (gegen die 8er-Bauzone: eine Zone, in + der eine ganze Armee Platz hat, ist keine Zone), **1 HP/Tick** (ein Zehntel + der aktiven Reparatur und unter jeder Waffenrate: es heilt zwischen + Gefechten, nicht während eines), **0 AE** (ein leeres Konto heilt gleich + schnell), **kein Stapeln** (stehen zwei Zonen übereinander, gewinnt die + erste). Deterministisch: feste Phasenposition, aufsteigende + Entitäts-Indizes, Ganzzahlarithmetik - **Eine Ressourcenleiste zeigt endlich, was die Wirtschaft tut (#137).** Aus der Proberunde vom 31.08.2026: „Mir fehlt ein globales Overlay, in dem man sieht, wie viel Strom man hat, vor allem auch wie viel Lagerplatz man noch hat." diff --git a/reports/v8.6.0/sprint-23/13-kimi-reparatur.md b/reports/v8.6.0/sprint-23/13-kimi-reparatur.md new file mode 100644 index 0000000..3f0550f --- /dev/null +++ b/reports/v8.6.0/sprint-23/13-kimi-reparatur.md @@ -0,0 +1,478 @@ +# kimi-agent report + +- when: 2026-08-31T07:50:33Z +- backend: cc +- model: k3[1m] +- mode: rw +- dir: /Volumes/2TB_CodingProjekte/Coding_Projekte/nova-wt/reparatur +- run: /Users/denniswestermann/.agent-runs/20260831-095033-81453 + +## Task + +Du arbeitest an "Project Nova" / HashKrieg, einem Unity-RTS mit deterministischer, +ganzzahliger Simulation. Doku und Berichte: Deutsch. Code und Docstrings: +Englisch, wie im Bestand. + +**ARBEITSVERZEICHNIS — der einzige Pfad, unter dem du liest und schreibst:** + + /Volumes/2TB_CodingProjekte/Coding_Projekte/nova-wt/reparatur + +Daneben liegt eine Arbeitskopie unter `/Volumes/2TB_CodingProjekte/Coding_Projekte/Project Nova`. +**Fass die nicht an, weder lesend noch schreibend.** + +## Der Auftrag — Issue #55, lies ihn zuerst + +Aus dem Betatest vom 09.08.2026, und am 31.08.2026 vom Inhaber erneut vermisst, +weil er es erwartet hatte und es nicht da war: + +> „Fahrzeugfabriken und Kasernen sollten einen Bereich in ihrer Nähe haben, in +> dem Einheiten langsam repariert werden." + +Reparatur existiert heute **nur als aktiver Befehl**: `CommandKind.Repair`, +ausgeführt von einer Builder-Einheit, verwaltet in +`ConstructionSystem.ProcessRepairOrders`. Beschädigte Panzerung ist damit +faktisch dauerhaft — man schickt jede Einheit einzeln mit einem Pionier los oder +verliert sie im nächsten Gefecht. + +## Die Entscheidung des Inhabers vom 31.08.2026 + +**E-3 — Nur passende Einheiten.** Die Fahrzeugfabrik repariert Fahrzeuge, die +Kaserne Fußtruppen: jedes Gebäude heilt nur, was es auch **produzieren** kann. +Eine Kaserne, die Panzer repariert, ist unlogisch, und die Gebäudewahl soll +Bedeutung behalten. + +Im Bestand gibt es dafür bereits eine Wahrheitsquelle — `ProducerBuildingRoles` +ist die Aufzählung der produzierenden Gebäude über dieselbe Definitionstabelle. +**Sieh nach, ob sich daraus ableiten lässt, welche Rollen ein Gebäude +produziert.** Wenn ja, ist das deine Zuordnung, und du erfindest keine zweite. +Wenn nein, sag es im Report und beschreib, was fehlt. + +## Was du entscheidest — und begründest + +Radius, Rate und Kosten sind **nicht** entschieden. Der Inhaber sagt „langsam". +Du legst konkrete Zahlen vor und begründest sie **gegen den Bestand**, nicht aus +dem Gefühl: + +- **Die Vorlage steht da.** `ProcessRepairOrders` hat bereits eine Heilrate, und + `LowPowerRepairRateHpPerTick = 5` zeigt, in welcher Größenordnung dieses Spiel + denkt. Setz die passive Rate **deutlich darunter** — sie soll zwischen + Gefechten heilen, nicht während eines. +- **Der Radius** gehört ins Verhältnis zu `BuildInfluenceRadiusCells = 8` und + zur Größe eines 3×3-Footprints. Eine Zone, in der eine Armee komplett Platz + hat, ist keine Zone mehr. +- **Kostet es etwas?** Beantworte es ausdrücklich. Wenn ja, wie verhält es sich + bei leerem Konto — heilt es langsamer oder gar nicht? Wenn nein, sag warum das + vertretbar ist. +- **Stapelt es sich bei mehreren Gebäuden?** Beantworte es. Nicht stapeln ist + fast immer die richtige Antwort, aber sag es und begründe es. + +Schreib alle vier Antworten in den Docstring, nicht nur in den Report — die +nächste Balance-Runde muss sie am Code finden. + +## Die Determinismus-Auflagen — nicht verhandelbar + +Die Heilung läuft in der Simulation: + +- **Feste Phasenposition in der Tickreihenfolge.** Wo genau, begründest du. Sieh + dir an, wo `ProcessRepairOrders` läuft, und ob deine Heilung dort hingehört + oder davor/dahinter. +- **Aufsteigende Entitäts-Indizes.** Nie über eine Reihenfolge iterieren, die + von Einfügereihenfolge oder Hash abhängt. +- **Fixed-Point, kein `float`, kein `double`, kein `UnityEngine.Random`.** Der + Wächter `NoFloatInSimulationTests` fängt dich sonst — und das zu Recht. +- **Keine Heilung über die Maximalgesundheit hinaus**, und keine an toten oder + noch im Bau befindlichen Einheiten. Beides als Test pinnen. + +## Was das bewegt + +Das ändert Simulationsverhalten und fügt Regelkonstanten hinzu — es bewegt +voraussichtlich `RulesHash64`, die Determinismus-Baselines und den gepinnten +Ausgang der kanonischen KI-Partie. + +**Verhalten und Baseline gehen NIE in denselben PR.** Rühr keine Baseline-Datei +und keinen gepinnten Golden-Wert an. **Liste im Report auf**, welche Datei, +welche Konstante und welcher Test nachgezogen werden müssen und mit welchem +alten Wert. Rote Tests dieser Gruppen sind erwartet — wörtlich in den Report, +nicht grün machen. + +## Schreibhoheit — verbindlich + +ERLAUBT: + Assets/_Project/Scripts/Simulation/Construction/ConstructionSystem.cs + nur die Reparaturseite + Assets/Tests/EditMode/Simulation/ neue Tests + tools/Nova.SimRunner.Tests/ neue Tests + reports/v8.6.0/sprint-23/ nur deine eigenen Dateien + +VERBOTEN: + Assets/_Project/Scripts/Simulation/Economy/ dort arbeitet ein anderer Worker + Assets/_Project/Scripts/Simulation/Combat|Movement|Factions|Pathfinding/ + Assets/_Project/Scripts/AI/ AI.Data/ + Assets/_Project/Scripts/Simulation/CommandsV1|Snapshots|Replays|Systems|State/ + eingefroren, D-ID-pflichtig + Assets/_Project/Scripts/Presentation/ Gameplay/ dort arbeiten zwei andere + Alle *Golden*- und *Baseline*-Tests, CanonicalAiOutcomeTests + CHANGELOG.md VERSION ROADMAP.md README.md plans/** global.json + +> **Die Platzierungsseite von `ConstructionSystem` gehört einem anderen Worker** +> (`ValidatePlacement`, `IsInsideBuildInfluence`, `HasMinimumBuildingSpacing`, +> `BuildingPlacementBlocker`). Fass sie nicht an. Wenn deine Arbeit sie berührt, +> **halt an und melde es** — dann schneiden wir die Pakete neu. + +**Den CHANGELOG fasst du nicht an.** Vorschlagstext in den Report. + +## Verifikation + + "/Volumes/2TB_CodingProjekte/Coding_Projekte/Project Nova/.dotnet/dotnet" test tools/Nova.SimRunner.Tests/Nova.SimRunner.Tests.csproj -c Release + +Ausgangsstand **739/739 grün**. Vorher und nachher fahren, beides wörtlich in +den Report — inklusive der erwartet roten Baseline-Tests. Ändere `global.json` +niemals. Unity hast du nicht; EditMode-Tests schreiben ja, ausführen nein. + +## Was du NICHT tust + +- Kein `git commit`, `git push`, `git add`, kein PR, kein `gh`-Aufruf. +- Keine Subagenten. +- **Keine Kampfwerte anfassen.** Die Heilrate ist Balance genug für einen PR. + +## Report + +Markdown nach `reports/v8.6.0/sprint-23/`. Struktur: + + 1. Die vier Zahlen (Radius, Rate, Kosten, Stapeln) — jede mit ihrer Begründung + gegen den Bestand + 2. Woher die Zuordnung Gebäude → reparierbare Rolle kommt + 3. Wo in der Tickreihenfolge geheilt wird und warum dort + 4. Welche Baselines nachgezogen werden müssen — Datei, Konstante, alter Wert + 5. Testlauf vorher / nachher, inklusive der erwartet roten + 6. Was unbelegt bleibt + 7. CHANGELOG-Vorschlagstext + +Schließe mit: + + STATUS: DONE | BLOCKED + - Befund 1 + - Befund 2 + - Befund 3 + +## Output + +# Passive Reparaturzonen (Issue #55, Entscheidung E-3) — Umsetzungsbericht + +Umgesetzt in `ConstructionSystem` (nur Reparaturseite): abgeschlossene Kasernen +und Fahrzeugfabriken projizieren eine passive Reparaturzone, die eigene +beschädigte Einheiten der produzierbaren Rollen heilt. Keine Zeile Bestandscode +entfernt (Diff: 262 Einfügungen, 4 Docstring-Zeilen erweitert), die +Platzierungsseite (`ValidatePlacement`, `IsInsideBuildInfluence`, +`HasMinimumBuildingSpacing`, `BuildingPlacementBlocker`) ist unangetastet. + +## 1. Die vier Zahlen — und ihre Begründung gegen den Bestand + +**Rate: 1 HP/Tick** (`PassiveRepairRateHpPerTick = 1`). Der Bestand denkt in +`RepairRateHpPerTick = 10` (aktiv, volle Energie) und +`LowPowerRepairRateHpPerTick = 5`; die Auflage lautete „deutlich darunter". +1 ist ein Zehntel der aktiven und ein Fünftel der Niedrigenergie-Rate. Gegen +die Kampftabelle gemessen ist das die entscheidende Eigenschaft: die +schwächste Waffe des Spiels (BasicInfantry, 10 Schaden pro 9 Ticks ≈ 1,1 +HP/Tick vor Panzerung) schlägt die Zone — eine Einheit unter Beschuss verliert +ihren Kampf weiter, die Zone heilt **zwischen** Gefechten, nicht während +eines. Auf der Zeitskala (10 Hz): ein halbtoter LightTank (275/550) ist in +~28 s voll, ein BattleTank von 25 % in ~82 s — spürbar „langsam", aber ohne +Pionier-Einsatz. Gleichzeitig bleibt die aktive, bezahlte Reparatur die +schnelle Option (10× Rate, kartenweit einsetzbar) und wird nicht kanibalisiert. +Bei Niedrigenergie folgt die Zone dem C4-Präzedenzfall aus Sprint 16.6 exakt: +Heilung nur an geraden Ticks — 0,5 HP/Tick im Mittel ohne Rundung, dasselbe +Idiom, das der exakte Q16.16-Multiplikator 0,5 für Baustellen und +Produktionswarteschlangen kodiert („ein Tick Fortschritt pro zwei Ticks"). + +**Radius: 3 Zellen** (`PassiveRepairRadiusCells = 3`), footprint-bezogener +Tschebyschew-Abstand (dieselbe D-104-Rechteckkonvention wie die Platzierung; +Eckzellen zählen mit). Zum Verhältnis: `BuildInfluenceRadiusCells = 8` ist das +**Territorium** eines Gebäudes, die Bauherren-Reichweite 1 der **Kontaktring**. +3 liegt deutlich unter 8 — die Zone ist der Hof des Gebäudes, nicht sein +Gebiet — und bedeutet eine Footprint-Breite Luft um ein 3×3-Gebäude: 72 freie +Zellen. Eine angeschlagene Kontrollgruppe findet Platz; eine komplette Armee +mit Begleitung nicht — eine Zone, in der eine Armee komplett Platz hat, ist +keine Zone mehr (Auflage). Zusätzlicher Maßstab aus dem Bestand: die Raffinerie +misst ihre Feldnaehe im selben 1..3-Zellen-Raster. + +**Kosten: 0 AE — bei leerem Konto heilt die Zone unverändert weiter.** Das +Feature existiert, weil der Betabericht die Einzel-Reparatur-Mikroverwaltung +bemängelt; ein Credit-Drip würde genau diese Reibung wieder einführen +(Einheiten beim Sparen aus der Zone schieben). Vertretbar ist „frei" aus drei +Bestandsgründen: (a) Der Preis ist bereits bezahlt — Gebäudekosten, +Energiebedarf (Fabrik 25, Kaserne 15) und die Opportunitätskosten +stillstehender Einheiten in einem 3-Zellen-Hof. (b) Die aktive Reparatur +behält ihren 30-%-Preis, weil sie etwas anderes verkauft: Tempo (10×) und +kartenweite Reichweite — die Differenzierung bleibt erhalten. (c) Passive +Gebäudeverhalten sind im Bestand über das **Energienetz** gekoppelt, nicht +über Credits (Energiebereitstellung frei, Gründungs-Harvester frei) — und +genau diese Kopplung trägt die Zone (Niedrigenergie-Halbierung), während ein +leeres AE-Konto sie nicht bremst. + +**Stapeln: nein — höchstens 1× Rate pro Einheit und Tick.** Die erste +passende Zone in aufsteigender Platzierungsreihenfolge gewinnt, der Scan +stoppt (konstruktiv, nicht nachträglich gedeckelt). Begründung: Überlappende +Auren dürften keine doppelten Produzenten belohnen — die Gebäude**wahl** soll +Bedeutung behalten (E-3), nicht die Gebäude**anzahl**; die gedeckelte +Eingangsheilung bleibt in der Kampfrechnung lesbar; und der Bestand +beantwortet dieselbe Frage bereits genauso (höchstens ein erreichbarer +Bauherr pro Reparaturziel und Tick). + +Alle vier Antworten stehen mit dieser Begründung im Docstring von +`ProcessPassiveRepairZones` (Q-040-Kandidaten), nicht nur hier. + +## 2. Woher die Zuordnung Gebäude → reparierbare Rolle kommt + +**Sie lässt sich ableiten — keine zweite Zuordnung erfunden.** Wahrheitsquelle +ist `SimUnitDefinition.ProducerRole` in `SimDefinitions.AllUnits`: jede +Einheitendefinition nennt die Gebäuderolle, die sie produziert. Genau diese +Tabelle werten bereits `ProductionSystem.IsProducerRole` (privat, Simulation) +und `ProducerBuildingRoles` (Gameplay/UI — die im Auftrag genannte +Aufzählung) aus. Die Gameplay-Klasse kann die Simulation nicht referenzieren +(Assemblierungsgrenze); darum leitet `ConstructionSystem` dieselbe Tabelle +statisch ab (`BuildPassiveRepairableTable`, pro Gebäuderolle eine +`UnitRoleMask` der produzierbaren Einheitenrollen). Ergebnis (D-077, beide +Fraktionen identisch): Kaserne → {BasicInfantry, AntiArmorInfantry}, +Fahrzeugfabrik → {ScoutVehicle, LightTank, BattleTank, Artillery}. + +Scope-Entscheidung, im Test gepinnt: die **Zone** erhalten nur die zwei im +Issue genannten Kampfeinheiten-Produzenten. Dieselbe Ableitung würde für HQ +{Builder} und Raffinerie {Harvester} ergeben — die Ableitung ist dafür +bereit, das Zone-Set ist die Entscheidung, und sie steht so im Docstring. +Ein Produzenten-Umbau (D-077-Präzedenz) bewegt die Reparatur-Zuordnung +automatisch mit der Tabelle. + +## 3. Wo in der Tickreihenfolge geheilt wird — und warum dort + +**Phase 4 (Construction und Production), als letzter Schritt von +`ConstructionSystem.ExecuteTick`, direkt nach `ProcessRepairOrders`.** +Begründung: + +- SimulationCore.md §2 legt Reparatur in die Konstruktionsdomäne (Phase 4); + ein eigenes neues System wäre ohnehin nicht zulässig gewesen + (`Simulation/Systems/` eingefroren, Host-Registrierung in fremden + Arbeitsgebieten). Der ConstructionSystem-Tick ist der dokumentierte Ort + beider Reparaturverhalten. +- Die Heilung liest Tick-Anfangspositionen: Bewegung ist Phase 6, also wird + eine Einheit, die in Tick T in die Zone läuft, frühestens in T+1 geheilt — + dieselbe „construction reads same-tick state"-Regel, die das System + überall dokumentiert. +- Sie heilt **vor** dem Kampf (Phase 8): Heilung und erneuter Schaden im + selben Tick — exakt die Reihenfolge, mit der die Gebäudereparatur bereits + lebt. +- Die Zielmengen der zwei Reparaturpässe sind disjunkt (Gebäuderollen vs. + Einheitenrollen), ihre Reihenfolge kann nicht interferieren; der aktive + Befehlspfad geht vor dem Umgebungsverhalten. +- `SweepDeadPlacements` läuft vorher: eine zerstörte Fabrik heilt ab dem + Folgetick nicht mehr, und die Platzierungstabelle ist beim Zonenlauf + bereits bereinigt. + +Determinismus-Auflagen, eingehalten und gepinnt: aufsteigende Indizes +(Entity-Store außen, Platzierungstabelle innen), reine Integer-Mathematik +(kein `float`/`double`/PRNG — `NoFloatInSimulationTests` grün), kein eigener +Zustand (Zone wird pro Tick aus Platzierungen + Entitäten abgeleitet, +Gründungs-Harvester-Präzedenz — das Snapshot-Layout bleibt unangetastet), +keine Heilung über MaxHealth, keine Heilung an Toten (inaktive Slots / +Rest-0-HP), an Baustellen (Ziel wie Anker), an Gebäuden oder an Feinden — +alles als Test gepinnt (`PassiveRepairZoneTests`, 11 Tests, beide Spuren). + +## 4. Welche Baselines nachgezogen werden müssen — Datei, Konstante, alter Wert + +**Dieser PR selbst zieht keine Baseline nach — 750/750 grün, alle +Pin-Gruppen unverändert.** Die antizipierte Bewegung tritt erst im Folge-PR +ein, der die neuen Regelkonstanten in die Regelrevision bindet (Verhalten und +Baseline getrennt, wie gefordert): + +1. `Assets/_Project/Scripts/Simulation/Replays/MatchFingerprint.cs` + (eingefroren, D-ID-pflichtig — **nicht von mir angefasst**): + `RulesRevisionV4 = 4` ergänzen; `ComputeRulesHash64` um Feld 15 + (`ConstructionSystem.PassiveRepairRateHpPerTick`, i32) und Feld 16 + (`ConstructionSystem.PassiveRepairRadiusCells`, i32) erweitern + (Feldzähler 14 → 16); `CurrentRulesRevision = RulesRevisionV4`. Erst + dieser Schritt bewegt `RulesHash64`. +2. `tools/Nova.SimRunner.Tests/MatchFingerprintTests.cs`: Zeile 100 pinnt den + aktuellen Stream auf **`0x05CCA8475789AD4AUL`** (Revision 3, „current") — + nach dem Revisions-Bump friert dort Revision 3 ein und der neue + Revision-4-Stream wird gepinnt. Unverändert bleiben Revision 1 + (`0x531CE8F614A16CB5UL`, Zeile 88) und Revision 2 (`0x07725EA26668C9F8UL`, + Zeile 89). +3. `tools/Nova.SimRunner.Tests/CanonicalAiOutcomeTests.cs`: + `PinnedDecidedTick = 2726u` (Zeile 47) und + `PinnedEndState = "0x10B83E94F86F2E55"` (Zeile 57). In **diesem** PR grün + geblieben — die Zone heilt in der kanonischen KI-Partie kein einziges + Mal (jede Heilung würde den Endzustand-Hash bewegen). Nach dem + Revisions-Bump aus Punkt 1 neu fahren und neu pinnen: der + Match-Fingerprint (mit `RulesHash64`) ist Teil des kanonischen Zustands + und fließt in `CalculateStateHash` ein — der Pin wandert dann auch ohne + Verhaltensänderung. +4. Determinismus-Baselines (`Determinism10000Tests`): selbst-referenziell + (Lauf gegen Lauf, keine Datei-Baseline) — bleiben grün und beweisen die + Determinismus-Auflagen des neuen Codes. +5. `SnapshotGoldenBytesTests` / `CommandGoldenBytesTests`: keine + Layout-/Schemaänderung (die Zone hält keinen Zustand, kein neuer + Befehl) — grün, nichts nachzuziehen. + +## 5. Testlauf vorher / nachher + +Befehl (beide Male, wörtlich): +`"/Volumes/2TB_CodingProjekte/Coding_Projekte/Project Nova/.dotnet/dotnet" test tools/Nova.SimRunner.Tests/Nova.SimRunner.Tests.csproj -c Release` + +**Vorher (Ausgangsstand, unverändertes Repo):** + + Bestanden! : Fehler: 0, erfolgreich: 739, übersprungen: 0, gesamt: 739, Dauer: 14 s - Nova.SimRunner.Tests.dll (net8.0) + +**Nachher (mit dieser Änderung + 11 neuen Tests):** + + Bestanden! : Fehler: 0, erfolgreich: 750, übersprungen: 0, gesamt: 750, Dauer: 15 s - Nova.SimRunner.Tests.dll (net8.0) + +**Erwartet rote Baseline-Tests: keine.** Anders als im Auftrag antizipiert, +aus drei belegten Gründen: (a) `RulesHash64` hasht eine feste Feldliste — +die neuen Konstanten bindet erst die neue Regelrevision, und +`MatchFingerprint.cs` ist für mich eingefroren; (b) die kanonische KI-Partie +löst keine Zonenheilung aus (Pin grün); (c) kein Snapshot-Layout geändert. +Einzelnachweise (gefilterter Lauf, `--no-build`): +`CanonicalAiMatch_DecidesOnThePinnedTick_WithThePinnedEndState` — Bestanden +[332 ms]; `ShortRun_TwoExecutions_ProduceIdenticalHashesAndSnapshotBytes` — +Bestanden; alle 11 `PassiveRepairZoneTests` — Bestanden. + +EditMode-Spur: `Assets/Tests/EditMode/Simulation/PassiveRepairZoneTests.cs` +(+.meta, frische GUID) ist das 1:1-Spiegelbild und **geschrieben, nicht +ausgeführt** — hier steht kein Unity. + +## 6. Was unbelegt bleibt + +- **HQ-/Raffinerie-Zonen** (Builder/Harvester passiv heilen): aus der + Tabelle ableitbar, aber nicht entschieden — der Issue nennt nur Kaserne + und Fahrzeugfabrik. Erweiterung ist eine Ein-Zeilen-Änderung am Zone-Set, + die Tests pinnen den Ist-Scope bewusst. +- **Keine Präsentation**: kein Zone-Overlay, kein HUD-Indikator — + Presentation/Gameplay sind anderen Workern zugewiesen. + `IsCellInsidePassiveRepairZone(slot, x, y)` ist die dafür vorgesehene + öffentliche Regel-Abfrage (`IsInsideBuildInfluence`-Präzedenz: die Regel + fragen, nicht neu ableiten); `GetPassiveRepairableRoles(role)` liefert die + Rollenzuordnung. +- **Die KI zieht beschädigte Einheiten nicht gezielt in Zonen** (`AI/` + eingefroren). Die Zone wirkt, sobald Einheiten dort stehen; ein + Rückzugsverhalten wäre ein eigenes Paket. +- **EditMode-Ausführung** steht aus (kein Unity auf dieser Maschine). +- **Balance**: alle vier Zahlen sind bewusst konservativ gelegte + Q-040-Kandidaten; die nächste Balance-Runde findet die Begründungen am + Code. + +## 7. CHANGELOG-Vorschlagstext (unter `[Unreleased]` → `Hinzugefügt`) + +- **Passive Reparaturzonen an Kaserne und Fahrzeugfabrik (Issue #55, E-3).** + Abgeschlossene Kasernen und Fahrzeugfabriken heilen eigene beschädigte + Einheiten der Rollen, die sie auch produzieren (Kaserne: Infanterie, + Fahrzeugfabrik: Fahrzeuge — aus der Definitionstabelle abgeleitet, + D-077), in einem footprint-bezogenen Tschebyschew-Radius von 3 Zellen um + das Gebäude, mit 1 HP/Tick (bei Niedrigenergie exakt halbiert: nur gerade + Ticks), kostenlos und ohne Stapelung mehrerer Zonen. Die aktive + Pionier-Reparatur (10 HP/Tick, 30 % des Neupreises) bleibt die schnelle, + überall verfügbare Option. Deterministisch in Phase 4 nach den + Reparaturaufträgen, ohne eigenen Snapshot-Zustand. + +## Geänderte / neue Dateien + +- `Assets/_Project/Scripts/Simulation/Construction/ConstructionSystem.cs` — + nur Reparaturseite: zwei Regelkonstanten, Ableitungstabelle + (`BuildPassiveRepairableTable`), zwei öffentliche Lesezugriffe + (`GetPassiveRepairableRoles`, `IsCellInsidePassiveRepairZone`), Tick-Hook + und `ProcessPassiveRepairZones`, Docstrings. Keine Bestandszeile Code + entfernt; Platzierungsseite unangetastet. +- `tools/Nova.SimRunner.Tests/PassiveRepairZoneTests.cs` — neu, 11 Tests. +- `Assets/Tests/EditMode/Simulation/PassiveRepairZoneTests.cs` (+`.meta`) — + EditMode-Spiegel. +- `reports/v8.6.0/sprint-23/13-kimi-reparatur.md` — dieser Bericht. + +STATUS: DONE +- E-3 umgesetzt: Zone heilt nur, was das Gebäude produzieren kann — aus `SimDefinitions.AllUnits`/`ProducerRole` abgeleitet, keine zweite Zuordnung; Scope (nur Kaserne/Fabrik) bewusst und gepinnt. +- Vier Zahlen entschieden und am Code begründet: Radius 3 (gegen 8er-Bauzone), 1 HP/Tick (Zehntel der aktiven Rate, unter jeder Waffe), 0 AE (leeres Konto heilt gleich), kein Stapeln (erste Zone gewinnt). +- Kein Baseline-PR nötig für dieses Verhalten: 739/739 → 750/750 grün, kanonische KI-Partie und alle Pins unverändert; Folge-PR für `RulesRevisionV4` in `MatchFingerprint.cs` (eingefroren) mit alten Pin-Werten gelistet. + + +## Exit + +exit=0 + +## Progress + + [00:14] · Ich beginne mit der Bestandsaufnahme: erst der Issue-Kontext, dann der relevante Code. + [00:14] Bash ls /Volumes/2TB_CodingProjekte/Coding_Projekte/nova-wt/reparatur + [00:14] Read /Volumes/2TB_CodingProjekte/Coding_Projekte/nova-wt/reparatur/Assets/_Project/Scripts/Sim… + [00:32] Grep /Volumes/2TB_CodingProjekte/Coding_Projekte/nova-wt/reparatur + [00:32] Grep /Volumes/2TB_CodingProjekte/Coding_Projekte/nova-wt/reparatur + [00:43] Read /Volumes/2TB_CodingProjekte/Coding_Projekte/nova-wt/reparatur/Assets/_Project/Scripts/Gam… + [00:43] Grep /Volumes/2TB_CodingProjekte/Coding_Projekte/nova-wt/reparatur/Assets/_Project/Scripts/Sim… + [00:54] Read /Volumes/2TB_CodingProjekte/Coding_Projekte/nova-wt/reparatur/Assets/_Project/Scripts/Sim… + [00:54] Glob Assets/_Project/Scripts/Simulation/**/*.cs + [01:01] Read /Volumes/2TB_CodingProjekte/Coding_Projekte/nova-wt/reparatur/Assets/_Project/Scripts/Sim… + [01:19] Read /Volumes/2TB_CodingProjekte/Coding_Projekte/nova-wt/reparatur/Assets/_Project/Scripts/Sim… + [01:19] Read /Volumes/2TB_CodingProjekte/Coding_Projekte/nova-wt/reparatur/Assets/_Project/Scripts/Sim… + [04:30] Read /Volumes/2TB_CodingProjekte/Coding_Projekte/nova-wt/reparatur/Assets/_Project/Scripts/Sim… + [04:30] Read /Volumes/2TB_CodingProjekte/Coding_Projekte/nova-wt/reparatur/reports/v8.6.0/sprint-23/13… + [04:52] Bash grep -n "phase" /Volumes/2TB_CodingProjekte/Coding_Projekte/nova-wt/reparatur/docs/tech/S… + [04:54] Grep /Volumes/2TB_CodingProjekte/Coding_Projekte/nova-wt/reparatur + [04:54] Read /Volumes/2TB_CodingProjekte/Coding_Projekte/nova-wt/reparatur/Assets/_Project/Scripts/Sim… + [05:06] Read /Volumes/2TB_CodingProjekte/Coding_Projekte/nova-wt/reparatur/Assets/_Project/Scripts/Cor… + [05:06] Bash grep -n -i "phase\|tick order\|Tickreihenfolge" "/Volumes/2TB_CodingProjekte/Coding_Proje… + [05:15] Bash grep -n -i "phase\|Abschnitt 2\|## 2" "/Volumes/2TB_CodingProjekte/Coding_Projekte/nova-w… + [05:31] Read /Volumes/2TB_CodingProjekte/Coding_Projekte/nova-wt/reparatur/tools/Nova.SimRunner.Tests/… + [05:31] Bash ls /Volumes/2TB_CodingProjekte/Coding_Projekte/nova-wt/reparatur/tools/Nova.SimRunner.Tes… + [05:43] Bash grep -n "class\|void Repair\|RepairOrder\|AssignRepair\|ProcessRepair" "/Volumes/2TB_Codi… + [05:43] Read /Volumes/2TB_CodingProjekte/Coding_Projekte/nova-wt/reparatur/Assets/Tests/EditMode/Simul… + [05:53] Read /Volumes/2TB_CodingProjekte/Coding_Projekte/nova-wt/reparatur/Assets/Tests/EditMode/Simul… + [06:03] Read /Volumes/2TB_CodingProjekte/Coding_Projekte/nova-wt/reparatur/Assets/Tests/EditMode/Simul… + [06:17] Bash ls /Volumes/2TB_CodingProjekte/Coding_Projekte/nova-wt/reparatur/tools/Nova.SimRunner.Tes… + [06:25] Read /Volumes/2TB_CodingProjekte/Coding_Projekte/nova-wt/reparatur/tools/Nova.SimRunner.Tests/… + [06:40] Grep /Volumes/2TB_CodingProjekte/Coding_Projekte/nova-wt/reparatur/tools/Nova.SimRunner.Tests + [06:49] Read /Volumes/2TB_CodingProjekte/Coding_Projekte/nova-wt/reparatur/tools/Nova.SimRunner.Tests/… + [07:17] Grep /Volumes/2TB_CodingProjekte/Coding_Projekte/nova-wt/reparatur/tools/Nova.SimRunner.Tests + [07:17] Grep /Volumes/2TB_CodingProjekte/Coding_Projekte/nova-wt/reparatur/tools/Nova.SimRunner.Tests/… + [07:35] Grep /Volumes/2TB_CodingProjekte/Coding_Projekte/nova-wt/reparatur/tools/Nova.SimRunner.Tests + [07:50] Bash sed -n '1,120p' /Volumes/2TB_CodingProjekte/Coding_Projekte/nova-wt/reparatur/tools/Nova.… + [10:29] · Jetzt prüfe ich die .NET-Testspur (csproj, Fixture-Stil) — sie ist meine einzige Kompilier- und Ausführungsverifikation. + [10:29] Read /Volumes/2TB_CodingProjekte/Coding_Projekte/nova-wt/reparatur/tools/Nova.SimRunner.Tests/… + [10:29] Read /Volumes/2TB_CodingProjekte/Coding_Projekte/nova-wt/reparatur/tools/Nova.SimRunner.Tests/… + [10:47] Bash "/Volumes/2TB_CodingProjekte/Coding_Projekte/Project Nova/.dotnet/dotnet" test tools/Nova… + [10:47] agent + Run baseline .NET test suite before changes (agent) + [11:03] Grep /Volumes/2TB_CodingProjekte/Coding_Projekte/nova-wt/reparatur/Assets/_Project/Scripts/Sim… + [11:04] agent = Run baseline .NET test suite before changes (completed) + [11:04] agent -> full output: /private/tmp/claude-501/-Volumes-2TB-CodingProjekte-Coding-Projekte-nova-wt-reparatur/aa77b4a4-e6f4-403a-87ca-0519dbbc3c3e/tasks/bwiwovd88.output + [11:04] Grep /Volumes/2TB_CodingProjekte/Coding_Projekte/nova-wt/reparatur/Assets/_Project/Scripts/Sim… + [11:15] · Baseline-Lauf ist durch. Ergebnis lesen: + [11:15] Read /private/tmp/claude-501/-Volumes-2TB-CodingProjekte-Coding-Projekte-nova-wt-reparatur/aa7… + [13:59] Grep /Volumes/2TB_CodingProjekte/Coding_Projekte/nova-wt/reparatur/Assets/_Project/Scripts/Sim… + [13:59] Grep /Volumes/2TB_CodingProjekte/Coding_Projekte/nova-wt/reparatur/Assets/_Project/Scripts/Sim… + [14:17] Edit /Volumes/2TB_CodingProjekte/Coding_Projekte/nova-wt/reparatur/Assets/_Project/Scripts/Sim… + [14:35] Edit /Volumes/2TB_CodingProjekte/Coding_Projekte/nova-wt/reparatur/Assets/_Project/Scripts/Sim… + [14:45] · Jetzt die öffentlichen Lesezugriffe im Query-Bereich: + [14:59] Edit /Volumes/2TB_CodingProjekte/Coding_Projekte/nova-wt/reparatur/Assets/_Project/Scripts/Sim… + [15:14] Edit /Volumes/2TB_CodingProjekte/Coding_Projekte/nova-wt/reparatur/Assets/_Project/Scripts/Sim… + [15:28] · Jetzt die Kernmethode mit dem vollständigen Entscheidungs-Docstring, direkt nach `RepairCostAtHealth`: + [16:07] Edit /Volumes/2TB_CodingProjekte/Coding_Projekte/nova-wt/reparatur/Assets/_Project/Scripts/Sim… + [16:21] Edit /Volumes/2TB_CodingProjekte/Coding_Projekte/nova-wt/reparatur/Assets/_Project/Scripts/Sim… + [16:40] Edit /Volumes/2TB_CodingProjekte/Coding_Projekte/nova-wt/reparatur/Assets/_Project/Scripts/Sim… + [18:28] Read /Volumes/2TB_CodingProjekte/Coding_Projekte/nova-wt/reparatur/Assets/Tests/EditMode/Simul… + [20:04] Write /Volumes/2TB_CodingProjekte/Coding_Projekte/nova-wt/reparatur/tools/Nova.SimRunner.Tests/… + [22:24] Edit /Volumes/2TB_CodingProjekte/Coding_Projekte/nova-wt/reparatur/tools/Nova.SimRunner.Tests/… + [22:49] · Jetzt das EditMode-Spiegelbild (gleiche Tests, Namespace `Nova.Simulation.Tests`) plus `.meta`: + [22:49] Bash uuidgen | tr -d '-' | tr 'A-F' 'a-f' + [24:29] Write /Volumes/2TB_CodingProjekte/Coding_Projekte/nova-wt/reparatur/Assets/Tests/EditMode/Simul… + [24:41] Write /Volumes/2TB_CodingProjekte/Coding_Projekte/nova-wt/reparatur/Assets/Tests/EditMode/Simul… + [25:08] · Jetzt der Nachher-Lauf der .NET-Spur (kompiliert auch die geänderte Simulation): + [25:09] Bash "/Volumes/2TB_CodingProjekte/Coding_Projekte/Project Nova/.dotnet/dotnet" test tools/Nova… + [25:12] agent + Run .NET test suite after the change (agent) + [25:47] Bash "/Volumes/2TB_CodingProjekte/Coding_Projekte/Project Nova/.dotnet/dotnet" test tools/Nova… + [26:42] Bash sed -n '1,60p' "/Volumes/2TB_CodingProjekte/Coding_Projekte/nova-wt/reparatur/reports/v8.… + [26:57] Bash ls "/Volumes/2TB_CodingProjekte/Coding_Projekte/nova-wt/reparatur/reports/v8.6.0/sprint-2… + [27:16] Bash grep -n "^## \|^# " "/Volumes/2TB_CodingProjekte/Coding_Projekte/nova-wt/reparatur/report… + [27:55] · Report-Stil ist klar. Kurz noch das CHANGELOG-Format für den Vorschlagstext prüfen: + [27:55] Bash sed -n '1,40p' "/Volumes/2TB_CodingProjekte/Coding_Projekte/nova-wt/reparatur/CHANGELOG.m… + [29:20] · Bevor ich den Report schreibe, der Beweis der Schreibhoheit — was habe ich tatsächlich angefasst: + [29:20] Bash git status --porcelain && git diff --stat + [29:31] Bash git diff Assets/_Project/Scripts/Simulation/Construction/ConstructionSystem.cs | grep -E … + [31:27] Edit /Volumes/2TB_CodingProjekte/Coding_Projekte/nova-wt/reparatur/reports/v8.6.0/sprint-23/13… + [32:04] · Fertig. Zusammenfassung der Arbeit: ## Was gebaut wurde (Issue #55, E-3) **`ConstructionSystem.cs` — nur Reparaturseite** (Diff: 262 Einfügungen, 4 Docstring-Z… + [32:04] finished diff --git a/tools/Nova.SimRunner.Tests/PassiveRepairZoneTests.cs b/tools/Nova.SimRunner.Tests/PassiveRepairZoneTests.cs new file mode 100644 index 0000000..ed8d3b0 --- /dev/null +++ b/tools/Nova.SimRunner.Tests/PassiveRepairZoneTests.cs @@ -0,0 +1,350 @@ +using NUnit.Framework; +using Nova.Core; +using Nova.Simulation; +using Nova.Simulation.Construction; +using Nova.Simulation.Definitions; +using Nova.Simulation.Economy; +using Nova.Simulation.Pathfinding; +using Nova.Simulation.State; + +namespace Nova.SimRunner.Tests +{ + /// + /// Passive producer repair zone suite (.NET lane), Issue #55 / owner + /// decision E-3 (2026-08-31): a completed Barracks or VehicleFactory + /// heals its OWNER's damaged units of the roles it can produce, inside + /// a footprint-aware Chebyshev radius of + /// , at + /// HP per + /// tick, free of charge, without stacking, with the exact low-power + /// even-tick halving — and never beyond MaxHealth, never the dead, + /// never the under-construction (target or anchor), never buildings, + /// never enemies. + /// Mirror of the EditMode lane PassiveRepairZoneTests. + /// + [TestFixture] + public sealed class PassiveRepairZoneTests + { + /// + /// The same minimal host the construction suite's repair tests use: + /// economy (phases 2/3) then construction (phase 4) — no movement, + /// no combat, so the zone is the only actor that can move unit hit + /// points. No Aetherium field: the command-path placement checks of + /// the site tests keep their field spacing trivially satisfied. + /// + private sealed class Fixture + { + public EntityManager Entities { get; } + public EconomySystem Economy { get; } + public ConstructionSystem Construction { get; } + public SimulationKernel Kernel { get; } + + public Fixture(long startingCredits = 1000, System.Action configure = null) + { + Entities = new EntityManager(64); + Economy = new EconomySystem(Entities, startingCredits); + var costField = new CostField(ConstructionSystem.GridSize, ConstructionSystem.GridSize); + Construction = new ConstructionSystem(Entities, Economy, costField); + Kernel = new SimulationKernel(new SimRandom(42UL)); + Kernel.RegisterSystem(Economy); + Kernel.RegisterSystem(Construction); + // Pre-start configuration hook (e.g. slot factions): the + // SetSlotFaction guard locks the assignment at Kernel.Start(). + configure?.Invoke(Economy); + Kernel.Start(); + } + + public EntityId SpawnUnit(byte slot, int x, int y, UnitRole role, int maxHealth, int currentHealth) + { + EntityId id = Entities.SpawnUnit( + slot, + new Transform2D(SimFixed.FromInt(x), SimFixed.FromInt(y)), + SimFixed.FromInt(3), + maxHealth: maxHealth, + role: role); + Entities.GetUnitRef(id).CurrentHealth = currentHealth; + return id; + } + + public int HealthOf(EntityId id) + { + return Entities.GetUnitRef(id).CurrentHealth; + } + + public void Step(int ticks) + { + for (int i = 0; i < ticks; i++) Kernel.StepTick(); + } + } + + /// Full-power base: HQ (capacity + 30 power), a Power plant and the named producer. + private static EntityId PlaceFullPowerProducer(Fixture f, ushort producerDefId, int originX, int originY, int anchorY) + { + Assert.That(f.Construction.PlaceCompletedBuilding(0, 3, 30, anchorY).IsValid, Is.True, + "HQ keeps the starting credits inside the D-106 capacity"); + Assert.That(f.Construction.PlaceCompletedBuilding(0, 5, 26, anchorY).IsValid, Is.True, + "Power plant: 130 provided, so the producer never sees low power"); + EntityId producer = f.Construction.PlaceCompletedBuilding(0, producerDefId, originX, originY); + Assert.That(producer.IsValid, Is.True, "producer placed completed"); + return producer; + } + + private static UnitRoleMask MaskOf(UnitRole role) + { + return (UnitRoleMask)(1u << (int)role); + } + + [Test] + public void Mapping_IsDerivedFromTheProducerAssignment_ContentPinned() + { + // E-3 restated as content (D-077 producer assignment, both + // factions identical): the Barracks heals the two infantry + // roles, the VehicleFactory the four vehicle roles. A producer + // reassignment in SimDefinitions moves this pin deliberately — + // the mapping must follow the table, never a second list. + Assert.That(ConstructionSystem.GetPassiveRepairableRoles(UnitRole.Barracks), + Is.EqualTo(MaskOf(UnitRole.BasicInfantry) | MaskOf(UnitRole.AntiArmorInfantry))); + Assert.That(ConstructionSystem.GetPassiveRepairableRoles(UnitRole.VehicleFactory), + Is.EqualTo(MaskOf(UnitRole.ScoutVehicle) | MaskOf(UnitRole.LightTank) + | MaskOf(UnitRole.BattleTank) | MaskOf(UnitRole.Artillery))); + + // Every other role projects no zone: the issue scope is the two + // combat-unit producers — not the HQ (Builder), not the + // Refinery (Harvester), and never a non-producer. + foreach (UnitRole role in new[] + { + UnitRole.Unit, UnitRole.Builder, UnitRole.Harvester, + UnitRole.HQ, UnitRole.Refinery, UnitRole.Power, UnitRole.Storage, + UnitRole.ResearchLab, UnitRole.Radar, UnitRole.DefensePlatform, + UnitRole.BasicInfantry, UnitRole.Artillery, + }) + { + Assert.That(ConstructionSystem.GetPassiveRepairableRoles(role), Is.EqualTo(UnitRoleMask.None), + $"{role} projects no passive repair zone (Issue #55 scope)"); + } + } + + [Test] + public void VehicleFactory_HealsOwnDamagedVehicle_EveryTick_ForFree() + { + var f = new Fixture(); + PlaceFullPowerProducer(f, 8, 20, 20, anchorY: 20); + EntityId tank = f.SpawnUnit(0, 25, 21, UnitRole.LightTank, maxHealth: 550, currentHealth: 100); + + f.Step(10); + + Assert.That(f.HealthOf(tank), Is.EqualTo(110), + "1 HP per tick — deliberately far below the active repair's 10 (heals between engagements)"); + Assert.That(f.Economy.GetPlayerEconomy(0).AetheriumCredits, Is.EqualTo(1000L), + "the zone is free: not one AE is debited, whatever the account holds"); + } + + [Test] + public void ZoneRadius_IsFootprintAwareChebyshev_BoundaryPinned() + { + var f = new Fixture(); + PlaceFullPowerProducer(f, 8, 20, 20, anchorY: 20); + // Footprint x/y 20..22; radius 3 covers the cells 17..25 per axis. + Assert.That(f.Construction.IsCellInsidePassiveRepairZone(0, 17, 17), Is.True, "near corner, distance 3"); + Assert.That(f.Construction.IsCellInsidePassiveRepairZone(0, 25, 25), Is.True, "far corner, Chebyshev 3"); + Assert.That(f.Construction.IsCellInsidePassiveRepairZone(0, 25, 21), Is.True, "edge cell, distance 3"); + Assert.That(f.Construction.IsCellInsidePassiveRepairZone(0, 16, 21), Is.False, "distance 4 is outside"); + Assert.That(f.Construction.IsCellInsidePassiveRepairZone(0, 21, 26), Is.False, "distance 4 is outside"); + Assert.That(f.Construction.IsCellInsidePassiveRepairZone(0, 26, 25), Is.False, "off the corner, Chebyshev 4"); + Assert.That(f.Construction.IsCellInsidePassiveRepairZone(0, 21, 21), Is.True, + "the footprint itself reads as distance 0 — consistent for an overlay; no unit can stand there"); + Assert.That(f.Construction.IsCellInsidePassiveRepairZone(1, 25, 21), Is.False, + "the zone answer is per owner: slot 1 owns no factory here"); + + // The same boundary as behavior, not only as a query. + EntityId inside = f.SpawnUnit(0, 25, 25, UnitRole.LightTank, maxHealth: 550, currentHealth: 100); + EntityId outside = f.SpawnUnit(0, 26, 25, UnitRole.LightTank, maxHealth: 550, currentHealth: 100); + f.Step(10); + Assert.That(f.HealthOf(inside), Is.EqualTo(110), "Chebyshev 3 from the footprint heals"); + Assert.That(f.HealthOf(outside), Is.EqualTo(100), "Chebyshev 4 does not"); + } + + [Test] + public void E3_BarracksHealsInfantryNotVehicles_VehicleFactoryHealsVehiclesNotInfantry() + { + var f = new Fixture(); + PlaceFullPowerProducer(f, 7, 20, 20, anchorY: 20); + Assert.That(f.Construction.PlaceCompletedBuilding(0, 8, 20, 40).IsValid, Is.True, "VehicleFactory"); + EntityId infantry = f.SpawnUnit(0, 23, 21, UnitRole.BasicInfantry, maxHealth: 90, currentHealth: 10); + EntityId tankAtBarracks = f.SpawnUnit(0, 24, 21, UnitRole.LightTank, maxHealth: 550, currentHealth: 100); + EntityId tank = f.SpawnUnit(0, 23, 41, UnitRole.LightTank, maxHealth: 550, currentHealth: 100); + EntityId infantryAtFactory = f.SpawnUnit(0, 24, 41, UnitRole.BasicInfantry, maxHealth: 90, currentHealth: 10); + + f.Step(10); + + Assert.That(f.HealthOf(infantry), Is.EqualTo(20), "the Barracks heals what it produces"); + Assert.That(f.HealthOf(tankAtBarracks), Is.EqualTo(100), + "a Barracks repairing tanks is illogical (E-3) — the building choice keeps its meaning"); + Assert.That(f.HealthOf(tank), Is.EqualTo(110), "the VehicleFactory heals what it produces"); + Assert.That(f.HealthOf(infantryAtFactory), Is.EqualTo(10), "the VehicleFactory does not heal infantry"); + } + + [Test] + public void ZoneScope_HqAndRefineryProjectNoZone() + { + // The derivation would yield Builder/Harvester for HQ/Refinery + // from the same table — the ISSUE SCOPE grants the zone only to + // the two combat-unit producers. Pin the scope so extending it + // is a deliberate decision, never a silent side effect. + var f = new Fixture(); + Assert.That(f.Construction.PlaceCompletedBuilding(0, 3, 20, 20).IsValid, Is.True, "HQ"); + Assert.That(f.Construction.PlaceCompletedBuilding(0, 4, 40, 20).IsValid, Is.True, + "Refinery (20 required, HQ provides 30: full power)"); + EntityId builder = f.SpawnUnit(0, 23, 21, UnitRole.Builder, maxHealth: 350, currentHealth: 100); + EntityId harvester = f.SpawnUnit(0, 43, 21, UnitRole.Harvester, maxHealth: 800, currentHealth: 100); + + f.Step(10); + + Assert.That(f.HealthOf(builder), Is.EqualTo(100), "the HQ projects no zone (issue scope)"); + Assert.That(f.HealthOf(harvester), Is.EqualTo(100), "the Refinery projects no zone (issue scope)"); + Assert.That(f.Construction.IsCellInsidePassiveRepairZone(0, 23, 21), Is.False); + Assert.That(f.Construction.IsCellInsidePassiveRepairZone(0, 43, 21), Is.False); + } + + [Test] + public void NoStacking_TwoCoveringFactories_HealOncePerTick() + { + var f = new Fixture(); + PlaceFullPowerProducer(f, 8, 20, 20, anchorY: 20); + Assert.That(f.Construction.PlaceCompletedBuilding(0, 8, 20, 24).IsValid, Is.True, "second VehicleFactory"); + // Cell (21,23): distance 1 to BOTH footprints (20..22 and y 24..26). + EntityId tank = f.SpawnUnit(0, 21, 23, UnitRole.LightTank, maxHealth: 550, currentHealth: 100); + Assert.That(f.Construction.IsCellInsidePassiveRepairZone(0, 21, 23), Is.True, "doubly covered cell"); + + f.Step(10); + + Assert.That(f.HealthOf(tank), Is.EqualTo(110), + "two covering zones heal once, not twice — the building count must not buy healing"); + } + + [Test] + public void NoOverheal_CapsAtMaxHealth_AndFullUnitsAreSkipped() + { + var f = new Fixture(); + PlaceFullPowerProducer(f, 8, 20, 20, anchorY: 20); + EntityId almostFull = f.SpawnUnit(0, 25, 21, UnitRole.LightTank, maxHealth: 550, currentHealth: 549); + EntityId full = f.SpawnUnit(0, 24, 21, UnitRole.LightTank, maxHealth: 550, currentHealth: 550); + + f.Step(5); + + Assert.That(f.HealthOf(almostFull), Is.EqualTo(550), "healing caps at MaxHealth, never beyond"); + Assert.That(f.HealthOf(full), Is.EqualTo(550), "a full unit is skipped, not re-topped"); + } + + [Test] + public void NoHealing_ForTheDead_ForSites_ForSiteAnchors_AndForBuildings() + { + var f = new Fixture(); + EntityId factory = PlaceFullPowerProducer(f, 8, 20, 60, anchorY: 60); + Assert.That(f.Construction.PlaceCompletedBuilding(0, 8, 20, 64).IsValid, Is.True, + "second VehicleFactory, its zone covering the first (distance 2)"); + f.Step(1); // commit the power balance: the rule-path placements below read it + + // (a) The dead: a despawned tank is a store slot, not a patient. + EntityId dead = f.SpawnUnit(0, 25, 61, UnitRole.LightTank, maxHealth: 550, currentHealth: 100); + Assert.That(f.Entities.DespawnUnit(dead), Is.True); + + // (c) Buildings are never patients: the first factory itself, + // damaged, standing inside the second factory's zone. + f.Entities.GetUnitRef(factory).CurrentHealth = 100; + + // (b) The under-construction TARGET: a paused Barracks site at + // 1 HP inside the zone (distance 2 to the factory footprint, no + // Builder alive to progress it). + Assert.That(f.Construction.TryPlaceBuilding(0, 7, 20, 56), Is.True, "site placed through the rule path"); + EntityId site = EntityId.Invalid; + UnitState[] units = f.Entities.RawUnits; + for (int i = 0; i < f.Entities.Capacity; i++) + { + if (units[i].IsActive && units[i].Role == UnitRole.Barracks) site = units[i].Id; + } + Assert.That(site.IsValid, Is.True, "the site entity exists (16.3: it carries its definition role)"); + Assert.That(f.HealthOf(site), Is.EqualTo(1), "a fresh site sits at 1 HP"); + + // (d) The under-construction ANCHOR: an unfinished Barracks + // site projects no zone for the infantry standing beside it + // (distance 1 — inside where a COMPLETED Barracks would heal). + Assert.That(f.Construction.TryPlaceBuilding(0, 7, 26, 56), Is.True, "paused Barracks site as anchor"); + EntityId infantry = f.SpawnUnit(0, 29, 57, UnitRole.BasicInfantry, maxHealth: 90, currentHealth: 10); + Assert.That(f.Construction.IsCellInsidePassiveRepairZone(0, 29, 57), Is.False, + "the site is no zone anchor — the query says so too"); + + f.Step(10); + + Assert.That(f.Entities.IsValid(dead), Is.False, "the dead stay dead — no resurrection, no crash"); + Assert.That(f.HealthOf(site), Is.EqualTo(1), + "units under construction are never healed (a site is a 1 HP entity of a building role)"); + Assert.That(f.HealthOf(factory), Is.EqualTo(100), + "buildings are never healed by zones — building repair stays the Builder's job"); + Assert.That(f.HealthOf(infantry), Is.EqualTo(10), + "a site projects no zone: only COMPLETED placements heal"); + } + + [Test] + public void OwnOnly_EnemyUnitsInsideTheZone_DoNotHeal() + { + var f = new Fixture(); + PlaceFullPowerProducer(f, 8, 20, 20, anchorY: 20); + EntityId own = f.SpawnUnit(0, 25, 21, UnitRole.LightTank, maxHealth: 550, currentHealth: 100); + EntityId enemy = f.SpawnUnit(1, 24, 21, UnitRole.LightTank, maxHealth: 480, currentHealth: 100); + + f.Step(10); + + Assert.That(f.HealthOf(own), Is.EqualTo(110), "the owner's units heal"); + Assert.That(f.HealthOf(enemy), Is.EqualTo(100), "an enemy standing in the same cells gains nothing"); + } + + [Test] + public void LowPower_HealsOnEvenTicksOnly_ExactHalving() + { + // 45 required (Refinery 20 + VehicleFactory 25) against the HQ's + // 30 provided: low power from the first recompute on. + var f = new Fixture(); + Assert.That(f.Construction.PlaceCompletedBuilding(0, 3, 30, 20).IsValid, Is.True, + "HQ keeps the starting credits inside the D-106 capacity"); + Assert.That(f.Construction.PlaceCompletedBuilding(0, 4, 40, 40).IsValid, Is.True, "Refinery"); + Assert.That(f.Construction.PlaceCompletedBuilding(0, 8, 20, 20).IsValid, Is.True, "VehicleFactory"); + EntityId tank = f.SpawnUnit(0, 25, 21, UnitRole.LightTank, maxHealth: 550, currentHealth: 100); + + f.Step(1); // tick 1 (odd): commits the balance, no heal + Assert.That(f.Economy.GetPlayerEconomy(0).IsLowPower, Is.True, "45 required vs 30 provided"); + Assert.That(f.HealthOf(tank), Is.EqualTo(100), "odd tick: the zone is silent"); + + f.Step(10); // ticks 2..11: the five even ticks heal + Assert.That(f.HealthOf(tank), Is.EqualTo(105), + "exactly half rate under low power: one heal per two ticks, no rounding (C4 precedent)"); + + f.Step(1); // tick 12 (even) + Assert.That(f.HealthOf(tank), Is.EqualTo(106), "even tick: the zone heals"); + f.Step(1); // tick 13 (odd) + Assert.That(f.HealthOf(tank), Is.EqualTo(106), "odd tick: the zone is silent again"); + } + + [Test] + public void Deterministic_IdenticalFixtures_IdenticalStateHash() + { + ulong first = RunZoneScenario(); + ulong second = RunZoneScenario(); + Assert.That(second, Is.EqualTo(first), + "the zone scan is ascending-index and parity-gated: identical setups hash identically"); + } + + private static ulong RunZoneScenario() + { + var f = new Fixture(); + PlaceFullPowerProducer(f, 8, 20, 20, anchorY: 20); + Assert.That(f.Construction.PlaceCompletedBuilding(0, 7, 40, 40).IsValid, Is.True, "Barracks"); + f.SpawnUnit(0, 25, 21, UnitRole.LightTank, maxHealth: 550, currentHealth: 100); + f.SpawnUnit(0, 24, 22, UnitRole.BattleTank, maxHealth: 1100, currentHealth: 700); + f.SpawnUnit(0, 43, 41, UnitRole.BasicInfantry, maxHealth: 90, currentHealth: 10); + f.SpawnUnit(1, 25, 25, UnitRole.LightTank, maxHealth: 480, currentHealth: 100); // enemy: untouched + f.Step(50); + return f.Kernel.CalculateStateHash(); + } + } +}