using ProjectM.Simulation;
using Unity.Burst;
using Unity.Collections;
using Unity.Entities;
using Unity.Mathematics;
using Unity.NetCode;
using Unity.Physics;
using Unity.Transforms;
namespace ProjectM.Server
{
///
/// Server-authoritative EXPEDITION BOSS brain — the SOLE mover/attacker of .WithAll<EnemyTag, BossState>()
/// (EnemyAISystem's Charger MOVE pass excludes it via .WithNone<BossState>(), so exactly one system
/// writes the boss's Position/Rotation/AttackWindup — the sole-writer invariant). Runs SERVER-ONLY in the plain
/// [UpdateAfter(EnemyAISystem)] (a linear chain, no sort cycle), once per
/// tick (interpolated ghost, no rollback → no Simulate filter, no IsFirstTimeFullyPredictingTick).
///
/// v2 boss = a real fight (operator-locked): chase the nearest living expedition player, then a telegraphed radial
/// SLAM — the client danger cue rides the replicated [GhostField] (CombatFeedbackSystem
/// draws a boss-scale ring). At/below HP it enters phase two: faster,
/// slams more often, and periodically summons swarmer adds. B4 (Phase 1): the boss ALSO lunges - a telegraphed
/// gap-closer on its own cooldown when the target sits outside slam reach; LungeState.UntilTick spans the
/// windup+travel so EnemyAISystem's IsLunging derive replicates the tell (the client suppresses the slam ring
/// off that bit), and BossState.PendingAttack (server-only byte) tells the shared windup-elapse branch WHICH
/// attack fires. Knockback-immune (the stamp
/// sites skip BossState; this system also clears any residual so nothing else can shove it). Summoned adds go
/// through so they carry the SAME ZoneEnemyTag/RoomTag/RegionTag stack the
/// room-clear gate + teardown depend on (dropping one would leak adds or clear the room early). All ticks route
/// through TickUtil.NonZero and compare with only (never raw uint).
///
[BurstCompile]
[WorldSystemFilter(WorldSystemFilterFlags.ServerSimulation)]
[UpdateInGroup(typeof(SimulationSystemGroup))]
[UpdateAfter(typeof(EnemyAISystem))]
public partial struct BossAISystem : ISystem
{
EntityQuery m_Bosses;
EntityQuery m_ZoneEnemies;
[BurstCompile]
public void OnCreate(ref SystemState state)
{
state.RequireForUpdate();
m_Bosses = state.GetEntityQuery(ComponentType.ReadOnly(), ComponentType.ReadOnly(), ComponentType.Exclude());
state.RequireForUpdate(m_Bosses);
m_ZoneEnemies = state.GetEntityQuery(ComponentType.ReadOnly(), ComponentType.Exclude()); // summon cap counts LIVING only (B3)
}
[BurstCompile]
public void OnUpdate(ref SystemState state)
{
var serverTick = SystemAPI.GetSingleton().ServerTick;
if (!serverTick.IsValid)
return;
uint now = serverTick.TickIndexForValidTick;
float dt = SystemAPI.Time.DeltaTime;
// Living EXPEDITION players — the boss's only valid targets. Snapshot once (stable query order).
var playerEntities = new NativeList(Allocator.Temp);
var playerPositions = new NativeList(Allocator.Temp);
foreach (var (xform, health, region, entity) in
SystemAPI.Query, RefRO, RefRO>()
.WithAll().WithEntityAccess())
{
if (health.ValueRO.Current <= 0f || region.ValueRO.Region != RegionId.Expedition)
continue;
playerEntities.Add(entity);
playerPositions.Add(xform.ValueRO.Position);
}
// Collide-and-slide setup (mirrors EnemyAISystem).
bool havePhysics = SystemAPI.TryGetSingleton(out var physics);
uint envMask = SystemAPI.TryGetSingleton(out var worldCol) ? worldCol.EnvironmentMask : 0u;
uint sweepMask = envMask | worldCol.StructureMask;
var envFilter = new CollisionFilter { BelongsTo = ~0u, CollidesWith = sweepMask, GroupIndex = 0 };
bool sweep = havePhysics && sweepMask != 0u;
const float SweepRadius = 0.8f; // the boss is a big body
int liveZone = m_ZoneEnemies.CalculateEntityCount();
// Summon resources (phase two): the swarmer prefab + baked transform + the current room byte.
bool haveDirector = SystemAPI.TryGetSingletonEntity(out var directorEntity);
Entity swarmerPrefab = Entity.Null;
LocalTransform swarmerBaked = default;
if (haveDirector)
{
var prefabs = SystemAPI.GetBuffer(directorEntity);
if (prefabs.Length > ZoneEnemyMath.KindSwarmer)
{
swarmerPrefab = prefabs[ZoneEnemyMath.KindSwarmer].Prefab;
if (swarmerPrefab != Entity.Null)
swarmerBaked = state.EntityManager.GetComponentData(swarmerPrefab);
}
}
byte roomByte = SystemAPI.TryGetSingleton(out var runInfo) ? (byte)(runInfo.CurrentRoom & 0xFF) : (byte)0;
var ecb = new EntityCommandBuffer(Allocator.Temp);
foreach (var (xform, stats, health, boss, windup, knockback, lunge) in
SystemAPI.Query, RefRO, RefRO, RefRW,
RefRW, RefRW, RefRW>()
.WithAll().WithNone())
{
float3 pos = xform.ValueRO.Position;
// Knockback-immune: never recoil (A4). Zero any residual so a competing stamp can't shove the boss.
if (knockback.ValueRO.UntilTick != 0u) knockback.ValueRW.UntilTick = 0u;
// Phase from the boss's own Current vs (server-side, real ×BossHealthMultiplier) Max.
float maxHp = math.max(1f, health.ValueRO.Max);
byte phase = health.ValueRO.Current <= maxHp * Tuning.BossPhase2HealthFraction ? (byte)2 : (byte)1;
boss.ValueRW.Phase = phase;
// Target: nearest living expedition player.
int tgt = -1; float bestSq = float.MaxValue;
for (int i = 0; i < playerPositions.Length; i++)
{
float d = math.distancesq(pos, playerPositions[i]);
if (d < bestSq) { bestSq = d; tgt = i; }
}
if (tgt < 0)
continue; // no valid target -> idle (InRoom-abort handles a fully-empty expedition)
float3 targetPos = playerPositions[tgt];
// Face the target (planar) at all times, incl. while telegraphing.
float3 toTarget = targetPos - pos; toTarget.y = 0f;
if (math.lengthsq(toTarget) > 1e-6f)
xform.ValueRW.Rotation = quaternion.LookRotationSafe(math.normalize(toTarget), math.up());
// --- SLAM in progress: root (the telegraph) until it lands, then AoE all players in the ring. ---
uint windRaw = windup.ValueRO.WindUpUntilTick;
if (windRaw != 0u)
{
var wt = new NetworkTick(windRaw);
if (!(wt.IsValid && wt.IsNewerThan(serverTick)))
{
// B4: the windup elapse fires whichever attack was PENDING - the shared AttackWindup field
// alone cannot tell them apart (review-confirmed: the naive reuse slams on a lunge elapse).
if (boss.ValueRO.PendingAttack == 1)
{
// Lunge commit: lock direction at travel start (the Charger contract - dodge DURING
// travel with dash i-frames). No unique damage: arriving re-opens the slam threat.
lunge.ValueRW.Dir = math.normalizesafe(toTarget.xz, new float2(0f, 1f));
lunge.ValueRW.Speed = Tuning.BossLungeSpeed;
lunge.ValueRW.UntilTick = TickUtil.NonZero(now + Tuning.BossLungeDurationTicks);
windup.ValueRW.WindUpUntilTick = 0u;
continue;
}
float slamSq = Tuning.BossSlamRadius * Tuning.BossSlamRadius;
for (int i = 0; i < playerEntities.Length; i++)
{
if (math.distancesq(pos, playerPositions[i]) > slamSq)
continue;
ecb.AppendToBuffer(playerEntities[i], new DamageEvent
{
Amount = Tuning.BossSlamDamage,
SourceNetworkId = -1, // environment / boss, not a player
SourceTick = TickUtil.NonZero(now),
});
}
windup.ValueRW.WindUpUntilTick = 0u;
uint baseCd = Tuning.BossSlamCooldownTicks;
uint cd = phase == 2
? (uint)math.max(1f, baseCd * Tuning.BossPhase2SlamCooldownMult)
: baseCd;
boss.ValueRW.SlamReadyTick = TickUtil.NonZero(now + cd);
}
continue; // rooted while winding up (the tell); rotation already written above
}
// --- B4 LUNGE travel in progress: committed movement along the locked direction. Wall-stop or
// timer ends it (the Charger contract); the replicated IsLunging bit rides LungeState.UntilTick. ---
if (lunge.ValueRO.UntilTick != 0u)
{
var blt = new NetworkTick(lunge.ValueRO.UntilTick);
if (blt.IsValid && blt.IsNewerThan(serverTick))
{
float3 intended = pos + new float3(lunge.ValueRO.Dir.x, 0f, lunge.ValueRO.Dir.y) * (lunge.ValueRO.Speed * dt);
intended.y = pos.y;
float3 moved = sweep ? EnemyMoveUtil.SweptMove(in physics, pos, intended, SweepRadius, envFilter) : intended;
xform.ValueRW.Position = moved;
if (math.lengthsq(lunge.ValueRO.Dir) > 1e-6f)
xform.ValueRW.Rotation = quaternion.LookRotationSafe(new float3(lunge.ValueRO.Dir.x, 0f, lunge.ValueRO.Dir.y), math.up());
float intendedDist = math.distance(pos.xz, intended.xz);
float actualDist = math.distance(pos.xz, moved.xz);
if (intendedDist > 1e-4f && actualDist < intendedDist * 0.5f)
{
lunge.ValueRW.UntilTick = 0u; // wall-stop -> end the travel early
boss.ValueRW.PendingAttack = 0;
boss.ValueRW.LungeReadyTick = TickUtil.NonZero(now + Tuning.BossLungeCooldownTicks);
}
continue; // committed this tick
}
lunge.ValueRW.UntilTick = 0u; // travel done
boss.ValueRW.PendingAttack = 0;
boss.ValueRW.LungeReadyTick = TickUtil.NonZero(now + Tuning.BossLungeCooldownTicks);
}
// --- Chase (no active slam). ---
float speed = stats.ValueRO.MoveSpeed * (phase == 2 ? Tuning.BossPhase2SpeedMult : 1f);
float stopDist = stats.ValueRO.AttackRange * 0.9f;
float3 vel = EnemyAIMath.SeekVelocity(pos, targetPos, speed, stopDist);
float3 newPos = pos + vel * dt; newPos.y = pos.y;
if (sweep) newPos = EnemyMoveUtil.SweptMove(in physics, pos, newPos, SweepRadius, envFilter);
xform.ValueRW.Position = newPos;
// Slam gate: ready + a player inside (ring + a small lead) -> commit a telegraphed slam.
bool slamReady = boss.ValueRO.SlamReadyTick == 0u
|| !new NetworkTick(boss.ValueRO.SlamReadyTick).IsNewerThan(serverTick);
float lead = Tuning.BossSlamRadius + 1.5f;
float tgtDistSq = math.distancesq(newPos, targetPos);
if (slamReady && tgtDistSq <= lead * lead)
{
windup.ValueRW.WindUpUntilTick = TickUtil.NonZero(now + Tuning.BossSlamWindupTicks);
boss.ValueRW.PendingAttack = 0;
}
else
{
// B4 lunge gate: target out of slam reach but within lunge range -> telegraphed gap-closer.
// LungeState.UntilTick spans windup+travel so the IsLunging ghost bit (derived by EnemyAISystem
// from LungeState) is ON for the whole move - the client suppresses the slam ring off that bit.
bool lungeReady = boss.ValueRO.LungeReadyTick == 0u
|| !new NetworkTick(boss.ValueRO.LungeReadyTick).IsNewerThan(serverTick);
if (lungeReady
&& tgtDistSq >= Tuning.BossLungeMinRange * Tuning.BossLungeMinRange
&& tgtDistSq <= Tuning.BossLungeMaxRange * Tuning.BossLungeMaxRange)
{
windup.ValueRW.WindUpUntilTick = TickUtil.NonZero(now + Tuning.BossLungeWindupTicks);
boss.ValueRW.PendingAttack = 1;
lunge.ValueRW.UntilTick = TickUtil.NonZero(now + Tuning.BossLungeWindupTicks + Tuning.BossLungeDurationTicks);
}
}
// Summon (phase two only): ready + under the live cap + a swarmer prefab wired.
if (phase == 2 && swarmerPrefab != Entity.Null && liveZone < Tuning.BossSummonLiveCap)
{
bool summonReady = boss.ValueRO.SummonReadyTick == 0u
|| !new NetworkTick(boss.ValueRO.SummonReadyTick).IsNewerThan(serverTick);
if (summonReady)
{
int toSpawn = math.min(Tuning.BossSummonCount, Tuning.BossSummonLiveCap - liveZone);
for (int k = 0; k < toSpawn; k++)
{
float3 spawnPos = EnemyAIMath.ClusterOffset(newPos, k, math.max(1, toSpawn), 2.5f);
spawnPos.y = newPos.y;
ZoneEnemySpawnUtil.Spawn(ecb, swarmerPrefab, in swarmerBaked, spawnPos, RegionId.Expedition, roomByte);
liveZone++;
}
boss.ValueRW.SummonReadyTick = TickUtil.NonZero(now + Tuning.BossSummonCooldownTicks);
}
}
}
ecb.Playback(state.EntityManager);
ecb.Dispose();
playerEntities.Dispose();
playerPositions.Dispose();
}
}
}