cd607ae156
Phase 1.5 environmental hazard v1 (design-review wf_2cb10454-fdf: 13 findings confirmed + folded; Build Spec in the vault). ~25% of room clutter seeds as EXPLOSIVE (Variant 3 on the existing replicated byte - zero new GhostFields/prefabs/RPCs). The FUSE is the review's fold - one mechanism closes both HIGHs: - Pop sites (projectile sweep + isServer-gated melee harvest) do NOT destroy an explosive: they zero the replicated Remaining + add a server-only BarrelFuse (~36 ticks). The client sees Remaining=0 on a still-alive barrel across snapshots -> unambiguous fuse cue, and booms at despawn ONLY when cached Remaining<=0 - a teardown despawn carries Remaining>0, so portal-advance teardowns can never fire false booms (HIGH #1). - HazardExplosionSystem (server, plain group, presence-gated on BarrelFuse, never lifecycle-gated) detonates at ExplodeTick: radius damage to LIVING enemies AND players (boss-slam player filter verbatim; friendly fire = the bait mechanic), SourceTick stamped at detonation = the authoring moment (HIGH #2: the authored-tick contract dash i-frames negate against), SourceNetworkId=-1 (the environment convention - a player id would consume Charger whiff-punish windows). Fuse rides the RoomTag'd barrel -> teardown cleans lit barrels free. - Lit barrels are unhittable at both snapshot sites (no double-pop). - Client: WorldFeedback caches Variant -> boom-vs-puff split (big burst + light flash + boom SFX vs the old puff); DynamicLightSystem gives Variant-3 barrels a red danger glow that STROBES once fused. Verified: 470/470 EditMode (4 new: fused pop instead of destroy + unhittable, both-sides damage w/ -1 source + authored tick, dead-player + radius filters, unelapsed-fuse inert); live smoke - seeded 3/8 explosive, real-sweep pop, CLIENT observed the fuse cue on the live ghost, pinned bait enemy took exactly 26 (30->4), walk-out escape confirmed; console clean. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
177 lines
8.8 KiB
C#
177 lines
8.8 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 <see cref="HazardExplosionSystem"/> (Exploding_Barrels_Build_Spec).
|
|
/// Pins the review-hardened contract: an ELAPSED fuse radius-damages LIVING enemies AND players
|
|
/// (friendly fire) with SourceNetworkId = -1 (the environment convention — a player id would score
|
|
/// Charger whiff-punishes) and destroys the barrel exactly once; a dead player in radius gets NOTHING
|
|
/// (the BossAISystem slam filter, copied verbatim); out-of-radius targets get nothing; an UN-ELAPSED
|
|
/// fuse does nothing. Also pins the pop-site half via ResourceHarvestSystem: an explosive (Variant 3)
|
|
/// clutter pop lights the fuse (Remaining -> 0, BarrelFuse added, entity ALIVE) instead of destroying,
|
|
/// and a lit barrel is unhittable.
|
|
/// </summary>
|
|
public class HazardExplosionSystemTests
|
|
{
|
|
const uint T0 = 1000;
|
|
|
|
static (World world, SimulationSystemGroup group) MakeWorld(string name)
|
|
{
|
|
var world = new World(name);
|
|
var group = world.GetOrCreateSystemManaged<SimulationSystemGroup>();
|
|
group.AddSystemToUpdateList(world.GetOrCreateSystem<HazardExplosionSystem>());
|
|
group.SortSystems();
|
|
world.SetTime(new TimeData(elapsedTime: 0f, deltaTime: 1f / 60f));
|
|
var nt = world.EntityManager.CreateEntity(typeof(NetworkTime));
|
|
world.EntityManager.SetComponentData(nt, new NetworkTime { ServerTick = new NetworkTick(T0) });
|
|
return (world, group);
|
|
}
|
|
|
|
static Entity MakeBarrel(EntityManager em, float3 pos, uint explodeTick)
|
|
{
|
|
var e = em.CreateEntity();
|
|
em.AddComponentData(e, LocalTransform.FromPosition(pos));
|
|
em.AddComponentData(e, new BlightClutter { Remaining = 0, Variant = 3, ScrapResourceId = ResourceId.Biomass, ScrapPerHit = 2f });
|
|
em.AddComponentData(e, new BarrelFuse { ExplodeTick = explodeTick });
|
|
return e;
|
|
}
|
|
|
|
static Entity MakePlayer(EntityManager em, float3 pos, float health, byte region)
|
|
{
|
|
var e = em.CreateEntity();
|
|
em.AddComponentData(e, LocalTransform.FromPosition(pos));
|
|
em.AddComponentData(e, new PlayerTag());
|
|
em.AddComponentData(e, new Health { Current = health, Max = 130f });
|
|
em.AddComponentData(e, new RegionTag { Region = region });
|
|
em.AddBuffer<DamageEvent>(e);
|
|
return e;
|
|
}
|
|
|
|
static Entity MakeEnemy(EntityManager em, float3 pos, float health)
|
|
{
|
|
var e = em.CreateEntity();
|
|
em.AddComponentData(e, LocalTransform.FromPosition(pos));
|
|
em.AddComponentData(e, new EnemyTag());
|
|
em.AddComponentData(e, new Health { Current = health, Max = 30f });
|
|
em.AddBuffer<DamageEvent>(e);
|
|
return e;
|
|
}
|
|
|
|
[Test]
|
|
public void Elapsed_Fuse_Damages_Living_Player_And_Enemy_And_Destroys_Barrel()
|
|
{
|
|
var (world, group) = MakeWorld("BoomBoth");
|
|
using (world)
|
|
{
|
|
var em = world.EntityManager;
|
|
var barrel = MakeBarrel(em, new float3(10, 1, 10), explodeTick: T0); // due exactly now
|
|
var player = MakePlayer(em, new float3(11, 1, 10), health: 100f, region: RegionId.Expedition);
|
|
var enemy = MakeEnemy(em, new float3(10, 1, 12), health: 30f);
|
|
|
|
group.Update();
|
|
|
|
Assert.IsFalse(em.Exists(barrel), "An elapsed fuse destroys the barrel.");
|
|
var pd = em.GetBuffer<DamageEvent>(player);
|
|
Assert.AreEqual(1, pd.Length, "Friendly fire: the living expedition player in radius is hit.");
|
|
Assert.AreEqual(Tuning.BarrelExplodeDamage, pd[0].Amount);
|
|
Assert.AreEqual(-1, pd[0].SourceNetworkId, "Environment convention: never a player id (Charger punish scoring).");
|
|
Assert.AreNotEqual(0u, pd[0].SourceTick, "SourceTick stamped at detonation (the authored-tick contract).");
|
|
Assert.AreEqual(1, em.GetBuffer<DamageEvent>(enemy).Length, "The living enemy in radius is hit too (the bait mechanic).");
|
|
}
|
|
}
|
|
|
|
[Test]
|
|
public void Dead_Player_And_Out_Of_Radius_Targets_Get_Nothing()
|
|
{
|
|
var (world, group) = MakeWorld("BoomFilters");
|
|
using (world)
|
|
{
|
|
var em = world.EntityManager;
|
|
MakeBarrel(em, new float3(10, 1, 10), explodeTick: T0);
|
|
var deadPlayer = MakePlayer(em, new float3(10.5f, 1, 10), health: 0f, region: RegionId.Expedition);
|
|
var farEnemy = MakeEnemy(em, new float3(10 + Tuning.BarrelExplodeRadius + 1f, 1, 10), health: 30f);
|
|
|
|
group.Update();
|
|
|
|
Assert.AreEqual(0, em.GetBuffer<DamageEvent>(deadPlayer).Length, "A dead player in radius gets NOTHING (the boss-slam filter).");
|
|
Assert.AreEqual(0, em.GetBuffer<DamageEvent>(farEnemy).Length, "Out of radius gets nothing.");
|
|
}
|
|
}
|
|
|
|
[Test]
|
|
public void Unelapsed_Fuse_Does_Nothing()
|
|
{
|
|
var (world, group) = MakeWorld("BoomWait");
|
|
using (world)
|
|
{
|
|
var em = world.EntityManager;
|
|
var barrel = MakeBarrel(em, new float3(10, 1, 10), explodeTick: T0 + 30); // still burning
|
|
var player = MakePlayer(em, new float3(10.5f, 1, 10), health: 100f, region: RegionId.Expedition);
|
|
|
|
group.Update();
|
|
|
|
Assert.IsTrue(em.Exists(barrel), "A burning fuse leaves the barrel alive.");
|
|
Assert.AreEqual(0, em.GetBuffer<DamageEvent>(player).Length, "No damage before detonation.");
|
|
}
|
|
}
|
|
|
|
// ---- pop-site half (ResourceHarvestSystem drives the fuse, never a direct destroy) ----
|
|
|
|
static (World world, SimulationSystemGroup group) MakeHarvestWorld(string name)
|
|
{
|
|
var world = new World(name);
|
|
var group = world.GetOrCreateSystemManaged<SimulationSystemGroup>();
|
|
group.AddSystemToUpdateList(world.GetOrCreateSystem<ResourceHarvestSystem>());
|
|
group.SortSystems();
|
|
world.SetTime(new TimeData(elapsedTime: 0f, deltaTime: 1f / 60f));
|
|
var em = world.EntityManager;
|
|
var ledger = em.CreateEntity(typeof(ResourceLedger));
|
|
em.AddBuffer<StorageEntry>(ledger);
|
|
var nt = em.CreateEntity(typeof(NetworkTime));
|
|
em.SetComponentData(nt, new NetworkTime { ServerTick = new NetworkTick(T0) });
|
|
return (world, group);
|
|
}
|
|
|
|
[Test]
|
|
public void Explosive_Pop_Lights_The_Fuse_Instead_Of_Destroying_And_Is_Unhittable()
|
|
{
|
|
var (world, group) = MakeHarvestWorld("PopFuse");
|
|
using (world)
|
|
{
|
|
var em = world.EntityManager;
|
|
var barrel = em.CreateEntity();
|
|
em.AddComponentData(barrel, LocalTransform.FromPosition(new float3(10, 1, 10)));
|
|
em.AddComponentData(barrel, new HitRadius { Value = 1f });
|
|
em.AddComponentData(barrel, new BlightClutter { Remaining = 2, Variant = 3, ScrapResourceId = ResourceId.Biomass, ScrapPerHit = 2f });
|
|
var proj = em.CreateEntity();
|
|
em.AddComponentData(proj, LocalTransform.FromPosition(new float3(10, 1, 10)));
|
|
em.AddComponentData(proj, new Projectile { Direction = new float2(1, 0), LastStep = 5f });
|
|
|
|
group.Update();
|
|
|
|
Assert.IsTrue(em.Exists(barrel), "An explosive pop LIGHTS THE FUSE — the barrel survives the hit.");
|
|
Assert.AreEqual(0, em.GetComponentData<BlightClutter>(barrel).Remaining, "Remaining=0 is the replicated fuse cue.");
|
|
Assert.IsTrue(em.HasComponent<BarrelFuse>(barrel), "The fuse component schedules detonation.");
|
|
Assert.AreEqual(TickUtil.NonZero(T0 + Tuning.BarrelFuseTicks), em.GetComponentData<BarrelFuse>(barrel).ExplodeTick);
|
|
Assert.IsFalse(em.Exists(proj), "The popping projectile is consumed.");
|
|
|
|
// A lit barrel is unhittable: a second projectile flies through (no target on the segment).
|
|
var proj2 = em.CreateEntity();
|
|
em.AddComponentData(proj2, LocalTransform.FromPosition(new float3(10, 1, 10)));
|
|
em.AddComponentData(proj2, new Projectile { Direction = new float2(0, 1), LastStep = 5f });
|
|
group.Update();
|
|
Assert.IsTrue(em.Exists(proj2), "A lit-fuse barrel is not a harvest target — the shot passes through.");
|
|
Assert.IsTrue(em.Exists(barrel), "Still alive until detonation.");
|
|
}
|
|
}
|
|
}
|
|
}
|