diff --git a/Assets/Tests/EditMode/AI/SkirmishAiTests.cs b/Assets/Tests/EditMode/AI/SkirmishAiTests.cs index 900163b..d4d9451 100644 --- a/Assets/Tests/EditMode/AI/SkirmishAiTests.cs +++ b/Assets/Tests/EditMode/AI/SkirmishAiTests.cs @@ -125,7 +125,7 @@ private static AiHost BuildAiHost(ulong seed) var economy = new EconomySystem(entities, EconomySystem.CanonicalMatchStartingCreditsAE); var construction = new ConstructionSystem(entities, economy, pathfinding.CostField); var production = new ProductionSystem(entities, economy, construction); - var fogOfWar = new FogOfWarSystem(entities, construction, teamCount: 2, MapWidth, MapHeight); + var fogOfWar = new FogOfWarSystem(entities, construction, economy, teamCount: 2, MapWidth, MapHeight); var combat = new CombatSystem(entities, fogOfWar, economy, construction); var victory = new VictorySystem(entities, construction); diff --git a/Assets/Tests/EditMode/Gameplay/CanonicalMatchSetupTests.cs b/Assets/Tests/EditMode/Gameplay/CanonicalMatchSetupTests.cs index 72eb349..a1c9c23 100644 --- a/Assets/Tests/EditMode/Gameplay/CanonicalMatchSetupTests.cs +++ b/Assets/Tests/EditMode/Gameplay/CanonicalMatchSetupTests.cs @@ -131,7 +131,7 @@ private static ReferenceHost BuildReferenceHost(ulong seed) var economy = new EconomySystem(entities, EconomySystem.CanonicalMatchStartingCreditsAE); var construction = new ConstructionSystem(entities, economy, pathfinding.CostField); var production = new ProductionSystem(entities, economy, construction); - var fogOfWar = new FogOfWarSystem(entities, construction, teamCount: 2, MapWidth, MapHeight); + var fogOfWar = new FogOfWarSystem(entities, construction, economy, teamCount: 2, MapWidth, MapHeight); var combat = new Nova.Simulation.Combat.CombatSystem(entities, fogOfWar, economy, construction); var victory = new Nova.Simulation.Victory.VictorySystem(entities, construction); diff --git a/Assets/Tests/EditMode/Simulation/CombatSystemTests.cs b/Assets/Tests/EditMode/Simulation/CombatSystemTests.cs index 94fa074..dc152be 100644 --- a/Assets/Tests/EditMode/Simulation/CombatSystemTests.cs +++ b/Assets/Tests/EditMode/Simulation/CombatSystemTests.cs @@ -64,7 +64,7 @@ public static TestHost Create(ulong seed, int capacity = 64, ushort width = 64, // FoW radar read also requires the placement register. var factions = new EconomySystem(entities); var construction = new Nova.Simulation.Construction.ConstructionSystem(entities, factions); - var fog = new FogOfWarSystem(entities, construction, teamCount: 2, width, height); + var fog = new FogOfWarSystem(entities, construction, factions, teamCount: 2, width, height); var combat = new CombatSystem(entities, fog, factions, construction); var kernel = new SimulationKernel(new SimRandom(seed)); @@ -644,7 +644,7 @@ public static ReplayHost Create(ulong seed) var economy = new EconomySystem(entities); // 16.5: the FoW radar read requires the placement register. var construction = new Nova.Simulation.Construction.ConstructionSystem(entities, economy); - var fog = new FogOfWarSystem(entities, construction, teamCount: 2, 64, 64); + var fog = new FogOfWarSystem(entities, construction, economy, teamCount: 2, 64, 64); var combat = new CombatSystem(entities, fog, economy, construction); var kernel = new SimulationKernel(new SimRandom(seed)); diff --git a/Assets/Tests/EditMode/Simulation/ConstructionSystemTests.cs b/Assets/Tests/EditMode/Simulation/ConstructionSystemTests.cs index 31c597a..61045ab 100644 --- a/Assets/Tests/EditMode/Simulation/ConstructionSystemTests.cs +++ b/Assets/Tests/EditMode/Simulation/ConstructionSystemTests.cs @@ -781,6 +781,8 @@ public void SellStorage_CapsRefundThenLoweredCapacityDrivesExcessDecay() public void Repair_BuilderRestoresHp_InReachOnly_AndResolvesAtFull() { var f = new Fixture(); + Assert.That(f.Construction.PlaceCompletedBuilding(0, 5, 40, 40).IsValid, Is.True, + "a completed power plant keeps the normal repair rate active"); EntityId barracks = f.Construction.PlaceCompletedBuilding(0, 7, 20, 20); uint raw = UnitCommandStateView.ToRawEntityId(barracks); f.Entities.GetUnitRef(barracks).CurrentHealth = 100; @@ -803,6 +805,27 @@ public void Repair_BuilderRestoresHp_InReachOnly_AndResolvesAtFull() "repair caps at MaxHealth and the order resolves"); } + [Test] + public void Repair_LowPower_ExactlyHalvesTheRate() + { + // 16.6 (C4, Economy.md repair rule): under LOW POWER the repair + // rate halves exactly — 5 HP per tick, no rounding. + var f = new Fixture(); + Assert.That(f.Construction.PlaceCompletedBuilding(0, 4, 40, 40).IsValid, Is.True, + "a completed Refinery (20 required, nothing provided) forces low power"); + EntityId barracks = f.Construction.PlaceCompletedBuilding(0, 7, 20, 20); + f.Entities.GetUnitRef(barracks).CurrentHealth = 100; + + EntityId builder = f.SpawnBuilder(0, 19, 20); + f.Step(1); // commit the balance: refinery + barracks draw 35, nothing provided + Assert.That(f.Economy.GetPlayerEconomy(0).IsLowPower, Is.True); + + f.Construction.AssignRepairOrder(UnitCommandStateView.ToRawEntityId(builder), UnitCommandStateView.ToRawEntityId(barracks)); + f.Step(10); + Assert.That(f.Entities.GetUnitRef(barracks).CurrentHealth, Is.EqualTo(150), + "5 HP per tick under low power — exactly half the provisional rate"); + } + [Test] public void Repair_Validation_RejectsNonBuilder_AndUndamagedTarget() { diff --git a/Assets/Tests/EditMode/Simulation/EconomyIntegrationTests.cs b/Assets/Tests/EditMode/Simulation/EconomyIntegrationTests.cs index e9e7a63..fd13859 100644 --- a/Assets/Tests/EditMode/Simulation/EconomyIntegrationTests.cs +++ b/Assets/Tests/EditMode/Simulation/EconomyIntegrationTests.cs @@ -61,7 +61,7 @@ public static EcoHost Create(ulong seed, int capacity = 256, ushort width = 64, var economy = new EconomySystem(entities); // 16.5: the FoW radar read requires the placement register. var construction = new Nova.Simulation.Construction.ConstructionSystem(entities, economy); - var fogOfWar = new FogOfWarSystem(entities, construction, teamCount: 2, width, height); + var fogOfWar = new FogOfWarSystem(entities, construction, economy, teamCount: 2, width, height); var combat = new CombatSystem(entities, fogOfWar, economy, construction); var kernel = new SimulationKernel(new SimRandom(seed)); diff --git a/Assets/Tests/EditMode/Simulation/FogOfWarSystemTests.cs b/Assets/Tests/EditMode/Simulation/FogOfWarSystemTests.cs index 849dc96..75181dc 100644 --- a/Assets/Tests/EditMode/Simulation/FogOfWarSystemTests.cs +++ b/Assets/Tests/EditMode/Simulation/FogOfWarSystemTests.cs @@ -35,13 +35,15 @@ private sealed class TestHost { public SimulationKernel Kernel { get; } public EntityManager Entities { get; } + public EconomySystem Economy { get; } public ConstructionSystem Construction { get; } public FogOfWarSystem Fog { get; } - private TestHost(SimulationKernel kernel, EntityManager entities, ConstructionSystem construction, FogOfWarSystem fog) + private TestHost(SimulationKernel kernel, EntityManager entities, EconomySystem economy, ConstructionSystem construction, FogOfWarSystem fog) { Kernel = kernel; Entities = entities; + Economy = economy; Construction = construction; Fog = fog; } @@ -51,19 +53,19 @@ public static TestHost Create(ulong seed, int capacity = 64, ushort width = 64, var entities = new EntityManager(capacity); var pathfinding = new PathfindingSystem(width, height); var movement = new MovementSystem(entities, pathfinding); - // 16.5: the FoW radar read requires the placement register — - // an unregistered economy/construction pair answers placement - // queries without ever ticking. + // 16.5/16.6: the FoW radar read requires the placement register + // and the power balance — an unregistered economy/construction + // pair answers both queries without ever ticking. var economy = new EconomySystem(entities); var construction = new ConstructionSystem(entities, economy); - var fog = new FogOfWarSystem(entities, construction, teamCount: 2, width, height); + var fog = new FogOfWarSystem(entities, construction, economy, teamCount: 2, width, height); var kernel = new SimulationKernel(new SimRandom(seed)); kernel.RegisterSystem(pathfinding); kernel.RegisterSystem(movement); kernel.RegisterSystem(fog); kernel.Start(); - return new TestHost(kernel, entities, construction, fog); + return new TestHost(kernel, entities, economy, construction, fog); } public void Step() => Kernel.StepTick(); @@ -286,6 +288,31 @@ public void RadarSignature_WithoutCompletedRadar_NoCoverage() Assert.That(pings.Count, Is.EqualTo(0), "no finished Radar building, no coverage at all"); } + [Test] + public void RadarSignatures_StopAtPowerDeficit_AndResumeWhenBalanceRecovers() + { + // 16.6 (C4, Economy.md Low-Power rule): at a power deficit the radar is the FIRST + // system to fall — no coverage, no pings. The deficit here is set + // directly on the balance (the rig's economy never recomputes). + var host = TestHost.Create(Seed); + Assert.That(host.Construction.PlaceCompletedBuilding(0, 10, 9, 14).IsValid, Is.True); + SpawnAt(host, 1, 24, 10, sightRadius: 5); // radar-covered, hidden + host.Step(2); + + var pings = new List(); + host.Fog.GetRadarSignatures(0, pings); + Assert.That(pings.Count, Is.EqualTo(1), "coverage with a finished Radar and a balanced grid"); + + host.Economy.GetPlayerEconomy(0).PowerRequired = 1; // deficit: 1 required > 0 provided + pings.Clear(); + host.Fog.GetRadarSignatures(0, pings); + Assert.That(pings.Count, Is.EqualTo(0), "LOW POWER takes the radar offline"); + + host.Economy.GetPlayerEconomy(0).PowerRequired = 0; + host.Fog.GetRadarSignatures(0, pings); + Assert.That(pings.Count, Is.EqualTo(1), "the radar comes back with the balance"); + } + [Test] public void HiddenWorldMetamorphic_HiddenEnemyVariation_LeavesTeamZeroViewIdentical() { diff --git a/Assets/Tests/EditMode/Simulation/MatchFingerprintV1Tests.cs b/Assets/Tests/EditMode/Simulation/MatchFingerprintV1Tests.cs index b1f4687..c91787f 100644 --- a/Assets/Tests/EditMode/Simulation/MatchFingerprintV1Tests.cs +++ b/Assets/Tests/EditMode/Simulation/MatchFingerprintV1Tests.cs @@ -78,6 +78,18 @@ public void ComputeHash_IsStableAcrossInstances_AndStubHashesAreDistinct() "D-106 rules must not match the legacy empty rules stub"); } + [Test] + public void CurrentRulesHash_MovesPastRevisionOne_ForLowPowerRepair() + { + ulong revisionOne = MatchFingerprint.ComputeRulesHash64(MatchFingerprint.RulesRevisionV1); + ulong current = MatchFingerprint.ComputeCurrentRulesHash64(); + + Assert.That(MatchFingerprint.CurrentRulesRevision, Is.EqualTo(MatchFingerprint.RulesRevisionV2)); + Assert.That(current, Is.EqualTo(MatchFingerprint.ComputeRulesHash64(MatchFingerprint.RulesRevisionV2))); + Assert.That(current, Is.Not.EqualTo(revisionOne), + "Sprint-16.6 C4 repair behavior must not share D-106's revision-1 rules identity"); + } + [Test] public void ComputeHash_AndEquality_AreSensitiveToEveryField() { diff --git a/Assets/Tests/EditMode/Simulation/ProductionConstructionIntegrationTests.cs b/Assets/Tests/EditMode/Simulation/ProductionConstructionIntegrationTests.cs index 3d8a584..82a39ca 100644 --- a/Assets/Tests/EditMode/Simulation/ProductionConstructionIntegrationTests.cs +++ b/Assets/Tests/EditMode/Simulation/ProductionConstructionIntegrationTests.cs @@ -62,7 +62,7 @@ public static ProdHost Create(ulong seed, int capacity = 256, long startingCredi var economy = new EconomySystem(entities, startingCredits); var construction = new ConstructionSystem(entities, economy, pathfinding.CostField); var production = new ProductionSystem(entities, economy, construction); - var fogOfWar = new FogOfWarSystem(entities, construction, teamCount: 2, 128, 128); + var fogOfWar = new FogOfWarSystem(entities, construction, economy, teamCount: 2, 128, 128); var combat = new CombatSystem(entities, fogOfWar, economy, construction); var kernel = new SimulationKernel(new SimRandom(seed)); diff --git a/Assets/Tests/EditMode/Simulation/ReplayV1Tests.cs b/Assets/Tests/EditMode/Simulation/ReplayV1Tests.cs index 54975ba..0c8896c 100644 --- a/Assets/Tests/EditMode/Simulation/ReplayV1Tests.cs +++ b/Assets/Tests/EditMode/Simulation/ReplayV1Tests.cs @@ -197,6 +197,29 @@ public void FingerprintMismatch_LegacyEmptyRules_RefusesPlaybackBeforeTickOne() "an old/new rules mismatch must be refused before execution"); } + [Test] + public void FingerprintMismatch_RevisionOneRules_RefusesPlaybackBeforeTickOne() + { + ReplayV1TestUtil.LiveMatch live = ReplayV1TestUtil.RunLiveMatch(); + MatchFingerprint revisionOne = MatchFingerprint.CreateCurrent( + MatchFingerprint.ComputeRulesHash64(MatchFingerprint.RulesRevisionV1), + live.Fingerprint.DefinitionsHash64, live.Fingerprint.MapHash64, + live.Fingerprint.GetSlotOccupancyCopy(), live.Fingerprint.GetSlotFactionCopy(), + live.Fingerprint.StartSeed, live.Fingerprint.InitialStateHash, + live.Fingerprint.InputDelayTicks); + + ReplayV1TestUtil.TestHost playback = ReplayV1TestUtil.CreatePlaybackHost(); + Assert.That( + ReplayPlayer.TryPlay( + live.ReplayBytes, revisionOne, playback.Kernel, playback.Ingress, + out ReplayPlaybackError error, out string detail), + Is.False); + Assert.That(error, Is.EqualTo(ReplayPlaybackError.FingerprintMismatch)); + StringAssert.Contains("RulesHash64", detail); + Assert.That(playback.Kernel.CurrentTick.Value, Is.EqualTo(0u), + "revision-1 rules must be refused before execution"); + } + [Test] public void FingerprintMismatch_DifferentSlotOccupancy_RefusesPlayback() { diff --git a/Assets/Tests/EditMode/Simulation/WeaponValuesTests.cs b/Assets/Tests/EditMode/Simulation/WeaponValuesTests.cs index 6762d1c..dcb8af9 100644 --- a/Assets/Tests/EditMode/Simulation/WeaponValuesTests.cs +++ b/Assets/Tests/EditMode/Simulation/WeaponValuesTests.cs @@ -323,7 +323,7 @@ public static TestHost Create() // FoW radar read also requires the placement register. var factions = new EconomySystem(entities); var construction = new Nova.Simulation.Construction.ConstructionSystem(entities, factions); - var fog = new FogOfWarSystem(entities, construction, teamCount: 2, 64, 64); + var fog = new FogOfWarSystem(entities, construction, factions, teamCount: 2, 64, 64); var combat = new CombatSystem(entities, fog, factions, construction); var kernel = new SimulationKernel(new SimRandom(Seed)); diff --git a/Assets/_Project/Scripts/Gameplay/Match/MatchRunner.cs b/Assets/_Project/Scripts/Gameplay/Match/MatchRunner.cs index cb8f4fb..7e104fc 100644 --- a/Assets/_Project/Scripts/Gameplay/Match/MatchRunner.cs +++ b/Assets/_Project/Scripts/Gameplay/Match/MatchRunner.cs @@ -219,7 +219,7 @@ public void InitializeMatch(MatchConfig config) Economy = new EconomySystem(Entities, _config.StartingCredits); Construction = new Simulation.Construction.ConstructionSystem(Entities, Economy, Pathfinding.CostField); Production = new Simulation.Production.ProductionSystem(Entities, Economy, Construction); - FogOfWar = new FogOfWarSystem(Entities, Construction, teamCount: 2, _mapWidth, _mapHeight); + FogOfWar = new FogOfWarSystem(Entities, Construction, Economy, teamCount: 2, _mapWidth, _mapHeight); Combat = new CombatSystem(Entities, FogOfWar, Economy, Construction); Victory = new Simulation.Victory.VictorySystem(Entities, Construction); diff --git a/Assets/_Project/Scripts/Presentation/UI/MinimapHud.cs b/Assets/_Project/Scripts/Presentation/UI/MinimapHud.cs index c23f199..4c15f85 100644 --- a/Assets/_Project/Scripts/Presentation/UI/MinimapHud.cs +++ b/Assets/_Project/Scripts/Presentation/UI/MinimapHud.cs @@ -6,6 +6,7 @@ using Nova.Gameplay.Match; using Nova.Simulation.CommandsV1; using Nova.Simulation.Definitions; +using Nova.Simulation.Economy; using Nova.Simulation.State; using Nova.Simulation.Vision; using EntityId = Nova.Core.EntityId; @@ -176,13 +177,19 @@ private void OnGUI() /// /// 16.5 (#54, C3): the minimap unlocks with the local slot's first - /// COMPLETED Radar building and goes dark when it is lost. One read, - /// shared by the draw and the hit test, so they can never disagree. + /// COMPLETED Radar building and goes dark when it is lost. 16.6 (C4, + /// Economy.md Low-Power rule): at a power deficit the radar is the FIRST system to fall — + /// the map goes dark too, exactly like the sim-side pings stop. One + /// read, shared by the draw and the hit test, so they can never + /// disagree. /// private bool LocalRadarOnline() { if (_runner == null || _runner.Construction == null || _runner.Session == null) return false; - return _runner.Construction.HasFinishedBuilding(_runner.Session.LocalSlot, UnitRole.Radar); + if (!_runner.Construction.HasFinishedBuilding(_runner.Session.LocalSlot, UnitRole.Radar)) return false; + EconomySystem economy = _runner.Economy; + if (economy == null) return false; + return !economy.GetPlayerEconomy(_runner.Session.LocalSlot).IsLowPower; } /// The local viewer team — the same convention as FogOfWarOverlayView/UnitViewManager. diff --git a/Assets/_Project/Scripts/Simulation/Construction/ConstructionSystem.cs b/Assets/_Project/Scripts/Simulation/Construction/ConstructionSystem.cs index f103b3a..eee7436 100644 --- a/Assets/_Project/Scripts/Simulation/Construction/ConstructionSystem.cs +++ b/Assets/_Project/Scripts/Simulation/Construction/ConstructionSystem.cs @@ -110,7 +110,8 @@ namespace Nova.Simulation.Construction /// damaged building: in reach (same Chebyshev rule) the target gains /// HP per tick up to its MaxHealth, /// where the order resolves; out of reach the order is HELD, never - /// dropped; Stop clears it. Repair is unaffected by low power. + /// dropped; Stop clears it. Under LOW POWER the rate halves exactly + /// (16.6, C4 — Economy.md repair rule; 10/2 is exact, no rounding). /// /// /// State (snapshot block , @@ -165,6 +166,9 @@ public sealed class ConstructionSystem : IStatefulSimSystem /// Provisional repair rate in HP per tick per repairing Builder (Q-040 candidate). public const int RepairRateHpPerTick = 10; + /// Repair rate in HP per tick while the owner's grid is in LOW POWER (C4, Sprint 16.6). + public const int LowPowerRepairRateHpPerTick = 5; + private struct SiteState { public bool IsActive; @@ -841,7 +845,17 @@ private void ProcessRepairOrders() continue; // held, not dropped } - int repaired = target.CurrentHealth + RepairRateHpPerTick; + // 16.6 (C4, Economy.md repair rule): LOW POWER halves the + // repair rate — 10/2 is exact, no rounding. The last stage of + // the shutdown order still repairs; it just repairs slower. + int rate = RepairRateHpPerTick; + ref readonly PlayerEconomyState repairEco = ref _economy.GetPlayerEconomy(target.PlayerId); + if (repairEco.IsLowPower) + { + rate = LowPowerRepairRateHpPerTick; + } + + int repaired = target.CurrentHealth + rate; target.CurrentHealth = repaired > target.MaxHealth ? target.MaxHealth : repaired; } } diff --git a/Assets/_Project/Scripts/Simulation/Replays/MatchFingerprint.cs b/Assets/_Project/Scripts/Simulation/Replays/MatchFingerprint.cs index aed952f..22f76cd 100644 --- a/Assets/_Project/Scripts/Simulation/Replays/MatchFingerprint.cs +++ b/Assets/_Project/Scripts/Simulation/Replays/MatchFingerprint.cs @@ -2,6 +2,7 @@ using System.Text; using Nova.Core; using Nova.Simulation.CommandsV1; +using Nova.Simulation.Construction; using Nova.Simulation.Economy; using Nova.Simulation.Snapshots; @@ -104,13 +105,20 @@ public sealed class MatchFingerprint : IEquatable public const string PrngIdV1 = "XorShift128PlusV1"; /// - /// Current deterministic rules revision. Revision 1 is the first - /// non-stub rules identity and binds the D-106 storage-cap behavior. - /// Behavior changes covered by - /// must bump this value or change one of the bound constants. + /// First non-stub deterministic rules revision; binds the D-106 + /// storage-cap behavior. /// public const ushort RulesRevisionV1 = 1; + /// + /// Current deterministic rules revision; adds the Sprint-16.6 C4 low-power + /// radar and repair behavior to revision 1. + /// + public const ushort RulesRevisionV2 = 2; + + /// The rules revision emitted by current hosts. + public const ushort CurrentRulesRevision = RulesRevisionV2; + /// Parser bound for one identifier string; checked before allocation. public const int MaxIdentifierBytes = 64; @@ -249,20 +257,24 @@ public static ulong ComputeEmptyContentStubHash(MatchContentStub stub) } /// - /// Canonical rules identity for the current simulation. Unlike the - /// legacy empty Rules stub, this binds the D-106 economy behavior that - /// can diverge without changing snapshot bytes or definition rows. - /// Old/new peers and replays therefore fail the exact-fingerprint gate - /// before executing tick 1 instead of desynchronizing at the first - /// excess-decay tick. + /// Canonical rules identity for one supported simulation revision. + /// This compatibility entry point exists so replay and relay tests can + /// represent prior rules exactly; hosts must use + /// . Unknown revisions are + /// rejected rather than guessed. /// - public static ulong ComputeCurrentRulesHash64() + public static ulong ComputeRulesHash64(ushort rulesRevision) { + if (rulesRevision != RulesRevisionV1 && rulesRevision != RulesRevisionV2) + { + throw new ArgumentOutOfRangeException(nameof(rulesRevision), rulesRevision, "Unknown rules revision."); + } + var hash = SimHashWriter.ForDefinitions(); hash.WriteFieldTag((uint)MatchContentStub.Rules); - hash.WriteUInt32(5); // ordered rule fields below + hash.WriteUInt32(rulesRevision == RulesRevisionV1 ? 5u : 7u); // ordered rule fields below hash.WriteFieldTag(1); - hash.WriteUInt16(RulesRevisionV1); + hash.WriteUInt16(rulesRevision); hash.WriteFieldTag(2); hash.WriteInt64(EconomySystem.HqBaseCapacityAE); hash.WriteFieldTag(3); @@ -271,9 +283,28 @@ public static ulong ComputeCurrentRulesHash64() hash.WriteInt32(EconomySystem.ExcessDecayPercent); hash.WriteFieldTag(5); hash.WriteInt32(EconomySystem.ExcessDecayIntervalTicks); + if (rulesRevision >= RulesRevisionV2) + { + hash.WriteFieldTag(6); + hash.WriteInt32(ConstructionSystem.RepairRateHpPerTick); + hash.WriteFieldTag(7); + hash.WriteInt32(ConstructionSystem.LowPowerRepairRateHpPerTick); + } return hash.Digest(); } + /// + /// Canonical rules identity for the current simulation. Revision 2 + /// binds both D-106 economy behavior and the Sprint-16.6 C4 low-power radar + /// and repair behavior that can diverge without changing snapshot + /// bytes or definition rows. Old/new peers and replays therefore fail + /// the exact-fingerprint gate before executing tick 1. + /// + public static ulong ComputeCurrentRulesHash64() + { + return ComputeRulesHash64(CurrentRulesRevision); + } + /// Occupancy of one reserved slot (index 0..7). public PlayerSlotOccupancy GetSlotOccupancy(int slot) { diff --git a/Assets/_Project/Scripts/Simulation/Vision/FogOfWarSystem.cs b/Assets/_Project/Scripts/Simulation/Vision/FogOfWarSystem.cs index 657eb95..bf573f5 100644 --- a/Assets/_Project/Scripts/Simulation/Vision/FogOfWarSystem.cs +++ b/Assets/_Project/Scripts/Simulation/Vision/FogOfWarSystem.cs @@ -28,9 +28,10 @@ namespace Nova.Simulation.Vision /// permission, and only from a COMPLETED Radar building (16.5, #54, C3): /// the building is the radiator, its own sight radius times /// is the coverage — no finished - /// Radar, no coverage. MS-1 simplification: team index equals the player - /// slot (D-058 activates exactly two slots; allied shared sight is - /// post-MS-1). + /// Radar, no coverage. At a power deficit the radar is the FIRST system + /// to fall (16.6, C4, Economy.md Low-Power rule): no coverage, no pings. MS-1 simplification: + /// team index equals the player slot (D-058 activates exactly two slots; + /// allied shared sight is post-MS-1). /// /// /// Determinism (FogOfWar.md section 5): integer distance tests in stable @@ -62,6 +63,7 @@ public sealed class FogOfWarSystem : IStatefulSimSystem private readonly EntityManager _entityManager; private readonly Construction.ConstructionSystem _construction; + private readonly Economy.EconomySystem _economy; private readonly byte[][] _masks; private readonly HashSet _radarSeenCells = new HashSet(); @@ -82,13 +84,16 @@ public sealed class FogOfWarSystem : IStatefulSimSystem /// /// The construction system is a REQUIRED dependency since 16.5 (#54): /// radar coverage derives from COMPLETED Radar placements, which only - /// its register can tell from sites and corpses. It is read-only here - /// (placement queries) and never ticked through this system. + /// its register can tell from sites and corpses. The economy is one + /// since 16.6 (C4, Economy.md Low-Power rule): at a power deficit the radar goes OFFLINE — + /// no pings, no coverage. Both are read-only here (placement queries, + /// balance reads) and never ticked through this system. /// - public FogOfWarSystem(EntityManager entityManager, Construction.ConstructionSystem construction, int teamCount = 2, ushort width = 128, ushort height = 128) + public FogOfWarSystem(EntityManager entityManager, Construction.ConstructionSystem construction, Economy.EconomySystem economy, int teamCount = 2, ushort width = 128, ushort height = 128) { _entityManager = entityManager ?? throw new ArgumentNullException(nameof(entityManager)); _construction = construction ?? throw new ArgumentNullException(nameof(construction)); + _economy = economy ?? throw new ArgumentNullException(nameof(economy)); if (teamCount < 1 || teamCount > MaxTeams) { throw new ArgumentOutOfRangeException(nameof(teamCount), teamCount, $"Team count must be in [1, {MaxTeams}]."); @@ -208,7 +213,9 @@ public int GetVisibleEntities(byte team, List results) /// team radiates, over the building's own sight radius times /// . A Radar site, a destroyed one /// and every other entity radiate nothing — without a finished Radar - /// the team has no coverage at all (and MinimapHud draws no map). + /// the team has no coverage at all (and MinimapHud draws no map). At + /// a power deficit the radar is the FIRST system to fall (16.6, C4, + /// Economy.md Low-Power rule): this method returns nothing. /// /// /// Cadence (Q-040(j), provisional): pings derive from live 10 Hz @@ -227,6 +234,14 @@ public int GetRadarSignatures(byte team, List results) throw new ArgumentOutOfRangeException(nameof(team), team, "Unknown team slot."); } + // 16.6 (C4, Economy.md Low-Power rule): at a power deficit the radar is the FIRST + // system to fall — no coverage, no pings. The minimap reads the + // same balance and goes dark with it. + if (_economy.GetPlayerEconomy(team).IsLowPower) + { + return 0; + } + byte[] mask = _masks[team]; UnitState[] units = _entityManager.RawUnits; int capacity = _entityManager.Capacity; diff --git a/CHANGELOG.md b/CHANGELOG.md index a38c3bb..e883c69 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -65,6 +65,18 @@ die Versionierung folgt (in der aktuellen Doku-Phase) dem Dokumentationsstand de sie belegt keine Verbesserung. ### Behoben +- **Low Power ist eine Waffe (C4, Sprint 16.6)** — bei Energiedefizit + fällt Radar zuerst: `FogOfWarSystem.GetRadarSignatures` liefert nichts mehr + (Economy als Pflicht-Abhängigkeit), und die Minimap geht mit aus (dieselbe + Bilanz, dieselbe Abfrage). Produktion und Bau behalten den exakten + Tempo-Malus (0.5 in Q16.16), und die Reparatur halbiert jetzt exakt + (10 → 5 HP/Tick, Economy.md-Reparaturregel). Die Regelidentität steigt auf + Revision 2 und bindet beide Reparaturraten; Revision-1-Replays und -Peers + werden gegenüber Revision-2-Hosts beziehungsweise -Peers vor Tick 1 mit + `RulesHash64`-Mismatch abgelehnt. Erst damit + ist der Angriff aufs Kraftwerk ein taktischer Zug. **Nicht dabei:** die Verteidigungs- + abschaltung — ob ein Turm feuert, entscheidet `CombatSystem` (Einheiten- + strang), das einen Strombegriff nicht kennt; Befund geht an 13B - **#53: Das Lager begrenzt das Konto (D-024/D-096/D-106)** — das Aetherium-Konto hat jetzt eine aus dem Gebäudebestand abgeleitete Obergrenze, nichts wird gespeichert (kein Zustandsfeld, kein Formatbruch): ein oder mehrere fertige diff --git a/docs/production/hashkrieg/16_Sprint_Wirtschaft.md b/docs/production/hashkrieg/16_Sprint_Wirtschaft.md index 1f0a0da..27b1e3f 100644 --- a/docs/production/hashkrieg/16_Sprint_Wirtschaft.md +++ b/docs/production/hashkrieg/16_Sprint_Wirtschaft.md @@ -1,6 +1,6 @@ # Sprint 16: Die Wirtschaft trägt sich selbst — kein Gebäude kostet Geld, ohne etwas zu tun -**Version:** 1.2.0 | **Status:** in Umsetzung | **Verantwortungsbereich:** Netzstrang (Maintainer) | **Sprint:** 16 | **Vorgänger:** [12_Sprint_Zu_Zweit.md](12_Sprint_Zu_Zweit.md) Strang C | **Parallel zu:** [13B](13B_Sprint_Einheitenverhalten.md) | **Regelwerk:** [13-15_Parallelbetrieb.md](13-15_Parallelbetrieb.md) | **UX-Gate:** human | **Leitsatz:** ein Gebäude, das Strom zieht und nichts tut, ist kein Platzhalter, sondern ein Schaden +**Version:** 1.3.0 | **Status:** in Umsetzung | **Verantwortungsbereich:** Netzstrang (Maintainer) | **Sprint:** 16 | **Vorgänger:** [12_Sprint_Zu_Zweit.md](12_Sprint_Zu_Zweit.md) Strang C | **Parallel zu:** [13B](13B_Sprint_Einheitenverhalten.md) | **Regelwerk:** [13-15_Parallelbetrieb.md](13-15_Parallelbetrieb.md) | **UX-Gate:** human | **Leitsatz:** ein Gebäude, das Strom zieht und nichts tut, ist kein Platzhalter, sondern ein Schaden ## Zweck @@ -224,6 +224,12 @@ Reparatur** in dieser Ordnung fallen. Heute existiert nur der Tempo-Malus über Erst damit wird ein Angriff auf das gegnerische Kraftwerk ein taktischer Zug — und erst damit wird 16.5 spürbar, weil ein Stromausfall die Minimap mitnimmt. +Die Reparaturhalbierung verändert autoritativen Simulationszustand, obwohl kein +Snapshot- oder Replay-Formatfeld hinzukommt. Deshalb bindet `RulesHash64` ab +diesem Paket **Revision 2** sowie die Reparaturraten 10/5 HP pro Tick. Ein +Revision-1-Replay oder -Peer bleibt strukturell lesbar, wird gegenüber einem +aktuellen Host aber vor Tick 1 mit `RulesHash64`-Mismatch abgelehnt. + ### 16.7 · Knappheit (C1) — **fasst die Startaufstellung an**, nicht `SimDefinitions` | Was | Heute | Ziel | @@ -381,13 +387,16 @@ den zutreffenden Grund. ## Versionsrelevanz -`minor` — neue spielbare Fähigkeiten und Verhaltensänderungen, kein Vertragsbruch. -Die Baseline-Neusetzung ist Zweck der Tests, kein Bruch. +`minor` — neue spielbare Fähigkeiten und Verhaltensänderungen; kein Zustands-, +Schema- oder Wireformatbruch. Die `RulesHash64`-Kompatibilitätsgrenze zwischen +Revision 1 und 2 ist beabsichtigt. Die Baseline-Neusetzung ist Zweck der Tests, +kein Bruch. ## Änderungsverlauf | Version | Datum | Änderung | Autor | |---|---|---|---| +| 1.3.0 | 2026-08-10 | C4-Kompatibilitätsgrenze dokumentiert: Low-Power-Reparatur bindet Rules-Revision 2 und 10/5 HP pro Tick, ohne Zustands- oder Schema-Bump | Codex / Dennis Westermann | | 1.2.0 | 2026-08-10 | D-106 für 16.4 festgeschrieben: einmalige HQ-Kontobasis, periodischer 25-%-Abbau des aktuellen Überhangs und Rules-Hash-Kompatibilitätsgrenze | Codex / Dennis Westermann | | 1.1.0 | 2026-08-10 | D-105-Integrationsausnahme für 16.3 dokumentiert: aktive Sites sind keine Kampfteilnehmer oder fertigen KI-Produzenten | Codex / Dennis Westermann | | 1.0.0 | 2026-08-09 | Erstfassung: Strang C aus Sprint 12 und die acht Betatest-Befunde im selben Schreibbereich zu einem Sprint zusammengeführt, am Code geprüft und nach Kosten sortiert | Orchestrator | diff --git a/tools/Nova.SimRunner.Tests/BarracksSpawnMatchConfigTests.cs b/tools/Nova.SimRunner.Tests/BarracksSpawnMatchConfigTests.cs index 56ac90f..b0d8ba9 100644 --- a/tools/Nova.SimRunner.Tests/BarracksSpawnMatchConfigTests.cs +++ b/tools/Nova.SimRunner.Tests/BarracksSpawnMatchConfigTests.cs @@ -99,7 +99,7 @@ private static MatchHost BuildMatchHost() var economy = new EconomySystem(entities, EconomySystem.CanonicalMatchStartingCreditsAE); var construction = new ConstructionSystem(entities, economy); var production = new ProductionSystem(entities, economy, construction); - var fogOfWar = new FogOfWarSystem(entities, construction, teamCount: 2, MapWidth, MapHeight); + var fogOfWar = new FogOfWarSystem(entities, construction, economy, teamCount: 2, MapWidth, MapHeight); var combat = new Nova.Simulation.Combat.CombatSystem(entities, fogOfWar, economy, construction); var victory = new Nova.Simulation.Victory.VictorySystem(entities, construction); diff --git a/tools/Nova.SimRunner.Tests/CanonicalMatchSetupTests.cs b/tools/Nova.SimRunner.Tests/CanonicalMatchSetupTests.cs index 4646772..337b67e 100644 --- a/tools/Nova.SimRunner.Tests/CanonicalMatchSetupTests.cs +++ b/tools/Nova.SimRunner.Tests/CanonicalMatchSetupTests.cs @@ -103,7 +103,7 @@ private static ReferenceHost BuildReferenceHost(ulong seed) var economy = new EconomySystem(entities, EconomySystem.CanonicalMatchStartingCreditsAE); var construction = new ConstructionSystem(entities, economy, pathfinding.CostField); var production = new ProductionSystem(entities, economy, construction); - var fogOfWar = new FogOfWarSystem(entities, construction, teamCount: 2, MapWidth, MapHeight); + var fogOfWar = new FogOfWarSystem(entities, construction, economy, teamCount: 2, MapWidth, MapHeight); var combat = new Nova.Simulation.Combat.CombatSystem(entities, fogOfWar, economy, construction); var victory = new Nova.Simulation.Victory.VictorySystem(entities, construction); diff --git a/tools/Nova.SimRunner.Tests/CombatSystemTests.cs b/tools/Nova.SimRunner.Tests/CombatSystemTests.cs index fcb6cd5..e17ad10 100644 --- a/tools/Nova.SimRunner.Tests/CombatSystemTests.cs +++ b/tools/Nova.SimRunner.Tests/CombatSystemTests.cs @@ -64,7 +64,7 @@ public static TestHost Create(ulong seed, int capacity = 64, ushort width = 64, // FoW radar read also requires the placement register. var factions = new EconomySystem(entities); var construction = new Nova.Simulation.Construction.ConstructionSystem(entities, factions); - var fog = new FogOfWarSystem(entities, construction, teamCount: 2, width, height); + var fog = new FogOfWarSystem(entities, construction, factions, teamCount: 2, width, height); var combat = new CombatSystem(entities, fog, factions, construction); var kernel = new SimulationKernel(new SimRandom(seed)); @@ -644,7 +644,7 @@ public static ReplayHost Create(ulong seed) var economy = new EconomySystem(entities); // 16.5: the FoW radar read requires the placement register. var construction = new Nova.Simulation.Construction.ConstructionSystem(entities, economy); - var fog = new FogOfWarSystem(entities, construction, teamCount: 2, 64, 64); + var fog = new FogOfWarSystem(entities, construction, economy, teamCount: 2, 64, 64); var combat = new CombatSystem(entities, fog, economy, construction); var kernel = new SimulationKernel(new SimRandom(seed)); diff --git a/tools/Nova.SimRunner.Tests/ConstructionSystemTests.cs b/tools/Nova.SimRunner.Tests/ConstructionSystemTests.cs index 49919ec..45e4782 100644 --- a/tools/Nova.SimRunner.Tests/ConstructionSystemTests.cs +++ b/tools/Nova.SimRunner.Tests/ConstructionSystemTests.cs @@ -781,6 +781,8 @@ public void SellStorage_CapsRefundThenLoweredCapacityDrivesExcessDecay() public void Repair_BuilderRestoresHp_InReachOnly_AndResolvesAtFull() { var f = new Fixture(); + Assert.That(f.Construction.PlaceCompletedBuilding(0, 5, 40, 40).IsValid, Is.True, + "a completed power plant keeps the normal repair rate active"); EntityId barracks = f.Construction.PlaceCompletedBuilding(0, 7, 20, 20); uint raw = UnitCommandStateView.ToRawEntityId(barracks); f.Entities.GetUnitRef(barracks).CurrentHealth = 100; @@ -803,6 +805,27 @@ public void Repair_BuilderRestoresHp_InReachOnly_AndResolvesAtFull() "repair caps at MaxHealth and the order resolves"); } + [Test] + public void Repair_LowPower_ExactlyHalvesTheRate() + { + // 16.6 (C4, Economy.md repair rule): under LOW POWER the repair + // rate halves exactly — 5 HP per tick, no rounding. + var f = new Fixture(); + Assert.That(f.Construction.PlaceCompletedBuilding(0, 4, 40, 40).IsValid, Is.True, + "a completed Refinery (20 required, nothing provided) forces low power"); + EntityId barracks = f.Construction.PlaceCompletedBuilding(0, 7, 20, 20); + f.Entities.GetUnitRef(barracks).CurrentHealth = 100; + + EntityId builder = f.SpawnBuilder(0, 19, 20); + f.Step(1); // commit the balance: refinery + barracks draw 35, nothing provided + Assert.That(f.Economy.GetPlayerEconomy(0).IsLowPower, Is.True); + + f.Construction.AssignRepairOrder(UnitCommandStateView.ToRawEntityId(builder), UnitCommandStateView.ToRawEntityId(barracks)); + f.Step(10); + Assert.That(f.Entities.GetUnitRef(barracks).CurrentHealth, Is.EqualTo(150), + "5 HP per tick under low power — exactly half the provisional rate"); + } + [Test] public void Repair_Validation_RejectsNonBuilder_AndUndamagedTarget() { diff --git a/tools/Nova.SimRunner.Tests/EconomyIntegrationTests.cs b/tools/Nova.SimRunner.Tests/EconomyIntegrationTests.cs index 79e133e..c38f81b 100644 --- a/tools/Nova.SimRunner.Tests/EconomyIntegrationTests.cs +++ b/tools/Nova.SimRunner.Tests/EconomyIntegrationTests.cs @@ -61,7 +61,7 @@ public static EcoHost Create(ulong seed, int capacity = 256, ushort width = 64, var economy = new EconomySystem(entities); // 16.5: the FoW radar read requires the placement register. var construction = new Nova.Simulation.Construction.ConstructionSystem(entities, economy); - var fogOfWar = new FogOfWarSystem(entities, construction, teamCount: 2, width, height); + var fogOfWar = new FogOfWarSystem(entities, construction, economy, teamCount: 2, width, height); var combat = new CombatSystem(entities, fogOfWar, economy, construction); var kernel = new SimulationKernel(new SimRandom(seed)); diff --git a/tools/Nova.SimRunner.Tests/FogOfWarSystemTests.cs b/tools/Nova.SimRunner.Tests/FogOfWarSystemTests.cs index 54750c1..7f31aa4 100644 --- a/tools/Nova.SimRunner.Tests/FogOfWarSystemTests.cs +++ b/tools/Nova.SimRunner.Tests/FogOfWarSystemTests.cs @@ -35,13 +35,15 @@ private sealed class TestHost { public SimulationKernel Kernel { get; } public EntityManager Entities { get; } + public EconomySystem Economy { get; } public ConstructionSystem Construction { get; } public FogOfWarSystem Fog { get; } - private TestHost(SimulationKernel kernel, EntityManager entities, ConstructionSystem construction, FogOfWarSystem fog) + private TestHost(SimulationKernel kernel, EntityManager entities, EconomySystem economy, ConstructionSystem construction, FogOfWarSystem fog) { Kernel = kernel; Entities = entities; + Economy = economy; Construction = construction; Fog = fog; } @@ -51,19 +53,19 @@ public static TestHost Create(ulong seed, int capacity = 64, ushort width = 64, var entities = new EntityManager(capacity); var pathfinding = new PathfindingSystem(width, height); var movement = new MovementSystem(entities, pathfinding); - // 16.5: the FoW radar read requires the placement register — - // an unregistered economy/construction pair answers placement - // queries without ever ticking. + // 16.5/16.6: the FoW radar read requires the placement register + // and the power balance — an unregistered economy/construction + // pair answers both queries without ever ticking. var economy = new EconomySystem(entities); var construction = new ConstructionSystem(entities, economy); - var fog = new FogOfWarSystem(entities, construction, teamCount: 2, width, height); + var fog = new FogOfWarSystem(entities, construction, economy, teamCount: 2, width, height); var kernel = new SimulationKernel(new SimRandom(seed)); kernel.RegisterSystem(pathfinding); kernel.RegisterSystem(movement); kernel.RegisterSystem(fog); kernel.Start(); - return new TestHost(kernel, entities, construction, fog); + return new TestHost(kernel, entities, economy, construction, fog); } public void Step() => Kernel.StepTick(); @@ -286,6 +288,31 @@ public void RadarSignature_WithoutCompletedRadar_NoCoverage() Assert.That(pings.Count, Is.EqualTo(0), "no finished Radar building, no coverage at all"); } + [Test] + public void RadarSignatures_StopAtPowerDeficit_AndResumeWhenBalanceRecovers() + { + // 16.6 (C4, Economy.md Low-Power rule): at a power deficit the radar is the FIRST + // system to fall — no coverage, no pings. The deficit here is set + // directly on the balance (the rig's economy never recomputes). + var host = TestHost.Create(Seed); + Assert.That(host.Construction.PlaceCompletedBuilding(0, 10, 9, 14).IsValid, Is.True); + SpawnAt(host, 1, 24, 10, sightRadius: 5); // radar-covered, hidden + host.Step(2); + + var pings = new List(); + host.Fog.GetRadarSignatures(0, pings); + Assert.That(pings.Count, Is.EqualTo(1), "coverage with a finished Radar and a balanced grid"); + + host.Economy.GetPlayerEconomy(0).PowerRequired = 1; // deficit: 1 required > 0 provided + pings.Clear(); + host.Fog.GetRadarSignatures(0, pings); + Assert.That(pings.Count, Is.EqualTo(0), "LOW POWER takes the radar offline"); + + host.Economy.GetPlayerEconomy(0).PowerRequired = 0; + host.Fog.GetRadarSignatures(0, pings); + Assert.That(pings.Count, Is.EqualTo(1), "the radar comes back with the balance"); + } + [Test] public void HiddenWorldMetamorphic_HiddenEnemyVariation_LeavesTeamZeroViewIdentical() { diff --git a/tools/Nova.SimRunner.Tests/LockstepNetworkTests.cs b/tools/Nova.SimRunner.Tests/LockstepNetworkTests.cs index ee0cdd2..6892fc1 100644 --- a/tools/Nova.SimRunner.Tests/LockstepNetworkTests.cs +++ b/tools/Nova.SimRunner.Tests/LockstepNetworkTests.cs @@ -332,7 +332,7 @@ public static ClientHost Create(RelayMatchClient client) var economy = new EconomySystem(entities, EconomySystem.CanonicalMatchStartingCreditsAE); var construction = new ConstructionSystem(entities, economy, pathfinding.CostField); var production = new ProductionSystem(entities, economy, construction); - var fogOfWar = new FogOfWarSystem(entities, construction, teamCount: 2, 128, 128); + var fogOfWar = new FogOfWarSystem(entities, construction, economy, teamCount: 2, 128, 128); var combat = new CombatSystem(entities, fogOfWar, economy, construction); var kernel = new SimulationKernel(new SimRandom(Seed)); @@ -407,7 +407,7 @@ public static ClientHost CreatePlayback() var economy = new EconomySystem(entities, EconomySystem.CanonicalMatchStartingCreditsAE); var construction = new ConstructionSystem(entities, economy, pathfinding.CostField); var production = new ProductionSystem(entities, economy, construction); - var fogOfWar = new FogOfWarSystem(entities, construction, teamCount: 2, 128, 128); + var fogOfWar = new FogOfWarSystem(entities, construction, economy, teamCount: 2, 128, 128); var combat = new CombatSystem(entities, fogOfWar, economy, construction); var kernel = new SimulationKernel(new SimRandom(Seed)); kernel.RegisterSystem(economy); @@ -1175,6 +1175,39 @@ public void LegacyEmptyRulesFingerprint_RefusesTheMatchBeforeRunning() server.Stop(); } + [Test] + public void RevisionOneRulesFingerprint_RefusesTheMatchBeforeRunning() + { + var server = new RelayServerCore(Token, Seed, Delay, string.Empty, _ => { }); + server.Start(0); + var clientA = new RelayMatchClient(); + var clientB = new RelayMatchClient(); + clientA.Connect("127.0.0.1", server.Port, Token); + clientB.Connect("127.0.0.1", server.Port, Token); + PumpUntil(server, clientA, clientB, () => clientA.HasOffer && clientB.HasOffer, "offers"); + + ClientHost hostA = ClientHost.Create(clientA); + ClientHost hostB = ClientHost.Create(clientB); + MatchFingerprint current = hostA.CreateFingerprint(); + MatchFingerprint revisionOne = MatchFingerprint.CreateCurrent( + MatchFingerprint.ComputeRulesHash64(MatchFingerprint.RulesRevisionV1), + current.DefinitionsHash64, current.MapHash64, + current.GetSlotOccupancyCopy(), current.GetSlotFactionCopy(), + current.StartSeed, current.InitialStateHash, current.InputDelayTicks); + + clientA.SubmitLocalProof(current.Serialize(), hostA.Kernel.SaveSnapshot()); + clientB.SubmitLocalProof(revisionOne.Serialize(), hostB.Kernel.SaveSnapshot()); + PumpUntil(server, clientA, clientB, + () => clientA.Phase == RelayClientPhase.Ended && clientB.Phase == RelayClientPhase.Ended, + "the relay refused the revision-1/current rules mismatch"); + + Assert.That(clientA.Phase, Is.Not.EqualTo(RelayClientPhase.Running)); + Assert.That(clientB.Phase, Is.Not.EqualTo(RelayClientPhase.Running)); + Assert.That(clientA.RejectReason, Does.Contain("RulesHash64")); + Assert.That(clientB.RejectReason, Does.Contain("RulesHash64")); + server.Stop(); + } + [Test] public void SidecarFingerprintMismatch_IsFoundByTheCentralComparator() { diff --git a/tools/Nova.SimRunner.Tests/MatchFingerprintTests.cs b/tools/Nova.SimRunner.Tests/MatchFingerprintTests.cs index e4889a7..aa5b6ad 100644 --- a/tools/Nova.SimRunner.Tests/MatchFingerprintTests.cs +++ b/tools/Nova.SimRunner.Tests/MatchFingerprintTests.cs @@ -79,6 +79,18 @@ public void ComputeHash_IsStableAcrossInstances_AndStubHashesAreDistinct() "D-106 rules must not match the legacy empty rules stub"); } + [Test] + public void CurrentRulesHash_MovesPastRevisionOne_ForLowPowerRepair() + { + ulong revisionOne = MatchFingerprint.ComputeRulesHash64(MatchFingerprint.RulesRevisionV1); + ulong current = MatchFingerprint.ComputeCurrentRulesHash64(); + + Assert.That(MatchFingerprint.CurrentRulesRevision, Is.EqualTo(MatchFingerprint.RulesRevisionV2)); + Assert.That(current, Is.EqualTo(MatchFingerprint.ComputeRulesHash64(MatchFingerprint.RulesRevisionV2))); + Assert.That(current, Is.Not.EqualTo(revisionOne), + "Sprint-16.6 C4 repair behavior must not share D-106's revision-1 rules identity"); + } + [Test] public void ComputeHash_AndEquality_AreSensitiveToEveryField() { diff --git a/tools/Nova.SimRunner.Tests/ProductionConstructionIntegrationTests.cs b/tools/Nova.SimRunner.Tests/ProductionConstructionIntegrationTests.cs index c0075c1..1ed919f 100644 --- a/tools/Nova.SimRunner.Tests/ProductionConstructionIntegrationTests.cs +++ b/tools/Nova.SimRunner.Tests/ProductionConstructionIntegrationTests.cs @@ -62,7 +62,7 @@ public static ProdHost Create(ulong seed, int capacity = 256, long startingCredi var economy = new EconomySystem(entities, startingCredits); var construction = new ConstructionSystem(entities, economy, pathfinding.CostField); var production = new ProductionSystem(entities, economy, construction); - var fogOfWar = new FogOfWarSystem(entities, construction, teamCount: 2, 128, 128); + var fogOfWar = new FogOfWarSystem(entities, construction, economy, teamCount: 2, 128, 128); var combat = new CombatSystem(entities, fogOfWar, economy, construction); var kernel = new SimulationKernel(new SimRandom(seed)); diff --git a/tools/Nova.SimRunner.Tests/ReplayTests.cs b/tools/Nova.SimRunner.Tests/ReplayTests.cs index 8f884d4..e5cd160 100644 --- a/tools/Nova.SimRunner.Tests/ReplayTests.cs +++ b/tools/Nova.SimRunner.Tests/ReplayTests.cs @@ -200,6 +200,29 @@ public void FingerprintMismatch_LegacyEmptyRules_RefusesPlaybackBeforeTickOne() "an old/new rules mismatch must be refused before execution"); } + [Test] + public void FingerprintMismatch_RevisionOneRules_RefusesPlaybackBeforeTickOne() + { + ReplayTestUtil.LiveMatch live = ReplayTestUtil.RunLiveMatch(); + MatchFingerprint revisionOne = MatchFingerprint.CreateCurrent( + MatchFingerprint.ComputeRulesHash64(MatchFingerprint.RulesRevisionV1), + live.Fingerprint.DefinitionsHash64, live.Fingerprint.MapHash64, + live.Fingerprint.GetSlotOccupancyCopy(), live.Fingerprint.GetSlotFactionCopy(), + live.Fingerprint.StartSeed, live.Fingerprint.InitialStateHash, + live.Fingerprint.InputDelayTicks); + + ReplayTestUtil.TestHost playback = ReplayTestUtil.CreatePlaybackHost(); + Assert.That( + ReplayPlayer.TryPlay( + live.ReplayBytes, revisionOne, playback.Kernel, playback.Ingress, + out ReplayPlaybackError error, out string detail), + Is.False); + Assert.That(error, Is.EqualTo(ReplayPlaybackError.FingerprintMismatch)); + Assert.That(detail, Does.Contain("RulesHash64")); + Assert.That(playback.Kernel.CurrentTick.Value, Is.EqualTo(0u), + "revision-1 rules must be refused before execution"); + } + [Test] public void FingerprintMismatch_DifferentSlotOccupancy_RefusesPlayback() { diff --git a/tools/Nova.SimRunner.Tests/SkirmishAiTests.cs b/tools/Nova.SimRunner.Tests/SkirmishAiTests.cs index 7b9f963..e8618cb 100644 --- a/tools/Nova.SimRunner.Tests/SkirmishAiTests.cs +++ b/tools/Nova.SimRunner.Tests/SkirmishAiTests.cs @@ -165,7 +165,7 @@ private static AiHost BuildAiHost(ulong seed, AiProfile? profile = null) var economy = new EconomySystem(entities, EconomySystem.CanonicalMatchStartingCreditsAE); var construction = new ConstructionSystem(entities, economy, pathfinding.CostField); var production = new ProductionSystem(entities, economy, construction); - var fogOfWar = new FogOfWarSystem(entities, construction, teamCount: 2, MapWidth, MapHeight); + var fogOfWar = new FogOfWarSystem(entities, construction, economy, teamCount: 2, MapWidth, MapHeight); var combat = new CombatSystem(entities, fogOfWar, economy, construction); var victory = new VictorySystem(entities, construction); diff --git a/tools/Nova.SimRunner.Tests/WeaponValuesTests.cs b/tools/Nova.SimRunner.Tests/WeaponValuesTests.cs index 0430aff..fe86bb3 100644 --- a/tools/Nova.SimRunner.Tests/WeaponValuesTests.cs +++ b/tools/Nova.SimRunner.Tests/WeaponValuesTests.cs @@ -323,7 +323,7 @@ public static TestHost Create() // FoW radar read also requires the placement register. var factions = new EconomySystem(entities); var construction = new Nova.Simulation.Construction.ConstructionSystem(entities, factions); - var fog = new FogOfWarSystem(entities, construction, teamCount: 2, 64, 64); + var fog = new FogOfWarSystem(entities, construction, factions, teamCount: 2, 64, 64); var combat = new CombatSystem(entities, fog, factions, construction); var kernel = new SimulationKernel(new SimRandom(Seed)); diff --git a/tools/Nova.SimRunner/Determinism10000Scenario.cs b/tools/Nova.SimRunner/Determinism10000Scenario.cs index 3ce8cd2..cbaf834 100644 --- a/tools/Nova.SimRunner/Determinism10000Scenario.cs +++ b/tools/Nova.SimRunner/Determinism10000Scenario.cs @@ -668,7 +668,7 @@ private static Host BuildHost(ulong seed, INovaLogger logger) var economy = new EconomySystem(entities, EconomySystem.CanonicalMatchStartingCreditsAE); var construction = new ConstructionSystem(entities, economy, pathfinding.CostField); var production = new ProductionSystem(entities, economy, construction); - var fogOfWar = new FogOfWarSystem(entities, construction, teamCount: 2, MapWidth, MapHeight); + var fogOfWar = new FogOfWarSystem(entities, construction, economy, teamCount: 2, MapWidth, MapHeight); var combat = new Nova.Simulation.Combat.CombatSystem(entities, fogOfWar, economy, construction); var victory = new Nova.Simulation.Victory.VictorySystem(entities, construction); diff --git a/tools/Nova.SimRunner/Program.cs b/tools/Nova.SimRunner/Program.cs index d4a5238..f1ffae6 100644 --- a/tools/Nova.SimRunner/Program.cs +++ b/tools/Nova.SimRunner/Program.cs @@ -369,7 +369,7 @@ private static ulong RunOnce(string runLabel, INovaLogger logger) var economy = new EconomySystem(entities); var construction = new Nova.Simulation.Construction.ConstructionSystem(entities, economy, pathfinding.CostField); var production = new Nova.Simulation.Production.ProductionSystem(entities, economy, construction); - var fogOfWar = new FogOfWarSystem(entities, construction, teamCount: 2, 128, 128); + var fogOfWar = new FogOfWarSystem(entities, construction, economy, teamCount: 2, 128, 128); var combat = new CombatSystem(entities, fogOfWar, economy, construction); // Canonical tick order (SimulationCore.md section 2): economy diff --git a/tools/Nova.SimRunner/Scale500PrecombatScenario.cs b/tools/Nova.SimRunner/Scale500PrecombatScenario.cs index 14d173d..97cf418 100644 --- a/tools/Nova.SimRunner/Scale500PrecombatScenario.cs +++ b/tools/Nova.SimRunner/Scale500PrecombatScenario.cs @@ -535,7 +535,7 @@ private static Host BuildHost(ScenarioOptions options, INovaLogger logger) var economy = new EconomySystem(entities); var construction = new ConstructionSystem(entities, economy, pathfinding.CostField); var production = new ProductionSystem(entities, economy, construction); - var fogOfWar = new FogOfWarSystem(entities, construction, teamCount: 2, MapWidth, MapHeight); + var fogOfWar = new FogOfWarSystem(entities, construction, economy, teamCount: 2, MapWidth, MapHeight); var host = new Host {