3109b86d71
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>
113 lines
5.3 KiB
C#
113 lines
5.3 KiB
C#
using NUnit.Framework;
|
|
using ProjectM.Server;
|
|
using ProjectM.Simulation;
|
|
using Unity.Core;
|
|
using Unity.Entities;
|
|
using Unity.Mathematics;
|
|
using Unity.NetCode;
|
|
using Unity.Transforms;
|
|
|
|
namespace ProjectM.Tests
|
|
{
|
|
/// <summary>
|
|
/// Plain-Entities EditMode tests for the Husk attack TELEGRAPH (the 2-phase strike in EnemyAISystem). The
|
|
/// strike no longer fires instantly: when a Husk is first in-range + cooldown-ready it commits a wind-up
|
|
/// (<see cref="AttackWindup.WindUpUntilTick"/> = now + Tuning.AttackWindupTicks, replicated so the client can
|
|
/// cue it) and damages NOTHING; the strike lands only when the wind-up tick elapses, and leaving range
|
|
/// mid-wind-up cancels it. Server timing is fully headless (the replication + client cue are the Play check).
|
|
/// </summary>
|
|
public class TelegraphTests
|
|
{
|
|
static void SetServerTick(World world, uint tick)
|
|
{
|
|
var em = world.EntityManager;
|
|
using var q = em.CreateEntityQuery(typeof(NetworkTime));
|
|
Entity e = q.IsEmpty ? em.CreateEntity(typeof(NetworkTime)) : q.GetSingletonEntity();
|
|
em.SetComponentData(e, new NetworkTime { ServerTick = new NetworkTick(tick) });
|
|
}
|
|
|
|
static (World world, SimulationSystemGroup group) MakeWorld(string name, uint serverTick)
|
|
{
|
|
var world = new World(name);
|
|
var group = world.GetOrCreateSystemManaged<SimulationSystemGroup>();
|
|
group.AddSystemToUpdateList(world.GetOrCreateSystem<EnemyAISystem>());
|
|
group.SortSystems();
|
|
world.SetTime(new TimeData(elapsedTime: 0f, deltaTime: 1f / 60f));
|
|
SetServerTick(world, serverTick);
|
|
return (world, group);
|
|
}
|
|
|
|
static Entity MakePlayer(EntityManager em, float3 pos)
|
|
{
|
|
var e = em.CreateEntity();
|
|
em.AddComponentData(e, LocalTransform.FromPosition(pos));
|
|
em.AddComponentData(e, new Health { Current = 100f, Max = 100f });
|
|
em.AddComponent<PlayerTag>(e);
|
|
em.AddBuffer<DamageEvent>(e);
|
|
em.AddComponentData(e, new RegionTag { Region = RegionId.Base });
|
|
return e;
|
|
}
|
|
|
|
static Entity MakeHusk(EntityManager em, float3 pos)
|
|
{
|
|
var e = em.CreateEntity();
|
|
em.AddComponentData(e, LocalTransform.FromPosition(pos));
|
|
em.AddComponentData(e, new EnemyStats { MoveSpeed = 3f, AttackRange = 1.6f, AttackDamage = 8f, AttackCooldownTicks = 36 });
|
|
em.AddComponentData(e, new EnemyAttackCooldown { NextAttackTick = 0 });
|
|
em.AddComponentData(e, new KnockbackState());
|
|
em.AddComponentData(e, new AttackWindup());
|
|
em.AddComponent<EnemyTag>(e);
|
|
em.AddComponentData(e, new RegionTag { Region = RegionId.Base });
|
|
return e;
|
|
}
|
|
|
|
[Test]
|
|
public void Husk_Winds_Up_First_Then_Strikes_At_Expiry()
|
|
{
|
|
var (world, group) = MakeWorld("TelegraphStrike", 200);
|
|
using (world)
|
|
{
|
|
var em = world.EntityManager;
|
|
var player = MakePlayer(em, new float3(10, 1, 0));
|
|
var husk = MakeHusk(em, new float3(9, 1, 0)); // distance 1 < AttackRange 1.6 -> in range
|
|
|
|
group.Update(); // tick 200: begins the wind-up, deals NO damage yet
|
|
uint expected = TickUtil.NonZero(200 + (uint)Tuning.AttackWindupTicks);
|
|
Assert.AreEqual(expected, em.GetComponentData<AttackWindup>(husk).WindUpUntilTick,
|
|
"An in-range, ready Husk commits a wind-up until now + AttackWindupTicks.");
|
|
Assert.AreEqual(0, em.GetBuffer<DamageEvent>(player).Length, "No damage lands during the wind-up.");
|
|
|
|
SetServerTick(world, expected);
|
|
group.Update(); // wind-up elapsed -> strike lands
|
|
Assert.AreEqual(1, em.GetBuffer<DamageEvent>(player).Length, "The strike lands exactly when the wind-up elapses.");
|
|
Assert.AreEqual(0u, em.GetComponentData<AttackWindup>(husk).WindUpUntilTick, "The wind-up resets after the strike.");
|
|
Assert.AreNotEqual(0u, em.GetComponentData<EnemyAttackCooldown>(husk).NextAttackTick, "The strike cooldown is stamped.");
|
|
}
|
|
}
|
|
|
|
[Test]
|
|
public void Leaving_Range_Mid_WindUp_Cancels_The_Strike()
|
|
{
|
|
var (world, group) = MakeWorld("TelegraphCancel", 200);
|
|
using (world)
|
|
{
|
|
var em = world.EntityManager;
|
|
var player = MakePlayer(em, new float3(10, 1, 0));
|
|
var husk = MakeHusk(em, new float3(9, 1, 0));
|
|
|
|
group.Update(); // begins the wind-up
|
|
uint windTick = em.GetComponentData<AttackWindup>(husk).WindUpUntilTick;
|
|
Assert.AreNotEqual(0u, windTick);
|
|
|
|
// Player flees far out of range before the wind-up completes.
|
|
em.SetComponentData(player, LocalTransform.FromPosition(new float3(60, 1, 0)));
|
|
SetServerTick(world, windTick);
|
|
group.Update();
|
|
|
|
Assert.AreEqual(0, em.GetBuffer<DamageEvent>(player).Length, "Leaving range mid-wind-up cancels the strike.");
|
|
Assert.AreEqual(0u, em.GetComponentData<AttackWindup>(husk).WindUpUntilTick, "The cancelled wind-up is cleared.");
|
|
}
|
|
}
|
|
}
|
|
}
|