From cd607ae1564af36071b6ecc174029f14ca5766d1 Mon Sep 17 00:00:00 2001 From: Luis Gonzalez Date: Fri, 10 Jul 2026 19:41:47 -0700 Subject: [PATCH] =?UTF-8?q?Hazard:=20exploding=20barrels=20=E2=80=94=20fus?= =?UTF-8?q?ed=20explosive=20clutter,=20friendly-fire=20bait?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../Client/Presentation/DynamicLightSystem.cs | 25 +++ .../Presentation/WorldFeedbackSystem.cs | 36 +++- .../Server/Combat/HazardExplosionSystem.cs | 97 ++++++++++ .../Combat/HazardExplosionSystem.cs.meta | 2 + .../Server/Economy/ResourceHarvestSystem.cs | 29 ++- .../Scripts/Server/Economy/RoomFieldSystem.cs | 3 +- .../Scripts/Simulation/Combat/BarrelFuse.cs | 20 ++ .../Simulation/Combat/BarrelFuse.cs.meta | 2 + .../Simulation/Player/MeleeComboSystem.cs | 23 ++- Assets/_Project/Scripts/Simulation/Tuning.cs | 13 ++ .../EditMode/HazardExplosionSystemTests.cs | 176 ++++++++++++++++++ .../HazardExplosionSystemTests.cs.meta | 2 + 12 files changed, 415 insertions(+), 13 deletions(-) create mode 100644 Assets/_Project/Scripts/Server/Combat/HazardExplosionSystem.cs create mode 100644 Assets/_Project/Scripts/Server/Combat/HazardExplosionSystem.cs.meta create mode 100644 Assets/_Project/Scripts/Simulation/Combat/BarrelFuse.cs create mode 100644 Assets/_Project/Scripts/Simulation/Combat/BarrelFuse.cs.meta create mode 100644 Assets/_Project/Tests/EditMode/HazardExplosionSystemTests.cs create mode 100644 Assets/_Project/Tests/EditMode/HazardExplosionSystemTests.cs.meta diff --git a/Assets/_Project/Scripts/Client/Presentation/DynamicLightSystem.cs b/Assets/_Project/Scripts/Client/Presentation/DynamicLightSystem.cs index 73d8d52a9..3beef1e9a 100644 --- a/Assets/_Project/Scripts/Client/Presentation/DynamicLightSystem.cs +++ b/Assets/_Project/Scripts/Client/Presentation/DynamicLightSystem.cs @@ -41,6 +41,7 @@ namespace ProjectM.Client GameObject _root; readonly Dictionary _projectileLights = new Dictionary(); readonly Dictionary _nodeLights = new Dictionary(); + readonly Dictionary _barrelLights = new Dictionary(); readonly HashSet _seen = new HashSet(); readonly List _dead = new List(); readonly Stack _pool = new Stack(); @@ -123,6 +124,30 @@ namespace ProjectM.Client } PruneUnseen(_nodeLights); + // ---- explosive-barrel danger glow (BlightClutter.Variant==3): steady red, STROBING once the + // fuse is lit (replicated Remaining hits 0 while the barrel still exists) — the flee cue. + _seen.Clear(); + foreach (var (clutter, lt, entity) in SystemAPI.Query, RefRO>().WithEntityAccess()) + { + if (clutter.ValueRO.Variant != 3) continue; + _seen.Add(entity); + if (!_barrelLights.TryGetValue(entity, out var blight)) + { + if (_barrelLights.Count >= 16) continue; + blight = Rent(); + _barrelLights[entity] = blight; + } + bool fused = clutter.ValueRO.Remaining <= 0; + blight.color = new Color(1f, 0.28f, 0.12f); + blight.range = fused ? 6f : 4f; + blight.intensity = fused + ? 2.6f + 1.8f * Mathf.Abs(Mathf.Sin(UnityEngine.Time.time * 14f)) + : 1.1f; + var bp = lt.ValueRO.Position; + blight.transform.position = new Vector3(bp.x, bp.y + 0.9f, bp.z); + } + PruneUnseen(_barrelLights); + // ---- portal light (RoomExplore only) ---- bool portalOn = false; if (SystemAPI.TryGetSingleton(out var runInfo) diff --git a/Assets/_Project/Scripts/Client/Presentation/WorldFeedbackSystem.cs b/Assets/_Project/Scripts/Client/Presentation/WorldFeedbackSystem.cs index 560cb7cb5..54c899be6 100644 --- a/Assets/_Project/Scripts/Client/Presentation/WorldFeedbackSystem.cs +++ b/Assets/_Project/Scripts/Client/Presentation/WorldFeedbackSystem.cs @@ -25,7 +25,7 @@ namespace ProjectM.Client [UpdateInGroup(typeof(PresentationSystemGroup))] public partial class WorldFeedbackSystem : SystemBase { - struct Cache { public int Remaining; public float3 Pos; public bool IsClutter; public Color Tint; } + struct Cache { public int Remaining; public float3 Pos; public bool IsClutter; public Color Tint; public byte Variant; } readonly Dictionary _cache = new(); readonly HashSet _seen = new(); @@ -36,11 +36,13 @@ namespace ProjectM.Client ParticleSystem _clearFx; AudioClip _chipClip; AudioClip _clearClip; + AudioClip _boomClip; protected override void OnCreate() { _chipClip = MakeClip("harvest_chip", 900f, 1400f, 0.06f, 0.30f); _clearClip = MakeClip("clutter_clear", 420f, 90f, 0.22f, 0.50f); + _boomClip = MakeClip("barrel_boom", 140f, 40f, 0.45f, 0.65f, noise: true, decay: 6f); } protected override void OnStartRunning() @@ -82,7 +84,7 @@ namespace ProjectM.Client SystemAPI.Query, RefRO>().WithEntityAccess()) { _seen.Add(e); - Observe(e, node.ValueRO.Remaining, xf.ValueRO.Position, false, TintForResource(node.ValueRO.ResourceId), haveLocal && math.distancesq(xf.ValueRO.Position, localPos) <= WorldFeelConfig.ProximityRange * WorldFeelConfig.ProximityRange); + Observe(e, node.ValueRO.Remaining, xf.ValueRO.Position, false, TintForResource(node.ValueRO.ResourceId), 0, haveLocal && math.distancesq(xf.ValueRO.Position, localPos) <= WorldFeelConfig.ProximityRange * WorldFeelConfig.ProximityRange); } // Blight clutter — chip on damage. @@ -90,7 +92,7 @@ namespace ProjectM.Client SystemAPI.Query, RefRO>().WithEntityAccess()) { _seen.Add(e); - Observe(e, clutter.ValueRO.Remaining, xf.ValueRO.Position, true, WorldFeelConfig.WildTint, haveLocal && math.distancesq(xf.ValueRO.Position, localPos) <= WorldFeelConfig.ProximityRange * WorldFeelConfig.ProximityRange); + Observe(e, clutter.ValueRO.Remaining, xf.ValueRO.Position, true, WorldFeelConfig.WildTint, clutter.ValueRO.Variant, haveLocal && math.distancesq(xf.ValueRO.Position, localPos) <= WorldFeelConfig.ProximityRange * WorldFeelConfig.ProximityRange); } // Prune: a despawn = the server destroyed it (node depleted / clutter shattered). Gate on proximity so @@ -107,12 +109,26 @@ namespace ProjectM.Client var c = _cache[_stale[i]]; if (haveLocal && math.distancesq(c.Pos, localPos) <= rangeSq) { - EmitTinted(_clearFx, (Vector3)c.Pos + Vector3.up * 0.6f, WorldFeelConfig.ClearBurstCount, c.Tint); - PlayClip(_clearClip, (Vector3)c.Pos, WorldFeelConfig.ClearSfxVolume); - if (c.IsClutter) + if (c.IsClutter && c.Variant == 3 && c.Remaining <= 0) { - PrototypeCameraRig.PunchFov(WorldFeelConfig.ClearFovKick, 90f); - PrototypeCameraRig.AddShake(WorldFeelConfig.ClearShake); + // EXPLOSIVE detonation: only a hit-popped barrel reaches despawn with a cached + // Remaining of 0 (a teardown despawn still carries Remaining>0) — the review-hardened + // boom-vs-puff split, so portal-advance teardowns can never fire false booms. + EmitTinted(_clearFx, (Vector3)c.Pos + Vector3.up * 0.6f, WorldFeelConfig.ClearBurstCount * 4, new Color(3.4f, 1.4f, 0.3f)); + DynamicLightSystem.RequestFlash((Vector3)c.Pos, new Color(1f, 0.62f, 0.25f), 1.6f); + PlayClip(_boomClip, (Vector3)c.Pos, 0.8f); + PrototypeCameraRig.PunchFov(WorldFeelConfig.ClearFovKick * 2.2f, 90f); + PrototypeCameraRig.AddShake(WorldFeelConfig.ClearShake * 2.5f); + } + else + { + EmitTinted(_clearFx, (Vector3)c.Pos + Vector3.up * 0.6f, WorldFeelConfig.ClearBurstCount, c.Tint); + PlayClip(_clearClip, (Vector3)c.Pos, WorldFeelConfig.ClearSfxVolume); + if (c.IsClutter) + { + PrototypeCameraRig.PunchFov(WorldFeelConfig.ClearFovKick, 90f); + PrototypeCameraRig.AddShake(WorldFeelConfig.ClearShake); + } } } _cache.Remove(_stale[i]); @@ -120,7 +136,7 @@ namespace ProjectM.Client } } -void Observe(Entity e, int remaining, float3 pos, bool isClutter, Color tint, bool nearLocal) +void Observe(Entity e, int remaining, float3 pos, bool isClutter, Color tint, byte variant, bool nearLocal) { if (_cache.TryGetValue(e, out var prev) && remaining < prev.Remaining) { @@ -132,7 +148,7 @@ void Observe(Entity e, int remaining, float3 pos, bool isClutter, Color tint, bo if (WorldFeelConfig.ChipShake > 0f) PrototypeCameraRig.AddShake(WorldFeelConfig.ChipShake); } } - _cache[e] = new Cache { Remaining = remaining, Pos = pos, IsClutter = isClutter, Tint = tint }; + _cache[e] = new Cache { Remaining = remaining, Pos = pos, IsClutter = isClutter, Tint = tint, Variant = variant }; } static Color TintForResource(byte resourceId) diff --git a/Assets/_Project/Scripts/Server/Combat/HazardExplosionSystem.cs b/Assets/_Project/Scripts/Server/Combat/HazardExplosionSystem.cs new file mode 100644 index 000000000..4a99b0126 --- /dev/null +++ b/Assets/_Project/Scripts/Server/Combat/HazardExplosionSystem.cs @@ -0,0 +1,97 @@ +using ProjectM.Simulation; +using Unity.Burst; +using Unity.Collections; +using Unity.Entities; +using Unity.Mathematics; +using Unity.NetCode; +using Unity.Transforms; + +namespace ProjectM.Server +{ + /// + /// Detonates lit-fuse EXPLOSIVE barrels (Exploding_Barrels_Build_Spec, design-review wf_2cb10454-fdf): + /// when a 's ExplodeTick elapses, radius-damage LIVING enemies AND players + /// (friendly fire = the bait mechanic), then the single DestroyEntity. Player gather mirrors the + /// BossAISystem slam filter VERBATIM (Health > 0 + RegionTag == Expedition — a dead player lying in + /// radius gets nothing; a review-confirmed precedent). is stamped at + /// DETONATION (the authoring moment — dash i-frames negate against it, honouring the DamageEvent + /// authored-tick contract) and = -1 (the environment + /// convention: a player id would score+consume Charger whiff-punish windows). Plain server + /// , NO ordering edges; presence-gated on BarrelFuse only — NEVER + /// lifecycle-gated (a fuse must always drain; it rides the RoomTag'd barrel, so room teardown / the + /// Staging sweep clean unexploded barrels for free). An invalid NetworkTime skips the tick; the fuse + /// persists. + /// + [BurstCompile] + [WorldSystemFilter(WorldSystemFilterFlags.ServerSimulation)] + [UpdateInGroup(typeof(SimulationSystemGroup))] + public partial struct HazardExplosionSystem : ISystem + { + [BurstCompile] + public void OnCreate(ref SystemState state) + { + state.RequireForUpdate(); + } + + [BurstCompile] + public void OnUpdate(ref SystemState state) + { + if (!SystemAPI.TryGetSingleton(out var netTime) || !netTime.ServerTick.IsValid) + return; + var serverTick = netTime.ServerTick; + + var ecb = new EntityCommandBuffer(Allocator.Temp); + var blasts = new NativeList(Allocator.Temp); + foreach (var (fuse, lt, barrel) in + SystemAPI.Query, RefRO>().WithEntityAccess()) + { + var until = new NetworkTick(fuse.ValueRO.ExplodeTick); + if (until.IsValid && until.IsNewerThan(serverTick)) continue; // still burning + blasts.Add(lt.ValueRO.Position); + ecb.DestroyEntity(barrel); // the single destroy site for a popped explosive + } + + if (blasts.Length > 0) + { + float radiusSq = Tuning.BarrelExplodeRadius * Tuning.BarrelExplodeRadius; + uint stamp = TickUtil.NonZero(serverTick.TickIndexForValidTick); + + // Players: the BossAISystem slam gather verbatim — living + expedition only. + foreach (var (hp, region, plt, player) in + SystemAPI.Query, RefRO, RefRO>() + .WithAll().WithEntityAccess()) + { + if (hp.ValueRO.Current <= 0f || region.ValueRO.Region != RegionId.Expedition) continue; + for (int i = 0; i < blasts.Length; i++) + if (math.distancesq(plt.ValueRO.Position.xz, blasts[i].xz) <= radiusSq) + ecb.AppendToBuffer(player, new DamageEvent + { + Amount = Tuning.BarrelExplodeDamage, + SourceNetworkId = -1, + SourceTick = stamp, + }); + } + + // Enemies: living only (Health > 0 also excludes Dying corpses, the established convention). + foreach (var (hp, elt, enemy) in + SystemAPI.Query, RefRO>() + .WithAll().WithEntityAccess()) + { + if (hp.ValueRO.Current <= 0f) continue; + for (int i = 0; i < blasts.Length; i++) + if (math.distancesq(elt.ValueRO.Position.xz, blasts[i].xz) <= radiusSq) + ecb.AppendToBuffer(enemy, new DamageEvent + { + Amount = Tuning.BarrelExplodeDamage, + SourceNetworkId = -1, + SourceTick = stamp, + }); + } + } + + ecb.Playback(state.EntityManager); + ecb.Dispose(); + blasts.Dispose(); + } + } +} diff --git a/Assets/_Project/Scripts/Server/Combat/HazardExplosionSystem.cs.meta b/Assets/_Project/Scripts/Server/Combat/HazardExplosionSystem.cs.meta new file mode 100644 index 000000000..a774b87d9 --- /dev/null +++ b/Assets/_Project/Scripts/Server/Combat/HazardExplosionSystem.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: cf7e54d631fbfc54aa3aaa08f9217641 \ No newline at end of file diff --git a/Assets/_Project/Scripts/Server/Economy/ResourceHarvestSystem.cs b/Assets/_Project/Scripts/Server/Economy/ResourceHarvestSystem.cs index ed036a2f3..9b2eadc39 100644 --- a/Assets/_Project/Scripts/Server/Economy/ResourceHarvestSystem.cs +++ b/Assets/_Project/Scripts/Server/Economy/ResourceHarvestSystem.cs @@ -51,6 +51,11 @@ namespace ProjectM.Server public void OnUpdate(ref SystemState state) { var ledgerEntity = SystemAPI.GetSingletonEntity(); + // Fuse scheduling needs the server tick; absent/invalid (plain test worlds) -> explosive pops fall + // back to plain destroy (old behaviour). + bool haveTick = SystemAPI.TryGetSingleton(out var hvNetTime) + && hvNetTime.ServerTick.IsValid; + uint nowTick = haveTick ? hvNetTime.ServerTick.TickIndexForValidTick : 0u; var ledger = SystemAPI.GetBuffer(ledgerEntity); // Resolve the harvesting player from the projectile's GhostOwner so yield lands in their PERSONAL @@ -80,6 +85,8 @@ namespace ProjectM.Server foreach (var (xform, hr, node, e) in SystemAPI.Query, RefRO, RefRO>().WithEntityAccess()) { + if (node.ValueRO.Remaining <= 0) continue; // spent (a lit fuse) is not a target + tgtEntity.Add(e); tgtPos.Add(xform.ValueRO.Position.xz); tgtRadius.Add(hr.ValueRO.Value); @@ -94,6 +101,8 @@ namespace ProjectM.Server foreach (var (xform, hr, clutter, e) in SystemAPI.Query, RefRO, RefRO>().WithEntityAccess()) { + if (clutter.ValueRO.Remaining <= 0) continue; // lit-fuse barrel: unhittable, detonation owns it + tgtEntity.Add(e); tgtPos.Add(xform.ValueRO.Position.xz); tgtRadius.Add(hr.ValueRO.Value); @@ -164,7 +173,25 @@ namespace ProjectM.Server if (!destroyed[bestIdx]) { destroyed[bestIdx] = true; - ecb.DestroyEntity(tgtEntity[bestIdx]); + if (tgtIsClutter[bestIdx] && tgtVariant[bestIdx] == 3 && haveTick) + { + // EXPLOSIVE pop (Variant 3): light the fuse instead of destroying — the replicated + // Remaining=0 on a still-alive barrel is the client's unambiguous fuse cue; + // HazardExplosionSystem detonates + destroys (Exploding_Barrels_Build_Spec). + SystemAPI.SetComponent(tgtEntity[bestIdx], new BlightClutter + { + Remaining = 0, + Variant = tgtVariant[bestIdx], + ScrapResourceId = tgtYieldId[bestIdx], + ScrapPerHit = tgtYieldPerHit[bestIdx], + }); + ecb.AddComponent(tgtEntity[bestIdx], new BarrelFuse + { + ExplodeTick = TickUtil.NonZero(nowTick + Tuning.BarrelFuseTicks), + }); + } + else + ecb.DestroyEntity(tgtEntity[bestIdx]); } } else if (tgtIsClutter[bestIdx]) diff --git a/Assets/_Project/Scripts/Server/Economy/RoomFieldSystem.cs b/Assets/_Project/Scripts/Server/Economy/RoomFieldSystem.cs index ae5676f9a..4f8160538 100644 --- a/Assets/_Project/Scripts/Server/Economy/RoomFieldSystem.cs +++ b/Assets/_Project/Scripts/Server/Economy/RoomFieldSystem.cs @@ -121,7 +121,8 @@ namespace ProjectM.Server float3 pos = RoomLayoutMath.ScatterInShape(plan.ShapeId, origin, i, cCount, ref crng); ecb.SetComponent(e, cBaked.WithPosition(pos)); var bc = cProto; - bc.Variant = (byte)(i % 3); + // ~25% EXPLOSIVE (Variant 3, the hazard — Exploding_Barrels_Build_Spec); rest stay cosmetic 0-2. + bc.Variant = crng.NextFloat() < 0.25f ? (byte)3 : (byte)(i % 3); ecb.SetComponent(e, bc); ecb.AddComponent(e, new RoomTag { Room = room }); } diff --git a/Assets/_Project/Scripts/Simulation/Combat/BarrelFuse.cs b/Assets/_Project/Scripts/Simulation/Combat/BarrelFuse.cs new file mode 100644 index 000000000..d55678349 --- /dev/null +++ b/Assets/_Project/Scripts/Simulation/Combat/BarrelFuse.cs @@ -0,0 +1,20 @@ +using Unity.Entities; + +namespace ProjectM.Simulation +{ + /// + /// Server-only lit-fuse marker on an EXPLOSIVE clutter barrel (BlightClutter.Variant==3) that has been + /// popped: the pop sites zero the barrel's replicated Remaining (the client's unambiguous fuse cue — + /// a still-alive barrel with Remaining==0 can only mean "about to blow"; a room-teardown despawn still + /// carries Remaining>0, so the client's boom-vs-puff choice at despawn cannot misfire) and add this + /// component instead of destroying. HazardExplosionSystem detonates when + /// elapses: radius damage to LIVING enemies AND players (friendly fire = the bait mechanic), then the + /// single DestroyEntity. Never replicated; the fuse rides the RoomTag'd barrel, so room teardown / the + /// Staging sweep clean lit barrels for free (no orphaned-event lifecycle). + /// + public struct BarrelFuse : IComponentData + { + /// Server tick the barrel detonates (via TickUtil.NonZero — 0 never means "ready"). + public uint ExplodeTick; + } +} diff --git a/Assets/_Project/Scripts/Simulation/Combat/BarrelFuse.cs.meta b/Assets/_Project/Scripts/Simulation/Combat/BarrelFuse.cs.meta new file mode 100644 index 000000000..9407427fb --- /dev/null +++ b/Assets/_Project/Scripts/Simulation/Combat/BarrelFuse.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: c677948826634c44fb355a4e00d7c5df \ No newline at end of file diff --git a/Assets/_Project/Scripts/Simulation/Player/MeleeComboSystem.cs b/Assets/_Project/Scripts/Simulation/Player/MeleeComboSystem.cs index c26f07735..1052ddb2f 100644 --- a/Assets/_Project/Scripts/Simulation/Player/MeleeComboSystem.cs +++ b/Assets/_Project/Scripts/Simulation/Player/MeleeComboSystem.cs @@ -206,6 +206,8 @@ namespace ProjectM.Simulation foreach (var (hx, node, he) in SystemAPI.Query, RefRO>().WithEntityAccess()) { + if (node.ValueRO.Remaining <= 0) continue; // spent is not a target + harvEntity.Add(he); harvPos.Add(hx.ValueRO.Position); harvRemaining.Add(node.ValueRO.Remaining); @@ -218,6 +220,8 @@ namespace ProjectM.Simulation foreach (var (hx, clutter, he) in SystemAPI.Query, RefRO>().WithEntityAccess()) { + if (clutter.ValueRO.Remaining <= 0) continue; // lit-fuse barrel: unhittable, detonation owns it + harvEntity.Add(he); harvPos.Add(hx.ValueRO.Position); harvRemaining.Add(clutter.ValueRO.Remaining); @@ -284,7 +288,24 @@ namespace ProjectM.Simulation if (rem <= 0) { harvDestroyed[i] = true; - ecb.DestroyEntity(harvEntity[i]); + if (harvIsClutter[i] && harvVariant[i] == 3) + { + // EXPLOSIVE pop (Variant 3): light the fuse instead of destroying — mirrors the + // projectile pop site; HazardExplosionSystem detonates (Exploding_Barrels_Build_Spec). + ecb.SetComponent(harvEntity[i], new BlightClutter + { + Remaining = 0, + Variant = harvVariant[i], + ScrapResourceId = harvYieldId[i], + ScrapPerHit = harvPerHit[i], + }); + ecb.AddComponent(harvEntity[i], new BarrelFuse + { + ExplodeTick = TickUtil.NonZero(now + Tuning.BarrelFuseTicks), + }); + } + else + ecb.DestroyEntity(harvEntity[i]); } else if (harvIsClutter[i]) { diff --git a/Assets/_Project/Scripts/Simulation/Tuning.cs b/Assets/_Project/Scripts/Simulation/Tuning.cs index 2bc3a859f..3614ca2c8 100644 --- a/Assets/_Project/Scripts/Simulation/Tuning.cs +++ b/Assets/_Project/Scripts/Simulation/Tuning.cs @@ -107,6 +107,19 @@ namespace ProjectM.Simulation /// first (counted at spawn; Health.Max replicates so the bar stays truthful). public const float BossHealthPerExtraPlayer = 0.75f; + // ---- Exploding barrels (Phase 1.5 hazard; server-only detonation consts — see Exploding_Barrels_Build_Spec) ---- + + /// Fuse ticks between the pop (Remaining hits 0) and detonation (~0.6 s @60): long enough for the + /// popper to step out + for the replicated Remaining=0 to straddle snapshots (the client's fuse cue). + public const uint BarrelFuseTicks = 36; + + /// Detonation radius (world units, XZ). Above melee reach so point-blank pops are punished, but a + /// fuse-length walk clears it comfortably. + public const float BarrelExplodeRadius = 3.25f; + + /// Detonation damage — hits LIVING enemies AND players alike (friendly fire = the bait mechanic). + public const float BarrelExplodeDamage = 26f; + // ---- Expedition BOSS (a scaled Charger given a real kit by BossAISystem; server-only feel consts) ---- /// Radial SLAM AoE radius (world units): a player inside this ring at wind-up elapse eats the hit diff --git a/Assets/_Project/Tests/EditMode/HazardExplosionSystemTests.cs b/Assets/_Project/Tests/EditMode/HazardExplosionSystemTests.cs new file mode 100644 index 000000000..ccba6f88b --- /dev/null +++ b/Assets/_Project/Tests/EditMode/HazardExplosionSystemTests.cs @@ -0,0 +1,176 @@ +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 +{ + /// + /// Plain-Entities EditMode tests for (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. + /// + public class HazardExplosionSystemTests + { + const uint T0 = 1000; + + static (World world, SimulationSystemGroup group) MakeWorld(string name) + { + var world = new World(name); + var group = world.GetOrCreateSystemManaged(); + group.AddSystemToUpdateList(world.GetOrCreateSystem()); + 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(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(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(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(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(deadPlayer).Length, "A dead player in radius gets NOTHING (the boss-slam filter)."); + Assert.AreEqual(0, em.GetBuffer(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(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(); + group.AddSystemToUpdateList(world.GetOrCreateSystem()); + group.SortSystems(); + world.SetTime(new TimeData(elapsedTime: 0f, deltaTime: 1f / 60f)); + var em = world.EntityManager; + var ledger = em.CreateEntity(typeof(ResourceLedger)); + em.AddBuffer(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(barrel).Remaining, "Remaining=0 is the replicated fuse cue."); + Assert.IsTrue(em.HasComponent(barrel), "The fuse component schedules detonation."); + Assert.AreEqual(TickUtil.NonZero(T0 + Tuning.BarrelFuseTicks), em.GetComponentData(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."); + } + } + } +} diff --git a/Assets/_Project/Tests/EditMode/HazardExplosionSystemTests.cs.meta b/Assets/_Project/Tests/EditMode/HazardExplosionSystemTests.cs.meta new file mode 100644 index 000000000..6305419d2 --- /dev/null +++ b/Assets/_Project/Tests/EditMode/HazardExplosionSystemTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: b4d2d1e676cddf54f9205fc04b92035f \ No newline at end of file