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