Game Scene Split up
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,7 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 1d4b5e92ac7b6ea4880d30545df94704
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,7 @@
|
||||
fileFormatVersion: 2
|
||||
guid: db8738f16e802c8488f9c22aa56e1e60
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
Binary file not shown.
@@ -0,0 +1,117 @@
|
||||
fileFormatVersion: 2
|
||||
guid: b3300c1dfff5c024b823d2f27af90e37
|
||||
TextureImporter:
|
||||
internalIDToNameTable: []
|
||||
externalObjects: {}
|
||||
serializedVersion: 13
|
||||
mipmaps:
|
||||
mipMapMode: 0
|
||||
enableMipMap: 1
|
||||
sRGBTexture: 1
|
||||
linearTexture: 0
|
||||
fadeOut: 0
|
||||
borderMipMap: 0
|
||||
mipMapsPreserveCoverage: 0
|
||||
alphaTestReferenceValue: 0.5
|
||||
mipMapFadeDistanceStart: 1
|
||||
mipMapFadeDistanceEnd: 3
|
||||
bumpmap:
|
||||
convertToNormalMap: 0
|
||||
externalNormalMap: 0
|
||||
heightScale: 0.25
|
||||
normalMapFilter: 0
|
||||
flipGreenChannel: 0
|
||||
isReadable: 0
|
||||
streamingMipmaps: 0
|
||||
streamingMipmapsPriority: 0
|
||||
vTOnly: 0
|
||||
ignoreMipmapLimit: 0
|
||||
grayScaleToAlpha: 0
|
||||
generateCubemap: 6
|
||||
cubemapConvolution: 0
|
||||
seamlessCubemap: 0
|
||||
textureFormat: 1
|
||||
maxTextureSize: 2048
|
||||
textureSettings:
|
||||
serializedVersion: 2
|
||||
filterMode: 1
|
||||
aniso: 1
|
||||
mipBias: 0
|
||||
wrapU: 0
|
||||
wrapV: 0
|
||||
wrapW: 0
|
||||
nPOTScale: 1
|
||||
lightmap: 0
|
||||
compressionQuality: 50
|
||||
spriteMode: 0
|
||||
spriteExtrude: 1
|
||||
spriteMeshType: 1
|
||||
alignment: 0
|
||||
spritePivot: {x: 0.5, y: 0.5}
|
||||
spritePixelsToUnits: 100
|
||||
spriteBorder: {x: 0, y: 0, z: 0, w: 0}
|
||||
spriteGenerateFallbackPhysicsShape: 1
|
||||
alphaUsage: 1
|
||||
alphaIsTransparency: 0
|
||||
spriteTessellationDetail: -1
|
||||
textureType: 0
|
||||
textureShape: 1
|
||||
singleChannelComponent: 0
|
||||
flipbookRows: 1
|
||||
flipbookColumns: 1
|
||||
maxTextureSizeSet: 0
|
||||
compressionQualitySet: 0
|
||||
textureFormatSet: 0
|
||||
ignorePngGamma: 0
|
||||
applyGammaDecoding: 0
|
||||
swizzle: 50462976
|
||||
cookieLightType: 0
|
||||
platformSettings:
|
||||
- serializedVersion: 4
|
||||
buildTarget: DefaultTexturePlatform
|
||||
maxTextureSize: 2048
|
||||
resizeAlgorithm: 0
|
||||
textureFormat: -1
|
||||
textureCompression: 1
|
||||
compressionQuality: 50
|
||||
crunchedCompression: 0
|
||||
allowsAlphaSplitting: 0
|
||||
overridden: 0
|
||||
ignorePlatformSupport: 0
|
||||
androidETC2FallbackOverride: 0
|
||||
forceMaximumCompressionQuality_BC6H_BC7: 0
|
||||
- serializedVersion: 4
|
||||
buildTarget: Standalone
|
||||
maxTextureSize: 2048
|
||||
resizeAlgorithm: 0
|
||||
textureFormat: -1
|
||||
textureCompression: 1
|
||||
compressionQuality: 50
|
||||
crunchedCompression: 0
|
||||
allowsAlphaSplitting: 0
|
||||
overridden: 0
|
||||
ignorePlatformSupport: 0
|
||||
androidETC2FallbackOverride: 0
|
||||
forceMaximumCompressionQuality_BC6H_BC7: 0
|
||||
spriteSheet:
|
||||
serializedVersion: 2
|
||||
sprites: []
|
||||
outline: []
|
||||
customData:
|
||||
physicsShape: []
|
||||
bones: []
|
||||
spriteID:
|
||||
internalID: 0
|
||||
vertices: []
|
||||
indices:
|
||||
edges: []
|
||||
weights: []
|
||||
secondaryTextures: []
|
||||
spriteCustomMetadata:
|
||||
entries: []
|
||||
nameFileIdTable: {}
|
||||
mipmapLimitGroupName:
|
||||
pSDRemoveMatte: 0
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -81,6 +81,10 @@ namespace ProjectM.Authoring
|
||||
// plus the server-only respawn timer.
|
||||
AddComponent<Dead>(entity);
|
||||
SetComponentEnabled<Dead>(entity, false);
|
||||
// Dev god-mode gate (enableable, server-only) baked DISABLED so toggling it is a bit flip, never structural.
|
||||
AddComponent<DebugGodMode>(entity);
|
||||
SetComponentEnabled<DebugGodMode>(entity, false);
|
||||
|
||||
AddComponent(entity, new RespawnState { RespawnTick = 0, DelayTicks = authoring.RespawnDelayTicks, InvulnTicks = authoring.RespawnInvulnTicks });
|
||||
AddComponent(entity, new RespawnInvuln { UntilTick = 0 });
|
||||
}
|
||||
|
||||
@@ -15,6 +15,22 @@ namespace ProjectM.Authoring
|
||||
/// </summary>
|
||||
public class CycleDirectorAuthoring : MonoBehaviour
|
||||
{
|
||||
[Header("Threat — post-expedition retaliation")]
|
||||
[Tooltip("A completed expedition (a player returning to base) can draw a retaliation siege.")]
|
||||
public bool PostExpeditionEnabled = true;
|
||||
|
||||
[Tooltip("Telegraph/arming delay (server ticks @60) between the return and the siege spawning.")]
|
||||
public uint PostExpeditionDelayTicks = 300;
|
||||
|
||||
[Tooltip("Siege size floor (Husk count) for a post-expedition retaliation.")]
|
||||
public int SiegeSizeBase = 5;
|
||||
|
||||
[Tooltip("Extra Husks per unit of resources hauled back this run (0 = flat SiegeSizeBase).")]
|
||||
public int SiegeSizePerResource = 0;
|
||||
|
||||
[Tooltip("Max server ticks a siege may run before it auto-collapses (no soft-lock). 0 = no cap.")]
|
||||
public uint SiegeTimeoutTicks = 3600;
|
||||
|
||||
private class CycleDirectorBaker : Baker<CycleDirectorAuthoring>
|
||||
{
|
||||
public override void Bake(CycleDirectorAuthoring authoring)
|
||||
@@ -22,13 +38,22 @@ namespace ProjectM.Authoring
|
||||
var entity = GetEntity(authoring, TransformUsageFlags.Dynamic);
|
||||
AddComponent(entity, new CycleState
|
||||
{
|
||||
Phase = CyclePhase.Expedition,
|
||||
Phase = CyclePhase.Calm,
|
||||
CycleNumber = 1,
|
||||
PhaseEndTick = 0u,
|
||||
});
|
||||
AddComponent<ResourceLedger>(entity);
|
||||
AddBuffer<StorageEntry>(entity);
|
||||
AddComponent(entity, new GoalProgress { Charge = 0, Target = 10 });
|
||||
AddComponent(entity, new ThreatConfig
|
||||
{
|
||||
PostExpeditionEnabled = (byte)(authoring.PostExpeditionEnabled ? 1 : 0),
|
||||
PostExpeditionDelayTicks = authoring.PostExpeditionDelayTicks,
|
||||
SizeBase = authoring.SiegeSizeBase,
|
||||
SizePerExpeditionResource = authoring.SiegeSizePerResource,
|
||||
StartCondition = ThreatStartCondition.Immediate,
|
||||
SiegeTimeoutTicks = authoring.SiegeTimeoutTicks,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 527bfff0c1216544093c653466a5e5af
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,64 @@
|
||||
#if UNITY_EDITOR
|
||||
using System.Collections.Generic;
|
||||
using ProjectM.Simulation;
|
||||
using Unity.Entities;
|
||||
using Unity.NetCode;
|
||||
using UnityEngine;
|
||||
|
||||
namespace ProjectM.Client
|
||||
{
|
||||
/// <summary>
|
||||
/// EDITOR-ONLY client sender for dev-tool <see cref="DebugCommandRequest"/> RPCs. Mirrors
|
||||
/// <c>StorageOpSendSystem</c>: static convenience methods enqueue into a queue that this client
|
||||
/// <see cref="SystemBase"/> drains into request entities each tick (so it works from the DebugOverlay's IMGUI
|
||||
/// AND headless from execute_code). The statics are reset on play-enter so a fast-enter-playmode reload can't
|
||||
/// replay a stale queue. The wire type is unconditional; this system is #if UNITY_EDITOR (stripped from builds).
|
||||
/// </summary>
|
||||
[WorldSystemFilter(WorldSystemFilterFlags.ClientSimulation)]
|
||||
public partial class DebugCommandSendSystem : SystemBase
|
||||
{
|
||||
struct Pending { public byte Op; public int ArgA; public int ArgB; }
|
||||
|
||||
static readonly List<Pending> s_Pending = new List<Pending>();
|
||||
|
||||
/// <summary>Queue a raw dev command for the next client tick.</summary>
|
||||
public static void Send(byte op, int argA = 0, int argB = 0)
|
||||
=> s_Pending.Add(new Pending { Op = op, ArgA = argA, ArgB = argB });
|
||||
|
||||
// Convenience wrappers (overlay buttons + execute_code).
|
||||
public static void SpawnWave(int size) => Send(DebugOp.SpawnWave, size);
|
||||
public static void EndSiege() => Send(DebugOp.EndSiege);
|
||||
public static void ClearEnemies() => Send(DebugOp.ClearEnemies);
|
||||
public static void SetCalm() => Send(DebugOp.SetCalm);
|
||||
public static void GrantResource(byte itemId, int count) => Send(DebugOp.GrantResource, itemId, count);
|
||||
public static void GrantUpgrade() => Send(DebugOp.GrantUpgrade);
|
||||
public static void Teleport(byte region) => Send(DebugOp.Teleport, region);
|
||||
public static void ToggleGod() => Send(DebugOp.ToggleGod);
|
||||
public static void Heal() => Send(DebugOp.Heal);
|
||||
public static void Kill() => Send(DebugOp.KillPlayer);
|
||||
public static void AdvanceGoal(int by) => Send(DebugOp.AdvanceGoal, by);
|
||||
public static void SetHeat(int heat) => Send(DebugOp.SetHeat, heat);
|
||||
|
||||
[RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.SubsystemRegistration)]
|
||||
static void ResetOnEnterPlayMode() => s_Pending.Clear();
|
||||
|
||||
protected override void OnUpdate()
|
||||
{
|
||||
if (s_Pending.Count == 0)
|
||||
return;
|
||||
if (!SystemAPI.TryGetSingletonEntity<NetworkId>(out var connection))
|
||||
return; // not connected yet — hold the queue
|
||||
|
||||
var em = EntityManager;
|
||||
for (int i = 0; i < s_Pending.Count; i++)
|
||||
{
|
||||
var p = s_Pending[i];
|
||||
var req = em.CreateEntity();
|
||||
em.AddComponentData(req, new DebugCommandRequest { Op = p.Op, ArgA = p.ArgA, ArgB = p.ArgB });
|
||||
em.AddComponentData(req, new SendRpcCommandRequest { TargetConnection = connection });
|
||||
}
|
||||
s_Pending.Clear();
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 6f0d6e2babc53f3489bcd6f7988d31ca
|
||||
@@ -0,0 +1,78 @@
|
||||
#if UNITY_EDITOR
|
||||
using ProjectM.Simulation;
|
||||
using UnityEngine;
|
||||
|
||||
namespace ProjectM.Client
|
||||
{
|
||||
/// <summary>
|
||||
/// EDITOR-ONLY in-game dev-tools overlay (IMGUI, mirrors <c>ConnectionUI</c>). Buttons enqueue
|
||||
/// <see cref="DebugCommandRequest"/> RPCs through <see cref="DebugCommandSendSystem"/>, so they drive the REAL
|
||||
/// server-authoritative paths (and work over a live connection too, not just in-editor). Drop it on a
|
||||
/// GameObject in the DevSandbox (or Game) scene. While open it forces the OS cursor visible
|
||||
/// (<see cref="AimPresentation.ForceCursorVisible"/>) so its buttons stay clickable even while aiming.
|
||||
/// Stripped from player builds (#if UNITY_EDITOR).
|
||||
/// </summary>
|
||||
public class DebugOverlay : MonoBehaviour
|
||||
{
|
||||
bool _open = true;
|
||||
int _siegeSize = 5;
|
||||
int _grantAmount = 50;
|
||||
|
||||
void OnDisable() => AimPresentation.ForceCursorVisible = false;
|
||||
|
||||
void OnGUI()
|
||||
{
|
||||
if (GUI.Button(new Rect(Screen.width - 96, 10, 86, 24), _open ? "DEV ▲" : "DEV ▼"))
|
||||
_open = !_open;
|
||||
|
||||
AimPresentation.ForceCursorVisible = _open;
|
||||
if (!_open)
|
||||
return;
|
||||
|
||||
GUILayout.BeginArea(new Rect(Screen.width - 232, 40, 222, 540), GUI.skin.box);
|
||||
GUILayout.Label("DEV TOOLS");
|
||||
|
||||
GUILayout.Label("- World -");
|
||||
_siegeSize = IntField("Siege size", _siegeSize);
|
||||
if (GUILayout.Button("Spawn Wave / Force Siege")) DebugCommandSendSystem.SpawnWave(_siegeSize);
|
||||
if (GUILayout.Button("End Siege")) DebugCommandSendSystem.EndSiege();
|
||||
if (GUILayout.Button("Clear Enemies")) DebugCommandSendSystem.ClearEnemies();
|
||||
if (GUILayout.Button("Force Calm")) DebugCommandSendSystem.SetCalm();
|
||||
if (GUILayout.Button("Advance Goal +1")) DebugCommandSendSystem.AdvanceGoal(1);
|
||||
|
||||
GUILayout.Space(6);
|
||||
GUILayout.Label("- Resources -");
|
||||
_grantAmount = IntField("Amount", _grantAmount);
|
||||
GUILayout.BeginHorizontal();
|
||||
if (GUILayout.Button("Aether")) DebugCommandSendSystem.GrantResource(ResourceId.Aether, _grantAmount);
|
||||
if (GUILayout.Button("Ore")) DebugCommandSendSystem.GrantResource(ResourceId.Ore, _grantAmount);
|
||||
if (GUILayout.Button("Bio")) DebugCommandSendSystem.GrantResource(ResourceId.Biomass, _grantAmount);
|
||||
GUILayout.EndHorizontal();
|
||||
if (GUILayout.Button("Grant Damage Upgrade")) DebugCommandSendSystem.GrantUpgrade();
|
||||
|
||||
GUILayout.Space(6);
|
||||
GUILayout.Label("- Player -");
|
||||
GUILayout.BeginHorizontal();
|
||||
if (GUILayout.Button("Heal")) DebugCommandSendSystem.Heal();
|
||||
if (GUILayout.Button("Kill")) DebugCommandSendSystem.Kill();
|
||||
if (GUILayout.Button("God")) DebugCommandSendSystem.ToggleGod();
|
||||
GUILayout.EndHorizontal();
|
||||
GUILayout.BeginHorizontal();
|
||||
if (GUILayout.Button("Go Base")) DebugCommandSendSystem.Teleport(RegionId.Base);
|
||||
if (GUILayout.Button("Go Expedition")) DebugCommandSendSystem.Teleport(RegionId.Expedition);
|
||||
GUILayout.EndHorizontal();
|
||||
|
||||
GUILayout.EndArea();
|
||||
}
|
||||
|
||||
static int IntField(string label, int value)
|
||||
{
|
||||
GUILayout.BeginHorizontal();
|
||||
GUILayout.Label(label, GUILayout.Width(70));
|
||||
string s = GUILayout.TextField(value.ToString(), GUILayout.Width(60));
|
||||
GUILayout.EndHorizontal();
|
||||
return int.TryParse(s, out var v) ? v : value;
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: d86864d4624a5a749994e6b7b14461fa
|
||||
@@ -12,10 +12,19 @@ namespace ProjectM.Client
|
||||
/// <summary>Active scheme (<see cref="ProjectM.Simulation.InputSchemeId"/>): 0 = mouse/keyboard, 1 = gamepad.</summary>
|
||||
public static byte Scheme;
|
||||
|
||||
/// <summary>When true, <see cref="AimReticleSystem"/> keeps the OS cursor VISIBLE even while aiming (the
|
||||
/// editor-only DebugOverlay sets this so its IMGUI buttons stay clickable). Non-#if so the reticle system
|
||||
/// can read it in any build; nothing sets it outside the editor.</summary>
|
||||
public static bool ForceCursorVisible;
|
||||
|
||||
// Static fields can survive editor domain reloads (fast enter-play-mode); reset on every play-enter so a
|
||||
// stale gamepad value from a prior session can't briefly hide the cursor / show the world reticle before
|
||||
// the input gather republishes the real scheme.
|
||||
[UnityEngine.RuntimeInitializeOnLoadMethod(UnityEngine.RuntimeInitializeLoadType.SubsystemRegistration)]
|
||||
static void ResetOnEnterPlayMode() => Scheme = ProjectM.Simulation.InputSchemeId.KeyboardMouse;
|
||||
static void ResetOnEnterPlayMode()
|
||||
{
|
||||
Scheme = ProjectM.Simulation.InputSchemeId.KeyboardMouse;
|
||||
ForceCursorVisible = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -144,7 +144,7 @@ namespace ProjectM.Client
|
||||
|
||||
// Hide the OS cursor only while aiming AND focused; restore otherwise (focus loss / pre-spawn) so an
|
||||
// unfocused editor or a windowed session is never stranded with an invisible pointer.
|
||||
bool wantHidden = haveTarget && Application.isFocused;
|
||||
bool wantHidden = haveTarget && Application.isFocused && !AimPresentation.ForceCursorVisible;
|
||||
if (wantHidden != _cursorHidden)
|
||||
{
|
||||
if (wantHidden) Cursor.lockState = CursorLockMode.None;
|
||||
|
||||
@@ -74,15 +74,13 @@ namespace ProjectM.Client
|
||||
}
|
||||
|
||||
// Ease the drone intensity toward the phase target (tenser during Defend).
|
||||
float target = phase == CyclePhase.Defend ? AmbientBaseVolume * 1.7f : AmbientBaseVolume;
|
||||
float target = phase == CyclePhase.Siege ? AmbientBaseVolume * 1.7f : AmbientBaseVolume;
|
||||
_ambient.volume = Mathf.MoveTowards(_ambient.volume, target, SystemAPI.Time.DeltaTime * 0.25f);
|
||||
}
|
||||
|
||||
void PlaySting(byte phase)
|
||||
{
|
||||
AudioClip clip = phase == CyclePhase.Defend ? _stingDefend
|
||||
: phase == CyclePhase.Build ? _stingBuild
|
||||
: _stingExpedition;
|
||||
AudioClip clip = phase == CyclePhase.Siege ? _stingDefend : _stingBuild;
|
||||
if (clip != null && _ambient != null)
|
||||
_ambient.PlayOneShot(clip, 0.6f);
|
||||
}
|
||||
|
||||
@@ -59,13 +59,13 @@ namespace ProjectM.Client
|
||||
{
|
||||
var endTick = new NetworkTick(cyc.PhaseEndTick);
|
||||
string detail;
|
||||
if (cyc.Phase == CyclePhase.Defend)
|
||||
if (cyc.Phase == CyclePhase.Siege)
|
||||
detail = "WAVE " + cyc.WaveNumber + " - " + _huskQuery.CalculateEntityCount() + " HUSKS";
|
||||
else if (haveTick && cyc.PhaseEndTick != 0 && endTick.IsValid && endTick.IsNewerThan(nt.ServerTick))
|
||||
detail = (endTick.TicksSince(nt.ServerTick) / 60) + "s";
|
||||
detail = "INCURSION IN " + (endTick.TicksSince(nt.ServerTick) / 60 + 1) + "s";
|
||||
else
|
||||
detail = "";
|
||||
_phaseText.text = PhaseLabel(cyc.Phase) + (detail.Length > 0 ? " - " + detail : "") + " CYCLE " + cyc.CycleNumber;
|
||||
_phaseText.text = PhaseLabel(cyc.Phase) + (detail.Length > 0 ? " - " + detail : "");
|
||||
_phaseText.color = PhaseColor(cyc.Phase);
|
||||
}
|
||||
else if (_phaseText != null)
|
||||
@@ -79,7 +79,7 @@ namespace ProjectM.Client
|
||||
bool onExpedition = cam != null && cam.transform.position.x > 500f;
|
||||
_locationText.text = onExpedition
|
||||
? "ON EXPEDITION - return through the gate"
|
||||
: "AT BASE" + (haveCycle && cyc.Phase == CyclePhase.Expedition ? " - step into the gate to deploy" : "");
|
||||
: "AT BASE - deploy through the gate when you're ready";
|
||||
_locationText.color = onExpedition ? new Color(1f, 0.8f, 0.4f) : new Color(0.6f, 0.85f, 1f);
|
||||
}
|
||||
|
||||
@@ -304,9 +304,8 @@ namespace ProjectM.Client
|
||||
{
|
||||
switch (phase)
|
||||
{
|
||||
case CyclePhase.Expedition: return new Color(0.45f, 0.85f, 1f);
|
||||
case CyclePhase.Defend: return new Color(1f, 0.5f, 0.3f);
|
||||
case CyclePhase.Build: return new Color(0.45f, 0.95f, 0.6f);
|
||||
case CyclePhase.Calm: return new Color(0.45f, 0.9f, 0.7f);
|
||||
case CyclePhase.Siege: return new Color(1f, 0.45f, 0.3f);
|
||||
default: return Color.white;
|
||||
}
|
||||
}
|
||||
@@ -315,9 +314,8 @@ namespace ProjectM.Client
|
||||
{
|
||||
switch (phase)
|
||||
{
|
||||
case CyclePhase.Expedition: return "EXPEDITION";
|
||||
case CyclePhase.Defend: return "DEFEND";
|
||||
case CyclePhase.Build: return "BUILD";
|
||||
case CyclePhase.Calm: return "AT BASE";
|
||||
case CyclePhase.Siege: return "UNDER SIEGE";
|
||||
default: return "";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -40,6 +40,13 @@ namespace ProjectM.Server
|
||||
{
|
||||
if (dmg.Length == 0)
|
||||
continue;
|
||||
// Dev god-mode: while enabled, this entity ignores ALL damage (server-authoritative, once per tick).
|
||||
if (SystemAPI.HasComponent<DebugGodMode>(entity) && SystemAPI.IsComponentEnabled<DebugGodMode>(entity))
|
||||
{
|
||||
dmg.Clear();
|
||||
continue;
|
||||
}
|
||||
|
||||
|
||||
// Respawn invulnerability: a freshly-recovered player ignores damage for a window.
|
||||
if (haveTick && netTime.ServerTick.IsValid && SystemAPI.HasComponent<RespawnInvuln>(entity))
|
||||
|
||||
@@ -41,8 +41,8 @@ namespace ProjectM.Server
|
||||
if (!serverTick.IsValid)
|
||||
return;
|
||||
uint now = serverTick.TickIndexForValidTick;
|
||||
// M6 Aether Cycle: the base-defense wave only runs during the Defend phase.
|
||||
if (SystemAPI.TryGetSingleton<CycleState>(out var cycle) && cycle.Phase != CyclePhase.Defend)
|
||||
// Player-driven loop: the base-defense wave only spawns during a Siege.
|
||||
if (SystemAPI.TryGetSingleton<CycleState>(out var cycle) && cycle.Phase != CyclePhase.Siege)
|
||||
return;
|
||||
|
||||
var director = SystemAPI.GetSingleton<WaveDirector>();
|
||||
|
||||
@@ -0,0 +1,209 @@
|
||||
#if UNITY_EDITOR
|
||||
using ProjectM.Simulation;
|
||||
using Unity.Collections;
|
||||
using Unity.Entities;
|
||||
using Unity.Mathematics;
|
||||
using Unity.NetCode;
|
||||
using Unity.Transforms;
|
||||
|
||||
namespace ProjectM.Server
|
||||
{
|
||||
/// <summary>
|
||||
/// EDITOR-ONLY server receiver for <see cref="DebugCommandRequest"/> dev-tool RPCs (from the DebugOverlay or
|
||||
/// execute_code). Applies authoritative effects so the dev buttons exercise the REAL server paths and work
|
||||
/// over a live connection too: force/end sieges, grant resources/upgrades, teleport, god-mode, heal/kill,
|
||||
/// advance the goal. Sender-targeted ops resolve the player via SourceConnection -> NetworkId -> GhostOwner
|
||||
/// (the RegionTransitSystem pattern). Plain server SimulationSystemGroup (NOT the predicted loop). Reuses
|
||||
/// StorageMath / StatModifier / RegionMath + the wave/cycle singletons. The whole system is #if UNITY_EDITOR
|
||||
/// (stripped from builds); the wire TYPE (<see cref="DebugCommandRequest"/>) is unconditional so the RPC
|
||||
/// collection hash matches across peers. Non-Burst (managed-simple, editor-only) — perf is irrelevant.
|
||||
/// </summary>
|
||||
[WorldSystemFilter(WorldSystemFilterFlags.ServerSimulation)]
|
||||
[UpdateInGroup(typeof(SimulationSystemGroup))]
|
||||
public partial struct DebugCommandReceiveSystem : ISystem
|
||||
{
|
||||
EntityQuery m_Husks;
|
||||
|
||||
public void OnCreate(ref SystemState state)
|
||||
{
|
||||
m_Husks = state.GetEntityQuery(ComponentType.ReadOnly<EnemyTag>());
|
||||
var builder = new EntityQueryBuilder(Allocator.Temp)
|
||||
.WithAll<DebugCommandRequest, ReceiveRpcCommandRequest>();
|
||||
state.RequireForUpdate(state.GetEntityQuery(builder));
|
||||
}
|
||||
|
||||
public void OnUpdate(ref SystemState state)
|
||||
{
|
||||
var ecb = new EntityCommandBuffer(Allocator.Temp);
|
||||
|
||||
// Connection NetworkId -> player entity (for sender-targeted ops).
|
||||
var playerByConn = new NativeHashMap<int, Entity>(8, Allocator.Temp);
|
||||
foreach (var (owner, e) in SystemAPI.Query<RefRO<GhostOwner>>().WithAll<PlayerTag>().WithEntityAccess())
|
||||
playerByConn[owner.ValueRO.NetworkId] = e;
|
||||
|
||||
bool haveCycle = SystemAPI.TryGetSingletonEntity<CycleState>(out var cycleEntity);
|
||||
|
||||
foreach (var (request, receive, reqEntity) in
|
||||
SystemAPI.Query<RefRO<DebugCommandRequest>, RefRO<ReceiveRpcCommandRequest>>().WithEntityAccess())
|
||||
{
|
||||
var cmd = request.ValueRO;
|
||||
|
||||
Entity sender = Entity.Null;
|
||||
var connEntity = receive.ValueRO.SourceConnection;
|
||||
if (SystemAPI.HasComponent<NetworkId>(connEntity))
|
||||
playerByConn.TryGetValue(SystemAPI.GetComponent<NetworkId>(connEntity).Value, out sender);
|
||||
|
||||
switch (cmd.Op)
|
||||
{
|
||||
case DebugOp.SpawnWave:
|
||||
if (haveCycle && SystemAPI.HasComponent<ThreatState>(cycleEntity))
|
||||
{
|
||||
var ts = SystemAPI.GetComponent<ThreatState>(cycleEntity);
|
||||
ts.PendingSiegeSize = math.max(1, cmd.ArgA);
|
||||
ts.ArmTick = 0; // fire as soon as CyclePhaseSystem sees it
|
||||
SystemAPI.SetComponent(cycleEntity, ts);
|
||||
}
|
||||
break;
|
||||
|
||||
case DebugOp.EndSiege:
|
||||
case DebugOp.SetCalm:
|
||||
CullHusks(ref ecb);
|
||||
if (SystemAPI.TryGetSingletonEntity<WaveState>(out var we))
|
||||
{
|
||||
var w = SystemAPI.GetComponent<WaveState>(we);
|
||||
w.Phase = WavePhase.Lull;
|
||||
w.RemainingToSpawn = 0;
|
||||
SystemAPI.SetComponent(we, w);
|
||||
}
|
||||
if (haveCycle && SystemAPI.HasComponent<ThreatState>(cycleEntity))
|
||||
{
|
||||
var ts = SystemAPI.GetComponent<ThreatState>(cycleEntity);
|
||||
ts.PendingSiegeSize = 0;
|
||||
ts.ArmTick = 0;
|
||||
ts.SiegeStartTick = 0;
|
||||
SystemAPI.SetComponent(cycleEntity, ts);
|
||||
}
|
||||
if (cmd.Op == DebugOp.SetCalm && haveCycle)
|
||||
{
|
||||
var cs = SystemAPI.GetComponent<CycleState>(cycleEntity);
|
||||
cs.Phase = CyclePhase.Calm;
|
||||
cs.PhaseEndTick = 0;
|
||||
SystemAPI.SetComponent(cycleEntity, cs);
|
||||
}
|
||||
break;
|
||||
|
||||
case DebugOp.ClearEnemies:
|
||||
CullHusks(ref ecb);
|
||||
break;
|
||||
|
||||
case DebugOp.GrantResource:
|
||||
if (SystemAPI.TryGetSingletonEntity<ResourceLedger>(out var ledgerE))
|
||||
{
|
||||
var ledger = SystemAPI.GetBuffer<StorageEntry>(ledgerE);
|
||||
StorageMath.Deposit(ledger, (ushort)cmd.ArgA, cmd.ArgB);
|
||||
}
|
||||
break;
|
||||
|
||||
case DebugOp.GrantUpgrade:
|
||||
if (sender != Entity.Null && SystemAPI.HasBuffer<StatModifier>(sender))
|
||||
GrowDamageModifier(SystemAPI.GetBuffer<StatModifier>(sender));
|
||||
break;
|
||||
|
||||
case DebugOp.Teleport:
|
||||
if (sender != Entity.Null && SystemAPI.HasComponent<RegionTag>(sender)
|
||||
&& SystemAPI.HasComponent<LocalTransform>(sender))
|
||||
{
|
||||
byte region = (byte)cmd.ArgA;
|
||||
SystemAPI.GetComponentRW<RegionTag>(sender).ValueRW.Region = region;
|
||||
float3 baseCenter = new float3(0f, 1f, 0f);
|
||||
if (SystemAPI.TryGetSingleton<BaseAnchor>(out var anchor))
|
||||
baseCenter = BaseGridMath.PlotCenter(anchor);
|
||||
SystemAPI.GetComponentRW<LocalTransform>(sender).ValueRW.Position =
|
||||
RegionMath.RegionOrigin(region, baseCenter);
|
||||
}
|
||||
break;
|
||||
|
||||
case DebugOp.ToggleGod:
|
||||
if (sender != Entity.Null && SystemAPI.HasComponent<DebugGodMode>(sender))
|
||||
SystemAPI.SetComponentEnabled<DebugGodMode>(sender, !SystemAPI.IsComponentEnabled<DebugGodMode>(sender));
|
||||
break;
|
||||
|
||||
case DebugOp.Heal:
|
||||
if (sender != Entity.Null && SystemAPI.HasComponent<Health>(sender))
|
||||
{
|
||||
var h = SystemAPI.GetComponent<Health>(sender);
|
||||
h.Current = SystemAPI.HasComponent<EffectiveCharacterStats>(sender)
|
||||
? SystemAPI.GetComponent<EffectiveCharacterStats>(sender).MaxHealth
|
||||
: h.Max;
|
||||
SystemAPI.SetComponent(sender, h);
|
||||
}
|
||||
break;
|
||||
|
||||
case DebugOp.KillPlayer:
|
||||
if (sender != Entity.Null && SystemAPI.HasComponent<Health>(sender))
|
||||
{
|
||||
var h = SystemAPI.GetComponent<Health>(sender);
|
||||
h.Current = 0f;
|
||||
SystemAPI.SetComponent(sender, h);
|
||||
}
|
||||
break;
|
||||
|
||||
case DebugOp.AdvanceGoal:
|
||||
if (haveCycle && SystemAPI.HasComponent<GoalProgress>(cycleEntity))
|
||||
{
|
||||
var g = SystemAPI.GetComponent<GoalProgress>(cycleEntity);
|
||||
g.Charge += math.max(1, cmd.ArgA);
|
||||
SystemAPI.SetComponent(cycleEntity, g);
|
||||
}
|
||||
break;
|
||||
|
||||
case DebugOp.SetHeat:
|
||||
if (haveCycle && SystemAPI.HasComponent<ThreatState>(cycleEntity))
|
||||
{
|
||||
var ts = SystemAPI.GetComponent<ThreatState>(cycleEntity);
|
||||
ts.Heat = cmd.ArgA;
|
||||
SystemAPI.SetComponent(cycleEntity, ts);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
ecb.DestroyEntity(reqEntity);
|
||||
}
|
||||
|
||||
ecb.Playback(state.EntityManager);
|
||||
ecb.Dispose();
|
||||
playerByConn.Dispose();
|
||||
}
|
||||
|
||||
void CullHusks(ref EntityCommandBuffer ecb)
|
||||
{
|
||||
var husks = m_Husks.ToEntityArray(Allocator.Temp);
|
||||
for (int i = 0; i < husks.Length; i++)
|
||||
ecb.DestroyEntity(husks[i]);
|
||||
husks.Dispose();
|
||||
}
|
||||
|
||||
static void GrowDamageModifier(DynamicBuffer<StatModifier> mods)
|
||||
{
|
||||
const uint debugSourceId = 0x00DEB061u; // distinct debug sentinel (replace-by-SourceId keeps it bounded)
|
||||
for (int i = 0; i < mods.Length; i++)
|
||||
{
|
||||
if (mods[i].SourceId == debugSourceId && mods[i].Target == (byte)StatTarget.Damage)
|
||||
{
|
||||
var m = mods[i];
|
||||
m.Value += 0.25f;
|
||||
mods[i] = m;
|
||||
return;
|
||||
}
|
||||
}
|
||||
mods.Add(new StatModifier
|
||||
{
|
||||
Target = (byte)StatTarget.Damage,
|
||||
Op = (byte)ModOp.PercentAdd,
|
||||
Value = 0.25f,
|
||||
SourceId = debugSourceId,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 8c34745ae117284439fa4487f586ad0e
|
||||
@@ -8,14 +8,14 @@ using Unity.Transforms;
|
||||
namespace ProjectM.Server
|
||||
{
|
||||
/// <summary>
|
||||
/// Server-only procedural expedition-field manager. Edge-triggered off the cycle phase (via the server-only
|
||||
/// <see cref="CycleRuntime.PrevPhase"/>): on ENTERING Expedition for a not-yet-seeded cycle it scatters
|
||||
/// <see cref="ResourceFieldSpawner.Count"/> resource-node ghosts (seeded by CycleNumber via
|
||||
/// <see cref="Unity.Mathematics.Random"/>) around the expedition region origin, each
|
||||
/// <see cref="RegionTag"/>{Expedition}; on LEAVING Expedition it destroys every node. Runs in the plain
|
||||
/// server SimulationSystemGroup <c>[UpdateAfter(CyclePhaseSystem)]</c> so the phase edge is observed the
|
||||
/// same tick. Server-authoritative; clients despawn nodes via GhostDespawnSystem. Per-cycle reproducible
|
||||
/// (the seed is the monotonic int CycleNumber, compared by equality — never tick math; never seed 0).
|
||||
/// Server-only procedural expedition-field manager. Re-keyed off PER-PLAYER PRESENCE (no global phase): it
|
||||
/// counts players whose server-only <see cref="RegionTag"/> is the Expedition region, and on the
|
||||
/// empty->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,40 +43,57 @@ 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);
|
||||
if (cycle.Phase == CyclePhase.Calm)
|
||||
{
|
||||
// Default calm: no pending siege => no countdown.
|
||||
cycle.PhaseEndTick = 0;
|
||||
|
||||
switch (cycle.Phase)
|
||||
if (SystemAPI.HasComponent<ThreatState>(cycleEntity))
|
||||
{
|
||||
case CyclePhase.Expedition:
|
||||
if (timedPhaseDue)
|
||||
var threat = SystemAPI.GetComponent<ThreatState>(cycleEntity);
|
||||
if (threat.PendingSiegeSize > 0)
|
||||
{
|
||||
cycle.Phase = CyclePhase.Defend;
|
||||
cycle.PhaseEndTick = 0; // Defend is wave-driven, not timed.
|
||||
runtime.DefendStartWave =
|
||||
SystemAPI.TryGetSingleton<WaveState>(out var w) ? w.WaveNumber : 0;
|
||||
// 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;
|
||||
|
||||
bool armed = threat.ArmTick == 0
|
||||
|| !new NetworkTick(threat.ArmTick).IsNewerThan(serverTick);
|
||||
|
||||
if (armed && SystemAPI.TryGetSingletonEntity<WaveState>(out var waveEntity))
|
||||
{
|
||||
// ---- 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;
|
||||
|
||||
case CyclePhase.Defend:
|
||||
}
|
||||
}
|
||||
}
|
||||
else if (cycle.Phase == CyclePhase.Siege)
|
||||
{
|
||||
if (DefendCleared(ref state, runtime.DefendStartWave))
|
||||
{
|
||||
cycle.Phase = CyclePhase.Build;
|
||||
cycle.PhaseEndTick = TickUtil.NonZero(now + CyclePhase.BuildTicks);
|
||||
}
|
||||
break;
|
||||
|
||||
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).
|
||||
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);
|
||||
@@ -79,7 +101,6 @@ namespace ProjectM.Server
|
||||
SystemAPI.SetComponent(cycleEntity, goal);
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
// 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,110 +31,131 @@ 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]
|
||||
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
|
||||
@@ -59,6 +59,8 @@ Long-form originals + the milestone each came from: `Docs/Vault/_Meta/CLAUDE_Bui
|
||||
- **Ownerless interpolated ghost ≠ owner-predicted for buffer replication.** A server-spawned ownerless ghost replicates a `[GhostField] IBufferElementData` to all clients with **no `OwnerSendType` / no `GhostOwner`** — server mutations just propagate. `OwnerSendType.All` + `GhostOwner` are only for a predicting owner to recompute its own state.
|
||||
- **One-off shared-state actions belong on an `IRpcCommand`, not a predicted `InputEvent`** (RPCs are reliable; one-shot `InputEvent`s — like `Fire` — drop under server tick-batching). RPC payloads are plain blittable fields (no `[GhostField]`), scalars only (`int CellX/CellZ`, not `int2`). For a SINGLE shared target resolve a **server singleton** — never put an `Entity` in the command; use ghost-id+spawn-tick (`SpawnedGhostEntityMap`) only for many targets.
|
||||
- **Apply server-only RPC effects in the server `SimulationSystemGroup`, NOT the predicted loop** (rollback would double-apply). Mutating a `DynamicBuffer` is not a structural change, so it's safe while iterating a different query.
|
||||
- **A system-ordering CYCLE is INVISIBLE to plain-Entities EditMode tests** (they register systems individually, unsorted) — it only throws `ComponentSystemSorter` "circular dependency cycle" at **world creation (Play)**. When you add a system with cross-system `[UpdateBefore/After]`, re-audit the EXISTING `[Update*]` attributes of the systems you order around (a new `After(A)+Before(B)` collided with B's pre-existing `After(A)`) and **always Play-validate**, not just EditMode. [[DR-017_Persistent_Base_Player_Driven_Pacing]]
|
||||
- **A dev/debug `IRpcCommand` wire TYPE must be UNCONDITIONAL (no `#if`)** — the reflection-built RpcCollection hash must match across release/dev peers or the connection handshake refuses. `#if UNITY_EDITOR`-gate only the send/receive SYSTEMS + overlay, never the request struct. **Re-mean bytes, don't rename**: an enum/const whose byte VALUES are unchanged keeps the `[GhostField]` serializer identical → a global-loop reframe stays re-bake-free (only authoring *default-value* edits re-bake the subscene).
|
||||
- **Derive enableable gates instead of replicating them.** e.g. player `Dead` = a LOCAL enableable derived every predicted tick from replicated `Health<=0` (rollback-correct on server + owner, no `[GhostEnabledBit]`). To write the bit on a disabled entity the query must visit it (`.WithPresent<Dead>()`); **bake the enableable DISABLED** so instances spawn off. Respawn/death *timing* is server-only.
|
||||
- **Cooldown/spawn "next tick" sentinels:** route every stored tick through **`TickUtil.NonZero(...)`** (a computed `ServerTick+delay` can wrap to 0, the "ready" sentinel) and compare with `NetworkTick.IsNewerThan` / `.TicksSince`, **never** raw `uint <` / subtraction.
|
||||
- **`GhostRelevancy` for region splits:** use `GhostRelevancyMode.SetIsIrrelevant` (not `SetIsRelevant`) so untagged/global ghosts stay relevant for free — only enumerate cross-region ghosts to hide. `RegionTag{byte Region}` is **server-only, NOT a `[GhostField]`** (server decides relevancy; client just gains/loses ghosts). `RelevantGhostForConnection{int Connection (=NetworkId.Value); int Ghost (=GhostInstance.ghostId)}`.
|
||||
@@ -98,6 +100,7 @@ Long-form originals + the milestone each came from: `Docs/Vault/_Meta/CLAUDE_Bui
|
||||
|
||||
### MCP / editor workflow ★
|
||||
- **Edit Assets `.cs` ONLY via MCP `apply_text_edits` / `create_script`** (Unity's scripting pipeline) — the raw `Write` tool does NOT reliably trigger a recompile on an unfocused editor → tests/`execute_code` run a **stale assembly**. (`Write`/`Edit` are fine for non-asset files: this vault, asmdef JSON, etc.)
|
||||
- **`apply_text_edits` with MULTIPLE non-adjacent edits in one call can MISALIGN** (a paired replace+delete hit the line *above* the target). One edit per call (or strict bottom-first), always with `precondition_sha256` (it returns the current SHA on mismatch). **`create_script` won't overwrite** an existing path; full-file rewrites = whole-span `apply_text_edits` (its brace-balance validator guards botched spans) or `manage_script delete`+`create_script` (NON-GUID-referenced files only — systems/tests, never authoring MonoBehaviours). `script_apply_edits replace_method` is safe for class methods but still **can't target a `struct : ISystem`** (use whole-span). [[DR-017_Persistent_Base_Player_Driven_Pacing]]
|
||||
- **`execute_code` runs as a method body** — no `using` directives (parse as statements); fully-qualify every type. Identify worlds by `world.Name == "ServerWorld"/"ClientWorld"` (flags overlap a shared `Game` bit).
|
||||
- **`manage_gameobject create` / `manage_prefabs modify_contents` `component_properties` SILENTLY DROP enum + Vector3 fields** — set those via a follow-up `manage_components set_property` and VERIFY through `mcpforunity://scene/gameobject/{id}/component/{Type}` (or read the baked component in `execute_code` after Play). `manage_material set_renderer_color` uses a runtime PropertyBlock that does NOT persist into Play — create + assign a material asset instead.
|
||||
- **New ghost prefab recipe:** `manage_asset duplicate` an existing correctly-configured ghost (e.g. `UpgradePickup.prefab`) → `manage_prefabs modify_contents` to swap the authoring MonoBehaviour (strip MeshFilter+MeshRenderer for an invisible state-holder) — its ownerless/interpolated `GhostAuthoringComponent` + `LinkedEntityGroupAuthoring` come free. **Runtime-spawn shared ghosts** via a one-shot server spawner; don't bake them into the subscene (dodges the prespawn handshake). Wire a baked spawner into the subscene: `manage_scene load additive` → `set_active_scene Gameplay` → create + set props + verify → `save` → `set_active_scene SampleScene` → `close_scene` (re-bakes on Play).
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
tags:
|
||||
- moc
|
||||
- home
|
||||
updated: 2026-06-03
|
||||
updated: 2026-06-04
|
||||
permalink: gamevault/00-home/home
|
||||
---
|
||||
|
||||
@@ -15,8 +15,8 @@ Multiplayer game on **Unity DOTS** (Entities) + **Netcode for Entities** (server
|
||||
- **Vision** → [[Pillars]] — design pillars & locked decisions · [[Identity]] — the fiction (sci-fi frontier colony)
|
||||
- **Game Design** → [[Systems_Index]] — per-system design docs
|
||||
- **Roadmap** → [[Milestones]] · [[Backlog]]
|
||||
- **Sessions** → `07_Sessions/2026/` — dated work logs (latest: [[2026-06-04_Polish_Backlog_Pass]])
|
||||
- **Decisions** → `07_Sessions/_Decisions/` — decision records DR-001 … DR-016 · [[DR-001_Netcode_Test_Harness]] · latest [[DR-016_Stage_G_Combat_Gameplay]]
|
||||
- **Sessions** → `07_Sessions/2026/` — dated work logs (latest: [[2026-06-04_M8_Persistent_Base_Player_Driven_Pacing]])
|
||||
- **Decisions** → `07_Sessions/_Decisions/` — decision records DR-001 … DR-017 · [[DR-001_Netcode_Test_Harness]] · latest [[DR-017_Persistent_Base_Player_Driven_Pacing]]
|
||||
- **Meta** → [[Documentation_Protocol]] · [[Tags]]
|
||||
- **Templates** → [[Session_Log_Template]] · [[Decision_Record_Template]]
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@ Unordered pool of candidate work. Promote to a [[Milestones|milestone]] when com
|
||||
- [x] Upgrade Unity 6.4 → 6.6 — done (now `6000.6.0a6`). Entities/Collections/Graphics → 6.5.0; **Netcode → 6.6.0 and Physics → 6.5.0 also renumbered into the editor line** (not independent 1.x as [[DR-001_Netcode_Test_Harness]] assumed). See [[2026-05-30_M1_Player_Slice]].
|
||||
- [x] Define the core gameplay loop and the first predicted player ghost — delivered as M1 ([[2026-05-30_M1_Player_Slice]]).
|
||||
- [x] **Re-validate the M1 play-tick on a stable Unity 6.x** — moot/subsumed 2026-06-04: M1–M6 are all runtime-validated on the stable **6.4.7** line (the 6.6 alpha netcode bug never affected 6.4.7). The original "blocked on 6.6 alpha" framing ([[DR-002_Unity66_Alpha_Netcode_Transport]]) no longer applies.
|
||||
- [ ] Replace template `SampleScene` with a dedicated bootstrap scene + gameplay subscene.
|
||||
- [x] Replace template `SampleScene` with a dedicated bootstrap scene + gameplay subscene — **done 2026-06-04** ([[DR-017_Persistent_Base_Player_Driven_Pacing]]): dedicated **`Game.unity`** (duplicated from SampleScene to preserve the SubScene `_SceneAsset`/`_SceneGUID` Hash128 MCP would drop) is now `EditorBuildSettings` index 0; a minimal **`DevSandbox.unity`** carries the dev overlay. SampleScene retained as a reference (retire later).
|
||||
- [x] Optional template cleanup: remove `com.unity.visualscripting`, `Assets/TutorialInfo/`, `Assets/Readme.asset` — **done 2026-06-03** (pre-M6 cleanup; see the "2026-06-03 Visual & Controls Polish" section below + [[2026-06-03_Pre_M6_Cleanup]]). Duplicate of the now-checked item there.
|
||||
- [x] Decide **relay provider** before M4 — resolved: **Direct IP/LAN now, Unity Relay later** ([[DR-005_M4_Connection_Model_Direct_IP]], [[2026-06-01_M4_LAN_CoOp_And_Classification_Fix]]).
|
||||
- [x] Decide home-base **grid 2D vs 3D** before M6 — resolved 2026-06-02: **planar single-level `int2` grid**, CellSize 1.0, 32×32 plot (full 3D/stacked deferred). Locked in `BaseGridMath` — [[DR-008_M5_HomeBase_BaseLayer_Storage]].
|
||||
@@ -28,7 +28,7 @@ Unordered pool of candidate work. Promote to a [[Milestones|milestone]] when com
|
||||
- [ ] **M3 follow-up — UI/icon/description pipeline** for abilities (managed lookup keyed by `AbilityId`, off the blob). Deferred from M3 ([[2026-05-31_M3_Data_Driven_Abilities]]).
|
||||
- [x] **M3 follow-up — timed / removable modifiers** — done 2026-06-04 ([[DR-016_Stage_G_Combat_Gameplay]]): a SEPARATE server-only `TimedModifier{SourceId,UntilTick}` buffer (keeps the replicated `StatModifier` layout byte-identical → no re-bake) + `TimedModifierExpirySystem` (expiry on `NetworkTick`) + `TimedModifierUtil.RemoveBySourceId` (clear-by-type). +4 EditMode tests.
|
||||
- [ ] **M3 follow-up — multi-prefab abilities** (a per-ability *different* projectile ghost) needs `ProjectileClassificationSystem` generalized beyond the single shared prefab.
|
||||
- [ ] **M3 follow-up — standalone-server debug modifier path** via `IRpcCommand` (current `DebugModifierInjectionSystem` is in-editor single-process only).
|
||||
- [x] **M3 follow-up — standalone-server debug modifier path** via `IRpcCommand` — **done 2026-06-04** ([[DR-017_Persistent_Base_Player_Driven_Pacing]]): the dev-tools `DebugCommandRequest` RPC (unconditional wire type; `#if UNITY_EDITOR` send/receive) drives modifiers/resources/state over a real connection, superseding the in-editor-only static-poke `DebugModifierInjectionSystem`. (Still editor-gated; flip the systems' guard to `DEVELOPMENT_BUILD` to ship in a dev player build.)
|
||||
- [x] **M3 follow-up — rate-limited turning** — done 2026-06-03 (pre-M6 cleanup): `PlayerAimSystem` now rotates `PlayerFacing` toward the aim target at `EffectiveCharacterStats.TurnRateRadiansPerSec` (authored 720°/s) instead of snapping; deterministic in the predicted loop (fixed-step `dt`, replays on rollback). [[2026-06-03_Pre_M6_Cleanup]].
|
||||
- [ ] **M3 polish — pickup visuals** (primitive sphere/default material currently); pickup auto-grant feel (continuous overlap).
|
||||
- [ ] **M5 follow-up — base/expedition subscene split + streaming (Option C)** — **superseded 2026-06-03 by [[DR-013_M6_Aether_Cycle_Region_Split]]** (coordinate-region + per-connection `GhostRelevancy` delivered the split without `SceneSystem` streaming). Kept only as a note for a future larger-world milestone where true async streaming is wanted; do NOT build streaming now. Original framing: the persistent-space split the locked world design ultimately needs (`SceneSystem.LoadSceneAsync`/`UnloadScene`, per-world load on the listen-server, enter-expedition/return-to-base transition). Deferred to its own world-architecture milestone — M6/M7 only need the anchor + grid, now done ([[DR-008_M5_HomeBase_BaseLayer_Storage]]). The physics-in-prediction + base-layer slices of M5 are done ([[DR-006_M5_Physics_In_Prediction]], [[DR-008_M5_HomeBase_BaseLayer_Storage]]).
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
tags:
|
||||
- roadmap
|
||||
- milestones
|
||||
updated: 2026-06-03
|
||||
updated: 2026-06-04
|
||||
permalink: gamevault/06-roadmap/milestones
|
||||
---
|
||||
|
||||
@@ -21,6 +21,7 @@ permalink: gamevault/06-roadmap/milestones
|
||||
| **— 2026-06-03 Pre-M6 cleanup —** | Loose-ends pass before M6: vault roadmap reconcile, Unity-template + orphaned-material removal, rate-limited turning, console/runtime health gate. | ✅ Done 2026-06-03 — [[2026-06-03_Pre_M6_Cleanup]] |
|
||||
| **M6 — The Aether Cycle (core loop)** | Reframed from "grid build placement" into the first vertical slice of the **core game loop**: Expedition (gather) → Defend (wave) → Build/Charge (spend), persistent base + procedural sorties, escalating toward a goal. Build placement is now Stage 3 of this milestone. | 🚧 In progress 2026-06-03 — **Stages 0–4 done + runtime-validated** on 6.4.7 (M6 core loop systems complete): **base/expedition split via coordinate-region + `GhostRelevancy`** (player transit despawns/re-grants the other region's ghosts; server==client); a **server phase-director** (Expedition→Defend→Build→Expedition auto-cycle, cycle 1→2, Husk `WaveSystem` only in Defend, escalation 4→6); and **resources + harvest** — a **global CycleDirector ghost** carrying the replicated `CycleState` + a shared resource **ledger** (relevant in every region, unlike the base storage), a procedural **expedition field** (8 resource-node ghosts seeded per cycle, region-scoped), and a tunnel-safe **harvest** sweep depositing into the ledger; client **HUD** shows phase + resource counts. Supersedes DR-008's "split requires streaming" framing. **Stage 3** (generic automation-ready **structure model** + data-driven catalog + grid **build-placement** RPC with co-op-atomic commit + a hitscan **turret** that auto-defends + **ability tiers** via a bounded StatModifier) and **Stage 4 goal meter** are **done + validated** (turret placed/Ore-deducted/replicated; two same-tick requests → one build; turrets killed the wave; ability damage 20→30 bounded; goal increments per cycle). Disk-persistence **writer deferred to post-M7** (M7-additive surface — tick fields + frozen schema — baked now); the structure model is the M7 production-chain foundation. Playable walk-in-gate loop with build/spend, visible in the HUD. — [[DR-014_M6_Build_Structures_Automation_Foundation]] — [[DR-013_M6_Aether_Cycle_Region_Split]], [[2026-06-03_M6_Aether_Cycle_CoreLoop]] |
|
||||
| **— 2026-06-04 Polish & backlog-clear pass —** | Comprehensive *sequential* polish across hygiene/reconcile, system-level tests, HUD (TMP + replicated wave number), procedural audio + combat juice, ghost-prop reskin + post-processing, new gameplay content, controls/UX, and a validation-harness + operator handoff. Clears the open [[Backlog]]. | 🚧 In progress 2026-06-04 |
|
||||
| **M8 — Persistent base & player-driven pacing** | Replace the forced-timer cycle with a persistent **Calm** base (no countdown) + **player-initiated** expeditions + **event-triggered** sieges (composite **ThreatDirector**, post-expedition retaliation); **dev-tools-over-RPC** (spawn/force-siege/grant/teleport/god-mode) + a dedicated **Game** scene + **DevSandbox**. | ✅ Done 2026-06-04 — runtime-validated on 6.4.7 (server+client, focused editor): boots into **Calm** (no timer); a returning player arms a retaliation siege (Gate→ThreatDirector→CyclePhase→Wave), the atomic WaveState seed spawns the **exact** configured size, **server==client** (phase + husk ghosts replicated); dev overlay drives the real server RPCs (unconditional wire type → handshake intact); god-mode baked present+disabled; `Game.unity` boots the calm base with the new "AT BASE — deploy when ready" HUD. EditMode **137/137**. Supersedes the M6 forced-timer rhythm. — [[DR-017_Persistent_Base_Player_Driven_Pacing]], [[2026-06-04_M8_Persistent_Base_Player_Driven_Pacing]] |
|
||||
| **M7 — Automation** | Self-running tick-based production chains (deterministic offline catch-up) | ⬜ |
|
||||
|
||||
Promote items from [[Backlog]] here when committed.
|
||||
@@ -0,0 +1,64 @@
|
||||
---
|
||||
date: 2026-06-04
|
||||
type: session
|
||||
tags: [session, core-loop, pacing, netcode, dev-tools, scenes, m8]
|
||||
---
|
||||
|
||||
# Session 2026-06-04 — M8: Persistent base & player-driven pacing
|
||||
|
||||
## Goal
|
||||
|
||||
Operator: *"I don't like the current pacing — the home base should feel persistent and like you aren't in a rush... when you are ready and have the inventory and prep you need for an expedition, you go. Attacks on the base will be triggered based on various events and timelines (possibly after an expedition). Built out properly — also make a separate scene and various dev tools to test/debug/validate (spawn waves, control game state, grant things). The main gameplay scene should feel like an actual game."*
|
||||
|
||||
Replaces the M6 **forced-timer treadmill** (Expedition 60s → Defend → Build 20s → repeat) with a **player-driven loop**: persistent **Calm** base by default → deploy when ready → **event-triggered Siege** (post-expedition retaliation) → defend → Calm. Decisions chosen by operator: **composite ThreatDirector**, **full staged build**, **new Game scene + DevSandbox**. Full rationale + the de-risked architecture: [[DR-017_Persistent_Base_Player_Driven_Pacing]].
|
||||
|
||||
## Process
|
||||
|
||||
- Phase-1 exploration (3 Explore agents: run-state/wave blast radius · scene/subscene/bootstrap wiring · dev-tools/RPC/test precedents).
|
||||
- Phase-2 **adversarial design review** (Workflow: 3 critics — netcode/determinism · reuse/scope · dev-tooling/scene/build-gotchas → synthesis). Caught the **co-op-breaking global-Expedition-phase flaw** (one byte can't represent player-A-out/player-B-home) and steered: reuse-symbols-re-mean-bytes, atomic WaveState siege seed, server-only ThreatConfig/State, duplicate-scenes-not-scratch, unconditional RPC type.
|
||||
- Plan approved → 5 staged steps, each `compile → read_console → EditMode → (Play where relevant)`.
|
||||
|
||||
## Done
|
||||
|
||||
### Stage 1 — Calm/Siege run-state + presence + boot-Calm + goal decouple
|
||||
- `CycleComponents.cs`: re-meaned `CyclePhase` → `Calm=0`/`Siege=1` (+ deprecated `Expedition`/`Defend`/`Build` aliases; byte values unchanged → **no serializer re-bake**); `CycleRuntime` → `ExpeditionEpoch`/`LastSpawnedEpoch`/`PrevExpeditionOccupied`.
|
||||
- `CyclePhaseSystem` rewritten to the **Calm↔Siege machine** (consume `ThreatState.PendingSiegeSize` → atomic `WaveState{Spawning, RemainingToSpawn=size, WaveNumber+1}` seed → Siege; exit on `DefendCleared` → Calm + goal `+1`). Boot = Calm (`CycleDirectorAuthoring` default + `CycleDirectorSpawnSystem` stamp `Phase=Calm, PhaseEndTick=0`, adds `ThreatState`). `ExpeditionFieldSystem` re-keyed off expedition-region presence + epoch. `ExpeditionGateSystem` hack deleted → increments `ThreatState.PendingReturns` on return. `WaveSystem` gate `!= Siege`.
|
||||
- Tests: rewrote `CyclePhaseSystemTests` (Calm holds; PendingSiege→Siege exact size; Siege→Calm + goal once; **co-op split-presence keeps global phase Calm**); `ExpeditionGateSystemTests` → return-signals-once. **137 baseline preserved.**
|
||||
|
||||
### Stage 2 — Composite ThreatDirector
|
||||
- `ThreatComponents.cs` (`ThreatConfig` + `ThreatState` + `ThreatStartCondition`, server-only, Heat/Schedule inert). `ThreatDirectorSystem` (Gate→ThreatDirector→CyclePhase order): post-expedition source arms a `SizeBase` siege after a telegraph; bounded-timeout collapse (no soft-lock). `ThreatConfig` baked via `CycleDirectorAuthoring` (inspector-tunable flat fields).
|
||||
- Tests: `ThreatDirectorSystemTests` ×5 (return→pending; multi-return de-dup; size=config-not-curve; Immediate arms via ArmTick; empty-base auto-resolves bounded).
|
||||
|
||||
### Stage 3 — Dev tools over RPC + god-mode
|
||||
- Unconditional `DebugCommandRequest`/`DebugOp` (Simulation); `DebugGodMode` enableable. `DebugCommandReceiveSystem` (server `#if UNITY_EDITOR` switch, reuses StorageMath/StatModifier/RegionMath/wave+cycle singletons, sender resolve). `DebugCommandSendSystem` (client static-queue → RPC, headless-friendly) + `DebugOverlay` MonoBehaviour (IMGUI). `HealthApplyDamageSystem` god-mode guard; `PlayerAuthoring` bakes `DebugGodMode` present+disabled; `AimPresentation.ForceCursorVisible` (overlay clickability); `[RuntimeInitializeOnLoadMethod]` resets.
|
||||
- Tests: `DebugCommandReceiveSystemTests` ×4 + god-mode skip-damage. **137/137.**
|
||||
|
||||
### Stage 4 — Scenes
|
||||
- Duplicated SampleScene → **`Game.unity`** (clean main, boots Calm) + **`DevSandbox.unity`** (Synty world disabled, `~DevTools`+overlay). Verified the SubScene `_SceneAsset` guid `9dc8ce2e…` + `_SceneGUID.x=3807153369` survived. `Game.unity` added to `EditorBuildSettings` index 0.
|
||||
|
||||
### Stage 5 — Feel pass
|
||||
- `HudSystem`: Calm/Siege labels + colors, "AT BASE — deploy through the gate when you're ready" prompt, "INCURSION IN Ns" telegraph (reuses `PhaseEndTick=ArmTick` countdown), dropped vestigial "CYCLE N". `AmbientAudioSystem`: Calm/Siege stinger + drone swell mapping.
|
||||
|
||||
### Validation
|
||||
- **EditMode 137/137** (was 127; rewrote/added per stage, no regressions), console clean.
|
||||
- **Focused-editor Play (server+client), SampleScene + Game.unity:** boot **Calm** (`Phase=0, PhaseEndTick=0`); re-bakes confirmed (`ThreatConfig{SizeBase=5,Delay=300,Timeout=3600}`, player `DebugGodMode` present+disabled); armed siege → **Siege**, **exactly 4 Husks** spawned, `WaveState{Spawning,Remaining=0}`, pending consumed; **server==client** (Phase + husk ghosts replicated). `Game.unity` screenshot: calm base + "AT BASE — deploy…" HUD. (`Assets/Screenshots/M8_Game_Calm_Boot.png`.)
|
||||
|
||||
## Decisions
|
||||
|
||||
- **Created [[DR-017_Persistent_Base_Player_Driven_Pacing]]** (supersedes the M6 forced-timer rhythm of [[DR-013_M6_Aether_Cycle_Region_Split]]/[[DR-014_M6_Build_Structures_Automation_Foundation]]; reuses their netcode infra).
|
||||
|
||||
## Build gotchas (→ [[CLAUDE]] addendum)
|
||||
|
||||
- A **system-ordering cycle is invisible to plain-Entities EditMode tests** (systems registered individually); only `ComponentSystemSorter` throws at Play/world-creation. Re-audit *existing* `[Update*]` attributes when reordering — caught the gate's stale `[UpdateAfter(CyclePhaseSystem)]` vs the new ThreatDirector chain.
|
||||
- **MCP `apply_text_edits` multi-edit-in-one-call can misalign** (a paired replace+delete hit the wrong line). One edit per call (or strict bottom-first), `precondition_sha256` always. `create_script` won't overwrite; `script_apply_edits replace_method` still can't target struct ISystems.
|
||||
- **Re-mean bytes, don't rename**: unchanged byte values keep the `[GhostField]` serializer identical → a global-loop reframe stayed re-bake-free (only authoring default-value edits re-bake the subscene).
|
||||
|
||||
## Open / deferred
|
||||
|
||||
- Base-integrity / visible fail-state (siege currently just collapses on timeout); haul-scaled retaliation (`SizePerExpeditionResource=0`); Heat/Schedule trigger sources (baked-but-inert); dev overlay in dev *player* builds; deploy-gate 3D marker. All defaulted/tunable — see [[DR-017_Persistent_Base_Player_Driven_Pacing]].
|
||||
|
||||
## Next
|
||||
|
||||
- **Build/automation/customization expansion** on the now-persistent base (the operator's stated next direction) — the ThreatDirector inert sources + the DR-014 structure tick fields are the additive hooks.
|
||||
- Multi-client (MPPM) run of the co-op split-presence + dev-tools-over-a-real-connection (the unconditional RPC type is the enabler).
|
||||
- Optional: base-integrity fail-state if losing a siege should have visible teeth.
|
||||
@@ -0,0 +1,56 @@
|
||||
---
|
||||
id: DR-017
|
||||
title: M8 — Persistent base & player-driven pacing (Calm↔Siege run-state, composite ThreatDirector, dev-tools-over-RPC, Game/DevSandbox scenes)
|
||||
status: accepted
|
||||
date: 2026-06-04
|
||||
tags:
|
||||
- decision
|
||||
- netcode
|
||||
- core-loop
|
||||
- pacing
|
||||
- dev-tools
|
||||
- world-architecture
|
||||
- m8
|
||||
permalink: gamevault/07-sessions/decisions/dr-017-persistent-base-player-driven-pacing
|
||||
---
|
||||
|
||||
# DR-017 — Persistent base & player-driven pacing
|
||||
|
||||
## Context
|
||||
|
||||
The M6 core loop ([[DR-013_M6_Aether_Cycle_Region_Split]] / [[DR-014_M6_Build_Structures_Automation_Foundation]]) shipped a **Dome-Keeper forced-timer rhythm**: `CyclePhaseSystem` auto-advanced Expedition (~60s) → Defend (wave) → Build (~20s) → repeat, +1 goal/lap, booting straight into a 60-second Expedition timer. The operator disliked the pacing: **the home base should feel persistent and unhurried** — with build/automation/customization layered on, you should **deploy on an expedition when YOU are ready** (inventory + prep), not on a treadmill; **base attacks should be event/timeline-triggered** ("possibly after an expedition"), not every lap. Plus: a **separate dev scene + dev tools** (spawn waves, control state, grant resources/upgrades, teleport, god-mode), and the **main scene should feel like an actual game**.
|
||||
|
||||
Operator chose (this session): **composite, data-driven ThreatDirector** (post-expedition default-on); **full staged build**; a **new dedicated Game scene + a DevSandbox scene**. A 3-critic + synthesis design-review Workflow ran pre-code (per [[CLAUDE]]'s netcode-slice rule) and caught a co-op-breaking flaw + steered the de-risked shape below.
|
||||
|
||||
## Decision
|
||||
|
||||
1. **Global run-state = `{Calm, Siege}` only; "expedition" is per-player presence (the co-op fix).** A single global `CycleState.Phase` byte cannot represent "player A out / player B home", so it carries only the shared posture: **`Calm`** (persistent, unhurried DEFAULT — build/prep, no countdown) or **`Siege`** (event-triggered base-defense wave). Being out is per-player (server-only `RegionTag`, read client-side by the HUD's `Camera.main.x > 500`). **Reuse symbols, re-mean bytes — no rename**: `CyclePhase.Calm = 0` (was Expedition), `Siege = 1` (was Defend), `Build = 2` retired; deprecated aliases kept so HUD/audio/tests compile through the cut-over. **Byte values are unchanged → the GhostField serializer layout is identical → the const re-mean forces no re-bake** (boot-into-Calm comes from the spawn-stamp + baker default, both byte 0). `CyclePhaseSystem` (name kept, behaviour rewritten) is the Calm↔Siege machine; boot = Calm (`CycleDirectorSpawnSystem` stamps `Phase=Calm, PhaseEndTick=0`). `ExpeditionFieldSystem` re-keyed off **expedition-region player presence + a server-only `ExpeditionEpoch`** (RNG seed; field lives while anyone is out, torn down when the last leaves) — replaces the global-phase edge + `CycleNumber`-as-seed.
|
||||
|
||||
2. **Composite ThreatDirector (server-only, data-driven, extensible).** Two **server-only** components on the global CycleDirector ghost (neither a `[GhostField]` → no ghost re-bake; future sources are additive): **`ThreatConfig`** (baked flat scalars via `CycleDirectorAuthoring`: PostExpedition enable/delay/sizeBase/sizePerResource, StartCondition, SiegeTimeoutTicks; Heat/Schedule fields **present-but-inert**) + **`ThreatState`** runtime (`PendingSiegeSize` = the single documented entry point, `ArmTick`, `PendingReturns`, `ExpeditionsCompleted`, `SiegeStartTick`, inert `Heat`/`NextScheduledTick`). **`ThreatDirectorSystem`** (plain server `SimulationSystemGroup`, ordered **Gate → ThreatDirector → CyclePhaseSystem → Wave**) consumes the gate's per-player `PendingReturns` (de-duped: the gate teleports the returner out of its radius) and arms a siege `SizeBase` after a telegraph delay; `StartCondition.Immediate` is the default (`RequirePlayerAtBase` would soft-lock an empty base). A **bounded-resolution timeout** culls the wave after `SiegeTimeoutTicks` so an unattended/empty-base siege can never soft-lock. **Siege sizing rides WaveSystem's own `Lull→Spawning` entry**: on the Calm→Siege edge `CyclePhaseSystem` writes `WaveState{ WaveNumber+1, Phase=Spawning, RemainingToSpawn=size, NextActionTick=now }` in one atomic `SetComponent` (Phase=Spawning bypasses WaveSystem's `(WaveNumber-1)*CountPerWave` escalation recompute, which only runs while `Phase==Lull`), so the siege spawns EXACTLY the director-chosen size and WaveSystem stays the sole `WaveState` writer thereafter — **no new SiegeState component**. **Goal decoupled**: `GoalProgress.Charge += 1` per **siege survived** (single global edge, co-op-safe), moved from the retired Build→Expedition edge.
|
||||
|
||||
3. **Dev tools over RPC (in-editor now; connection-ready).** A single **unconditional** wire type `DebugCommandRequest : IRpcCommand { byte Op; int ArgA; int ArgB }` (+ `DebugOp` byte consts) — **never `#if`-gated** (the reflection-built RpcCollection hash must match across release/dev peers; gating the *type* breaks the handshake). Only the systems/overlay are `#if UNITY_EDITOR`: `DebugCommandSendSystem` (client, static-queue → request entities, like `StorageOpSendSystem`; also drives headless from `execute_code`), `DebugCommandReceiveSystem` (server switch, plain `SimulationSystemGroup`, reusing `StorageMath.Deposit` / `StatModifier` replace-by-SourceId / `RegionMath` / the wave+cycle singletons; sender-targeted ops resolve `SourceConnection → NetworkId → GhostOwner`), and a `DebugOverlay` MonoBehaviour (IMGUI `OnGUI`, mirroring `ConnectionUI`). Ops: SpawnWave/EndSiege/ClearEnemies/SetCalm/GrantResource/GrantUpgrade/Teleport/ToggleGod/Heal/Kill/AdvanceGoal/SetHeat. **God-mode** = server-only enableable `DebugGodMode` baked **present+DISABLED** on the player prefab (mirrors `Dead`; bit-flip, no structural change), guarded in `HealthApplyDamageSystem` beside the `RespawnInvuln` early-out. The overlay forces the OS cursor visible (`AimPresentation.ForceCursorVisible`, non-#if) so its buttons stay clickable while aiming; all debug statics reset via `[RuntimeInitializeOnLoadMethod]`.
|
||||
|
||||
4. **Dedicated Game scene + DevSandbox, built by DUPLICATION.** `manage_asset duplicate` SampleScene → **`Game.unity`** (clean main scene; boots Calm; kept Synty world + post-FX + glue) and → **`DevSandbox.unity`** (Synty world disabled, `~DevTools` GameObject with the overlay). Duplication (a file copy) preserves the `GameplaySubScene` `Unity.Scenes.SubScene` `_SceneAsset` guid + non-zero `_SceneGUID` Hash128 verbatim — MCP `component_properties` silently drops Hash128, so a scratch rebuild would bake nothing. Both reference the SAME `Gameplay.unity` subscene. `Game.unity` added to `EditorBuildSettings` at **index 0** (a player build boots Game). HUD/audio re-meaned to the Calm/Siege semantics (deploy prompt, incursion telegraph reusing the `PhaseEndTick=ArmTick` countdown).
|
||||
|
||||
## Consequences
|
||||
|
||||
- **Validated:** EditMode **137/137** green (rewrote `CyclePhaseSystemTests`; +`ThreatDirectorSystemTests` ×5, +`DebugCommandReceiveSystemTests` ×4, +god-mode skip-damage, +gate return-signal; incl. a co-op split-presence invariant test). **Focused-editor Play (server+client):** boots into **Calm** (`Phase=0, PhaseEndTick=0`, no countdown); the Stage-2/3 re-bakes landed (`ThreatConfig{SizeBase=5,Delay=300,Timeout=3600}` on the director, `DebugGodMode` present+disabled on the player); arming a siege → `CyclePhaseSystem` → **Siege**, WaveSystem spawned **exactly 4** Husks (`WaveState{Spawning, Remaining=0, WaveNum=1}`), `ThreatState{Pending=0, SiegeStart=…}`; **server==client** (Phase + 4 Husk ghosts replicated; client connecting confirms the unconditional RPC type didn't break the handshake). `Game.unity` boots into the calm base with the new HUD ("AT BASE — deploy through the gate when you're ready"). Console clean (only the pre-existing in-editor Server-Tick-Batching warning).
|
||||
- **No new asmdefs.** New code under `…/World/` (`ThreatComponents`, `ThreatDirectorSystem`), `…/Debug/` (Simulation `DebugCommandRequest`/`DebugGodMode`; Server `DebugCommandReceiveSystem`; Client `DebugCommandSendSystem`/`DebugOverlay`). Supersedes the M6 forced-timer rhythm; reuses region split / GhostRelevancy / runtime-ghost / tick-safe math / RPC recipe / gates / ledger verbatim.
|
||||
- **Foundation for the build/automation expansion**: the persistent calm base is now the default state with room for the upcoming building/automation/customization layer; the ThreatDirector's inert Heat/Schedule sources + the structure tick fields ([[DR-014_M6_Build_Structures_Automation_Foundation]]) are the additive hooks.
|
||||
|
||||
## Open / deferred (defaulted, tunable)
|
||||
|
||||
- **Empty-base / unattended-siege "teeth"**: this slice ships the bounded-timeout collapse (no soft-lock) but **no base-integrity stat / fail-state** yet — defaulted to "siege just collapses"; a visible base-integrity HUD bar is a follow-up (would fold one `[GhostField]`).
|
||||
- **Haul-scaled retaliation**: `SizePerExpeditionResource` baked but ships **0** (flat sieges); enabling is a one-line tuning change, no re-bake.
|
||||
- **Heat + Schedule trigger sources**: config fields baked-but-inert; drop in additively (server-only, no re-bake).
|
||||
- **Dev overlay in dev *player* builds**: `#if UNITY_EDITOR` only this slice (no `DEVELOPMENT_BUILD` configured); the wire type stays unconditional so switching later is just the systems' guard.
|
||||
- **Deploy-gate world marker / top-down DevSandbox cam**: the deploy affordance is HUD-text for now; a 3D gate marker is polish.
|
||||
|
||||
## Build gotchas recorded this session
|
||||
|
||||
- **A system-ordering cycle is NOT caught by plain-Entities EditMode tests** (they register systems individually) — it only throws `ComponentSystemSorter` "circular dependency" at **world creation (Play)**. Caught here: a new system's `[UpdateAfter(A)]+[UpdateBefore(B)]` collided with B's pre-existing `[UpdateAfter(A)]`. Always Play-validate after adding cross-system `[Update*]` attributes; re-audit the *existing* attributes of every system you reorder around.
|
||||
- **MCP `apply_text_edits` with MULTIPLE non-adjacent edits in one call can misalign** (observed: a paired replace+delete deleted the line *above* the intended one). Do one edit per call (or strictly bottom-first, verified), with `precondition_sha256`. The structured `script_apply_edits` (`insert_method`, `replace_method`) is safer for class methods — but `replace_method` still can't target `struct : ISystem`.
|
||||
- **`create_script` does not overwrite** an existing path (errors); `manage_script` has only create/read/delete. Full-file rewrites = `apply_text_edits` over the whole span, or delete+create for non-GUID-referenced files (systems/tests). Its brace-balance validator will reject a botched span — a useful guard.
|
||||
- **Re-mean-bytes-don't-rename** kept a global-loop reframe re-bake-free: an enum/const whose byte values are unchanged leaves the `[GhostField]` serializer identical, so only authoring *default-value* edits (not the const re-mean) trigger a subscene re-bake.
|
||||
|
||||
Builds on + supersedes the forced-rhythm framing of [[DR-013_M6_Aether_Cycle_Region_Split]] / [[DR-014_M6_Build_Structures_Automation_Foundation]]; reuses the RPC/runtime-ghost/tick-safe patterns from [[DR-008_M5_HomeBase_BaseLayer_Storage]] and the StatModifier path from [[DR-004_M3_DataDriven_Abilities_Modifiers]]. Serves the persistent-base + player-driven pillars in [[Pillars]].
|
||||
@@ -5,8 +5,14 @@ EditorBuildSettings:
|
||||
m_ObjectHideFlags: 0
|
||||
serializedVersion: 2
|
||||
m_Scenes:
|
||||
- enabled: 1
|
||||
path: Assets/Scenes/Game.unity
|
||||
guid: db8738f16e802c8488f9c22aa56e1e60
|
||||
- enabled: 1
|
||||
path: Assets/Scenes/SampleScene.unity
|
||||
guid: 99c9720ab356a0642a771bea13969a05
|
||||
- enabled: 1
|
||||
path: Assets/Scenes/DevSandbox.unity
|
||||
guid: 1d4b5e92ac7b6ea4880d30545df94704
|
||||
m_configObjects:
|
||||
com.unity.input.settings.actions: {fileID: -944628639613478452, guid: 5aa62f6ed584c43b791e76f2fd31820f, type: 3}
|
||||
|
||||
Reference in New Issue
Block a user