Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -298,7 +298,7 @@ public void PowerFormatters_NameBalanceConsequenceAndBuildingDraw()
{
Assert.AreEqual("Strom 30/20", CommandCardPresenter.FormatPowerBalance(30, 20));
Assert.AreEqual(
"Strom 20/40 · LOW POWER: Produktion ½",
"Strom 20/40 · LOW POWER: Produktion ½ · Türme aus",
CommandCardPresenter.FormatPowerBalance(20, 40));

Assert.IsTrue(SimDefinitions.TryGetBuilding(
Expand Down
4 changes: 2 additions & 2 deletions Assets/Tests/EditMode/Gameplay/ResourceBarPresenterTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -186,7 +186,7 @@ public void BuildModel_PowerDeficit_WarnsCriticalAndNamesTheConsequences()

Assert.IsTrue(model.IsLowPower);
Assert.AreEqual("Strom 60/80", model.PowerText);
Assert.AreEqual("Strommangel — Produktion ½ · Reparatur ½ · Radar aus", model.WarningText);
Assert.AreEqual("Strommangel — Produktion ½ · Reparatur ½ · Radar aus · Türme aus", model.WarningText);
Assert.IsTrue(model.IsCritical);
}

Expand All @@ -196,7 +196,7 @@ public void BuildModel_FullStoreAndDeficit_JoinsBothWarningsStorageFirst()
ResourceBarModel model = ResourceBarPresenter.BuildModel(3000, 3000, 60, 80);

Assert.AreEqual(
"Lager voll — Einnahmen verfallen | Strommangel — Produktion ½ · Reparatur ½ · Radar aus",
"Lager voll — Einnahmen verfallen | Strommangel — Produktion ½ · Reparatur ½ · Radar aus · Türme aus",
model.WarningText);
Assert.IsTrue(model.IsCritical, "the deficit is an active penalty — the join escalates to red");
}
Expand Down
96 changes: 96 additions & 0 deletions Assets/Tests/EditMode/Simulation/CombatSystemTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -244,6 +244,102 @@ public void AutoAcquire_DefensePlatform_FiresOnItsOwn()
Assert.That(HealthOf(host, attacker), Is.LessThan(200), "the platform actually fires");
}

// ------------------------------------------------------------------
// #67, D-122: defence platforms go offline in power deficit
// ------------------------------------------------------------------

[Test]
public void LowPower_DefensePlatform_StaysSilent_AndResumesWhenPowerReturns()
{
var host = TestHost.Create(Seed);
EntityId platform = SpawnAt(host, 0, 10, 10, maxHealth: 600);
host.Entities.GetUnitRef(platform).Role = UnitRole.DefensePlatform; // armed: 20 dmg, range 10, cd 10
EntityId target = SpawnAt(host, 1, 13, 10, maxHealth: 1000);

// The economy system is not registered in this host, so nothing
// recomputes the balance: setting it directly is authoritative.
ref PlayerEconomyState eco = ref host.Economy.GetPlayerEconomy(0);
eco.PowerProvided = 30;
eco.PowerRequired = 40; // deficit
Assert.That(host.Economy.GetPlayerEconomy(0).IsLowPower, Is.True);

host.Step(30);
Assert.That(host.Entities.GetUnitRef(platform).AttackTarget.IsValid, Is.False,
"offline: no auto-acquisition while the owner is in power deficit");
Assert.That(HealthOf(host, target), Is.EqualTo(1000), "offline: no shot");
Assert.That(host.Entities.GetUnitRef(platform).WeaponCooldownTicks, Is.EqualTo(0),
"the cooldown keeps running down while offline");

host.Economy.GetPlayerEconomy(0).PowerProvided = 100; // balance restored
host.Step(1);
Assert.That(host.Entities.GetUnitRef(platform).AttackTarget, Is.EqualTo(target),
"power back: the platform acquires again in the very next tick");
Assert.That(HealthOf(host, target), Is.EqualTo(1000 - 20),
"power back: it fires at once, no warm-up (Economy.md: restores all systems immediately)");
}

[Test]
public void LowPower_DoesNotSilenceUnits_OnlyBuildings()
{
var host = TestHost.Create(Seed);
EntityId unit = SpawnAt(host, 0, 10, 10, maxHealth: 100); // generic armed unit role
EntityId target = SpawnAt(host, 1, 13, 10, maxHealth: 1000);
ref PlayerEconomyState eco = ref host.Economy.GetPlayerEconomy(0);
eco.PowerProvided = 0;
eco.PowerRequired = 50;

host.Step(3);
Assert.That(host.Entities.GetUnitRef(unit).AttackTarget, Is.EqualTo(target),
"a mobile unit is not a power consumer and still acquires");
Assert.That(HealthOf(host, target), Is.LessThan(1000),
"units keep firing in a power deficit; only building roles fall");
}

[Test]
public void LowPower_OnlyTheDeficitOwnersPlatformFalls()
{
var host = TestHost.Create(Seed);
EntityId mine = SpawnAt(host, 0, 10, 10, maxHealth: 600);
host.Entities.GetUnitRef(mine).Role = UnitRole.DefensePlatform;
EntityId theirs = SpawnAt(host, 1, 13, 10, maxHealth: 600);
host.Entities.GetUnitRef(theirs).Role = UnitRole.DefensePlatform;
host.Economy.GetPlayerEconomy(0).PowerRequired = 10; // slot 0 in deficit, slot 1 balanced

host.Step(3);
Assert.That(HealthOf(host, mine), Is.LessThan(600),
"the balanced owner's platform shoots the deficit owner's platform");
Assert.That(HealthOf(host, theirs), Is.EqualTo(600),
"the deficit owner's platform is offline");
}

[Test]
public void LowPower_OutageDropsTheHeldTarget_SoRecoveryReacquiresTheNearest()
{
var host = TestHost.Create(Seed);
EntityId platform = SpawnAt(host, 0, 10, 10, maxHealth: 600);
host.Entities.GetUnitRef(platform).Role = UnitRole.DefensePlatform;
EntityId first = SpawnAt(host, 1, 13, 10, maxHealth: 5000);
host.Step(2);
Assert.That(host.Entities.GetUnitRef(platform).AttackTarget, Is.EqualTo(first),
"precondition: the platform holds the first hostile");

ref PlayerEconomyState eco = ref host.Economy.GetPlayerEconomy(0);
eco.PowerProvided = 0;
eco.PowerRequired = 10; // outage
host.Step(1);
Assert.That(host.Entities.GetUnitRef(platform).AttackTarget.IsValid, Is.False,
"an outage drops the held target (failure behaviour F, D-122)");

Teleport(host, first, 40, 10); // the old target walks off
EntityId second = SpawnAt(host, 1, 12, 10, maxHealth: 5000);
host.Economy.GetPlayerEconomy(0).PowerRequired = 0; // recovery
host.Step(1);
Assert.That(host.Entities.GetUnitRef(platform).AttackTarget, Is.EqualTo(second),
"recovery acquires the nearest hostile instead of resuming a stale lock");
host.Step(12);
Assert.That(HealthOf(host, second), Is.LessThan(5000));
}

[Test]
public void ActiveDefensePlatformSite_NeitherAcquiresNorExecutesExplicitAttack()
{
Expand Down
6 changes: 4 additions & 2 deletions Assets/_Project/Scripts/Gameplay/UI/CommandCardPresenter.cs
Original file line number Diff line number Diff line change
Expand Up @@ -362,13 +362,15 @@ public static BuildingPlacementBlocker EvaluateBuildingPlacementBlocker(

/// <summary>
/// Compact live grid balance for the build bar. Low power must name
/// its gameplay consequence where the player makes build decisions.
/// its gameplay consequence where the player makes build decisions —
/// including that the defence platforms go offline (#67, D-122), so a
/// deficit caused by one's own build-out is not a silent trap.
/// </summary>
public static string FormatPowerBalance(int powerProvided, int powerRequired)
{
string balance = $"Strom {powerProvided}/{powerRequired}";
return powerRequired > powerProvided
? balance + " · LOW POWER: Produktion ½"
? balance + " · LOW POWER: Produktion ½ · Türme aus"
: balance;
}

Expand Down
4 changes: 2 additions & 2 deletions Assets/_Project/Scripts/Gameplay/UI/ResourceBarPresenter.cs
Original file line number Diff line number Diff line change
Expand Up @@ -99,8 +99,8 @@ public static class ResourceBarPresenter
/// <summary>Warning while the balance sits ABOVE the ceiling: the excess decays 25%/s (D-024/D-106), so the warning names the action (red).</summary>
public const string StorageOverflowWarning = "Überschuss verfällt — Lager bauen!";

/// <summary>Warning under low power, naming the three live consequences so the rule stops being invisible (16.6 C4, Sprint-16 production factor).</summary>
public const string LowPowerWarning = "Strommangel — Produktion ½ · Reparatur ½ · Radar aus";
/// <summary>Warning under low power, naming the four live consequences so the rule stops being invisible (16.6 C4, Sprint-16 production factor; "Türme aus" since #67, D-122).</summary>
public const string LowPowerWarning = "Strommangel — Produktion ½ · Reparatur ½ · Radar aus · Türme aus";

/// <summary>
/// The storage situation, with the two edges the account can sit on:
Expand Down
67 changes: 56 additions & 11 deletions Assets/_Project/Scripts/Simulation/Combat/CombatSystem.cs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
using System;
using Nova.Core;
using Nova.Simulation.Construction;
using Nova.Simulation.Economy;
using Nova.Simulation.State;
using Nova.Simulation.Vision;

Expand Down Expand Up @@ -97,6 +98,24 @@ namespace Nova.Simulation.Combat
/// self-computed sight exists on this path.
/// </para>
/// <para>
/// LOW-POWER OFFLINE RULE (#67, D-122; docs/gamedesign/Economy.md): a
/// BUILDING-role entity (<see cref="Definitions.SimDefinitions.IsBuildingRole"/>
/// — in MS-1 that is the DefensePlatform, the only armed building) whose
/// owner is in power deficit (<see cref="PlayerEconomyState.IsLowPower"/>)
/// is OFFLINE: it neither acquires a target (phase 2) nor holds one — its
/// attack order is DROPPED (phase 3) so that, once power returns, the next
/// acquisition re-picks the nearest hostile instead of resuming an old
/// lock. Units are never affected. The weapon cooldown keeps ticking down
/// in phase 1 on purpose: a platform whose power returns fires again
/// immediately ("restores all systems at once", Economy.md). The balance
/// is read from the economy state as it stands at the START of the combat
/// phase — the Economy system is registered (and therefore runs) before
/// Combat, so a power plant destroyed in tick N takes the platforms
/// offline from tick N+1 on: one tick of latency, fully deterministic.
/// The power balance is derived state, so the rule adds nothing to
/// snapshots or state layout.
/// </para>
/// <para>
/// Per-unit-type weapons from docs/gamedesign/Weapons.md ARE wired now —
/// the flat 15-damage-for-everyone placeholder is gone. What remains
/// provisional: same-team attack orders are not filtered in this slice
Expand Down Expand Up @@ -129,27 +148,30 @@ public sealed class CombatSystem : ISimSystem

private readonly EntityManager _entityManager;
private readonly FogOfWarSystem _fogOfWar;
private readonly ISlotFactionLookup _factions;
private readonly EconomySystem _economy;
private readonly ConstructionSystem _construction;

public string Name => "CombatSystem";

/// <summary>
/// Combat needs the slot factions because weapon profiles are
/// faction-resolved (faction identity slice): the same role fights
/// with different numbers depending on the owner slot's faction.
/// The lookup is the economy state — the single home of the faction
/// assignment — injected at construction like the FoW reference.
/// Combat needs the economy for two reads: the slot factions, because
/// weapon profiles are faction-resolved (faction identity slice — the
/// same role fights with different numbers depending on the owner
/// slot's faction), and the owner's power balance, because defence
/// platforms go offline in power deficit (#67, D-122). The economy
/// state is the single home of both — injected at construction like
/// the FoW reference (same pattern as Fog of War and Construction)
/// and registered BEFORE combat so the balance is current.
/// </summary>
public CombatSystem(
EntityManager entityManager,
FogOfWarSystem fogOfWar,
ISlotFactionLookup factions,
EconomySystem economy,
ConstructionSystem construction)
{
_entityManager = entityManager ?? throw new ArgumentNullException(nameof(entityManager));
_fogOfWar = fogOfWar ?? throw new ArgumentNullException(nameof(fogOfWar));
_factions = factions ?? throw new ArgumentNullException(nameof(factions));
_economy = economy ?? throw new ArgumentNullException(nameof(economy));
_construction = construction ?? throw new ArgumentNullException(nameof(construction));
}

Expand Down Expand Up @@ -191,8 +213,9 @@ public void ExecuteTick(Tick tick)
if (!attacker.IsActive || attacker.AttackTarget.IsValid
|| _construction.IsActiveSite(attacker.Id)) continue;

WeaponProfile weapon = WeaponProfiles.Get(_factions.GetSlotFaction(attacker.PlayerId), attacker.Role);
WeaponProfile weapon = WeaponProfiles.Get(_economy.GetSlotFaction(attacker.PlayerId), attacker.Role);
if (!weapon.IsArmed) continue;
if (IsOffline(in attacker)) continue; // #67, D-122: no acquisition in power deficit
if (attacker.PlayerId >= _fogOfWar.TeamCount) continue;
TeamView view = _fogOfWar.GetTeamView(attacker.PlayerId);

Expand Down Expand Up @@ -260,9 +283,19 @@ public void ExecuteTick(Tick tick)
// range and cadence. An unarmed role (base damage 0) holds
// its order exactly like an out-of-range one — it simply has
// nothing to fire, so it never starts a cooldown either.
WeaponProfile weapon = WeaponProfiles.Get(_factions.GetSlotFaction(attacker.PlayerId), attacker.Role);
WeaponProfile weapon = WeaponProfiles.Get(_economy.GetSlotFaction(attacker.PlayerId), attacker.Role);
if (!weapon.IsArmed) continue;

// #67, D-122: an offline platform neither fires nor holds its
// target. Dropping the order (instead of keeping it) means the
// recovery re-acquires the nearest hostile in phase 2. The
// cooldown is untouched — phase 1 keeps counting it down.
if (IsOffline(in attacker))
{
attacker.AttackTarget = EntityId.Invalid;
continue;
}

// Legality: range AND committed visibility. A living target
// that fails either check is held, never dropped.
if (!IsInRange(in attacker, in target, weapon.AttackRange)) continue;
Expand All @@ -277,7 +310,7 @@ public void ExecuteTick(Tick tick)
int damage = DamageMatrix.Resolve(
weapon.AttackDamage,
weapon.DamageType,
WeaponProfiles.GetArmorClass(_factions.GetSlotFaction(target.PlayerId), target.Role));
WeaponProfiles.GetArmorClass(_economy.GetSlotFaction(target.PlayerId), target.Role));

target.CurrentHealth -= damage;
attacker.WeaponCooldownTicks = weapon.AttackCooldownTicks;
Expand All @@ -293,6 +326,18 @@ public void Shutdown()
{
}

/// <summary>
/// Low-power offline rule (#67, D-122): true for a building-role
/// attacker whose owner slot is currently in power deficit. Units are
/// never offline. Reads the balance the Economy system left at the
/// start of this tick (one tick of latency, deterministic).
/// </summary>
private bool IsOffline(in UnitState attacker)
{
return Definitions.SimDefinitions.IsBuildingRole(attacker.Role)
&& _economy.GetPlayerEconomy(attacker.PlayerId).IsLowPower;
}

/// <summary>
/// Exact range test (boundary inclusive): center distance squared
/// &lt;= (range + target radius)^2, computed in widened Q32.32 long
Expand Down
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -258,6 +258,18 @@ die Versionierung folgt (in der aktuellen Doku-Phase) dem Dokumentationsstand de
sie belegt keine Verbesserung.

### Geändert
- **Verteidigungsplattformen fallen bei Strommangel aus (#67, D-122).** Ein
Besitzer im Defizit (`PlayerEconomyState.IsLowPower`) verliert die Feuerkraft
seiner Gebäuderollen: `CombatSystem` erfasst keine Ziele, feuert nicht und
verwirft ein gehaltenes Ziel; der Cooldown läuft weiter, bei Rückkehr des
Stroms feuert die Plattform sofort wieder. Einheiten sind nie betroffen; die
Bilanz gilt vom Tickanfang (ein Tick Latenz). Dritter Konstruktorparameter von
`CombatSystem` ist jetzt das `EconomySystem` (alle Aufrufer übergaben es
schon). Ressourcenleiste und Baubar nennen „Türme aus“; Buildings.md,
Economy.md und DemoRunbook.md nachgezogen. **Keine Baseline und kein Pin
bewegt sich, `RulesHash64` ebenfalls nicht** – ein weiterer Beleg für die
Hash-Lücke aus #108 (Reihenfolge zur Regelrevision V4 offen, siehe D-122).
Die Unity-EditMode-Tests (Spiegel und UI-Texte) sind nicht gelaufen.
- **Die lebende Dokumentation heißt jetzt Hashkrieg (#14, Stufe 3).** 54 Dateien,
84 Stellen — die Marke in der Prosa unter `docs/**` und in den Wurzeldateien.
Zwei Regeln haben den Umfang bestimmt. Erstens **E-3: nur die Marke.** Die
Expand Down
Loading
Loading