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
@@ -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