Files
Project-M/Assets/_Project/Scripts/Server/Debug/DebugCommandReceiveSystem.cs
T
kronic 3836e9c842 Phase 1 B3: enemies die with a corpse window instead of popping out of existence
Server: HealthApplyDamageSystem marks EnemyTag Dying{UntilTick} (TickUtil.
NonZero, ~54 ticks) on the lethal 0-crossing instead of instant destroy,
zeroes every live cue (replicated AttackWindup, LungeState + IsLunging bit,
KnockbackState), destroys on expiry; plain-world tests keep instant destroy
(no NetworkTime) so the suite's assertions stay meaningful.

Every consumer now ignores corpses (all confirmed entity-count/unfiltered by
the design review): EnemyAISystem all 4 passes, BossAISystem brain + summon
cap, RoomEnemyDirector room-clear + MaxAlive fit, WaveSystem cap + cleared,
CyclePhaseSystem breach wipe + DefendCleared, ThreatDirector timeout cull,
TurretFire targeting, CoreDamage drain, ProjectileDamage snapshot (corpses
are not shields), AbilityFire auto-aim candidates, debug cull + telemetry.
Wipe passes skip Dying to avoid cross-ECB double-destroys.

Client: EnemyAnimationDriveSystem finally drives the controller's IsDead
param (Health.Current<=0 is the replicated death read; corpse locomotion
zeroed); CombatFeedbackSystem moves the kill CRUNCH to the 0-crossing (the
prune-edge timing would land it ~1 s late) - the prune keeps a small
dissolve puff for corpses and the legacy full read for alive-vanish culls.

456/456 EditMode.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 18:34:12 -07:00

254 lines
13 KiB
C#

#if UNITY_EDITOR
using ProjectM.Simulation;
using Unity.Collections;
using Unity.Entities;
using Unity.Mathematics;
using Unity.NetCode;
using Unity.Transforms;
namespace ProjectM.Server
{
/// <summary>
/// EDITOR-ONLY server receiver for <see cref="DebugCommandRequest"/> dev-tool RPCs (from the DebugOverlay or
/// execute_code). Applies authoritative effects so the dev buttons exercise the REAL server paths and work
/// over a live connection too: force/end sieges, grant resources/upgrades, teleport, god-mode, heal/kill,
/// advance the goal. Sender-targeted ops resolve the player via SourceConnection -> NetworkId -> GhostOwner
/// (the RegionTransitSystem pattern). Plain server SimulationSystemGroup (NOT the predicted loop). Reuses
/// StorageMath / StatModifier / RegionMath + the wave/cycle singletons. The whole system is #if UNITY_EDITOR
/// (stripped from builds); the wire TYPE (<see cref="DebugCommandRequest"/>) is unconditional so the RPC
/// collection hash matches across peers. Non-Burst (managed-simple, editor-only) — perf is irrelevant.
/// </summary>
[WorldSystemFilter(WorldSystemFilterFlags.ServerSimulation)]
[UpdateInGroup(typeof(SimulationSystemGroup))]
public partial struct DebugCommandReceiveSystem : ISystem
{
EntityQuery m_Husks;
public void OnCreate(ref SystemState state)
{
m_Husks = state.GetEntityQuery(ComponentType.ReadOnly<EnemyTag>(), ComponentType.Exclude<Dying>()); // corpses expire on their own (B3; avoids a cross-ECB double-destroy)
var builder = new EntityQueryBuilder(Allocator.Temp)
.WithAll<DebugCommandRequest, ReceiveRpcCommandRequest>();
state.RequireForUpdate(state.GetEntityQuery(builder));
}
public void OnUpdate(ref SystemState state)
{
var ecb = new EntityCommandBuffer(Allocator.Temp);
// Connection NetworkId -> player entity (for sender-targeted ops).
var playerByConn = new NativeHashMap<int, Entity>(8, Allocator.Temp);
foreach (var (owner, e) in SystemAPI.Query<RefRO<GhostOwner>>().WithAll<PlayerTag>().WithEntityAccess())
playerByConn[owner.ValueRO.NetworkId] = e;
bool haveCycle = SystemAPI.TryGetSingletonEntity<CycleState>(out var cycleEntity);
foreach (var (request, receive, reqEntity) in
SystemAPI.Query<RefRO<DebugCommandRequest>, RefRO<ReceiveRpcCommandRequest>>().WithEntityAccess())
{
var cmd = request.ValueRO;
Entity sender = Entity.Null;
var connEntity = receive.ValueRO.SourceConnection;
if (SystemAPI.HasComponent<NetworkId>(connEntity))
playerByConn.TryGetValue(SystemAPI.GetComponent<NetworkId>(connEntity).Value, out sender);
switch (cmd.Op)
{
case DebugOp.SpawnWave:
if (haveCycle && SystemAPI.HasComponent<ThreatState>(cycleEntity))
{
var ts = SystemAPI.GetComponent<ThreatState>(cycleEntity);
ts.PendingSiegeSize = math.max(1, cmd.ArgA);
ts.ArmTick = 0; // fire as soon as CyclePhaseSystem sees it
SystemAPI.SetComponent(cycleEntity, ts);
}
break;
case DebugOp.EndSiege:
case DebugOp.SetCalm:
CullHusks(ref ecb);
if (SystemAPI.TryGetSingletonEntity<WaveState>(out var we))
{
var w = SystemAPI.GetComponent<WaveState>(we);
w.Phase = WavePhase.Lull;
w.RemainingToSpawn = 0;
SystemAPI.SetComponent(we, w);
}
if (haveCycle && SystemAPI.HasComponent<ThreatState>(cycleEntity))
{
var ts = SystemAPI.GetComponent<ThreatState>(cycleEntity);
ts.PendingSiegeSize = 0;
ts.ArmTick = 0;
ts.SiegeStartTick = 0;
SystemAPI.SetComponent(cycleEntity, ts);
}
if (cmd.Op == DebugOp.SetCalm && haveCycle)
{
var cs = SystemAPI.GetComponent<CycleState>(cycleEntity);
cs.Phase = CyclePhase.Calm;
cs.PhaseEndTick = 0;
SystemAPI.SetComponent(cycleEntity, cs);
}
break;
case DebugOp.ClearEnemies:
CullHusks(ref ecb);
break;
case DebugOp.GrantResource:
if (SystemAPI.TryGetSingletonEntity<ResourceLedger>(out var ledgerE))
{
var ledger = SystemAPI.GetBuffer<StorageEntry>(ledgerE);
StorageMath.Deposit(ledger, (ushort)cmd.ArgA, cmd.ArgB);
}
break;
case DebugOp.GrantUpgrade:
if (sender != Entity.Null && SystemAPI.HasBuffer<StatModifier>(sender))
GrowDamageModifier(SystemAPI.GetBuffer<StatModifier>(sender));
break;
case DebugOp.Teleport:
if (sender != Entity.Null && SystemAPI.HasComponent<RegionTag>(sender)
&& SystemAPI.HasComponent<LocalTransform>(sender))
{
byte region = (byte)cmd.ArgA;
SystemAPI.GetComponentRW<RegionTag>(sender).ValueRW.Region = region;
float3 baseCenter = new float3(0f, 1f, 0f);
if (SystemAPI.TryGetSingleton<BaseAnchor>(out var anchor))
baseCenter = BaseGridMath.PlotCenter(anchor);
SystemAPI.GetComponentRW<LocalTransform>(sender).ValueRW.Position =
RegionMath.RegionOrigin(region, baseCenter);
}
break;
case DebugOp.ToggleGod:
if (sender != Entity.Null && SystemAPI.HasComponent<DebugGodMode>(sender))
SystemAPI.SetComponentEnabled<DebugGodMode>(sender, !SystemAPI.IsComponentEnabled<DebugGodMode>(sender));
break;
case DebugOp.Heal:
if (sender != Entity.Null && SystemAPI.HasComponent<Health>(sender))
{
var h = SystemAPI.GetComponent<Health>(sender);
h.Current = SystemAPI.HasComponent<EffectiveCharacterStats>(sender)
? SystemAPI.GetComponent<EffectiveCharacterStats>(sender).MaxHealth
: h.Max;
SystemAPI.SetComponent(sender, h);
}
break;
case DebugOp.KillPlayer:
if (sender != Entity.Null && SystemAPI.HasComponent<Health>(sender))
{
var h = SystemAPI.GetComponent<Health>(sender);
h.Current = 0f;
SystemAPI.SetComponent(sender, h);
}
break;
case DebugOp.AdvanceGoal:
if (haveCycle && SystemAPI.HasComponent<GoalProgress>(cycleEntity))
{
var g = SystemAPI.GetComponent<GoalProgress>(cycleEntity);
g.Charge += math.max(1, cmd.ArgA);
SystemAPI.SetComponent(cycleEntity, g);
}
break;
case DebugOp.SetHeat:
if (haveCycle && SystemAPI.HasComponent<ThreatState>(cycleEntity))
{
var ts = SystemAPI.GetComponent<ThreatState>(cycleEntity);
ts.Heat = cmd.ArgA;
SystemAPI.SetComponent(cycleEntity, ts);
}
break;
case DebugOp.SetTuning:
if (SystemAPI.TryGetSingleton<TuningConfig>(out var tuningCfg))
{
TuningConfig.Apply(ref tuningCfg, (byte)cmd.ArgA, cmd.ArgB / 1000f);
SystemAPI.SetSingleton(tuningCfg);
}
break;
case DebugOp.SetClass:
// Swap an already-spawned player's class IN PLACE (editor dev tool). Class = two replicated
// pieces: the AbilityRef Fire slot + the ClassSourceId-tagged StatModifier seeds; the owner's
// StatRecomputeSystem refolds EffectiveCharacterStats. Server-authoritative + prediction-correct
// (same buffer-mutation path as GrantUpgrade). Reapply + AbilityRef run unconditionally so the
// class is correct even on a corpse; the heal is gated on a LIVING player so we don't resurrect
// it out-of-band and race PlayerRespawnSystem (which refills to the new max on respawn itself).
if (sender != Entity.Null && SystemAPI.HasComponent<AbilityRef>(sender)
&& SystemAPI.HasBuffer<StatModifier>(sender))
{
var classMods = SystemAPI.GetBuffer<StatModifier>(sender);
Entity dir2 = Entity.Null;
bool haveMeta2 = SystemAPI.TryGetSingleton<MetaUpgradeCatalog>(out var metaCat2)
&& SystemAPI.TryGetSingletonEntity<ResourceLedger>(out dir2) && SystemAPI.HasBuffer<MetaTierState>(dir2);
var metaRec2 = haveMeta2 ? SystemAPI.GetBuffer<MetaTierState>(dir2) : default;
// DR-046: the FULL swap (class seeds + meta re-sync) now lives in the shared ClassSwapUtil,
// used by BOTH this dev path and the base ClassSelectReceiveSystem so they cannot drift.
ClassSwapUtil.Apply((byte)cmd.ArgA, classMods, haveMeta2, metaCat2, metaRec2,
out byte swNewClass, out byte swNewAbility);
SystemAPI.SetComponent(sender, new AbilityRef { Id = swNewAbility });
if (SystemAPI.HasComponent<PlayerClass>(sender))
SystemAPI.SetComponent(sender, new PlayerClass { ClassId = swNewClass });
if (SystemAPI.HasComponent<AbilityCooldown>(sender))
SystemAPI.SetComponent(sender, new AbilityCooldown { NextFireTick = 0 });
if (SystemAPI.HasComponent<Health>(sender) && SystemAPI.HasComponent<CharacterStatsRef>(sender)
&& SystemAPI.TryGetSingleton<AbilityDatabase>(out var abilityDb2))
{
byte charId2 = SystemAPI.GetComponent<CharacterStatsRef>(sender).Id;
if (abilityDb2.Value.Value.TryGetCharacter(charId2, out var baseChar2))
{
var hp2 = SystemAPI.GetComponent<Health>(sender);
ClassSwapUtil.HealClamp(ref hp2, baseChar2.MaxHealth, classMods);
SystemAPI.SetComponent(sender, hp2);
}
}
}
break;
}
ecb.DestroyEntity(reqEntity);
}
ecb.Playback(state.EntityManager);
ecb.Dispose();
playerByConn.Dispose();
}
void CullHusks(ref EntityCommandBuffer ecb)
{
var husks = m_Husks.ToEntityArray(Allocator.Temp);
for (int i = 0; i < husks.Length; i++)
ecb.DestroyEntity(husks[i]);
husks.Dispose();
}
static void GrowDamageModifier(DynamicBuffer<StatModifier> mods)
{
const uint debugSourceId = 0x00DEB061u; // distinct debug sentinel (replace-by-SourceId keeps it bounded)
for (int i = 0; i < mods.Length; i++)
{
if (mods[i].SourceId == debugSourceId && mods[i].Target == (byte)StatTarget.Damage)
{
var m = mods[i];
m.Value += 0.25f;
mods[i] = m;
return;
}
}
mods.Add(new StatModifier
{
Target = (byte)StatTarget.Damage,
Op = (byte)ModOp.PercentAdd,
Value = 0.25f,
SourceId = debugSourceId,
});
}
}
}
#endif