#if UNITY_EDITOR
using ProjectM.Simulation;
using Unity.Collections;
using Unity.Entities;
using Unity.Mathematics;
using Unity.NetCode;
using Unity.Transforms;
namespace ProjectM.Server
{
///
/// EDITOR-ONLY server receiver for 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/stop waves, clear enemies, grant resources/upgrades, teleport, god-mode,
/// heal/kill, class swap, gym enemy spawns. Sender-targeted ops resolve the player via SourceConnection ->
/// NetworkId -> GhostOwner (the RegionTransitSystem pattern). Plain server SimulationSystemGroup (NOT the
/// predicted loop). The whole system is #if UNITY_EDITOR (stripped from builds); the wire TYPE
/// () is unconditional so the RPC collection hash matches across peers.
/// Non-Burst (managed-simple, editor-only) — perf is irrelevant.
///
[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(), ComponentType.Exclude()); // corpses expire on their own (B3; avoids a cross-ECB double-destroy)
var builder = new EntityQueryBuilder(Allocator.Temp)
.WithAll();
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(8, Allocator.Temp);
foreach (var (owner, e) in SystemAPI.Query>().WithAll().WithEntityAccess())
playerByConn[owner.ValueRO.NetworkId] = e;
uint now = SystemAPI.TryGetSingleton(out var netTime) && netTime.ServerTick.IsValid
? netTime.ServerTick.TickIndexForValidTick : 0u;
foreach (var (request, receive, reqEntity) in
SystemAPI.Query, RefRO>().WithEntityAccess())
{
var cmd = request.ValueRO;
Entity sender = Entity.Null;
var connEntity = receive.ValueRO.SourceConnection;
PlayerResolve.TryResolve(ref state, playerByConn, connEntity, out sender);
switch (cmd.Op)
{
case DebugOp.SpawnWave: // re-meant (LANTERN): force the NEXT wave to start this tick
if (SystemAPI.TryGetSingletonEntity(out var forceWaveE))
{
var fw = SystemAPI.GetComponent(forceWaveE);
fw.Phase = WavePhase.Lull;
fw.NextActionTick = 0; // due immediately -> WaveSystem starts the next (bigger) wave
SystemAPI.SetComponent(forceWaveE, fw);
}
break;
case DebugOp.EndSiege: // re-meant (LANTERN): "quiet the arena" — cull husks + push the next wave far out
CullHusks(ref ecb);
if (SystemAPI.TryGetSingletonEntity(out var stopWaveE))
{
var w = SystemAPI.GetComponent(stopWaveE);
w.Phase = WavePhase.Lull;
w.RemainingToSpawn = 0;
w.NextActionTick = TickUtil.NonZero(now + 216000u); // ~1 h @ 60 Hz: waves stay quiet for the session
SystemAPI.SetComponent(stopWaveE, w);
}
break;
case DebugOp.ClearEnemies:
CullHusks(ref ecb);
break;
case DebugOp.GrantResource:
if (SystemAPI.TryGetSingletonEntity(out var ledgerE))
{
var ledger = SystemAPI.GetBuffer(ledgerE);
StorageMath.Deposit(ledger, (ushort)cmd.ArgA, cmd.ArgB);
}
break;
case DebugOp.GrantUpgrade:
if (sender != Entity.Null && SystemAPI.HasBuffer(sender))
GrowDamageModifier(SystemAPI.GetBuffer(sender));
break;
case DebugOp.Teleport:
if (sender != Entity.Null && SystemAPI.HasComponent(sender)
&& SystemAPI.HasComponent(sender))
{
byte region = (byte)cmd.ArgA;
SystemAPI.GetComponentRW(sender).ValueRW.Region = region;
float3 baseCenter = new float3(0f, 1f, 0f);
if (SystemAPI.TryGetSingleton(out var anchor))
baseCenter = BaseGridMath.PlotCenter(anchor);
SystemAPI.GetComponentRW(sender).ValueRW.Position =
RegionMath.RegionOrigin(region, baseCenter);
}
break;
case DebugOp.ToggleGod:
if (sender != Entity.Null && SystemAPI.HasComponent(sender))
SystemAPI.SetComponentEnabled(sender, !SystemAPI.IsComponentEnabled(sender));
break;
case DebugOp.Heal:
if (sender != Entity.Null && SystemAPI.HasComponent(sender))
{
var h = SystemAPI.GetComponent(sender);
h.Current = SystemAPI.HasComponent(sender)
? SystemAPI.GetComponent(sender).MaxHealth
: h.Max;
SystemAPI.SetComponent(sender, h);
}
break;
case DebugOp.KillPlayer:
if (sender != Entity.Null && SystemAPI.HasComponent(sender))
{
var h = SystemAPI.GetComponent(sender);
h.Current = 0f;
SystemAPI.SetComponent(sender, h);
}
break;
case DebugOp.SetTuning:
if (SystemAPI.TryGetSingleton(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 frame IN PLACE (editor dev tool). Frame = FrameId + the
// ClassSourceId-tagged StatModifier seeds + the 4-socket Spark loadout; the owner's
// StatRecomputeSystem refolds EffectiveCharacterStats. Server-authoritative + prediction-
// correct (same buffer-mutation path as GrantUpgrade). The swap runs even on a corpse; the
// heal is gated on a LIVING player so we don't resurrect out-of-band and race
// PlayerRespawnSystem (which refills to the new max on respawn itself).
if (sender != Entity.Null && SystemAPI.HasBuffer(sender)
&& SystemAPI.HasBuffer(sender))
{
var classMods = SystemAPI.GetBuffer(sender);
Entity dir2 = Entity.Null;
bool haveMeta2 = SystemAPI.TryGetSingleton(out var metaCat2)
&& SystemAPI.TryGetSingletonEntity(out dir2) && SystemAPI.HasBuffer(dir2);
var metaRec2 = haveMeta2 ? SystemAPI.GetBuffer(dir2) : default;
// DR-046: the FULL swap (class seeds + meta re-sync) 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);
if (SystemAPI.HasComponent(sender))
SystemAPI.SetComponent(sender, new FrameId { Value = swNewClass });
if (SystemAPI.HasComponent(sender))
SystemAPI.SetComponent(sender, new PlayerClass { ClassId = swNewClass });
ClassTraits.FrameLoadout(swNewClass, out byte sf0, out byte sf1, out byte sf2, out byte sf3);
var swSockets = SystemAPI.GetBuffer(sender);
swSockets.Clear();
swSockets.Add(new AbilitySocket { SparkId = sf0 });
swSockets.Add(new AbilitySocket { SparkId = sf1 });
swSockets.Add(new AbilitySocket { SparkId = sf2 });
swSockets.Add(new AbilitySocket { SparkId = sf3 });
if (SystemAPI.HasComponent(sender))
SystemAPI.SetComponent(sender, default(SocketCooldown));
if (SystemAPI.HasComponent(sender) && SystemAPI.HasComponent(sender)
&& SystemAPI.TryGetSingleton(out var abilityDb2))
{
byte charId2 = SystemAPI.GetComponent(sender).Id;
if (abilityDb2.Value.Value.TryGetCharacter(charId2, out var baseChar2))
{
var hp2 = SystemAPI.GetComponent(sender);
ClassSwapUtil.HealClamp(ref hp2, baseChar2.MaxHealth, classMods);
SystemAPI.SetComponent(sender, hp2);
}
}
}
break;
break;
case DebugOp.SpawnEnemy:
// GYM: spawn a chosen enemy KIND (Drowner/Grindylow) from the baked roster near the sender.
if (sender != Entity.Null && SystemAPI.HasComponent(sender)
&& SystemAPI.TryGetSingletonEntity(out var gymEntity)
&& SystemAPI.HasBuffer(gymEntity))
{
var roster = SystemAPI.GetBuffer(gymEntity);
Entity enemyPrefab = Entity.Null;
for (int r = 0; r < roster.Length; r++)
if (roster[r].Kind == (byte)cmd.ArgA) { enemyPrefab = roster[r].Prefab; break; }
if (enemyPrefab != Entity.Null)
{
var sPos = SystemAPI.GetComponent(sender).Position;
var bakedEnemyLt = state.EntityManager.GetComponentData(enemyPrefab);
int spawnCount = math.max(1, cmd.ArgB);
for (int k = 0; k < spawnCount; k++)
{
float ang = k * 0.7f;
float3 pos = sPos + new float3(math.cos(ang), 0f, math.sin(ang)) * 5f;
pos.y = sPos.y;
var enemy = ecb.Instantiate(enemyPrefab);
ecb.SetComponent(enemy, bakedEnemyLt.WithPosition(pos));
ecb.AddComponent(enemy, new RegionTag { Region = RegionId.Base });
}
}
}
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 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