Files
kronic 56cf60cce3 Slice Combat Depth (MC-2): enemy-variety server spine — Spitter, Swarmer, 4-type mix (DR-041)
Adds the server-authoritative mechanics for three new enemy archetypes on top of
the Grunt/Charger base, plus the weighted wave-composition that introduces them:

- Spitter: a ranged Husk variant (SpitterState) that holds a preferred range-band
  (advance/retreat/hold via EnemyAIMath.BandVelocity) and fires a telegraphed,
  dodgeable EnemyProjectile. New server EnemyProjectileMoveSystem (integrate +
  store LastStep) + EnemyProjectileDamageSystem (region-filtered swept hit-test
  rebuilt from LastStep — DR-018 anti-tunnelling; players use HitRadius, structures
  a const radius; at-most-once destroy). Concurrent-spit soft cap, soft-fail retry.
- Swarmer: marker tag + deterministic cluster spawn (1 slot = 1 pack;
  EnemyAIMath.ClusterOffset), MaxAlive counts ENTITIES so a pack defers if it
  won't fit.
- 4-type weighted mix: MixBands -> ZoneEnemyMath.WaveSlots/KindForSlot/
  PackSizeForSlot drives both the expedition director and (fork-4a) the base siege,
  with a mandatory MaxAlive cap. Legacy WaveSize/IsChargerSlot kept + parity-tested.
- Discriminator stays component-presence (no enum in Bursted systems): query-
  partition guards keep each enemy moved by exactly one EnemyAISystem pass
  (sole-Position-writer). EnemyTelegraph.IsCharger -> Kind byte for the client cue.

New authoring (Spitter/Swarmer/EnemyProjectile) + expanded director authorings with
tunable mix/cluster defaults. 13 new EditMode tests (mix composition + legacy parity,
band/cluster math, projectile move + cross-region + swept anti-tunnelling regressions);
full suite green before commit.

Dormant until the prefab/subscene wiring lands (next): the new systems guard on
TryGetSingleton/RequireForUpdate, so with no prefabs wired the new types stay inert.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 20:06:56 -07:00

92 lines
4.4 KiB
C#

using NUnit.Framework;
using ProjectM.Simulation;
namespace ProjectM.Tests
{
/// <summary>
/// MC-2 pure-math tests for the 4-type weighted composition (ZoneEnemyMath.WaveSlots / KindForSlot /
/// PackSizeForSlot) shared by both enemy directors. Deterministic integer math (no ECS world). The PARITY test
/// pins that the legacy band reproduces the old 2-type WaveSize/IsChargerSlot EXACTLY, so the base-siege size +
/// composition is provably controlled where it must be (the fork-4a safety net).
/// </summary>
public class ZoneEnemyMixTests
{
static MixBands Bands(int g, int c, int sp, int sw, int cPer, int spPer, int swPer, int packPer = 0) => new MixBands
{
GruntBase = g, ChargerBase = c, SpitterBase = sp, SwarmerSlotBase = sw,
ChargerPerEpoch = cPer, SpitterPerEpoch = spPer, SwarmerSlotPerEpoch = swPer, SwarmerPackPerEpoch = packPer,
};
[Test]
public void WaveSlots_LowerBoundedAtOne_AndSumsTheBands()
{
Assert.AreEqual(1, ZoneEnemyMath.WaveSlots(1, Bands(0, 0, 0, 0, 0, 0, 0)), "empty band still yields a fight");
Assert.AreEqual(5, ZoneEnemyMath.WaveSlots(1, Bands(4, 1, 0, 0, 1, 0, 0)), "4 grunts + 1 charger at epoch 1");
Assert.AreEqual(7, ZoneEnemyMath.WaveSlots(3, Bands(4, 1, 0, 0, 1, 0, 0)), "epoch 3: +1 charger/epoch -> 4+(1+2)");
Assert.AreEqual(4 + 2 + 1 + 1, ZoneEnemyMath.WaveSlots(2, Bands(4, 1, 0, 0, 1, 1, 1)), "epoch 2: 4 grunts + 2 chargers + 1 spitter + 1 swarmer-slot");
}
[Test]
public void KindForSlot_Deterministic()
{
var b = Bands(4, 1, 1, 1, 1, 1, 1);
for (int slot = 0; slot < 30; slot++)
Assert.AreEqual(ZoneEnemyMath.KindForSlot(5, slot, b), ZoneEnemyMath.KindForSlot(5, slot, b), "stable per (epoch,slot)");
}
[Test]
public void KindForSlot_GruntFloorFixed_ThreatsGrowWithEpoch()
{
var b = Bands(4, 1, 0, 0, 1, 0, 0); // grunts fixed at 4, chargers grow
CountKinds(b, 1, out int g1, out int c1, out int _, out int _);
Assert.AreEqual(4, g1); Assert.AreEqual(1, c1);
CountKinds(b, 5, out int g5, out int c5, out int _, out int _);
Assert.AreEqual(4, g5, "grunt count is a fixed floor"); Assert.AreEqual(5, c5, "chargers = base + (epoch-1)");
}
[Test]
public void KindForSlot_ParityWithLegacyIsChargerSlot()
{
for (int g = 0; g <= 6; g++)
for (int c = 0; c <= 4; c++)
for (int e = 1; e <= 6; e++)
{
var b = Bands(g, c, 0, 0, 1, 0, 0); // legacy band: charger ramps +1/epoch, no spitter/swarmer
int size = ZoneEnemyMath.WaveSlots(e, b);
Assert.AreEqual(ZoneEnemyMath.WaveSize(e, g, c), size, $"WaveSlots vs WaveSize g{g} c{c} e{e}");
for (int slot = 0; slot < size + 3; slot++)
{
bool legacy = ZoneEnemyMath.IsChargerSlot(e, slot, g, c);
bool now = ZoneEnemyMath.KindForSlot(e, slot, b) == ZoneEnemyMath.KindCharger;
Assert.AreEqual(legacy, now, $"parity g{g} c{c} e{e} slot{slot}");
}
}
}
[Test]
public void PackSizeForSlot_FixedByDefault_RampsWhenSet()
{
var fixedBand = Bands(0, 0, 0, 1, 0, 0, 1);
Assert.AreEqual(4, ZoneEnemyMath.PackSizeForSlot(1, 0, fixedBand, 4), "base pack");
Assert.AreEqual(4, ZoneEnemyMath.PackSizeForSlot(5, 0, fixedBand, 4), "no ramp -> fixed across epochs");
var rampBand = Bands(0, 0, 0, 1, 0, 0, 1, packPer: 2);
Assert.AreEqual(4 + 2 * 2, ZoneEnemyMath.PackSizeForSlot(3, 0, rampBand, 4), "epoch 3 ramp +2*(3-1)");
Assert.GreaterOrEqual(ZoneEnemyMath.PackSizeForSlot(1, 0, fixedBand, 0), 1, "lower-bounded at 1");
}
static void CountKinds(MixBands b, int epoch, out int g, out int c, out int sp, out int sw)
{
g = c = sp = sw = 0;
int size = ZoneEnemyMath.WaveSlots(epoch, b);
for (int slot = 0; slot < size; slot++)
{
byte k = ZoneEnemyMath.KindForSlot(epoch, slot, b);
if (k == ZoneEnemyMath.KindGrunt) g++;
else if (k == ZoneEnemyMath.KindCharger) c++;
else if (k == ZoneEnemyMath.KindSpitter) sp++;
else sw++;
}
}
}
}