Files
Project-M/Assets/_Project/Tests/EditMode/ZoneEnemyMathTests.cs
T
kronic 3109b86d71 Slice 3: Expedition Combat Spine — epoch-seeded zone waves (DR-040)
Reactivate the dormant Expedition region as a procedural combat venue.
v1 loop: walk the gate -> fight an epoch-seeded enemy wave in the
expedition -> clear -> return -> flat Ore reward (once per epoch) ->
escalated retaliation base siege.

- New sim types: ZoneEnemyTag, ZoneEnemyDirector (+ ZoneEnemyPrefab
  buffer), ZoneEnemyState, ZoneEnemyMath (grunt->charger composition
  by epoch). ZoneEnemyDirectorSystem (server, Burst): drip-spawns the
  wave at a deterministic ring under a MaxAlive cap while a player is
  out and the base is Calm; marks ClearedThisEpoch on a real clear.
  [UpdateAfter(ExpeditionFieldSystem)] only (avoids a sort cycle).
- BLOCKER 1: EnemyAISystem region-filters target selection (player +
  structure snapshots gain parallel region lists; no base structures /
  no Core fallback for expedition husks).
- BLOCKER 3: WaveSystem, ThreatDirectorSystem timeout cull, and
  CyclePhaseSystem DefendCleared + Core-breach cull all count/cull
  RegionTag{Base} husks only (the breach cull was caught region-blind
  by the post-impl review: a base breach wiped the live expedition
  wave and spuriously paid the reward).
- BLOCKER 4: reward de-duped via CycleRuntime.LastRewardedEpoch +
  ClearedThisEpoch; ExpeditionGateSystem deposits RewardOre once/epoch.
- ExpeditionFieldSystem teardown also culls zone enemies + region-
  guards the clutter loop. Subscene wired with the director + roster.

368/368 EditMode green + clean netcode Play smoke. Docs: DR-040 ->
built, session log, CLAUDE.md cross-region tag-reaudit rule.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 22:58:26 -07:00

61 lines
2.5 KiB
C#

using NUnit.Framework;
using ProjectM.Simulation;
namespace ProjectM.Tests
{
/// <summary>
/// Pure-function tests for <see cref="ZoneEnemyMath"/> (no ECS world): the deterministic, save-reproducible
/// expedition-wave composition. Pins the lower bound, the per-epoch ramp, and the grunt-heavy -> charger-heavy
/// shift (grunt count held fixed; the per-epoch growth is all chargers).
/// </summary>
public class ZoneEnemyMathTests
{
[Test]
public void WaveSize_LowerBoundedAtOne()
{
Assert.AreEqual(1, ZoneEnemyMath.WaveSize(0, 0, 0), "an occupied expedition always has at least one enemy");
Assert.AreEqual(1, ZoneEnemyMath.WaveSize(-5, 0, 0), "epoch is floored at 1 internally");
}
[Test]
public void WaveSize_BaselinePlusOnePerEpoch()
{
Assert.AreEqual(5, ZoneEnemyMath.WaveSize(1, 4, 1), "epoch 1: 4 grunts + 1 charger");
Assert.AreEqual(7, ZoneEnemyMath.WaveSize(3, 4, 1), "epoch 3: baseline 5 + (3-1) ramp");
}
[Test]
public void IsChargerSlot_Epoch1_GruntsFirst_OneChargerLast()
{
// epoch 1, G=4 C=1 -> size 5, only the last slot is a charger.
Assert.IsFalse(ZoneEnemyMath.IsChargerSlot(1, 0, 4, 1));
Assert.IsFalse(ZoneEnemyMath.IsChargerSlot(1, 3, 4, 1));
Assert.IsTrue(ZoneEnemyMath.IsChargerSlot(1, 4, 4, 1));
}
[Test]
public void Composition_GruntCountFixed_ChargerShareGrowsWithEpoch()
{
AssertComposition(epoch: 1, grunts: 4, chargers: 1, expectGrunts: 4, expectChargers: 1);
AssertComposition(epoch: 5, grunts: 4, chargers: 1, expectGrunts: 4, expectChargers: 5);
}
static void AssertComposition(int epoch, int grunts, int chargers, int expectGrunts, int expectChargers)
{
int size = ZoneEnemyMath.WaveSize(epoch, grunts, chargers);
int g = 0, c = 0;
for (int slot = 0; slot < size; slot++)
if (ZoneEnemyMath.IsChargerSlot(epoch, slot, grunts, chargers)) c++; else g++;
Assert.AreEqual(expectGrunts, g, $"grunt count at epoch {epoch}");
Assert.AreEqual(expectChargers, c, $"charger count at epoch {epoch}");
}
[Test]
public void IsChargerSlot_Deterministic()
{
for (int slot = 0; slot < 9; slot++)
Assert.AreEqual(ZoneEnemyMath.IsChargerSlot(5, slot, 4, 1), ZoneEnemyMath.IsChargerSlot(5, slot, 4, 1));
}
}
}