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>
110 lines
4.8 KiB
C#
110 lines
4.8 KiB
C#
using NUnit.Framework;
|
|
using ProjectM.Server;
|
|
using ProjectM.Simulation;
|
|
using Unity.Core;
|
|
using Unity.Entities;
|
|
using Unity.Mathematics;
|
|
using Unity.Transforms;
|
|
|
|
namespace ProjectM.Tests
|
|
{
|
|
/// <summary>
|
|
/// Plain-Entities EditMode tests for the once-per-epoch zone-clear reward folded into
|
|
/// <see cref="ExpeditionGateSystem"/> (DR-040 BLOCKER 4). A returning player banks flat Ore to the shared ledger
|
|
/// IFF this epoch's expedition wave was actually cleared and not yet rewarded — and never twice for the same
|
|
/// epoch (the co-op same-tick / gate-re-entry de-dup).
|
|
/// </summary>
|
|
public class ExpeditionGateRewardTests
|
|
{
|
|
static (World world, SimulationSystemGroup group, Entity cycle) MakeWorld(string name,
|
|
int epoch, byte clearedThisEpoch, int lastRewardedEpoch)
|
|
{
|
|
var world = new World(name);
|
|
var group = world.GetOrCreateSystemManaged<SimulationSystemGroup>();
|
|
group.AddSystemToUpdateList(world.GetOrCreateSystem<ExpeditionGateSystem>());
|
|
group.SortSystems();
|
|
world.SetTime(new TimeData(elapsedTime: 0f, deltaTime: 1f / 60f));
|
|
var em = world.EntityManager;
|
|
|
|
// CycleDirector-like entity: cycle state/runtime + the shared resource ledger + threat state.
|
|
var cyc = em.CreateEntity(typeof(CycleState), typeof(CycleRuntime), typeof(ResourceLedger), typeof(ThreatState));
|
|
em.SetComponentData(cyc, new CycleState { Phase = CyclePhase.Calm });
|
|
em.SetComponentData(cyc, new CycleRuntime
|
|
{
|
|
ExpeditionEpoch = epoch, ClearedThisEpoch = clearedThisEpoch, LastRewardedEpoch = lastRewardedEpoch,
|
|
});
|
|
em.AddBuffer<StorageEntry>(cyc);
|
|
|
|
// Zone-enemy director singleton (only RewardOre matters to the reward fold).
|
|
var dir = em.CreateEntity(typeof(ZoneEnemyDirector));
|
|
em.SetComponentData(dir, new ZoneEnemyDirector { RewardOre = 25 });
|
|
|
|
// A gate Expedition->Base sitting at the expedition origin.
|
|
var gate = em.CreateEntity(typeof(ExpeditionGate), typeof(LocalTransform));
|
|
em.SetComponentData(gate, new ExpeditionGate
|
|
{
|
|
FromRegion = RegionId.Expedition, ToRegion = RegionId.Base, Radius = 3f, ArrivalPos = new float3(0, 1, 0),
|
|
});
|
|
em.SetComponentData(gate, LocalTransform.FromPosition(new float3(1000, 1, 0)));
|
|
|
|
return (world, group, cyc);
|
|
}
|
|
|
|
static Entity MakeExpeditionPlayerAtGate(EntityManager em)
|
|
{
|
|
var e = em.CreateEntity();
|
|
em.AddComponentData(e, new RegionTag { Region = RegionId.Expedition });
|
|
em.AddComponentData(e, LocalTransform.FromPosition(new float3(1000, 1, 0)));
|
|
em.AddComponent<PlayerTag>(e);
|
|
return e;
|
|
}
|
|
|
|
static int OreInLedger(EntityManager em, Entity cyc)
|
|
{
|
|
var buf = em.GetBuffer<StorageEntry>(cyc);
|
|
for (int i = 0; i < buf.Length; i++)
|
|
if (buf[i].ItemId == (ushort)ResourceId.Ore) return buf[i].Count;
|
|
return 0;
|
|
}
|
|
|
|
[Test]
|
|
public void Cleared_Return_Banks_Ore_Once()
|
|
{
|
|
var (world, group, cyc) = MakeWorld("GateRewardOnce", epoch: 1, clearedThisEpoch: 1, lastRewardedEpoch: 0);
|
|
using (world)
|
|
{
|
|
var em = world.EntityManager;
|
|
var player = MakeExpeditionPlayerAtGate(em);
|
|
|
|
group.Update(); // player walks the gate back to base -> reward
|
|
|
|
Assert.AreEqual(25, OreInLedger(em, cyc), "a cleared return banks RewardOre to the shared ledger");
|
|
Assert.AreEqual(1, em.GetComponentData<CycleRuntime>(cyc).LastRewardedEpoch, "the epoch is marked rewarded");
|
|
|
|
// Force a second same-epoch return (the player is back in the expedition at the gate).
|
|
em.SetComponentData(player, new RegionTag { Region = RegionId.Expedition });
|
|
em.SetComponentData(player, LocalTransform.FromPosition(new float3(1000, 1, 0)));
|
|
|
|
group.Update(); // returns again, but the epoch was already rewarded
|
|
|
|
Assert.AreEqual(25, OreInLedger(em, cyc), "the same epoch never pays twice (co-op / re-entry de-dup)");
|
|
}
|
|
}
|
|
|
|
[Test]
|
|
public void Uncleared_Return_Banks_Nothing()
|
|
{
|
|
var (world, group, cyc) = MakeWorld("GateRewardUncleared", epoch: 1, clearedThisEpoch: 0, lastRewardedEpoch: 0);
|
|
using (world)
|
|
{
|
|
var em = world.EntityManager;
|
|
MakeExpeditionPlayerAtGate(em);
|
|
|
|
group.Update();
|
|
|
|
Assert.AreEqual(0, OreInLedger(em, cyc), "returning without clearing the wave banks nothing (no farming)");
|
|
}
|
|
}
|
|
}
|
|
}
|