LANTERN P1 Group B: 5-Spark systems — Aoe/Hitscan dispatch + reel/zone/decoy

Aoe/Hitscan archetype dispatch in AbilityFireSystem (both-worlds cooldown;
server-only ecb.Instantiate — the client-never-instantiates invariant; ZoneEffect
vs DecoyTag chosen by prefab membership). Harpooner REEL as a homing state
(ReelState + ReelSystem re-aim the hit Husk's KnockbackState toward the caster's
live position each tick; EnemyAISystem stays sole Position writer), stamped by
ProjectileDamageSystem on a Reel-flag hit. ZonePulseSystem (enemy-only periodic
AoE, caster attribution, no friendly fire, Vortex pull, born-0 lazy-stamp).
Decoy aggro in EnemyAISystem + DecoySystem despawn. AbilityDefBlob gains
EffectFlags (Reel) + DurationTicks. +3 ZonePulseSystem EditMode tests (497 green).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-15 00:00:41 -07:00
parent 8f070ff83c
commit 36950ca21d
24 changed files with 738 additions and 20 deletions
@@ -73,6 +73,17 @@ namespace ProjectM.Server
structurePositions.Add(sx.ValueRO.Position);
structureRegions.Add(sr.ValueRO.Region);
}
// LANTERN decoy-wisp aggro: a decoy draws Husks like a fake target. Region-agnostic gather (gym).
var decoyEntities = new NativeList<Entity>(Allocator.Temp);
var decoyPositions = new NativeList<float3>(Allocator.Temp);
foreach (var (dx, dh, de) in
SystemAPI.Query<RefRO<LocalTransform>, RefRO<Health>>()
.WithAll<DecoyTag>().WithEntityAccess())
{
if (dh.ValueRO.Current <= 0f) continue; // a spent decoy is being despawned; don't aggro a corpse
decoyEntities.Add(de);
decoyPositions.Add(dx.ValueRO.Position);
}
// END-1: the Engine Core is a FALLBACK target. When no living player/structure remains, undefended
// Husks march on the base heart (PlotCenter) so the base can be overrun instead of the swarm idling.
@@ -80,7 +91,7 @@ namespace ProjectM.Server
&& SystemAPI.TryGetSingleton<CoreIntegrity>(out var coreInteg) && coreInteg.Current > 0;
float3 corePos = coreAlive ? BaseGridMath.PlotCenter(SystemAPI.GetSingleton<BaseAnchor>()) : float3.zero;
if (playerEntities.Length == 0 && structureEntities.Length == 0 && !coreAlive)
if (playerEntities.Length == 0 && structureEntities.Length == 0 && decoyEntities.Length == 0 && !coreAlive)
{
playerEntities.Dispose();
playerPositions.Dispose();
@@ -88,12 +99,15 @@ namespace ProjectM.Server
structureEntities.Dispose();
structurePositions.Dispose();
structureRegions.Dispose();
decoyEntities.Dispose();
decoyPositions.Dispose();
return;
}
float dt = SystemAPI.Time.DeltaTime;
var serverTick = SystemAPI.GetSingleton<NetworkTime>().ServerTick;
uint now = serverTick.TickIndexForValidTick;
float decoyAggroSq = 30f * 30f; // LANTERN decoy-wisp aggro radius (world units, squared)
// Live feel knobs (MC-0): one read, guarded at use. Server-only — clients never simulate enemies.
var tune = SystemAPI.TryGetSingleton<TuningConfig>(out var tcfg) ? tcfg : TuningConfig.Defaults();
float structAggro = math.max(0f, tune.StructureAggroWeight);
@@ -147,12 +161,30 @@ namespace ProjectM.Server
// EB-1 fortress aggro: nearest of players (weight 1) + structures (StructureAggroWeight) — a wall/
// turret is the preferred target unless a player is in the way (closer after weighting).
EnemyAIMath.PickWeightedNearest(pos, playerPositions, playerRegions, structurePositions, structureRegions, huskRegion, structAggro, out bool tgtIsStruct, out int tgtIdx);
if (tgtIdx < 0 && !huskCoreAlive)
continue; // no player/structure and no Core -> nothing to seek
Entity targetEntity = tgtIdx < 0 ? Entity.Null
: (tgtIsStruct ? structureEntities[tgtIdx] : playerEntities[tgtIdx]);
float3 targetPos = tgtIdx < 0 ? corePos
: (tgtIsStruct ? structurePositions[tgtIdx] : playerPositions[tgtIdx]);
// Decoy aggro (LANTERN): a decoy-wisp within range is the PREFERRED target -- overrides player/structure/core.
int decoyIdx = -1;
float decoyBestSq = decoyAggroSq;
for (int di = 0; di < decoyPositions.Length; di++)
{
float dsq = math.distancesq(pos.xz, decoyPositions[di].xz);
if (dsq <= decoyBestSq) { decoyBestSq = dsq; decoyIdx = di; }
}
Entity targetEntity;
float3 targetPos;
if (decoyIdx >= 0)
{
targetEntity = decoyEntities[decoyIdx];
targetPos = decoyPositions[decoyIdx];
}
else
{
if (tgtIdx < 0 && !huskCoreAlive)
continue; // no decoy, no player/structure, and no Core -> nothing to seek
targetEntity = tgtIdx < 0 ? Entity.Null
: (tgtIsStruct ? structureEntities[tgtIdx] : playerEntities[tgtIdx]);
targetPos = tgtIdx < 0 ? corePos
: (tgtIsStruct ? structurePositions[tgtIdx] : playerPositions[tgtIdx]);
}
// Seek: stop just inside strike range so the Husk holds position to attack.
float stopDistance = stats.ValueRO.AttackRange * 0.9f;
@@ -665,6 +697,8 @@ namespace ProjectM.Server
structureEntities.Dispose();
structurePositions.Dispose();
structureRegions.Dispose();
decoyEntities.Dispose();
decoyPositions.Dispose();
}
// Swept collide-and-slide for server-authoritative enemy movement — delegates to the shared