Game Scene Split up

This commit is contained in:
2026-06-04 13:45:46 -07:00
parent dbc4a92a86
commit 16b01bec38
49 changed files with 11976 additions and 188 deletions
File diff suppressed because it is too large Load Diff
+7
View File
@@ -0,0 +1,7 @@
fileFormatVersion: 2
guid: 1d4b5e92ac7b6ea4880d30545df94704
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
File diff suppressed because it is too large Load Diff
+7
View File
@@ -0,0 +1,7 @@
fileFormatVersion: 2
guid: db8738f16e802c8488f9c22aa56e1e60
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
Binary file not shown.
@@ -0,0 +1,117 @@
fileFormatVersion: 2
guid: b3300c1dfff5c024b823d2f27af90e37
TextureImporter:
internalIDToNameTable: []
externalObjects: {}
serializedVersion: 13
mipmaps:
mipMapMode: 0
enableMipMap: 1
sRGBTexture: 1
linearTexture: 0
fadeOut: 0
borderMipMap: 0
mipMapsPreserveCoverage: 0
alphaTestReferenceValue: 0.5
mipMapFadeDistanceStart: 1
mipMapFadeDistanceEnd: 3
bumpmap:
convertToNormalMap: 0
externalNormalMap: 0
heightScale: 0.25
normalMapFilter: 0
flipGreenChannel: 0
isReadable: 0
streamingMipmaps: 0
streamingMipmapsPriority: 0
vTOnly: 0
ignoreMipmapLimit: 0
grayScaleToAlpha: 0
generateCubemap: 6
cubemapConvolution: 0
seamlessCubemap: 0
textureFormat: 1
maxTextureSize: 2048
textureSettings:
serializedVersion: 2
filterMode: 1
aniso: 1
mipBias: 0
wrapU: 0
wrapV: 0
wrapW: 0
nPOTScale: 1
lightmap: 0
compressionQuality: 50
spriteMode: 0
spriteExtrude: 1
spriteMeshType: 1
alignment: 0
spritePivot: {x: 0.5, y: 0.5}
spritePixelsToUnits: 100
spriteBorder: {x: 0, y: 0, z: 0, w: 0}
spriteGenerateFallbackPhysicsShape: 1
alphaUsage: 1
alphaIsTransparency: 0
spriteTessellationDetail: -1
textureType: 0
textureShape: 1
singleChannelComponent: 0
flipbookRows: 1
flipbookColumns: 1
maxTextureSizeSet: 0
compressionQualitySet: 0
textureFormatSet: 0
ignorePngGamma: 0
applyGammaDecoding: 0
swizzle: 50462976
cookieLightType: 0
platformSettings:
- serializedVersion: 4
buildTarget: DefaultTexturePlatform
maxTextureSize: 2048
resizeAlgorithm: 0
textureFormat: -1
textureCompression: 1
compressionQuality: 50
crunchedCompression: 0
allowsAlphaSplitting: 0
overridden: 0
ignorePlatformSupport: 0
androidETC2FallbackOverride: 0
forceMaximumCompressionQuality_BC6H_BC7: 0
- serializedVersion: 4
buildTarget: Standalone
maxTextureSize: 2048
resizeAlgorithm: 0
textureFormat: -1
textureCompression: 1
compressionQuality: 50
crunchedCompression: 0
allowsAlphaSplitting: 0
overridden: 0
ignorePlatformSupport: 0
androidETC2FallbackOverride: 0
forceMaximumCompressionQuality_BC6H_BC7: 0
spriteSheet:
serializedVersion: 2
sprites: []
outline: []
customData:
physicsShape: []
bones: []
spriteID:
internalID: 0
vertices: []
indices:
edges: []
weights: []
secondaryTextures: []
spriteCustomMetadata:
entries: []
nameFileIdTable: {}
mipmapLimitGroupName:
pSDRemoveMatte: 0
userData:
assetBundleName:
assetBundleVariant:
@@ -81,6 +81,10 @@ namespace ProjectM.Authoring
// plus the server-only respawn timer.
AddComponent<Dead>(entity);
SetComponentEnabled<Dead>(entity, false);
// Dev god-mode gate (enableable, server-only) baked DISABLED so toggling it is a bit flip, never structural.
AddComponent<DebugGodMode>(entity);
SetComponentEnabled<DebugGodMode>(entity, false);
AddComponent(entity, new RespawnState { RespawnTick = 0, DelayTicks = authoring.RespawnDelayTicks, InvulnTicks = authoring.RespawnInvulnTicks });
AddComponent(entity, new RespawnInvuln { UntilTick = 0 });
}
@@ -15,6 +15,22 @@ namespace ProjectM.Authoring
/// </summary>
public class CycleDirectorAuthoring : MonoBehaviour
{
[Header("Threat — post-expedition retaliation")]
[Tooltip("A completed expedition (a player returning to base) can draw a retaliation siege.")]
public bool PostExpeditionEnabled = true;
[Tooltip("Telegraph/arming delay (server ticks @60) between the return and the siege spawning.")]
public uint PostExpeditionDelayTicks = 300;
[Tooltip("Siege size floor (Husk count) for a post-expedition retaliation.")]
public int SiegeSizeBase = 5;
[Tooltip("Extra Husks per unit of resources hauled back this run (0 = flat SiegeSizeBase).")]
public int SiegeSizePerResource = 0;
[Tooltip("Max server ticks a siege may run before it auto-collapses (no soft-lock). 0 = no cap.")]
public uint SiegeTimeoutTicks = 3600;
private class CycleDirectorBaker : Baker<CycleDirectorAuthoring>
{
public override void Bake(CycleDirectorAuthoring authoring)
@@ -22,13 +38,22 @@ namespace ProjectM.Authoring
var entity = GetEntity(authoring, TransformUsageFlags.Dynamic);
AddComponent(entity, new CycleState
{
Phase = CyclePhase.Expedition,
Phase = CyclePhase.Calm,
CycleNumber = 1,
PhaseEndTick = 0u,
});
AddComponent<ResourceLedger>(entity);
AddBuffer<StorageEntry>(entity);
AddComponent(entity, new GoalProgress { Charge = 0, Target = 10 });
AddComponent(entity, new ThreatConfig
{
PostExpeditionEnabled = (byte)(authoring.PostExpeditionEnabled ? 1 : 0),
PostExpeditionDelayTicks = authoring.PostExpeditionDelayTicks,
SizeBase = authoring.SiegeSizeBase,
SizePerExpeditionResource = authoring.SiegeSizePerResource,
StartCondition = ThreatStartCondition.Immediate,
SiegeTimeoutTicks = authoring.SiegeTimeoutTicks,
});
}
}
}
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 527bfff0c1216544093c653466a5e5af
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,64 @@
#if UNITY_EDITOR
using System.Collections.Generic;
using ProjectM.Simulation;
using Unity.Entities;
using Unity.NetCode;
using UnityEngine;
namespace ProjectM.Client
{
/// <summary>
/// EDITOR-ONLY client sender for dev-tool <see cref="DebugCommandRequest"/> RPCs. Mirrors
/// <c>StorageOpSendSystem</c>: static convenience methods enqueue into a queue that this client
/// <see cref="SystemBase"/> drains into request entities each tick (so it works from the DebugOverlay's IMGUI
/// AND headless from execute_code). The statics are reset on play-enter so a fast-enter-playmode reload can't
/// replay a stale queue. The wire type is unconditional; this system is #if UNITY_EDITOR (stripped from builds).
/// </summary>
[WorldSystemFilter(WorldSystemFilterFlags.ClientSimulation)]
public partial class DebugCommandSendSystem : SystemBase
{
struct Pending { public byte Op; public int ArgA; public int ArgB; }
static readonly List<Pending> s_Pending = new List<Pending>();
/// <summary>Queue a raw dev command for the next client tick.</summary>
public static void Send(byte op, int argA = 0, int argB = 0)
=> s_Pending.Add(new Pending { Op = op, ArgA = argA, ArgB = argB });
// Convenience wrappers (overlay buttons + execute_code).
public static void SpawnWave(int size) => Send(DebugOp.SpawnWave, size);
public static void EndSiege() => Send(DebugOp.EndSiege);
public static void ClearEnemies() => Send(DebugOp.ClearEnemies);
public static void SetCalm() => Send(DebugOp.SetCalm);
public static void GrantResource(byte itemId, int count) => Send(DebugOp.GrantResource, itemId, count);
public static void GrantUpgrade() => Send(DebugOp.GrantUpgrade);
public static void Teleport(byte region) => Send(DebugOp.Teleport, region);
public static void ToggleGod() => Send(DebugOp.ToggleGod);
public static void Heal() => Send(DebugOp.Heal);
public static void Kill() => Send(DebugOp.KillPlayer);
public static void AdvanceGoal(int by) => Send(DebugOp.AdvanceGoal, by);
public static void SetHeat(int heat) => Send(DebugOp.SetHeat, heat);
[RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.SubsystemRegistration)]
static void ResetOnEnterPlayMode() => s_Pending.Clear();
protected override void OnUpdate()
{
if (s_Pending.Count == 0)
return;
if (!SystemAPI.TryGetSingletonEntity<NetworkId>(out var connection))
return; // not connected yet — hold the queue
var em = EntityManager;
for (int i = 0; i < s_Pending.Count; i++)
{
var p = s_Pending[i];
var req = em.CreateEntity();
em.AddComponentData(req, new DebugCommandRequest { Op = p.Op, ArgA = p.ArgA, ArgB = p.ArgB });
em.AddComponentData(req, new SendRpcCommandRequest { TargetConnection = connection });
}
s_Pending.Clear();
}
}
}
#endif
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 6f0d6e2babc53f3489bcd6f7988d31ca
@@ -0,0 +1,78 @@
#if UNITY_EDITOR
using ProjectM.Simulation;
using UnityEngine;
namespace ProjectM.Client
{
/// <summary>
/// EDITOR-ONLY in-game dev-tools overlay (IMGUI, mirrors <c>ConnectionUI</c>). Buttons enqueue
/// <see cref="DebugCommandRequest"/> RPCs through <see cref="DebugCommandSendSystem"/>, so they drive the REAL
/// server-authoritative paths (and work over a live connection too, not just in-editor). Drop it on a
/// GameObject in the DevSandbox (or Game) scene. While open it forces the OS cursor visible
/// (<see cref="AimPresentation.ForceCursorVisible"/>) so its buttons stay clickable even while aiming.
/// Stripped from player builds (#if UNITY_EDITOR).
/// </summary>
public class DebugOverlay : MonoBehaviour
{
bool _open = true;
int _siegeSize = 5;
int _grantAmount = 50;
void OnDisable() => AimPresentation.ForceCursorVisible = false;
void OnGUI()
{
if (GUI.Button(new Rect(Screen.width - 96, 10, 86, 24), _open ? "DEV ▲" : "DEV ▼"))
_open = !_open;
AimPresentation.ForceCursorVisible = _open;
if (!_open)
return;
GUILayout.BeginArea(new Rect(Screen.width - 232, 40, 222, 540), GUI.skin.box);
GUILayout.Label("DEV TOOLS");
GUILayout.Label("- World -");
_siegeSize = IntField("Siege size", _siegeSize);
if (GUILayout.Button("Spawn Wave / Force Siege")) DebugCommandSendSystem.SpawnWave(_siegeSize);
if (GUILayout.Button("End Siege")) DebugCommandSendSystem.EndSiege();
if (GUILayout.Button("Clear Enemies")) DebugCommandSendSystem.ClearEnemies();
if (GUILayout.Button("Force Calm")) DebugCommandSendSystem.SetCalm();
if (GUILayout.Button("Advance Goal +1")) DebugCommandSendSystem.AdvanceGoal(1);
GUILayout.Space(6);
GUILayout.Label("- Resources -");
_grantAmount = IntField("Amount", _grantAmount);
GUILayout.BeginHorizontal();
if (GUILayout.Button("Aether")) DebugCommandSendSystem.GrantResource(ResourceId.Aether, _grantAmount);
if (GUILayout.Button("Ore")) DebugCommandSendSystem.GrantResource(ResourceId.Ore, _grantAmount);
if (GUILayout.Button("Bio")) DebugCommandSendSystem.GrantResource(ResourceId.Biomass, _grantAmount);
GUILayout.EndHorizontal();
if (GUILayout.Button("Grant Damage Upgrade")) DebugCommandSendSystem.GrantUpgrade();
GUILayout.Space(6);
GUILayout.Label("- Player -");
GUILayout.BeginHorizontal();
if (GUILayout.Button("Heal")) DebugCommandSendSystem.Heal();
if (GUILayout.Button("Kill")) DebugCommandSendSystem.Kill();
if (GUILayout.Button("God")) DebugCommandSendSystem.ToggleGod();
GUILayout.EndHorizontal();
GUILayout.BeginHorizontal();
if (GUILayout.Button("Go Base")) DebugCommandSendSystem.Teleport(RegionId.Base);
if (GUILayout.Button("Go Expedition")) DebugCommandSendSystem.Teleport(RegionId.Expedition);
GUILayout.EndHorizontal();
GUILayout.EndArea();
}
static int IntField(string label, int value)
{
GUILayout.BeginHorizontal();
GUILayout.Label(label, GUILayout.Width(70));
string s = GUILayout.TextField(value.ToString(), GUILayout.Width(60));
GUILayout.EndHorizontal();
return int.TryParse(s, out var v) ? v : value;
}
}
}
#endif
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: d86864d4624a5a749994e6b7b14461fa
@@ -12,10 +12,19 @@ namespace ProjectM.Client
/// <summary>Active scheme (<see cref="ProjectM.Simulation.InputSchemeId"/>): 0 = mouse/keyboard, 1 = gamepad.</summary>
public static byte Scheme;
/// <summary>When true, <see cref="AimReticleSystem"/> keeps the OS cursor VISIBLE even while aiming (the
/// editor-only DebugOverlay sets this so its IMGUI buttons stay clickable). Non-#if so the reticle system
/// can read it in any build; nothing sets it outside the editor.</summary>
public static bool ForceCursorVisible;
// Static fields can survive editor domain reloads (fast enter-play-mode); reset on every play-enter so a
// stale gamepad value from a prior session can't briefly hide the cursor / show the world reticle before
// the input gather republishes the real scheme.
[UnityEngine.RuntimeInitializeOnLoadMethod(UnityEngine.RuntimeInitializeLoadType.SubsystemRegistration)]
static void ResetOnEnterPlayMode() => Scheme = ProjectM.Simulation.InputSchemeId.KeyboardMouse;
static void ResetOnEnterPlayMode()
{
Scheme = ProjectM.Simulation.InputSchemeId.KeyboardMouse;
ForceCursorVisible = false;
}
}
}
@@ -144,7 +144,7 @@ namespace ProjectM.Client
// Hide the OS cursor only while aiming AND focused; restore otherwise (focus loss / pre-spawn) so an
// unfocused editor or a windowed session is never stranded with an invisible pointer.
bool wantHidden = haveTarget && Application.isFocused;
bool wantHidden = haveTarget && Application.isFocused && !AimPresentation.ForceCursorVisible;
if (wantHidden != _cursorHidden)
{
if (wantHidden) Cursor.lockState = CursorLockMode.None;
@@ -74,15 +74,13 @@ namespace ProjectM.Client
}
// Ease the drone intensity toward the phase target (tenser during Defend).
float target = phase == CyclePhase.Defend ? AmbientBaseVolume * 1.7f : AmbientBaseVolume;
float target = phase == CyclePhase.Siege ? AmbientBaseVolume * 1.7f : AmbientBaseVolume;
_ambient.volume = Mathf.MoveTowards(_ambient.volume, target, SystemAPI.Time.DeltaTime * 0.25f);
}
void PlaySting(byte phase)
{
AudioClip clip = phase == CyclePhase.Defend ? _stingDefend
: phase == CyclePhase.Build ? _stingBuild
: _stingExpedition;
AudioClip clip = phase == CyclePhase.Siege ? _stingDefend : _stingBuild;
if (clip != null && _ambient != null)
_ambient.PlayOneShot(clip, 0.6f);
}
@@ -59,13 +59,13 @@ namespace ProjectM.Client
{
var endTick = new NetworkTick(cyc.PhaseEndTick);
string detail;
if (cyc.Phase == CyclePhase.Defend)
if (cyc.Phase == CyclePhase.Siege)
detail = "WAVE " + cyc.WaveNumber + " - " + _huskQuery.CalculateEntityCount() + " HUSKS";
else if (haveTick && cyc.PhaseEndTick != 0 && endTick.IsValid && endTick.IsNewerThan(nt.ServerTick))
detail = (endTick.TicksSince(nt.ServerTick) / 60) + "s";
detail = "INCURSION IN " + (endTick.TicksSince(nt.ServerTick) / 60 + 1) + "s";
else
detail = "";
_phaseText.text = PhaseLabel(cyc.Phase) + (detail.Length > 0 ? " - " + detail : "") + " CYCLE " + cyc.CycleNumber;
_phaseText.text = PhaseLabel(cyc.Phase) + (detail.Length > 0 ? " - " + detail : "");
_phaseText.color = PhaseColor(cyc.Phase);
}
else if (_phaseText != null)
@@ -79,7 +79,7 @@ namespace ProjectM.Client
bool onExpedition = cam != null && cam.transform.position.x > 500f;
_locationText.text = onExpedition
? "ON EXPEDITION - return through the gate"
: "AT BASE" + (haveCycle && cyc.Phase == CyclePhase.Expedition ? " - step into the gate to deploy" : "");
: "AT BASE - deploy through the gate when you're ready";
_locationText.color = onExpedition ? new Color(1f, 0.8f, 0.4f) : new Color(0.6f, 0.85f, 1f);
}
@@ -304,9 +304,8 @@ namespace ProjectM.Client
{
switch (phase)
{
case CyclePhase.Expedition: return new Color(0.45f, 0.85f, 1f);
case CyclePhase.Defend: return new Color(1f, 0.5f, 0.3f);
case CyclePhase.Build: return new Color(0.45f, 0.95f, 0.6f);
case CyclePhase.Calm: return new Color(0.45f, 0.9f, 0.7f);
case CyclePhase.Siege: return new Color(1f, 0.45f, 0.3f);
default: return Color.white;
}
}
@@ -315,9 +314,8 @@ namespace ProjectM.Client
{
switch (phase)
{
case CyclePhase.Expedition: return "EXPEDITION";
case CyclePhase.Defend: return "DEFEND";
case CyclePhase.Build: return "BUILD";
case CyclePhase.Calm: return "AT BASE";
case CyclePhase.Siege: return "UNDER SIEGE";
default: return "";
}
}
@@ -40,6 +40,13 @@ namespace ProjectM.Server
{
if (dmg.Length == 0)
continue;
// Dev god-mode: while enabled, this entity ignores ALL damage (server-authoritative, once per tick).
if (SystemAPI.HasComponent<DebugGodMode>(entity) && SystemAPI.IsComponentEnabled<DebugGodMode>(entity))
{
dmg.Clear();
continue;
}
// Respawn invulnerability: a freshly-recovered player ignores damage for a window.
if (haveTick && netTime.ServerTick.IsValid && SystemAPI.HasComponent<RespawnInvuln>(entity))
@@ -41,8 +41,8 @@ namespace ProjectM.Server
if (!serverTick.IsValid)
return;
uint now = serverTick.TickIndexForValidTick;
// M6 Aether Cycle: the base-defense wave only runs during the Defend phase.
if (SystemAPI.TryGetSingleton<CycleState>(out var cycle) && cycle.Phase != CyclePhase.Defend)
// Player-driven loop: the base-defense wave only spawns during a Siege.
if (SystemAPI.TryGetSingleton<CycleState>(out var cycle) && cycle.Phase != CyclePhase.Siege)
return;
var director = SystemAPI.GetSingleton<WaveDirector>();
@@ -0,0 +1,209 @@
#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>());
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;
}
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
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 8c34745ae117284439fa4487f586ad0e
@@ -8,14 +8,14 @@ using Unity.Transforms;
namespace ProjectM.Server
{
/// <summary>
/// Server-only procedural expedition-field manager. Edge-triggered off the cycle phase (via the server-only
/// <see cref="CycleRuntime.PrevPhase"/>): on ENTERING Expedition for a not-yet-seeded cycle it scatters
/// <see cref="ResourceFieldSpawner.Count"/> resource-node ghosts (seeded by CycleNumber via
/// <see cref="Unity.Mathematics.Random"/>) around the expedition region origin, each
/// <see cref="RegionTag"/>{Expedition}; on LEAVING Expedition it destroys every node. Runs in the plain
/// server SimulationSystemGroup <c>[UpdateAfter(CyclePhaseSystem)]</c> so the phase edge is observed the
/// same tick. Server-authoritative; clients despawn nodes via GhostDespawnSystem. Per-cycle reproducible
/// (the seed is the monotonic int CycleNumber, compared by equality — never tick math; never seed 0).
/// Server-only procedural expedition-field manager. Re-keyed off PER-PLAYER PRESENCE (no global phase): it
/// counts players whose server-only <see cref="RegionTag"/> is the Expedition region, and on the
/// empty-&gt;occupied edge (a new sortie) bumps <see cref="CycleRuntime.ExpeditionEpoch"/> and scatters
/// <see cref="ResourceFieldSpawner.Count"/> resource-node ghosts (seeded by the epoch) around the expedition
/// origin, each RegionTag{Expedition}; on the occupied-&gt;empty edge (the LAST player left) it destroys every
/// node. So the field lives as long as anyone is out there, not on a global timer. Plain server
/// SimulationSystemGroup. Server-authoritative; clients despawn nodes via GhostDespawnSystem. Per-epoch
/// reproducible (the seed is the monotonic int epoch, compared by equality — never tick math, never 0).
/// </summary>
[BurstCompile]
[WorldSystemFilter(WorldSystemFilterFlags.ServerSimulation)]
@@ -34,10 +34,21 @@ namespace ProjectM.Server
public void OnUpdate(ref SystemState state)
{
var cycleEntity = SystemAPI.GetSingletonEntity<CycleState>();
var cycle = SystemAPI.GetComponent<CycleState>(cycleEntity);
var runtime = SystemAPI.GetComponent<CycleRuntime>(cycleEntity);
var spawner = SystemAPI.GetSingleton<ResourceFieldSpawner>();
// Per-player presence: is anyone currently out in the expedition region?
int expeditionPlayers = 0;
foreach (var region in SystemAPI.Query<RefRO<RegionTag>>().WithAll<PlayerTag>())
if (region.ValueRO.Region == RegionId.Expedition)
expeditionPlayers++;
bool occupied = expeditionPlayers > 0;
bool wasOccupied = runtime.PrevExpeditionOccupied != 0;
// empty -> occupied: a new sortie begins; bump the epoch so the field reseeds fresh.
if (occupied && !wasOccupied)
runtime.ExpeditionEpoch += 1;
float3 baseCenter = new float3(0f, 1f, 0f);
if (SystemAPI.TryGetSingleton<BaseAnchor>(out var anchor))
baseCenter = BaseGridMath.PlotCenter(anchor);
@@ -45,14 +56,14 @@ namespace ProjectM.Server
var ecb = new EntityCommandBuffer(Allocator.Temp);
// SPAWN edge: entered Expedition for a cycle we have not seeded yet.
if (cycle.Phase == CyclePhase.Expedition
&& runtime.LastSpawnedCycle != cycle.CycleNumber
// SPAWN: a player is out and this epoch has not been seeded yet.
if (occupied
&& runtime.LastSpawnedEpoch != runtime.ExpeditionEpoch
&& spawner.Prefab != Entity.Null)
{
var baseXform = SystemAPI.GetComponent<LocalTransform>(spawner.Prefab);
var prefabNode = SystemAPI.GetComponent<ResourceNode>(spawner.Prefab);
var rng = new Random((uint)math.max(1, cycle.CycleNumber));
var rng = new Random((uint)math.max(1, runtime.ExpeditionEpoch));
int count = math.max(1, spawner.Count);
for (int i = 0; i < count; i++)
{
@@ -69,17 +80,17 @@ namespace ProjectM.Server
rn.ResourceId = (byte)(ResourceId.Aether + (byte)(i % 3));
ecb.SetComponent(node, rn);
}
runtime.LastSpawnedCycle = cycle.CycleNumber;
runtime.LastSpawnedEpoch = runtime.ExpeditionEpoch;
}
// DESTROY edge: left Expedition — clear the whole field.
if (runtime.PrevPhase == CyclePhase.Expedition && cycle.Phase != CyclePhase.Expedition)
// DESTROY: the last player left the expedition — clear the whole field.
if (wasOccupied && !occupied)
{
foreach (var (rn, e) in SystemAPI.Query<RefRO<ResourceNode>>().WithEntityAccess())
ecb.DestroyEntity(e);
}
runtime.PrevPhase = cycle.Phase;
runtime.PrevExpeditionOccupied = (byte)(occupied ? 1 : 0);
SystemAPI.SetComponent(cycleEntity, runtime);
ecb.Playback(state.EntityManager);
@@ -33,7 +33,6 @@ namespace ProjectM.Server
var serverTick = SystemAPI.GetSingleton<NetworkTime>().ServerTick;
if (!serverTick.IsValid)
return;
uint now = serverTick.TickIndexForValidTick;
var spawnerEntity = SystemAPI.GetSingletonEntity<CycleDirectorSpawner>();
var spawner = SystemAPI.GetComponent<CycleDirectorSpawner>(spawnerEntity);
@@ -50,14 +49,15 @@ namespace ProjectM.Server
xform.Position = BaseGridMath.PlotCenter(anchor);
ecb.SetComponent(director, xform);
// Override the baked CycleState with the real start tick; add server-only bookkeeping.
// Boot the run-state in Calm (the persistent default) — no timer; ThreatDirector arms sieges.
ecb.SetComponent(director, new CycleState
{
Phase = CyclePhase.Expedition,
Phase = CyclePhase.Calm,
CycleNumber = 1,
PhaseEndTick = TickUtil.NonZero(now + CyclePhase.ExpeditionTicks),
PhaseEndTick = 0u,
});
ecb.AddComponent(director, new CycleRuntime { DefendStartWave = 0 });
ecb.AddComponent(director, new ThreatState());
}
// One-shot: remove the spawner so RequireForUpdate fails and the system idles.
@@ -1,17 +1,22 @@
using ProjectM.Simulation;
using Unity.Burst;
using Unity.Entities;
using Unity.Mathematics;
using Unity.NetCode;
namespace ProjectM.Server
{
/// <summary>
/// Server-authoritative macro-loop director for "The Aether Cycle": Expedition (timed) -> Defend
/// (wave-driven) -> Build (timed) -> next cycle. Maintains the <see cref="CycleState"/> singleton and gates
/// <see cref="WaveSystem"/> so the base-defense wave only spawns during Defend. Runs in the plain server
/// SimulationSystemGroup (NOT prediction) before <see cref="WaveSystem"/>. All timing is wrap-safe
/// NetworkTick math (<see cref="TickUtil.NonZero"/> + <see cref="Unity.NetCode.NetworkTick.IsNewerThan"/>),
/// never raw uint compares. The CycleState/CycleRuntime live on the runtime-spawned CycleDirector ghost.
/// Server-authoritative macro-loop director for the PLAYER-DRIVEN loop. The base sits in <c>Calm</c>
/// (persistent, unhurried — build/prep at your pace, no countdown) until the <see cref="ThreatState"/> arms a
/// siege, then flips to <c>Siege</c> (the base-defense wave) and back to <c>Calm</c> when the wave is cleared.
/// There is no global "Expedition" phase — being out on an expedition is per-player presence (server-only
/// <see cref="RegionTag"/>), read client-side by the HUD, so one global byte never has to represent
/// "player A out / player B home." Maintains the replicated <see cref="CycleState"/> singleton and gates
/// <see cref="WaveSystem"/> (waves spawn only during Siege). Runs in the plain server SimulationSystemGroup
/// before WaveSystem. All timing is wrap-safe NetworkTick math (<see cref="ProjectM.Simulation.TickUtil.NonZero"/>
/// + <see cref="Unity.NetCode.NetworkTick.IsNewerThan"/>), never raw uint compares. Lives on the
/// runtime-spawned CycleDirector ghost. Supersedes the forced timed Expedition→Defend→Build cycle.
/// </summary>
[BurstCompile]
[WorldSystemFilter(WorldSystemFilterFlags.ServerSimulation)]
@@ -38,48 +43,64 @@ namespace ProjectM.Server
uint now = serverTick.TickIndexForValidTick;
var cycleEntity = SystemAPI.GetSingletonEntity<CycleState>();
var cycle = SystemAPI.GetComponent<CycleState>(cycleEntity);
var runtime = SystemAPI.GetComponent<CycleRuntime>(cycleEntity);
bool timedPhaseDue =
cycle.PhaseEndTick != 0 && !new NetworkTick(cycle.PhaseEndTick).IsNewerThan(serverTick);
switch (cycle.Phase)
if (cycle.Phase == CyclePhase.Calm)
{
case CyclePhase.Expedition:
if (timedPhaseDue)
{
cycle.Phase = CyclePhase.Defend;
cycle.PhaseEndTick = 0; // Defend is wave-driven, not timed.
runtime.DefendStartWave =
SystemAPI.TryGetSingleton<WaveState>(out var w) ? w.WaveNumber : 0;
}
break;
// Default calm: no pending siege => no countdown.
cycle.PhaseEndTick = 0;
case CyclePhase.Defend:
if (DefendCleared(ref state, runtime.DefendStartWave))
if (SystemAPI.HasComponent<ThreatState>(cycleEntity))
{
var threat = SystemAPI.GetComponent<ThreatState>(cycleEntity);
if (threat.PendingSiegeSize > 0)
{
cycle.Phase = CyclePhase.Build;
cycle.PhaseEndTick = TickUtil.NonZero(now + CyclePhase.BuildTicks);
}
break;
// Telegraph: mirror the arm tick into the replicated PhaseEndTick so the HUD can show an
// "incursion in Ns" countdown (reuses the existing HUD countdown path) while it arms.
cycle.PhaseEndTick = threat.ArmTick;
case CyclePhase.Build:
if (timedPhaseDue)
{
cycle.Phase = CyclePhase.Expedition;
cycle.CycleNumber += 1;
cycle.PhaseEndTick = TickUtil.NonZero(now + CyclePhase.ExpeditionTicks);
// Long-arc goal: one charge per completed cycle (single writer).
if (SystemAPI.HasComponent<GoalProgress>(cycleEntity))
bool armed = threat.ArmTick == 0
|| !new NetworkTick(threat.ArmTick).IsNewerThan(serverTick);
if (armed && SystemAPI.TryGetSingletonEntity<WaveState>(out var waveEntity))
{
var goal = SystemAPI.GetComponent<GoalProgress>(cycleEntity);
goal.Charge += 1;
SystemAPI.SetComponent(cycleEntity, goal);
// ---- Calm -> Siege: seed WaveSystem's own Spawning entry atomically. Writing
// Phase=Spawning bypasses its Lull escalation recompute (WaveSystem only recomputes
// RemainingToSpawn while Phase==Lull), so the siege spawns EXACTLY the director-chosen
// size and WaveSystem stays the sole WaveState writer thereafter. ----
var w = SystemAPI.GetComponent<WaveState>(waveEntity);
runtime.DefendStartWave = w.WaveNumber; // capture BEFORE the bump (DefendCleared tests > this)
w.WaveNumber += 1;
w.Phase = WavePhase.Spawning;
w.RemainingToSpawn = math.max(1, threat.PendingSiegeSize);
w.NextActionTick = TickUtil.NonZero(now); // spawn the first Husk this tick
SystemAPI.SetComponent(waveEntity, w);
cycle.Phase = CyclePhase.Siege;
cycle.PhaseEndTick = 0; // Siege is wave-driven, not timed.
threat.PendingSiegeSize = 0; // consume once
threat.ArmTick = 0;
SystemAPI.SetComponent(cycleEntity, threat);
}
}
break;
}
}
else if (cycle.Phase == CyclePhase.Siege)
{
if (DefendCleared(ref state, runtime.DefendStartWave))
{
cycle.Phase = CyclePhase.Calm;
cycle.PhaseEndTick = 0;
// Long-arc goal: +1 per siege survived (single writer; was +1 per completed timed cycle).
if (SystemAPI.HasComponent<GoalProgress>(cycleEntity))
{
var goal = SystemAPI.GetComponent<GoalProgress>(cycleEntity);
goal.Charge += 1;
SystemAPI.SetComponent(cycleEntity, goal);
}
}
}
// Surface the live wave number on the replicated CycleState for the HUD (single writer).
@@ -90,8 +111,8 @@ namespace ProjectM.Server
SystemAPI.SetComponent(cycleEntity, runtime);
}
// The Defend wave has run for this phase (WaveNumber advanced past the captured start), is fully
// spawned, and no Husks remain alive.
// The Siege wave has run for this phase (WaveNumber advanced past the captured start), is fully spawned,
// and no Husks remain alive.
bool DefendCleared(ref SystemState state, int defendStartWave)
{
if (!SystemAPI.TryGetSingleton<WaveState>(out var wave))
@@ -11,15 +11,16 @@ namespace ProjectM.Server
/// Server-only walk-in gate transit: a player who walks within a gate's radius (and whose region matches the
/// gate's <see cref="ExpeditionGate.FromRegion"/>) is transited to the gate's ToRegion at its ArrivalPos
/// (RegionTag flipped + LocalTransform teleported — GhostRelevancy re-scopes their ghosts, as in
/// <c>RegionTransitSystem</c>). Returning to the BASE during the Expedition phase expires the Expedition
/// timer so Defend starts early ("timer cap + early return"). Plain server SimulationSystemGroup
/// <c>[UpdateAfter(CyclePhaseSystem)]</c>. Arrival points are offset from the destination gate so a transited
/// player does not immediately re-trigger.
/// <c>RegionTransitSystem</c>). Returning to BASE signals the ThreatDirector (a completed expedition can draw a
/// retaliation siege) by incrementing <see cref="ProjectM.Simulation.ThreatState.PendingReturns"/>. Plain server
/// SimulationSystemGroup, ordered BEFORE CyclePhaseSystem (Gate -> ThreatDirector -> RunState) so the return is
/// consumed the same tick. Arrival points are offset from the destination gate so a transited player does not
/// immediately re-trigger.
/// </summary>
[BurstCompile]
[WorldSystemFilter(WorldSystemFilterFlags.ServerSimulation)]
[UpdateInGroup(typeof(SimulationSystemGroup))]
[UpdateAfter(typeof(CyclePhaseSystem))]
[UpdateBefore(typeof(CyclePhaseSystem))]
public partial struct ExpeditionGateSystem : ISystem
{
[BurstCompile]
@@ -70,15 +71,14 @@ namespace ProjectM.Server
gatePos.Dispose();
gateArrival.Dispose();
// Early return: a player came back to base mid-Expedition -> expire the Expedition timer (-> Defend).
if (returnedToBase && SystemAPI.TryGetSingletonEntity<CycleState>(out var cycleEntity))
// A player returned to base from an expedition -> signal the ThreatDirector (it sizes/arms any
// retaliation siege). The gate teleports the returner out of its radius, so this fires once per return.
if (returnedToBase && SystemAPI.TryGetSingletonEntity<ThreatState>(out var threatEntity))
{
var cs = SystemAPI.GetComponent<CycleState>(cycleEntity);
if (cs.Phase == CyclePhase.Expedition)
{
cs.PhaseEndTick = 1; // CyclePhaseSystem sees timedPhaseDue next tick -> Defend
SystemAPI.SetComponent(cycleEntity, cs);
}
var threat = SystemAPI.GetComponent<ThreatState>(threatEntity);
threat.PendingReturns += 1;
threat.ExpeditionsCompleted += 1;
SystemAPI.SetComponent(threatEntity, threat);
}
}
}
@@ -0,0 +1,112 @@
using ProjectM.Simulation;
using Unity.Burst;
using Unity.Collections;
using Unity.Entities;
using Unity.Mathematics;
using Unity.NetCode;
namespace ProjectM.Server
{
/// <summary>
/// Server-only composite ThreatDirector — the data-driven base-attack SCHEDULER. It owns the decision of WHEN
/// and HOW BIG a siege is; <see cref="CyclePhaseSystem"/> owns the Calm↔Siege transition. The single documented
/// hand-off is <see cref="ThreatState.PendingSiegeSize"/> (this system sets it; CyclePhaseSystem consumes it).
/// This slice wires ONE source — POST-EXPEDITION retaliation: a player returning to base (counted as
/// <see cref="ThreatState.PendingReturns"/> by <see cref="ExpeditionGateSystem"/>) arms a siege of
/// <see cref="ThreatConfig.SizeBase"/> Husks after a <see cref="ThreatConfig.PostExpeditionDelayTicks"/>
/// telegraph. The Heat/Schedule sources are reserved (config baked-but-inert) so they drop in additively with
/// no re-bake. It also enforces a BOUNDED siege lifetime (<see cref="ThreatConfig.SiegeTimeoutTicks"/>): an
/// unattended siege (e.g. an empty base) auto-collapses so the loop can never soft-lock. Runs in the plain
/// server SimulationSystemGroup, ordered Gate -> ThreatDirector -> RunState(CyclePhaseSystem) -> Wave so a
/// return is consumed the same tick. All timing is wrap-safe NetworkTick math (TickUtil.NonZero +
/// NetworkTick.IsNewerThan / TicksSince), never raw uint.
/// </summary>
[BurstCompile]
[WorldSystemFilter(WorldSystemFilterFlags.ServerSimulation)]
[UpdateInGroup(typeof(SimulationSystemGroup))]
[UpdateAfter(typeof(ExpeditionGateSystem))]
[UpdateBefore(typeof(CyclePhaseSystem))]
public partial struct ThreatDirectorSystem : ISystem
{
EntityQuery m_Husks;
[BurstCompile]
public void OnCreate(ref SystemState state)
{
state.RequireForUpdate<NetworkTime>();
state.RequireForUpdate<CycleState>();
state.RequireForUpdate<ThreatState>();
state.RequireForUpdate<ThreatConfig>();
m_Husks = state.GetEntityQuery(ComponentType.ReadOnly<EnemyTag>());
}
[BurstCompile]
public void OnUpdate(ref SystemState state)
{
var serverTick = SystemAPI.GetSingleton<NetworkTime>().ServerTick;
if (!serverTick.IsValid)
return;
uint now = serverTick.TickIndexForValidTick;
var cycleEntity = SystemAPI.GetSingletonEntity<CycleState>();
var cycle = SystemAPI.GetComponent<CycleState>(cycleEntity);
var threat = SystemAPI.GetComponent<ThreatState>(cycleEntity);
var config = SystemAPI.GetComponent<ThreatConfig>(cycleEntity);
// ---- SOURCE: post-expedition retaliation. A returning player arms ONE siege (simultaneous returns
// collapse to a single arming — extending the de-dup the gate's one-increment-per-return starts). ----
if (config.PostExpeditionEnabled != 0 && threat.PendingReturns > 0)
{
if (cycle.Phase == CyclePhase.Calm && threat.PendingSiegeSize == 0)
{
int size = config.SizeBase + config.SizePerExpeditionResource * 0; // haul-scaling deferred (field baked)
threat.PendingSiegeSize = math.max(1, size);
threat.ArmTick = TickUtil.NonZero(now + config.PostExpeditionDelayTicks);
}
threat.PendingReturns = 0; // consume regardless so returns can't pile up
}
// ---- BOUNDED RESOLUTION: a Siege can't drag forever. Record its start; after SiegeTimeoutTicks cull
// the remaining Husks + stop spawning so CyclePhaseSystem's DefendCleared returns the base to Calm. ----
if (cycle.Phase == CyclePhase.Siege)
{
if (threat.SiegeStartTick == 0)
{
threat.SiegeStartTick = TickUtil.NonZero(now);
}
else if (config.SiegeTimeoutTicks > 0)
{
var start = new NetworkTick(threat.SiegeStartTick);
if (start.IsValid && serverTick.TicksSince(start) > (int)config.SiegeTimeoutTicks)
{
// Collapse the siege: cull every remaining Husk (a cached tag query, never RefRO on a tag).
var husks = m_Husks.ToEntityArray(Allocator.Temp);
if (husks.Length > 0)
{
var ecb = new EntityCommandBuffer(Allocator.Temp);
for (int i = 0; i < husks.Length; i++)
ecb.DestroyEntity(husks[i]);
ecb.Playback(state.EntityManager);
ecb.Dispose();
}
husks.Dispose();
if (SystemAPI.TryGetSingletonEntity<WaveState>(out var waveEntity))
{
var w = SystemAPI.GetComponent<WaveState>(waveEntity);
w.RemainingToSpawn = 0;
SystemAPI.SetComponent(waveEntity, w);
}
threat.SiegeStartTick = 0;
}
}
}
else
{
threat.SiegeStartTick = 0; // not under siege
}
SystemAPI.SetComponent(cycleEntity, threat);
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 3cd1beb28c2b1f84398722a95d1ee784
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: d0bd734fa8e96e047b5651bf8adc98d4
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,64 @@
using Unity.NetCode;
namespace ProjectM.Simulation
{
/// <summary>
/// One-shot dev-tools command from a client to the server (the DebugOverlay / execute_code). A scalar-only
/// <see cref="IRpcCommand"/> (byte opcode + two ints, the project's scalar-RPC convention) so a single wire
/// type covers every dev action via <see cref="DebugOp"/>. **Unconditional (no #if)** — the reflection-built
/// RpcCollection hash must match across release/dev peers, so only the SEND/RECEIVE systems + overlay are
/// #if-gated, never this type. The server applies it authoritatively in DebugCommandReceiveSystem, so the dev
/// buttons exercise the real paths and work over a live connection too.
/// </summary>
public struct DebugCommandRequest : IRpcCommand
{
/// <summary>Which action to perform (see <see cref="DebugOp"/>).</summary>
public byte Op;
/// <summary>First scalar argument (size / item id / region id / amount — per-op).</summary>
public int ArgA;
/// <summary>Second scalar argument (count — per-op).</summary>
public int ArgB;
}
/// <summary>Opcodes for <see cref="DebugCommandRequest.Op"/> (bytes — never an enum on the wire).</summary>
public static class DebugOp
{
/// <summary>Arm + immediately fire a siege of ArgA Husks (ArgA = size).</summary>
public const byte SpawnWave = 0;
/// <summary>Collapse the current siege (cull Husks, stop spawning, clear pending) -> back to Calm.</summary>
public const byte EndSiege = 1;
/// <summary>Cull every living Husk now (leaves the phase alone).</summary>
public const byte ClearEnemies = 2;
/// <summary>Hard-reset the run-state to Calm (clears any siege/pending).</summary>
public const byte SetCalm = 3;
/// <summary>Deposit ArgB of resource ArgA (a <see cref="ResourceId"/>) into the shared ledger.</summary>
public const byte GrantResource = 4;
/// <summary>Grow the sender's damage upgrade by one tier (a debug StatModifier).</summary>
public const byte GrantUpgrade = 5;
/// <summary>Teleport the sender to region ArgA (a <see cref="RegionId"/>).</summary>
public const byte Teleport = 6;
/// <summary>Toggle the sender's <see cref="DebugGodMode"/> (damage immunity).</summary>
public const byte ToggleGod = 7;
/// <summary>Heal the sender to full.</summary>
public const byte Heal = 8;
/// <summary>Kill the sender (Health -> 0; the normal death/respawn loop takes over).</summary>
public const byte KillPlayer = 9;
/// <summary>Add ArgA to the long-arc goal charge.</summary>
public const byte AdvanceGoal = 10;
/// <summary>Set ThreatState.Heat to ArgA (inert until the Heat source ships).</summary>
public const byte SetHeat = 11;
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: bfce4227d5639a84e87b36a1f9b03f50
@@ -0,0 +1,15 @@
using Unity.Entities;
namespace ProjectM.Simulation
{
/// <summary>
/// Dev god-mode marker for a player: while ENABLED, the server skips all damage to this entity
/// (<c>HealthApplyDamageSystem</c> early-outs, exactly like the respawn-invuln window). A server-only
/// ENABLEABLE component (NOT a [GhostField]) — the server is authoritative; the client never needs it.
/// Baked PRESENT but DISABLED on the player prefab (mirrors the <c>Dead</c> gate) so toggling it is a bit
/// flip, never a structural change / sync point. Toggled by the editor-only DebugCommandReceiveSystem. The
/// type is unconditional (it is referenced by the always-compiled HealthApplyDamageSystem); in a player build
/// nothing ever enables it, so the guard is a harmless always-false branch.
/// </summary>
public struct DebugGodMode : IComponentData, IEnableableComponent { }
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 9af8b73ede4319646a3347322999ec36
@@ -26,39 +26,55 @@ namespace ProjectM.Simulation
[GhostField] public int WaveNumber;
}
/// <summary>Phase constants for <see cref="CycleState.Phase"/> (a byte, not an enum, for trivial Burst/serialization).</summary>
/// <summary>Phase constants for <see cref="CycleState.Phase"/> — the GLOBAL shared posture (a byte, not an enum, for trivial Burst/serialization). Being out on an expedition is per-player presence (server-only RegionTag), NOT a global phase.</summary>
public static class CyclePhase
{
/// <summary>Out in the procedural field gathering resources (timed).</summary>
// Re-meaned IN PLACE — the byte VALUES are unchanged from the old Expedition/Defend/Build, so the
// [GhostField] serializer layout is identical (the const re-mean alone forces no re-bake). slot 0 (was
// Expedition) -> Calm; slot 1 (was Defend) -> Siege; slot 2 (was Build) -> retired.
/// <summary>The persistent, unhurried home base — the DEFAULT posture. No countdown; build/prep at your pace.</summary>
public const byte Calm = 0;
/// <summary>The base is under assault by a Husk wave (event-triggered; ends when the wave is cleared).</summary>
public const byte Siege = 1;
// ---- Deprecated aliases (kept so HUD/audio/tests keep compiling through the cut-over; cleaned up later). ----
/// <summary>DEPRECATED alias of <see cref="Calm"/>.</summary>
public const byte Expedition = 0;
/// <summary>The base is under assault by a Husk wave (ends when the wave is cleared).</summary>
/// <summary>DEPRECATED alias of <see cref="Siege"/>.</summary>
public const byte Defend = 1;
/// <summary>Calm at base: spend resources to build/upgrade (timed).</summary>
/// <summary>DEPRECATED, unreachable — the timed Build phase is retired.</summary>
public const byte Build = 2;
/// <summary>Expedition phase duration in server ticks (SimulationTickRate = 60). Tunable; short for the M6 slice.</summary>
public const uint ExpeditionTicks = 3600; // ~60s cap (early return via the gate ends it sooner)
/// <summary>DEPRECATED — the forced Expedition timer is retired (the loop is player-driven). Kept so existing crefs resolve.</summary>
public const uint ExpeditionTicks = 3600;
/// <summary>Build phase duration in server ticks.</summary>
public const uint BuildTicks = 1200; // ~20s
/// <summary>DEPRECATED — the forced Build timer is retired. Kept so existing crefs resolve.</summary>
public const uint BuildTicks = 1200;
}
/// <summary>
/// Server-only bookkeeping for the cycle state machine that must NOT replicate (kept separate from the
/// replicated <see cref="CycleState"/>). Records the wave number captured when the Defend phase began so
/// the director can detect "this Defend's wave has now been spawned and cleared".
/// Server-only bookkeeping for the run-state machine that must NOT replicate (kept separate from the
/// replicated <see cref="CycleState"/>). Records the wave number captured when the current Siege began plus
/// the procedural-expedition-field session epoch (bumped when the expedition region goes empty-&gt;occupied so
/// the field reseeds per sortie).
/// </summary>
public struct CycleRuntime : IComponentData
{
/// <summary>WaveState.WaveNumber captured at the moment the current Defend phase started.</summary>
/// <summary>WaveState.WaveNumber captured the moment the current Siege started (DefendCleared tests &gt; this).</summary>
public int DefendStartWave;
/// <summary>Cycle phase from the previous tick — lets ExpeditionFieldSystem edge-detect entering/leaving Expedition.</summary>
public byte PrevPhase;
/// <summary>Monotonic expedition-field session counter; bumped on the expedition region's empty-&gt;occupied edge so each sortie reseeds. RNG seed (never tick math; never 0, via max(1, ...)).</summary>
public int ExpeditionEpoch;
/// <summary>CycleNumber the expedition field was last seeded for (compared by int equality, never tick math).</summary>
public int LastSpawnedCycle;
/// <summary>The <see cref="ExpeditionEpoch"/> the field was last seeded for (compared by int equality).</summary>
public int LastSpawnedEpoch;
/// <summary>Previous-tick expedition occupancy (1 = at least one player out), for the empty&lt;-&gt;occupied edge.</summary>
public byte PrevExpeditionOccupied;
}
}
@@ -0,0 +1,85 @@
using Unity.Entities;
namespace ProjectM.Simulation
{
/// <summary>
/// Baked, server-only tuning for the composite ThreatDirector (the data-driven base-attack scheduler). Lives
/// on the global CycleDirector entity but is NOT a [GhostField] — the server alone decides when a siege
/// fires; clients learn about it only through the replicated <see cref="CycleState.Phase"/> flip to Siege.
/// Flat scalar fields (never an enum/array — dodges the MCP authoring-drop gotcha + stays Burst-trivial). This
/// slice wires only the POST-EXPEDITION source; the Heat/Schedule fields are baked-but-inert so those sources
/// drop in later additively with NO re-bake (server-only layout, not a ghost serializer change).
/// </summary>
public struct ThreatConfig : IComponentData
{
// ---- Post-expedition retaliation (the only source wired this slice) ----
/// <summary>1 = a completed expedition (a player returning to base) can draw a retaliation siege.</summary>
public byte PostExpeditionEnabled;
/// <summary>Telegraph/arming delay (server ticks) between the trigger and the siege actually spawning.</summary>
public uint PostExpeditionDelayTicks;
/// <summary>Siege size floor (Husk count) for a post-expedition retaliation.</summary>
public int SizeBase;
/// <summary>Extra Husks per unit of resources hauled back this run (0 = a flat <see cref="SizeBase"/> siege).</summary>
public int SizePerExpeditionResource;
/// <summary>How a pending siege starts (see <see cref="ThreatStartCondition"/>).</summary>
public byte StartCondition;
/// <summary>Max server ticks a Siege may run before it auto-collapses (remaining Husks culled) so an unattended/empty-base siege can never soft-lock. 0 = no cap.</summary>
public uint SiegeTimeoutTicks;
// ---- Reserved, present-but-inert this slice (additive later, no re-bake) ----
public byte HeatEnabled;
public float HeatPerTickAtBase;
public float HeatPerHarvest;
public float HeatThreshold;
public byte ScheduleEnabled;
public uint ScheduleIntervalTicks;
}
/// <summary>Start-condition constants for <see cref="ThreatConfig.StartCondition"/> (bytes — never an enum, never in an RPC).</summary>
public static class ThreatStartCondition
{
/// <summary>DEFAULT: arm via the telegraph countdown (<see cref="ThreatState.ArmTick"/>) then fire — even at an empty base.</summary>
public const byte Immediate = 0;
/// <summary>Hold the pending siege until ≥1 player is in the base region OR the arm tick + a grace window elapses (bounded — never a soft-lock).</summary>
public const byte RequirePlayerAtBase = 1;
}
/// <summary>
/// Server-only runtime state of the ThreatDirector, on the global CycleDirector entity beside
/// <see cref="CycleRuntime"/>. NOT replicated. <see cref="PendingSiegeSize"/> is the single documented entry
/// point: any source (post-expedition, dev tools, later Heat/Schedule) sets it; <c>CyclePhaseSystem</c>
/// consumes it on the Calm→Siege edge and zeroes it. All stored ticks are wrap-safe (TickUtil.NonZero +
/// NetworkTick compares), never raw uint.
/// </summary>
public struct ThreatState : IComponentData
{
/// <summary>Husk count of the armed siege; 0 = none pending. Consumed (zeroed) by CyclePhaseSystem at Siege entry.</summary>
public int PendingSiegeSize;
/// <summary>Server tick the pending siege fires (telegraph). 0 = fire as soon as seen. Routed through TickUtil.NonZero.</summary>
public uint ArmTick;
/// <summary>Server tick the current Siege began (0 = not under siege). The bounded-resolution timeout measures from here (TickUtil.NonZero) so an unattended/empty-base siege can never soft-lock.</summary>
public uint SiegeStartTick;
/// <summary>Count of expeditions completed (a player returned to base). Drives the post-expedition source + stats.</summary>
public int ExpeditionsCompleted;
/// <summary>Return events the gate has signalled but the director has not yet consumed (the gate teleports the player out of its radius, so one increment per return — natural de-dup).</summary>
public int PendingReturns;
/// <summary>Accumulated heat (inert this slice; the Heat source reads/writes it later).</summary>
public float Heat;
/// <summary>Next scheduled-siege tick (inert this slice; the Schedule source uses it later). TickUtil.NonZero when used.</summary>
public uint NextScheduledTick;
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 2e66b1e7c715ceb418459c9323853271
@@ -8,10 +8,13 @@ using Unity.NetCode;
namespace ProjectM.Tests
{
/// <summary>
/// Plain-Entities EditMode tests for the server-only <see cref="CyclePhaseSystem"/> — the macro-loop director
/// (Expedition → Defend → Build → next cycle). A bare world is seeded with a NetworkTime singleton and a cycle
/// entity carrying CycleState + CycleRuntime (and optionally WaveState / GoalProgress). All timing is wrap-safe
/// NetworkTick math; these tests pin each phase transition and the per-cycle goal-charge increment.
/// Plain-Entities EditMode tests for the server-only <see cref="CyclePhaseSystem"/> — the PLAYER-DRIVEN
/// run-state director (Calm ↔ Siege). A bare world is seeded with a NetworkTime singleton and a cycle entity
/// carrying CycleState + CycleRuntime (+ optionally ThreatState / WaveState / GoalProgress). The global phase
/// is only ever Calm or Siege — being out on an expedition is per-player presence, NOT a global phase — so
/// these pin: Calm holds with no pending siege; an armed ThreatState.PendingSiegeSize enters Siege and seeds
/// WaveState's Spawning entry at the EXACT size; a cleared Siege returns to Calm and charges the goal once;
/// and split co-op presence never produces a non-Calm phase. All timing is wrap-safe NetworkTick math.
/// </summary>
public class CyclePhaseSystemTests
{
@@ -28,111 +31,132 @@ namespace ProjectM.Tests
return (world, group);
}
static Entity MakeCycle(EntityManager em, byte phase, uint phaseEndTick, int cycleNumber, int defendStartWave)
static Entity MakeCycle(EntityManager em, byte phase, int defendStartWave)
{
var e = em.CreateEntity(typeof(CycleState), typeof(CycleRuntime));
em.SetComponentData(e, new CycleState { Phase = phase, PhaseEndTick = phaseEndTick, CycleNumber = cycleNumber });
em.SetComponentData(e, new CycleState { Phase = phase, PhaseEndTick = 0u, CycleNumber = 1 });
em.SetComponentData(e, new CycleRuntime { DefendStartWave = defendStartWave });
return e;
}
static void MakeWaveState(EntityManager em, int waveNumber, int remainingToSpawn)
static void AddThreat(EntityManager em, Entity cycle, int pendingSiegeSize, uint armTick)
{
em.AddComponentData(cycle, new ThreatState { PendingSiegeSize = pendingSiegeSize, ArmTick = armTick });
}
static Entity MakeWaveState(EntityManager em, int waveNumber, byte phase, int remainingToSpawn)
{
var e = em.CreateEntity(typeof(WaveState));
em.SetComponentData(e, new WaveState { WaveNumber = waveNumber, RemainingToSpawn = remainingToSpawn });
em.SetComponentData(e, new WaveState { WaveNumber = waveNumber, Phase = phase, RemainingToSpawn = remainingToSpawn });
return e;
}
[Test]
public void Expedition_Enters_Defend_When_Timer_Due_Capturing_StartWave()
public void Calm_Holds_When_No_PendingSiege()
{
var (world, group) = MakeWorld("CycleExpToDefend", serverTick: 200);
var (world, group) = MakeWorld("CalmHolds", serverTick: 200);
using (world)
{
var em = world.EntityManager;
var cycle = MakeCycle(em, CyclePhase.Expedition, phaseEndTick: 100, cycleNumber: 1, defendStartWave: 0);
MakeWaveState(em, waveNumber: 5, remainingToSpawn: 0);
var cycle = MakeCycle(em, CyclePhase.Calm, defendStartWave: 0);
AddThreat(em, cycle, pendingSiegeSize: 0, armTick: 0);
group.Update();
var cs = em.GetComponentData<CycleState>(cycle);
Assert.AreEqual(CyclePhase.Defend, cs.Phase, "An expired Expedition timer enters Defend.");
Assert.AreEqual(0u, cs.PhaseEndTick, "Defend is wave-driven, so PhaseEndTick is cleared.");
Assert.AreEqual(CyclePhase.Calm, em.GetComponentData<CycleState>(cycle).Phase,
"With no pending siege the base stays Calm — no forced timer.");
}
}
[Test]
public void PendingSiege_Enters_Siege_And_Seeds_WaveState_Spawning_With_Exact_Size()
{
var (world, group) = MakeWorld("PendingSiege", serverTick: 200);
using (world)
{
var em = world.EntityManager;
var cycle = MakeCycle(em, CyclePhase.Calm, defendStartWave: 0);
AddThreat(em, cycle, pendingSiegeSize: 7, armTick: 0); // armTick 0 => fire immediately
var wave = MakeWaveState(em, waveNumber: 5, phase: WavePhase.Lull, remainingToSpawn: 0);
group.Update();
Assert.AreEqual(CyclePhase.Siege, em.GetComponentData<CycleState>(cycle).Phase,
"An armed pending siege enters Siege.");
var w = em.GetComponentData<WaveState>(wave);
Assert.AreEqual(WavePhase.Spawning, w.Phase,
"WaveState is driven into Spawning (bypassing the Lull escalation recompute).");
Assert.AreEqual(7, w.RemainingToSpawn,
"RemainingToSpawn is the EXACT director-chosen siege size (not the escalation curve).");
Assert.AreEqual(6, w.WaveNumber, "WaveNumber advances by one for the siege.");
Assert.AreEqual(5, em.GetComponentData<CycleRuntime>(cycle).DefendStartWave,
"DefendStartWave captures the current WaveState.WaveNumber.");
"DefendStartWave captures the pre-bump wave number.");
Assert.AreEqual(0, em.GetComponentData<ThreatState>(cycle).PendingSiegeSize,
"The pending siege is consumed (zeroed) so it fires exactly once.");
}
}
[Test]
public void Expedition_Holds_While_Timer_Pending()
public void Siege_Exits_To_Calm_On_DefendCleared_And_Charges_Goal_Once()
{
var (world, group) = MakeWorld("CycleExpHold", serverTick: 200);
var (world, group) = MakeWorld("SiegeClears", serverTick: 200);
using (world)
{
var em = world.EntityManager;
var cycle = MakeCycle(em, CyclePhase.Expedition, phaseEndTick: 5000, cycleNumber: 1, defendStartWave: 0);
group.Update();
Assert.AreEqual(CyclePhase.Expedition, em.GetComponentData<CycleState>(cycle).Phase,
"Expedition holds until its timer is due.");
}
}
[Test]
public void Defend_Enters_Build_When_Wave_Cleared()
{
var (world, group) = MakeWorld("CycleDefendToBuild", serverTick: 200);
using (world)
{
var em = world.EntityManager;
var cycle = MakeCycle(em, CyclePhase.Defend, phaseEndTick: 0, cycleNumber: 1, defendStartWave: 1);
// Wave advanced past the captured start, fully spawned, and no Husks alive (none created).
MakeWaveState(em, waveNumber: 2, remainingToSpawn: 0);
group.Update();
var cs = em.GetComponentData<CycleState>(cycle);
Assert.AreEqual(CyclePhase.Build, cs.Phase, "A cleared Defend wave enters Build.");
Assert.AreNotEqual(0u, cs.PhaseEndTick, "Build is timed, so a PhaseEndTick is stamped.");
}
}
[Test]
public void Build_Enters_Expedition_Incrementing_Cycle_And_Goal()
{
var (world, group) = MakeWorld("CycleBuildToExp", serverTick: 200);
using (world)
{
var em = world.EntityManager;
var cycle = MakeCycle(em, CyclePhase.Build, phaseEndTick: 100, cycleNumber: 1, defendStartWave: 0);
var cycle = MakeCycle(em, CyclePhase.Siege, defendStartWave: 5);
em.AddComponentData(cycle, new GoalProgress { Charge = 0, Target = 10 });
// Wave advanced past the captured start, fully spawned, no Husks alive (none created).
MakeWaveState(em, waveNumber: 6, phase: WavePhase.Spawning, remainingToSpawn: 0);
group.Update();
var cs = em.GetComponentData<CycleState>(cycle);
Assert.AreEqual(CyclePhase.Expedition, cs.Phase, "An expired Build timer starts the next Expedition.");
Assert.AreEqual(2, cs.CycleNumber, "CycleNumber increments on the new cycle.");
Assert.AreEqual(CyclePhase.Calm, em.GetComponentData<CycleState>(cycle).Phase,
"A cleared siege returns to Calm.");
Assert.AreEqual(1, em.GetComponentData<GoalProgress>(cycle).Charge,
"One goal charge accrues per completed cycle (single writer).");
"One goal charge accrues per siege survived (single writer).");
}
}
[Test]
[Test]
public void Coop_Split_Presence_Keeps_Global_Phase_Calm()
{
var (world, group) = MakeWorld("CoopSplit", serverTick: 200);
using (world)
{
var em = world.EntityManager;
var cycle = MakeCycle(em, CyclePhase.Calm, defendStartWave: 0);
AddThreat(em, cycle, pendingSiegeSize: 0, armTick: 0);
// One player out on expedition, one home — the GLOBAL phase machine must ignore presence.
var pOut = em.CreateEntity(typeof(RegionTag), typeof(PlayerTag));
em.SetComponentData(pOut, new RegionTag { Region = RegionId.Expedition });
var pHome = em.CreateEntity(typeof(RegionTag), typeof(PlayerTag));
em.SetComponentData(pHome, new RegionTag { Region = RegionId.Base });
group.Update();
Assert.AreEqual(CyclePhase.Calm, em.GetComponentData<CycleState>(cycle).Phase,
"Split presence (one out, one home) never drives the single global phase — Expedition is per-player.");
}
}
[Test]
public void WaveNumber_Is_Synced_From_WaveState_For_The_Hud()
{
var (world, group) = MakeWorld("CycleWaveSync", serverTick: 200);
var (world, group) = MakeWorld("WaveSync", serverTick: 200);
using (world)
{
var em = world.EntityManager;
var cycle = MakeCycle(em, CyclePhase.Defend, phaseEndTick: 0, cycleNumber: 1, defendStartWave: 1);
MakeWaveState(em, waveNumber: 4, remainingToSpawn: 2);
var cycle = MakeCycle(em, CyclePhase.Siege, defendStartWave: 5);
MakeWaveState(em, waveNumber: 4, phase: WavePhase.Spawning, remainingToSpawn: 2);
group.Update();
Assert.AreEqual(4, em.GetComponentData<CycleState>(cycle).WaveNumber,
"CycleState.WaveNumber mirrors the server-only WaveState.WaveNumber so the replicated-state-only HUD can show it.");
"CycleState.WaveNumber mirrors the server-only WaveState.WaveNumber for the replicated-state-only HUD.");
}
}
}
}
}
@@ -0,0 +1,132 @@
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
{
/// <summary>
/// Plain-Entities EditMode tests for the editor-only <see cref="DebugCommandReceiveSystem"/> — the
/// server-side dev-tools RPC dispatcher. A bare world is seeded with the relevant singletons + a
/// DebugCommandRequest (+ ReceiveRpcCommandRequest) entity. These pin that grant-resource deposits to the
/// ledger, spawn-wave arms the pending siege, end-siege forces the wave to Lull + clears pending, and a
/// sender-targeted teleport resolves the player from the source connection and flips its region.
/// </summary>
public class DebugCommandReceiveSystemTests
{
static (World world, SimulationSystemGroup group) MakeWorld(string name)
{
var world = new World(name);
var group = world.GetOrCreateSystemManaged<SimulationSystemGroup>();
group.AddSystemToUpdateList(world.GetOrCreateSystem<DebugCommandReceiveSystem>());
group.SortSystems();
world.SetTime(new TimeData(elapsedTime: 0f, deltaTime: 1f / 60f));
return (world, group);
}
static Entity MakeRequest(EntityManager em, byte op, int argA, int argB, Entity sourceConnection)
{
var e = em.CreateEntity(typeof(DebugCommandRequest), typeof(ReceiveRpcCommandRequest));
em.SetComponentData(e, new DebugCommandRequest { Op = op, ArgA = argA, ArgB = argB });
em.SetComponentData(e, new ReceiveRpcCommandRequest { SourceConnection = sourceConnection });
return e;
}
[Test]
public void GrantResource_Deposits_To_Ledger()
{
var (world, group) = MakeWorld("DebugGrantResource");
using (world)
{
var em = world.EntityManager;
var ledger = em.CreateEntity(typeof(ResourceLedger), typeof(StorageEntry));
MakeRequest(em, DebugOp.GrantResource, ResourceId.Aether, 50, Entity.Null);
group.Update();
var buf = em.GetBuffer<StorageEntry>(ledger);
int aether = 0;
for (int i = 0; i < buf.Length; i++)
if (buf[i].ItemId == ResourceId.Aether) aether = buf[i].Count;
Assert.AreEqual(50, aether, "GrantResource deposits the amount into the shared ledger.");
}
}
[Test]
public void SpawnWave_Arms_PendingSiege()
{
var (world, group) = MakeWorld("DebugSpawnWave");
using (world)
{
var em = world.EntityManager;
var dir = em.CreateEntity(typeof(CycleState), typeof(ThreatState));
em.SetComponentData(dir, new CycleState { Phase = CyclePhase.Calm });
MakeRequest(em, DebugOp.SpawnWave, 8, 0, Entity.Null);
group.Update();
Assert.AreEqual(8, em.GetComponentData<ThreatState>(dir).PendingSiegeSize,
"SpawnWave arms a pending siege of the requested size.");
}
}
[Test]
public void EndSiege_Forces_WaveState_Lull_And_Clears_Pending()
{
var (world, group) = MakeWorld("DebugEndSiege");
using (world)
{
var em = world.EntityManager;
var dir = em.CreateEntity(typeof(CycleState), typeof(ThreatState));
em.SetComponentData(dir, new CycleState { Phase = CyclePhase.Siege });
em.SetComponentData(dir, new ThreatState { PendingSiegeSize = 5, SiegeStartTick = 100 });
var wave = em.CreateEntity(typeof(WaveState));
em.SetComponentData(wave, new WaveState { Phase = WavePhase.Spawning, RemainingToSpawn = 3 });
for (int i = 0; i < 2; i++)
em.CreateEntity(typeof(EnemyTag));
MakeRequest(em, DebugOp.EndSiege, 0, 0, Entity.Null);
group.Update();
var w = em.GetComponentData<WaveState>(wave);
Assert.AreEqual(WavePhase.Lull, w.Phase, "EndSiege drives the wave to Lull.");
Assert.AreEqual(0, w.RemainingToSpawn, "EndSiege stops further spawning.");
Assert.AreEqual(0, em.GetComponentData<ThreatState>(dir).PendingSiegeSize, "EndSiege clears any pending siege.");
using var husks = em.CreateEntityQuery(typeof(EnemyTag));
Assert.AreEqual(0, husks.CalculateEntityCount(), "EndSiege culls the remaining Husks.");
}
}
[Test]
public void Teleport_Resolves_Sender_Only()
{
var (world, group) = MakeWorld("DebugTeleport");
using (world)
{
var em = world.EntityManager;
var connection = em.CreateEntity(typeof(NetworkId));
em.SetComponentData(connection, new NetworkId { Value = 1 });
var player = em.CreateEntity(typeof(PlayerTag), typeof(GhostOwner), typeof(RegionTag), typeof(LocalTransform));
em.SetComponentData(player, new GhostOwner { NetworkId = 1 });
em.SetComponentData(player, new RegionTag { Region = RegionId.Base });
em.SetComponentData(player, LocalTransform.FromPosition(new float3(0, 1, 0)));
MakeRequest(em, DebugOp.Teleport, RegionId.Expedition, 0, connection);
group.Update();
Assert.AreEqual(RegionId.Expedition, em.GetComponentData<RegionTag>(player).Region,
"Teleport flips the SENDER's region.");
Assert.Greater(em.GetComponentData<LocalTransform>(player).Position.x, 500f,
"Teleport moves the sender to the expedition region (far +X).");
}
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 8b91e9e729b69964f8b9c2e2b1f5ec2f
@@ -13,8 +13,8 @@ namespace ProjectM.Tests
/// transit). A bare world is seeded with an <c>ExpeditionGate</c> (+ LocalTransform) and a player
/// (RegionTag + LocalTransform + PlayerTag). A player whose region matches the gate's FromRegion and who is
/// within the gate radius is transited (RegionTag flipped + LocalTransform teleported to ArrivalPos).
/// Returning to base during the Expedition phase caps the cycle phase timer. Pins the proximity gate, the
/// region/radius guards, and the early-return phase cap.
/// Returning to base signals the ThreatDirector (the post-expedition retaliation source) exactly once. Pins
/// the proximity gate, the region/radius guards, and the return signal.
/// </summary>
public class ExpeditionGateSystemTests
{
@@ -101,22 +101,24 @@ namespace ProjectM.Tests
}
[Test]
public void Return_To_Base_During_Expedition_Caps_The_Phase_Timer()
public void Return_To_Base_Signals_ThreatDirector_Once()
{
var (world, group) = MakeWorld("GateReturnCapWorld");
var (world, group) = MakeWorld("GateReturnSignalWorld");
using (world)
{
var em = world.EntityManager;
MakeGate(em, new float3(0, 1, 0), RegionId.Expedition, RegionId.Base, radius: 15f, arrival: new float3(0, 1, 0));
MakePlayer(em, new float3(3, 1, 0), RegionId.Expedition);
var cycle = em.CreateEntity(typeof(CycleState));
em.SetComponentData(cycle, new CycleState { Phase = CyclePhase.Expedition, PhaseEndTick = 5000, CycleNumber = 1 });
var threat = em.CreateEntity(typeof(ThreatState));
em.SetComponentData(threat, new ThreatState());
group.Update();
Assert.AreEqual(1u, em.GetComponentData<CycleState>(cycle).PhaseEndTick,
"Returning to base mid-Expedition caps PhaseEndTick to 1 so Defend starts next tick.");
var ts = em.GetComponentData<ThreatState>(threat);
Assert.AreEqual(1, ts.PendingReturns,
"Returning to base signals the ThreatDirector exactly once (the gate teleports the returner out of its radius).");
Assert.AreEqual(1, ts.ExpeditionsCompleted, "A completed expedition is counted.");
}
}
}
@@ -130,5 +130,29 @@ namespace ProjectM.Tests
"Health.Current must be untouched when there are no DamageEvents.");
}
}
[Test]
public void God_Mode_Skips_All_Damage()
{
var (world, group) = MakeWorld("HealthApplyDamageGodModeWorld");
using (world)
{
var em = world.EntityManager;
var entity = em.CreateEntity(typeof(Health), typeof(DamageEvent), typeof(DebugGodMode));
em.SetComponentData(entity, new Health { Current = 50f, Max = 50f });
em.SetComponentEnabled<DebugGodMode>(entity, true);
var dmg = em.GetBuffer<DamageEvent>(entity);
dmg.Add(new DamageEvent { Amount = 80f, SourceNetworkId = 9 });
group.Update();
Assert.AreEqual(50f, em.GetComponentData<Health>(entity).Current, 1e-4f,
"An enabled DebugGodMode entity ignores all damage.");
Assert.AreEqual(0, em.GetBuffer<DamageEvent>(entity).Length,
"The damage buffer is still drained (cleared) under god-mode.");
}
}
}
}
@@ -0,0 +1,155 @@
using NUnit.Framework;
using ProjectM.Server;
using ProjectM.Simulation;
using Unity.Core;
using Unity.Entities;
using Unity.NetCode;
namespace ProjectM.Tests
{
/// <summary>
/// Plain-Entities EditMode tests for the server-only <see cref="ThreatDirectorSystem"/> — the composite
/// base-attack scheduler. A bare world is seeded with a NetworkTime singleton and a CycleDirector entity
/// carrying CycleState + ThreatState + ThreatConfig. These pin the post-expedition source (a return arms a
/// siege of the configured size, with simultaneous returns de-duped to one), that the event-siege size is the
/// config floor — never the WaveSystem escalation curve — that the telegraph ArmTick is now + delay, and that
/// an unattended siege auto-collapses after the timeout (no soft-lock). All timing is wrap-safe NetworkTick.
/// </summary>
public class ThreatDirectorSystemTests
{
static (World world, SimulationSystemGroup group) MakeWorld(string name, uint serverTick)
{
var world = new World(name);
var group = world.GetOrCreateSystemManaged<SimulationSystemGroup>();
group.AddSystemToUpdateList(world.GetOrCreateSystem<ThreatDirectorSystem>());
group.SortSystems();
world.SetTime(new TimeData(elapsedTime: 0f, deltaTime: 1f / 60f));
var em = world.EntityManager;
var nt = em.CreateEntity(typeof(NetworkTime));
em.SetComponentData(nt, new NetworkTime { ServerTick = new NetworkTick(serverTick) });
return (world, group);
}
static ThreatConfig DefaultConfig() => new ThreatConfig
{
PostExpeditionEnabled = 1,
PostExpeditionDelayTicks = 300,
SizeBase = 5,
SizePerExpeditionResource = 0,
StartCondition = ThreatStartCondition.Immediate,
SiegeTimeoutTicks = 3600,
};
static Entity MakeDirector(EntityManager em, byte phase, ThreatState threat, ThreatConfig config)
{
var e = em.CreateEntity(typeof(CycleState), typeof(ThreatState), typeof(ThreatConfig));
em.SetComponentData(e, new CycleState { Phase = phase, CycleNumber = 1 });
em.SetComponentData(e, threat);
em.SetComponentData(e, config);
return e;
}
[Test]
public void PostExpedition_Return_Edge_Sets_PendingSiegeSize()
{
var (world, group) = MakeWorld("ThreatReturn", serverTick: 200);
using (world)
{
var em = world.EntityManager;
var dir = MakeDirector(em, CyclePhase.Calm, new ThreatState { PendingReturns = 1 }, DefaultConfig());
group.Update();
var ts = em.GetComponentData<ThreatState>(dir);
Assert.AreEqual(5, ts.PendingSiegeSize, "A return arms a siege of SizeBase Husks.");
Assert.AreNotEqual(0u, ts.ArmTick, "The siege is armed with a telegraph tick.");
Assert.AreEqual(0, ts.PendingReturns, "The return is consumed.");
}
}
[Test]
public void Multi_Player_Simultaneous_Return_Charges_Pending_Once()
{
var (world, group) = MakeWorld("ThreatMultiReturn", serverTick: 200);
using (world)
{
var em = world.EntityManager;
var dir = MakeDirector(em, CyclePhase.Calm, new ThreatState { PendingReturns = 3 }, DefaultConfig());
group.Update();
var ts = em.GetComponentData<ThreatState>(dir);
Assert.AreEqual(5, ts.PendingSiegeSize, "Three simultaneous returns still arm exactly one siege (de-dup).");
Assert.AreEqual(0, ts.PendingReturns, "All returns are consumed.");
}
}
[Test]
public void Siege_Size_Equals_Config_Not_Escalation_Curve()
{
var (world, group) = MakeWorld("ThreatSizeConfig", serverTick: 200);
using (world)
{
var em = world.EntityManager;
var dir = MakeDirector(em, CyclePhase.Calm, new ThreatState { PendingReturns = 1 }, DefaultConfig());
// A high wave number must NOT influence the event-siege size.
var w = em.CreateEntity(typeof(WaveState));
em.SetComponentData(w, new WaveState { WaveNumber = 30 });
group.Update();
Assert.AreEqual(5, em.GetComponentData<ThreatState>(dir).PendingSiegeSize,
"Event-siege size is the config SizeBase, never the WaveSystem escalation curve.");
}
}
[Test]
public void StartCondition_Immediate_Arms_Via_ArmTick()
{
var (world, group) = MakeWorld("ThreatArm", serverTick: 1000);
using (world)
{
var em = world.EntityManager;
var config = DefaultConfig();
config.PostExpeditionDelayTicks = 120;
var dir = MakeDirector(em, CyclePhase.Calm, new ThreatState { PendingReturns = 1 }, config);
group.Update();
Assert.AreEqual(1120u, em.GetComponentData<ThreatState>(dir).ArmTick,
"Immediate start arms the siege at now + the telegraph delay (1000 + 120).");
}
}
[Test]
public void Empty_Base_Siege_Auto_Resolves_Bounded()
{
var (world, group) = MakeWorld("ThreatTimeout", serverTick: 200);
using (world)
{
var em = world.EntityManager;
var config = DefaultConfig();
config.SiegeTimeoutTicks = 60;
// SiegeStartTick 100, now 200 => 100 ticks elapsed > 60 timeout.
var dir = MakeDirector(em, CyclePhase.Siege, new ThreatState { SiegeStartTick = 100 }, config);
var w = em.CreateEntity(typeof(WaveState));
em.SetComponentData(w, new WaveState { RemainingToSpawn = 2, Phase = WavePhase.Spawning });
// Three Husks still on the field with no one to clear them.
for (int i = 0; i < 3; i++)
em.CreateEntity(typeof(EnemyTag));
group.Update();
using var huskQuery = em.CreateEntityQuery(typeof(EnemyTag));
Assert.AreEqual(0, huskQuery.CalculateEntityCount(),
"A timed-out (unattended) siege culls the remaining Husks so it can never soft-lock.");
Assert.AreEqual(0, em.GetComponentData<WaveState>(w).RemainingToSpawn,
"The wave stops spawning when the siege collapses.");
Assert.AreEqual(0u, em.GetComponentData<ThreatState>(dir).SiegeStartTick,
"The siege clock resets after collapse.");
}
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 351a99057b08e3847b239782bfef893e