Game Scene Split up
This commit is contained in:
@@ -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->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->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->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 > 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->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<->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
|
||||
Reference in New Issue
Block a user