LANTERN purge B3+B5: delete the cycle/core/win-lose spine + onboarding; save epoch v7
Deletes CyclePhaseSystem, GoalReachedSystem, CoreDamage/CoreRestore, ThreatDirector, CoreIntegrity/GoalProgress/RunPhase/RunOutcome/ThreatState components, CoreVisualFeedbackSystem, and the whole Client/Onboarding slice (+6 test files). Keepers reworked: RunDirectorSystem (UpdateBefore attr + launch guard + goal/threat bank removed; sole SaveRequest raiser now), CycleDirectorSpawnSystem (ledger/meta host only), WaveSystem UNGATED (waves run wherever a WaveDirector is baked), EnemyAISystem core-fallback stripped, AmbientAudioSystem reworked (bed + run cues; no CycleState gate), MusicSystem RunInfo-only, HudSystem big trim (goal meter, core bar, siege banner, terminal banner, outcome flash, onboarding hook all gone), MetaShop/ClassPrep/AimReticle siege gates dropped, DebugOverlay/ops re-meant (SpawnWave=force next wave, EndSiege=quiet arena; SetCalm/AdvanceGoal/SetHeat retired, bytes reserved), TuningConfig Core knobs retired (ids 20-23 reserved), StorageMath.DrainFraction deleted, HowToPlay copy rewritten. Save epoch v7 (fresh epoch, operator-approved): SaveData drops goal/core/outcome + conveyor/machine-IO fields; MinLoadableVersion=7; PendingSave/PendingStructure trimmed; RollTerminalCampaignForward deleted; SaveStructureScan signature slimmed. 390 tests green; Play world-creation clean (player + waves live, no exceptions). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -5,99 +5,33 @@ using UnityEngine;
|
|||||||
namespace ProjectM.Authoring
|
namespace ProjectM.Authoring
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Authoring for the GLOBAL cycle-director ghost prefab: an ownerless INTERPOLATED ghost (the
|
/// Authoring for the GLOBAL director ghost prefab: an ownerless INTERPOLATED ghost (the
|
||||||
/// GhostAuthoringComponent is inherited when this prefab is duplicated from UpgradePickup.prefab) that
|
/// GhostAuthoringComponent is inherited when this prefab is duplicated from UpgradePickup.prefab) that
|
||||||
/// carries the replicated macro-loop state (<see cref="CycleState"/>) and the shared resource ledger
|
/// carries the shared resource ledger (a <see cref="StorageEntry"/> buffer marked by
|
||||||
/// (a <see cref="StorageEntry"/> buffer marked by <see cref="ResourceLedger"/>). It is GLOBAL — it must
|
/// <see cref="ResourceLedger"/>), the replicated run-lifecycle FSM (<see cref="RunInfo"/>), the
|
||||||
|
/// expedition-objective readout, and the per-class permanent-meta tier buffer. It is GLOBAL — it must
|
||||||
/// carry NO <see cref="RegionTag"/> so GhostRelevancy keeps it relevant to every connection regardless of
|
/// carry NO <see cref="RegionTag"/> so GhostRelevancy keeps it relevant to every connection regardless of
|
||||||
/// region. The server CycleDirectorSpawnSystem overrides the baked CycleState at spawn (real PhaseEndTick)
|
/// region. (The old cycle/siege/goal/core state is retired — LANTERN purge.)
|
||||||
/// and adds the server-only CycleRuntime.
|
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public class CycleDirectorAuthoring : MonoBehaviour
|
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.")]
|
|
||||||
[Min(0)] public int SiegeSizeBase = 5;
|
|
||||||
|
|
||||||
[Tooltip("Extra Husks per unit of resources hauled back this run (0 = flat SiegeSizeBase).")]
|
|
||||||
[Min(0)] 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;
|
|
||||||
[Header("Threat — scheduled base sieges (DR-042: DISABLED — reserved/inert hook)")]
|
|
||||||
[Tooltip("DR-042: OFF. A blind timed cadence was the AFK win path (auto-armed sieges the SiegeTimeout auto-cleared). The win-driver is now expedition clears; base sieges are post-expedition retaliation only. Code path kept as a config-inert reserved hook.")]
|
|
||||||
public bool ScheduleEnabled = false;
|
|
||||||
|
|
||||||
[Tooltip("Server ticks (@60) between scheduled base sieges. First fire is one interval out (mine/build grace).")]
|
|
||||||
public uint ScheduleIntervalTicks = 2700;
|
|
||||||
|
|
||||||
[Tooltip("Extra Husks per surviving wave (siege size = SiegeSizeBase + this * WaveNumber). 0 = flat.")]
|
|
||||||
[Min(0)] public int ScheduleSizePerWave = 1;
|
|
||||||
|
|
||||||
[Header("Endgame — Engine Core (END-1)")]
|
|
||||||
[Tooltip("Baked integrity ceiling of the losable Engine Core. Current is born full (or the persisted wounded value on Continue).")]
|
|
||||||
[Min(1)] public int CoreIntegrityMax = 100;
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
private class CycleDirectorBaker : Baker<CycleDirectorAuthoring>
|
private class CycleDirectorBaker : Baker<CycleDirectorAuthoring>
|
||||||
{
|
{
|
||||||
public override void Bake(CycleDirectorAuthoring authoring)
|
public override void Bake(CycleDirectorAuthoring authoring)
|
||||||
{
|
{
|
||||||
var entity = GetEntity(authoring, TransformUsageFlags.Dynamic);
|
var entity = GetEntity(authoring, TransformUsageFlags.Dynamic);
|
||||||
AddComponent(entity, new CycleState
|
|
||||||
{
|
|
||||||
Phase = CyclePhase.Calm,
|
|
||||||
CycleNumber = 1,
|
|
||||||
PhaseEndTick = 0u,
|
|
||||||
});
|
|
||||||
AddComponent<ResourceLedger>(entity);
|
AddComponent<ResourceLedger>(entity);
|
||||||
AddBuffer<StorageEntry>(entity);
|
AddBuffer<StorageEntry>(entity);
|
||||||
AddComponent(entity, new GoalProgress { Charge = 0, Target = 2 }); // DR-044 demo pacing: 2 expedition clears -> the climactic final siege (~10-20 min hands-off run)
|
|
||||||
// END-1: the losable Engine Core rides this GLOBAL ghost (no new ghost / no relevancy). Born full;
|
|
||||||
// CycleDirectorSpawnSystem overrides Current with a persisted wounded value on Continue.
|
|
||||||
AddComponent(entity, new CoreIntegrity
|
|
||||||
{
|
|
||||||
Current = authoring.CoreIntegrityMax,
|
|
||||||
Max = authoring.CoreIntegrityMax,
|
|
||||||
OverrunTick = 0u,
|
|
||||||
});
|
|
||||||
// END-2: the terminal run outcome is REPLICATED ([GhostField]) so the client HUD shows the win/loss
|
|
||||||
// banner by observing it. Baked here -> part of the ghost serializer (one re-bake). Born InProgress;
|
|
||||||
// CycleDirectorSpawnSystem overrides it with a persisted Victory/Loss on Continue.
|
|
||||||
AddComponent(entity, new RunOutcome { Value = RunOutcomeId.InProgress });
|
|
||||||
|
|
||||||
// DR-042 C7b: replicated expedition-objective summary (the HUD 'enemies remaining / cleared' readout).
|
// DR-042 C7b: replicated expedition-objective summary (the HUD 'enemies remaining / cleared' readout).
|
||||||
// Born Idle; ZoneEnemyDirectorSystem is the sole writer. New [GhostField] component -> re-hashes the
|
// Born Idle; RoomEnemyDirectorSystem is the sole writer.
|
||||||
// runtime-spawned director ghost (server + client bake the same prefab -> hash matches), like CoreIntegrity.
|
|
||||||
AddComponent(entity, new ExpeditionObjective { State = ExpeditionObjectiveState.Idle, Remaining = 0 });
|
AddComponent(entity, new ExpeditionObjective { State = ExpeditionObjectiveState.Idle, Remaining = 0 });
|
||||||
|
|
||||||
// Expedition redesign: the replicated run-lifecycle FSM (RunInfo, 17 [GhostField]s) + the per-class
|
// Expedition redesign: the replicated run-lifecycle FSM (RunInfo) + the per-class permanent-meta
|
||||||
// permanent-meta tier buffer (MetaTierState) BOTH land in this ONE coordinated re-bake (front-loaded
|
// tier buffer (MetaTierState). Born Staging/empty; server RunDirectorSystem / MetaSpendSystem are
|
||||||
// ghost layout — the writer systems arrive across Steps 2–13 while the state sits inert/default).
|
// the sole writers.
|
||||||
// Born Staging/empty; server RunDirectorSystem / MetaSpendSystem are the sole writers.
|
|
||||||
AddComponent(entity, new RunInfo { Lifecycle = RunLifecycle.Staging });
|
AddComponent(entity, new RunInfo { Lifecycle = RunLifecycle.Staging });
|
||||||
AddBuffer<MetaTierState>(entity);
|
AddBuffer<MetaTierState>(entity);
|
||||||
|
|
||||||
|
|
||||||
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,
|
|
||||||
ScheduleEnabled = (byte)(authoring.ScheduleEnabled ? 1 : 0),
|
|
||||||
ScheduleIntervalTicks = authoring.ScheduleIntervalTicks,
|
|
||||||
ScheduleSizePerWave = authoring.ScheduleSizePerWave,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,18 +26,15 @@ namespace ProjectM.Client
|
|||||||
=> s_Pending.Add(new Pending { Op = op, ArgA = argA, ArgB = argB });
|
=> s_Pending.Add(new Pending { Op = op, ArgA = argA, ArgB = argB });
|
||||||
|
|
||||||
// Convenience wrappers (overlay buttons + execute_code).
|
// Convenience wrappers (overlay buttons + execute_code).
|
||||||
public static void SpawnWave(int size) => Send(DebugOp.SpawnWave, size);
|
public static void SpawnWave() => Send(DebugOp.SpawnWave); // re-meant: force the next wave now
|
||||||
public static void EndSiege() => Send(DebugOp.EndSiege);
|
public static void StopWaves() => Send(DebugOp.EndSiege); // re-meant: quiet the arena (cull + delay waves)
|
||||||
public static void ClearEnemies() => Send(DebugOp.ClearEnemies);
|
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 GrantResource(byte itemId, int count) => Send(DebugOp.GrantResource, itemId, count);
|
||||||
public static void GrantUpgrade() => Send(DebugOp.GrantUpgrade);
|
public static void GrantUpgrade() => Send(DebugOp.GrantUpgrade);
|
||||||
public static void Teleport(byte region) => Send(DebugOp.Teleport, region);
|
public static void Teleport(byte region) => Send(DebugOp.Teleport, region);
|
||||||
public static void ToggleGod() => Send(DebugOp.ToggleGod);
|
public static void ToggleGod() => Send(DebugOp.ToggleGod);
|
||||||
public static void Heal() => Send(DebugOp.Heal);
|
public static void Heal() => Send(DebugOp.Heal);
|
||||||
public static void Kill() => Send(DebugOp.KillPlayer);
|
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);
|
|
||||||
/// <summary>Set the <see cref="ProjectM.Simulation.TuningKnob"/> knob to value (server-applied, x1000 fixed-point; MC-0).</summary>
|
/// <summary>Set the <see cref="ProjectM.Simulation.TuningKnob"/> knob to value (server-applied, x1000 fixed-point; MC-0).</summary>
|
||||||
public static void SetTuning(byte knob, float value) => Send(DebugOp.SetTuning, knob, Mathf.RoundToInt(value * 1000f));
|
public static void SetTuning(byte knob, float value) => Send(DebugOp.SetTuning, knob, Mathf.RoundToInt(value * 1000f));
|
||||||
/// <summary>Swap the sender's class to <paramref name="classId"/> (a <see cref="ProjectM.Simulation.CharacterId"/> byte); server-authoritative (class-switch dev tool).</summary>
|
/// <summary>Swap the sender's class to <paramref name="classId"/> (a <see cref="ProjectM.Simulation.CharacterId"/> byte); server-authoritative (class-switch dev tool).</summary>
|
||||||
|
|||||||
@@ -15,7 +15,6 @@ namespace ProjectM.Client
|
|||||||
public class DebugOverlay : MonoBehaviour
|
public class DebugOverlay : MonoBehaviour
|
||||||
{
|
{
|
||||||
bool _open = true;
|
bool _open = true;
|
||||||
int _siegeSize = 5;
|
|
||||||
int _grantAmount = 50;
|
int _grantAmount = 50;
|
||||||
bool _tuningOpen;
|
bool _tuningOpen;
|
||||||
Vector2 _scroll;
|
Vector2 _scroll;
|
||||||
@@ -37,12 +36,9 @@ namespace ProjectM.Client
|
|||||||
_scroll = GUILayout.BeginScrollView(_scroll);
|
_scroll = GUILayout.BeginScrollView(_scroll);
|
||||||
|
|
||||||
GUILayout.Label("- World -");
|
GUILayout.Label("- World -");
|
||||||
_siegeSize = IntField("Siege size", _siegeSize);
|
if (GUILayout.Button("Force Next Wave")) DebugCommandSendSystem.SpawnWave();
|
||||||
if (GUILayout.Button("Spawn Wave / Force Siege")) DebugCommandSendSystem.SpawnWave(_siegeSize);
|
if (GUILayout.Button("Stop Waves")) DebugCommandSendSystem.StopWaves();
|
||||||
if (GUILayout.Button("End Siege")) DebugCommandSendSystem.EndSiege();
|
|
||||||
if (GUILayout.Button("Clear Enemies")) DebugCommandSendSystem.ClearEnemies();
|
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.Space(6);
|
||||||
GUILayout.Label("- Resources -");
|
GUILayout.Label("- Resources -");
|
||||||
@@ -112,9 +108,6 @@ namespace ProjectM.Client
|
|||||||
TuningRow("Melee combo len", TuningKnob.MeleeComboLength, 1f, "0");
|
TuningRow("Melee combo len", TuningKnob.MeleeComboLength, 1f, "0");
|
||||||
GUILayout.Space(4);
|
GUILayout.Space(4);
|
||||||
TuningRow("Struct aggro w", TuningKnob.StructureAggroWeight, 0.1f, "0.00"); // EB-1: <1 prefers structures
|
TuningRow("Struct aggro w", TuningKnob.StructureAggroWeight, 0.1f, "0.00"); // EB-1: <1 prefers structures
|
||||||
TuningRow("Core dmg/husk", TuningKnob.CoreDamagePerHusk, 1f, "0"); // END-1: integrity per breaching Husk
|
|
||||||
TuningRow("Core regen int", TuningKnob.CoreRegenIntervalTicks, 1f, "0"); // END-1: ticks between +1 in Calm
|
|
||||||
TuningRow("Core overrun %", TuningKnob.CoreOverrunDrainPct, 0.05f, "0.00"); // END-1: ledger fraction lost on breach
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +0,0 @@
|
|||||||
fileFormatVersion: 2
|
|
||||||
guid: 0861914135cacf948ae2adfd7f7d6870
|
|
||||||
folderAsset: yes
|
|
||||||
DefaultImporter:
|
|
||||||
externalObjects: {}
|
|
||||||
userData:
|
|
||||||
assetBundleName:
|
|
||||||
assetBundleVariant:
|
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
using UnityEngine;
|
|
||||||
|
|
||||||
namespace ProjectM.Client
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// Tiny static coordination bridge for the first-run onboarding overlay. <see cref="Active"/> is true while a
|
|
||||||
/// coach-mark step is on screen (set each frame by <see cref="OnboardingSystem"/>); <see cref="HudSystem"/>
|
|
||||||
/// reads it to suppress its own ad-hoc location/gate hint so the player ever sees a single prompt voice.
|
|
||||||
/// A presentation-layer static, so it is RESET on play-enter (the CLAUDE.md stale-static rule) to avoid a
|
|
||||||
/// stale flag surviving a fast-enter-playmode domain reload and leaving the HUD hint suppressed.
|
|
||||||
/// </summary>
|
|
||||||
public static class OnboardingState
|
|
||||||
{
|
|
||||||
/// <summary>True while the coach-mark sequence is the active prompt voice (a step is being shown).</summary>
|
|
||||||
public static bool Active;
|
|
||||||
|
|
||||||
/// <summary>True only while an EARLY base-framing step (Welcome/Move/Build/Fabricator/ReadyUp) is showing, so
|
|
||||||
/// HudSystem blanks its own location line for those (the coach-mark owns the voice) but LEAVES the room/siege/
|
|
||||||
/// out-of-ammo cues visible during the combat steps (D4). Set each frame by OnboardingSystem.</summary>
|
|
||||||
public static bool SuppressLocationLine;
|
|
||||||
|
|
||||||
|
|
||||||
[RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.SubsystemRegistration)]
|
|
||||||
static void ResetOnPlayEnter() { Active = false; SuppressLocationLine = false; }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
fileFormatVersion: 2
|
|
||||||
guid: 4e9b3bb074eef1c40b90591e85b90e32
|
|
||||||
@@ -1,145 +0,0 @@
|
|||||||
using ProjectM.Simulation;
|
|
||||||
|
|
||||||
namespace ProjectM.Client
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// Pure, engine-free logic for the first-run onboarding coach-mark sequence — the testable core of
|
|
||||||
/// <see cref="OnboardingSystem"/> (mirrors the project's <c>*Math</c> helper discipline; no UnityEngine /
|
|
||||||
/// Entities types so it unit-tests as plain C#). Defines the ordered step list, a <see cref="Snapshot"/> of the
|
|
||||||
/// observable client state each step reads, the deterministic per-step completion test, prompt copy, the
|
|
||||||
/// spatial-cue kind, and the persisted-mask helpers.
|
|
||||||
///
|
|
||||||
/// RE-AUTHORED for the expedition redesign (demo polish): base nodes are gone (mining happens IN the run),
|
|
||||||
/// so the old at-base Mine step is dead — the first lap is now Build (grubstake Ore) → Fabricator →
|
|
||||||
/// READY UP → fight rooms (mine in-run) → boon → boss/return → defend. Step BITS are re-meant, never
|
|
||||||
/// renamed-in-place semantics: a veteran's full mask stays dormant; a partial first-run mask at worst
|
|
||||||
/// replays one beat.
|
|
||||||
///
|
|
||||||
/// Pacing (operator-locked = soft-gated): a step shows until its action is performed (no per-step timeout),
|
|
||||||
/// EXCEPT two info beats — <see cref="Fabricator"/> and <see cref="Defend"/> — which also auto-advance, plus
|
|
||||||
/// the timed <see cref="Welcome"/> strip. Veteran / co-op auto-suppress falls out for free: the count-based
|
|
||||||
/// steps (<see cref="Build"/>, <see cref="Fabricator"/>) test an ABSOLUTE structure count, so a client joining
|
|
||||||
/// an already-built base satisfies them on entry and skips straight past.
|
|
||||||
/// </summary>
|
|
||||||
public static class OnboardingStepMath
|
|
||||||
{
|
|
||||||
// ---- ordered steps (byte ids; bit i of GameSettings.OnboardingMask = step i complete) ----
|
|
||||||
public const byte Welcome = 0; // tiny win-condition framing strip (timed)
|
|
||||||
public const byte Move = 1;
|
|
||||||
public const byte Build = 2; // open palette + place a Turret (the 50-Ore grubstake covers it)
|
|
||||||
public const byte Fabricator = 3; // Ore -> Charge (soft info beat)
|
|
||||||
public const byte ReadyUp = 4; // press T / click READY — the party launches together
|
|
||||||
public const byte Rooms = 5; // fight the rooms; attack crystal nodes to haul resources
|
|
||||||
public const byte Boon = 6; // clear a room -> pick 1 of 3 boons + choose the path
|
|
||||||
public const byte Return = 7; // fell the boss — the haul + the Engine charge come home
|
|
||||||
public const byte Defend = 8; // survive the retaliation siege (soft info beat)
|
|
||||||
public const byte Done = 9; // closing beat
|
|
||||||
public const byte StepCount = 10;
|
|
||||||
|
|
||||||
// ---- tunable thresholds (public so the EditMode tests pin the contract) ----
|
|
||||||
public const float WelcomeSeconds = 5f;
|
|
||||||
public const float MoveThreshold = 3f; // accumulated player movement (world units)
|
|
||||||
public const float FabricatorSoftSeconds = 14f; // soft beat auto-advance if no Fabricator built
|
|
||||||
public const float DefendNoSiegeSeconds = 20f; // advance if no siege ever materialises
|
|
||||||
public const float DoneSeconds = 6f; // closing beat lingers before going dormant
|
|
||||||
public const float RoomsSeconds = 7f; // D2: keep the mine-the-crystals prompt + node pointer up a beat AFTER teleport (past the ~3s launch countdown -> ~4s in the room)
|
|
||||||
public const float ReturnMaxSeconds = 75f; // D3: soft backstop so a missed homecoming signal can NEVER stall the sequence
|
|
||||||
|
|
||||||
// ---- spatial-cue kinds the System resolves to a live world target ----
|
|
||||||
public const byte PointerNone = 0;
|
|
||||||
public const byte PointerOreNode = 1;
|
|
||||||
public const byte PointerBaseGate = 2; // RETIRED target (walk-in gate died with the redesign); byte kept
|
|
||||||
public const byte PointerExpeditionGate = 3; // RETIRED target; byte kept for mask/step stability
|
|
||||||
|
|
||||||
/// <summary>Observable client state for one evaluation. Built by the System from ECS + input each frame.</summary>
|
|
||||||
public struct Snapshot
|
|
||||||
{
|
|
||||||
public float StepElapsed; // seconds the current step has been shown
|
|
||||||
public float MoveDistance; // accumulated player movement since the Move step began
|
|
||||||
public int TurretCount; // live Turret structures (absolute)
|
|
||||||
public int FabricatorCount; // live Fabricator structures (absolute)
|
|
||||||
public bool LocalReady; // the LOCAL player's replicated PlayerReady flag
|
|
||||||
public byte Lifecycle; // replicated RunInfo.Lifecycle (RunLifecycle.*)
|
|
||||||
public bool OnExpedition; // local player is in the expedition region
|
|
||||||
public byte ObjectiveState; // ExpeditionObjective.State (Idle/Active/Cleared)
|
|
||||||
public bool SawSiege; // a Siege phase was observed while the Defend step was showing
|
|
||||||
public bool WasOnExpedition;// D3: latched true once the player was seen on expedition during the Return step (so a start-at-base Return doesn't instantly satisfy)
|
|
||||||
|
|
||||||
public byte Phase; // CycleState.Phase (Calm/Siege)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>True when the step's taught action is complete (or its soft timeout has elapsed).</summary>
|
|
||||||
public static bool IsSatisfied(byte step, in Snapshot s)
|
|
||||||
{
|
|
||||||
switch (step)
|
|
||||||
{
|
|
||||||
case Welcome: return s.StepElapsed >= WelcomeSeconds;
|
|
||||||
case Move: return s.MoveDistance >= MoveThreshold;
|
|
||||||
case Build: return s.TurretCount >= 1;
|
|
||||||
case Fabricator: return s.FabricatorCount >= 1 || s.StepElapsed >= FabricatorSoftSeconds;
|
|
||||||
case ReadyUp: return s.LocalReady || s.Lifecycle != RunLifecycle.Staging;
|
|
||||||
case Rooms: return s.OnExpedition && s.StepElapsed >= RoomsSeconds; // D2: show the mine prompt + node pointer IN the room, not the instant we teleport
|
|
||||||
case Boon: return s.ObjectiveState == ExpeditionObjectiveState.Cleared
|
|
||||||
|| s.Lifecycle == RunLifecycle.RoomReward
|
|
||||||
|| s.Lifecycle == RunLifecycle.RouteSelect;
|
|
||||||
case Return: return (s.WasOnExpedition && !s.OnExpedition) || s.StepElapsed >= ReturnMaxSeconds; // D3: home AFTER being on expedition, else a soft timeout (never gate on the 1-tick Returning edge)
|
|
||||||
case Defend: return s.SawSiege ? s.Phase == CyclePhase.Calm : s.StepElapsed >= DefendNoSiegeSeconds;
|
|
||||||
case Done: return s.StepElapsed >= DoneSeconds;
|
|
||||||
default: return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>Which world target (if any) the prompt should point at this step.</summary>
|
|
||||||
public static byte PointerKind(byte step)
|
|
||||||
{
|
|
||||||
switch (step)
|
|
||||||
{
|
|
||||||
case Rooms: return PointerOreNode; // in-run crystal nodes (base nodes no longer exist)
|
|
||||||
default: return PointerNone;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>Ultra-short, verb-first prompt copy with the player's real input glyph (scheme-aware).</summary>
|
|
||||||
public static string Prompt(byte step, bool gamepad)
|
|
||||||
{
|
|
||||||
string move = gamepad ? "Left Stick" : "WASD";
|
|
||||||
string build = gamepad ? "Y" : "Tab"; // matches the existing HUD build-discovery chip glyph
|
|
||||||
switch (step)
|
|
||||||
{
|
|
||||||
case Welcome: return "CLEAR 2 EXPEDITIONS to charge the Engine, then survive the FINAL SIEGE. (Space to continue · Esc → How to Play)";
|
|
||||||
case Move: return move + " — Move " + (gamepad ? "B" : "LShift") + " — Dash (brief invulnerability)";
|
|
||||||
case Build: return build + " — open Build, place a Turret by your Core (your " + ProjectM.Simulation.Tuning.StartingOre + " starting Ore covers it)";
|
|
||||||
case Fabricator: return "Build a Fabricator — turrets need Charge (Ore → ammo)";
|
|
||||||
case ReadyUp: return "Press T (or click READY UP) — when everyone is ready, the party launches";
|
|
||||||
case Rooms: return (gamepad ? "X attack · LT class ability" : "LMB attack · RMB class ability") + " — clear the room; shoot the glowing crystals for your haul";
|
|
||||||
case Boon: return "Clear the room — pick 1 of 3 BOONS, then choose your path on the map";
|
|
||||||
case Return: return "Fell the ALPHA HUSK, then return home — your haul + the Engine charge (+1) come with you";
|
|
||||||
case Defend: return "Defend the Core! — a completed run provokes a retaliation siege";
|
|
||||||
case Done: return "You've got it — clear 2 expeditions to fill the Engine, then hold the final siege to win.";
|
|
||||||
default: return "";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- persisted-mask helpers (GameSettings.OnboardingMask) ----
|
|
||||||
|
|
||||||
/// <summary>D4: the early BASE-framing steps (the coach-mark owns the prompt voice, so the HUD blanks its own
|
|
||||||
/// location line) vs the combat steps (the HUD MUST keep showing room/siege/out-of-ammo cues). Welcome..ReadyUp
|
|
||||||
/// are early. Read each frame by OnboardingSystem to set OnboardingState.SuppressLocationLine.</summary>
|
|
||||||
public static bool IsEarlyStep(byte step) => step <= ReadyUp;
|
|
||||||
|
|
||||||
/// <summary>All steps complete (the sequence is dormant).</summary>
|
|
||||||
public static bool AllComplete(int mask)
|
|
||||||
{
|
|
||||||
int all = (1 << StepCount) - 1;
|
|
||||||
return (mask & all) == all;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>The lowest not-yet-completed step (resume point); <see cref="Done"/> when all are complete.</summary>
|
|
||||||
public static byte FirstIncomplete(int mask)
|
|
||||||
{
|
|
||||||
for (byte i = 0; i < StepCount; i++)
|
|
||||||
if ((mask & (1 << i)) == 0) return i;
|
|
||||||
return Done;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
fileFormatVersion: 2
|
|
||||||
guid: c264496096436e74ebba163a7a5d2205
|
|
||||||
@@ -1,332 +0,0 @@
|
|||||||
using ProjectM.Simulation;
|
|
||||||
using Unity.Entities;
|
|
||||||
using Unity.Mathematics;
|
|
||||||
using Unity.NetCode;
|
|
||||||
using Unity.Transforms;
|
|
||||||
using UnityEngine;
|
|
||||||
using UnityEngine.UIElements;
|
|
||||||
|
|
||||||
namespace ProjectM.Client
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// First-run onboarding overlay — a CLIENT-ONLY, observe-only presentation <see cref="SystemBase"/> in
|
|
||||||
/// <see cref="PresentationSystemGroup"/> (same shape/constraints as <see cref="HudSystem"/>: never mutates the
|
|
||||||
/// sim, never destroys a ghost, reads already-replicated state once per frame). Owns its own runtime UIDocument
|
|
||||||
/// (sortingOrder 60 — above the HUD's 50, below the pause overlay's 100) showing a single bottom-center
|
|
||||||
/// coach-mark prompt plus a world-space directional pointer.
|
|
||||||
///
|
|
||||||
/// The sequence is PER-CLIENT and client-local: progress lives in <see cref="GameSettings.OnboardingMask"/>
|
|
||||||
/// (via <see cref="SettingsService"/>), keyed to THIS player's own first-encounter — so a veteran host sees
|
|
||||||
/// nothing, a brand-new join-client is still taught, and a save wipe never re-teaches the host (the mask is in
|
|
||||||
/// settings.json, not the host-only SaveData). Soft-gated pacing: a step shows until its action is done; the
|
|
||||||
/// pure rules + auto-suppress (absolute count checks) live in <see cref="OnboardingStepMath"/>.
|
|
||||||
/// </summary>
|
|
||||||
[WorldSystemFilter(WorldSystemFilterFlags.ClientSimulation)]
|
|
||||||
[UpdateInGroup(typeof(PresentationSystemGroup))]
|
|
||||||
public partial class OnboardingSystem : SystemBase
|
|
||||||
{
|
|
||||||
const float ExpeditionRegionXMin = RegionMath.RegionBoundaryX; // player x past this = the +1000 expedition region (mirrors HudSystem)
|
|
||||||
|
|
||||||
GameObject _go;
|
|
||||||
UIDocument _doc;
|
|
||||||
bool _built;
|
|
||||||
Label _prompt;
|
|
||||||
Label _pointer;
|
|
||||||
|
|
||||||
// step machine (in-memory; persisted to the mask on each completion)
|
|
||||||
bool _maskLoaded;
|
|
||||||
int _mask;
|
|
||||||
byte _step;
|
|
||||||
bool _stepInit;
|
|
||||||
float _stepElapsed;
|
|
||||||
float _moveAccum;
|
|
||||||
float3 _lastPos;
|
|
||||||
bool _sawSiege;
|
|
||||||
bool _wasOnExpedition; // D3: latched true once the player is seen on expedition during the Return step
|
|
||||||
|
|
||||||
|
|
||||||
protected override void OnStartRunning()
|
|
||||||
{
|
|
||||||
if (_go != null) return;
|
|
||||||
_go = new GameObject("~Onboarding");
|
|
||||||
_doc = _go.AddComponent<UIDocument>();
|
|
||||||
_doc.panelSettings = MenuUi.LoadPanelSettings();
|
|
||||||
_doc.sortingOrder = 60; // above HUD (50), below pause (100)
|
|
||||||
}
|
|
||||||
|
|
||||||
protected override void OnDestroy()
|
|
||||||
{
|
|
||||||
OnboardingState.Active = false; // never let the static outlive its owning system (HUD suppression)
|
|
||||||
if (_go != null) Object.Destroy(_go);
|
|
||||||
}
|
|
||||||
|
|
||||||
protected override void OnUpdate()
|
|
||||||
{
|
|
||||||
if (_doc == null) return;
|
|
||||||
var root = _doc.rootVisualElement;
|
|
||||||
if (root == null) return;
|
|
||||||
if (!_built) { BuildTree(root); _built = true; }
|
|
||||||
OnboardingState.SuppressLocationLine = false; // D4: default off each frame; a shown step sets it per-step below
|
|
||||||
|
|
||||||
|
|
||||||
float dt = SystemAPI.Time.DeltaTime; // wall-frame delta — correct in a presentation system
|
|
||||||
|
|
||||||
// ---- local player presence + position ----
|
|
||||||
bool havePlayer = false; float3 playerPos = default;
|
|
||||||
foreach (var lt in SystemAPI.Query<RefRO<LocalTransform>>().WithAll<GhostOwnerIsLocal, PlayerTag>())
|
|
||||||
{ havePlayer = true; playerPos = lt.ValueRO.Position; break; }
|
|
||||||
|
|
||||||
var settings = SettingsService.Current;
|
|
||||||
if (!_maskLoaded)
|
|
||||||
{
|
|
||||||
_mask = settings.OnboardingMask;
|
|
||||||
_step = OnboardingStepMath.FirstIncomplete(_mask);
|
|
||||||
_stepInit = false;
|
|
||||||
_maskLoaded = true;
|
|
||||||
}
|
|
||||||
bool hintsOn = settings.TutorialHints != 0;
|
|
||||||
|
|
||||||
// Dormant (hints off / all steps done) or no local player yet → fully hidden, no voice.
|
|
||||||
if (!hintsOn || OnboardingStepMath.AllComplete(_mask) || !havePlayer)
|
|
||||||
{
|
|
||||||
OnboardingState.Active = false;
|
|
||||||
root.style.display = DisplayStyle.None;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- remaining observable state ----
|
|
||||||
byte lifecycle = RunLifecycle.Staging;
|
|
||||||
if (SystemAPI.TryGetSingleton<RunInfo>(out var runInfo)) lifecycle = runInfo.Lifecycle;
|
|
||||||
bool localReady = false;
|
|
||||||
foreach (var pr in SystemAPI.Query<RefRO<PlayerReady>>().WithAll<GhostOwnerIsLocal, PlayerTag>())
|
|
||||||
{ localReady = pr.ValueRO.Value != 0; break; }
|
|
||||||
CountStructures(out int turrets, out int fabs);
|
|
||||||
byte phase = CyclePhase.Calm;
|
|
||||||
if (SystemAPI.TryGetSingleton<CycleState>(out var cyc)) phase = cyc.Phase;
|
|
||||||
byte objState = ExpeditionObjectiveState.Idle;
|
|
||||||
if (SystemAPI.TryGetSingleton<ExpeditionObjective>(out var obj)) objState = obj.State;
|
|
||||||
bool onExp = playerPos.x > ExpeditionRegionXMin;
|
|
||||||
|
|
||||||
// ---- per-step entry init (baselines) ----
|
|
||||||
if (!_stepInit)
|
|
||||||
{
|
|
||||||
_stepElapsed = 0f; _moveAccum = 0f; _sawSiege = false; _wasOnExpedition = false;
|
|
||||||
_lastPos = playerPos;
|
|
||||||
_stepInit = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- advance (FROZEN while the pause overlay is open, so the timed beats — Welcome/Fabricator/
|
|
||||||
// Defend/Done — aren't silently lost behind the pause dim that sits above this overlay) ----
|
|
||||||
if (!PauseMenuController.Open)
|
|
||||||
{
|
|
||||||
_stepElapsed += dt;
|
|
||||||
if (_step == OnboardingStepMath.Move) _moveAccum += math.distance(playerPos, _lastPos);
|
|
||||||
if (_step == OnboardingStepMath.Defend && phase == CyclePhase.Siege) _sawSiege = true;
|
|
||||||
if (_step == OnboardingStepMath.Return && onExp) _wasOnExpedition = true; // D3: latch "was on expedition" so a start-at-base Return doesn't instantly satisfy
|
|
||||||
|
|
||||||
|
|
||||||
var snap = new OnboardingStepMath.Snapshot
|
|
||||||
{
|
|
||||||
StepElapsed = _stepElapsed,
|
|
||||||
MoveDistance = _moveAccum,
|
|
||||||
LocalReady = localReady,
|
|
||||||
Lifecycle = lifecycle,
|
|
||||||
TurretCount = turrets,
|
|
||||||
FabricatorCount = fabs,
|
|
||||||
OnExpedition = onExp,
|
|
||||||
ObjectiveState = objState,
|
|
||||||
SawSiege = _sawSiege,
|
|
||||||
WasOnExpedition = _wasOnExpedition,
|
|
||||||
Phase = phase,
|
|
||||||
};
|
|
||||||
|
|
||||||
// The two pure-message beats can be dismissed with any input EXCEPT Esc (Esc opens Pause; see
|
|
||||||
// AnyInputPressed) so following the "Esc → Pause → How to Play" hint doesn't self-skip the framing.
|
|
||||||
bool skip = (_step == OnboardingStepMath.Welcome && ConfirmPressed()) || (_step == OnboardingStepMath.Done && AnyInputPressed()); // D5: Welcome dismisses ONLY on an explicit confirm (not WASD) so the win-condition strip is actually read
|
|
||||||
if (skip || OnboardingStepMath.IsSatisfied(_step, snap))
|
|
||||||
{
|
|
||||||
_mask |= (1 << _step);
|
|
||||||
Persist(_mask);
|
|
||||||
_step = OnboardingStepMath.FirstIncomplete(_mask); // auto-suppressed steps cascade one/frame
|
|
||||||
_stepInit = false;
|
|
||||||
if (OnboardingStepMath.AllComplete(_mask))
|
|
||||||
{
|
|
||||||
OnboardingState.Active = false;
|
|
||||||
root.style.display = DisplayStyle.None;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
_lastPos = playerPos;
|
|
||||||
|
|
||||||
// ---- show the current step ----
|
|
||||||
OnboardingState.Active = true;
|
|
||||||
OnboardingState.SuppressLocationLine = OnboardingStepMath.IsEarlyStep(_step); // D4: blank the HUD line only for the early base-framing steps
|
|
||||||
root.style.display = DisplayStyle.Flex;
|
|
||||||
bool gamepad = AimPresentation.Scheme == InputSchemeId.Gamepad;
|
|
||||||
_prompt.text = OnboardingStepMath.Prompt(_step, gamepad);
|
|
||||||
UpdatePointer(_step, playerPos);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- state gathering helpers ----
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
void CountStructures(out int turrets, out int fabs)
|
|
||||||
{
|
|
||||||
turrets = 0; fabs = 0;
|
|
||||||
foreach (var ps in SystemAPI.Query<RefRO<PlacedStructure>>())
|
|
||||||
{
|
|
||||||
byte t = ps.ValueRO.Type;
|
|
||||||
if (t == StructureType.Turret) turrets++;
|
|
||||||
else if (t == StructureType.Fabricator) fabs++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
void Persist(int mask)
|
|
||||||
{
|
|
||||||
var s = SettingsService.Current;
|
|
||||||
s.OnboardingMask = mask;
|
|
||||||
SettingsService.Save(s); // atomic write; ~once per completed step
|
|
||||||
}
|
|
||||||
|
|
||||||
static bool AnyInputPressed()
|
|
||||||
{
|
|
||||||
var kb = UnityEngine.InputSystem.Keyboard.current;
|
|
||||||
// any key dismisses a message beat — EXCEPT Esc, which is the pause key (don't self-skip the framing).
|
|
||||||
if (kb != null && kb.anyKey.wasPressedThisFrame && !kb.escapeKey.wasPressedThisFrame) return true;
|
|
||||||
var ms = UnityEngine.InputSystem.Mouse.current;
|
|
||||||
if (ms != null && ms.leftButton.wasPressedThisFrame) return true;
|
|
||||||
var gp = UnityEngine.InputSystem.Gamepad.current;
|
|
||||||
if (gp != null && (gp.buttonSouth.wasPressedThisFrame || gp.startButton.wasPressedThisFrame)) return true;
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// D5: an EXPLICIT confirm only (Space/Enter/click/South) — movement keys must NOT skip the Welcome strip.
|
|
||||||
static bool ConfirmPressed()
|
|
||||||
{
|
|
||||||
var kb = UnityEngine.InputSystem.Keyboard.current;
|
|
||||||
if (kb != null && (kb.spaceKey.wasPressedThisFrame || kb.enterKey.wasPressedThisFrame)) return true;
|
|
||||||
var ms = UnityEngine.InputSystem.Mouse.current;
|
|
||||||
if (ms != null && ms.leftButton.wasPressedThisFrame) return true;
|
|
||||||
var gp = UnityEngine.InputSystem.Gamepad.current;
|
|
||||||
if (gp != null && (gp.buttonSouth.wasPressedThisFrame || gp.startButton.wasPressedThisFrame)) return true;
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
// ---- world-space pointer ----
|
|
||||||
|
|
||||||
bool ResolveTarget(byte kind, float3 playerPos, out float3 target)
|
|
||||||
{
|
|
||||||
target = default;
|
|
||||||
if (kind == OnboardingStepMath.PointerOreNode)
|
|
||||||
{
|
|
||||||
float best = float.MaxValue; bool found = false;
|
|
||||||
foreach (var lt in SystemAPI.Query<RefRO<LocalTransform>>().WithAll<ResourceNode>())
|
|
||||||
{
|
|
||||||
float d = math.distancesq(lt.ValueRO.Position, playerPos);
|
|
||||||
if (d < best) { best = d; target = lt.ValueRO.Position; found = true; }
|
|
||||||
}
|
|
||||||
return found;
|
|
||||||
}
|
|
||||||
// Step 11: the walk-in ExpeditionGate was RETIRED (the ready-check launches runs), so gate pointers
|
|
||||||
// have no world target — hide the arrow; the step text still teaches. Step 14's HUD pass re-points
|
|
||||||
// onboarding at the READY panel instead.
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
void UpdatePointer(byte step, float3 playerPos)
|
|
||||||
{
|
|
||||||
byte kind = OnboardingStepMath.PointerKind(step);
|
|
||||||
var cam = Camera.main;
|
|
||||||
var root = _doc.rootVisualElement;
|
|
||||||
if (kind == OnboardingStepMath.PointerNone || cam == null || !ResolveTarget(kind, playerPos, out float3 target))
|
|
||||||
{ _pointer.style.display = DisplayStyle.None; return; }
|
|
||||||
|
|
||||||
float pw = root.layout.width, ph = root.layout.height;
|
|
||||||
if (pw <= 1f || ph <= 1f) { _pointer.style.display = DisplayStyle.None; return; }
|
|
||||||
|
|
||||||
Vector3 sp = cam.WorldToScreenPoint((Vector3)target);
|
|
||||||
bool behind = sp.z < 0f;
|
|
||||||
float px = (sp.x / Mathf.Max(1f, Screen.width)) * pw;
|
|
||||||
float py = (1f - sp.y / Mathf.Max(1f, Screen.height)) * ph;
|
|
||||||
if (behind) { px = pw - px; py = ph - py; }
|
|
||||||
|
|
||||||
const float margin = 64f;
|
|
||||||
bool off = behind || px < margin || px > pw - margin || py < margin || py > ph - margin;
|
|
||||||
|
|
||||||
float cx = pw * 0.5f, cy = ph * 0.5f;
|
|
||||||
float dx = px - cx, dy = py - cy;
|
|
||||||
float len = Mathf.Sqrt(dx * dx + dy * dy);
|
|
||||||
if (len < 0.001f) { dx = 1f; dy = 0f; len = 1f; }
|
|
||||||
float ndx = dx / len, ndy = dy / len;
|
|
||||||
|
|
||||||
float ax, ay;
|
|
||||||
if (off)
|
|
||||||
{
|
|
||||||
// intersect the center→target ray with the margin rectangle (edge arrow)
|
|
||||||
float tx = (ndx > 0 ? (pw - margin - cx) : (margin - cx)) / (Mathf.Abs(ndx) < 1e-4f ? (ndx < 0 ? -1e-4f : 1e-4f) : ndx);
|
|
||||||
float ty = (ndy > 0 ? (ph - margin - cy) : (margin - cy)) / (Mathf.Abs(ndy) < 1e-4f ? (ndy < 0 ? -1e-4f : 1e-4f) : ndy);
|
|
||||||
float tt = Mathf.Min(Mathf.Abs(tx), Mathf.Abs(ty));
|
|
||||||
ax = cx + ndx * tt; ay = cy + ndy * tt;
|
|
||||||
}
|
|
||||||
else { ax = px; ay = py - 44f; } // float just above the on-screen target
|
|
||||||
|
|
||||||
float angle = Mathf.Atan2(dy, dx) * Mathf.Rad2Deg; // "▶" art points +x at 0°
|
|
||||||
_pointer.style.left = ax - 15f;
|
|
||||||
_pointer.style.top = ay - 18f;
|
|
||||||
_pointer.style.rotate = new StyleRotate(new Rotate(new Angle(angle)));
|
|
||||||
_pointer.style.display = DisplayStyle.Flex;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- UITK construction ----
|
|
||||||
|
|
||||||
void BuildTree(VisualElement root)
|
|
||||||
{
|
|
||||||
root.style.position = Position.Absolute;
|
|
||||||
root.style.left = 0; root.style.right = 0; root.style.top = 0; root.style.bottom = 0;
|
|
||||||
root.pickingMode = PickingMode.Ignore; // never eat world clicks
|
|
||||||
|
|
||||||
var panel = new VisualElement();
|
|
||||||
panel.style.position = Position.Absolute;
|
|
||||||
panel.style.bottom = 210; panel.style.left = 0; panel.style.right = 0;
|
|
||||||
panel.style.flexDirection = FlexDirection.Row;
|
|
||||||
panel.style.justifyContent = Justify.Center;
|
|
||||||
panel.style.alignItems = Align.Center;
|
|
||||||
panel.pickingMode = PickingMode.Ignore;
|
|
||||||
|
|
||||||
var chip = new VisualElement();
|
|
||||||
chip.style.backgroundColor = new Color(0.05f, 0.07f, 0.10f, 0.92f);
|
|
||||||
chip.style.paddingLeft = 22; chip.style.paddingRight = 22;
|
|
||||||
chip.style.paddingTop = 10; chip.style.paddingBottom = 10;
|
|
||||||
chip.style.maxWidth = 920;
|
|
||||||
chip.pickingMode = PickingMode.Ignore;
|
|
||||||
MenuUi.Round(chip, 8);
|
|
||||||
MenuUi.Border(chip, new Color(MenuUi.Accent.r, MenuUi.Accent.g, MenuUi.Accent.b, 0.55f), 1);
|
|
||||||
|
|
||||||
_prompt = new Label(string.Empty);
|
|
||||||
_prompt.style.color = MenuUi.TextCol;
|
|
||||||
_prompt.style.fontSize = 18;
|
|
||||||
_prompt.style.unityFontStyleAndWeight = FontStyle.Bold;
|
|
||||||
_prompt.style.unityTextAlign = TextAnchor.MiddleCenter;
|
|
||||||
_prompt.style.whiteSpace = WhiteSpace.Normal;
|
|
||||||
var theme = HudTheme.Get();
|
|
||||||
if (theme != null) theme.ApplyBody(_prompt.style);
|
|
||||||
chip.Add(_prompt);
|
|
||||||
panel.Add(chip);
|
|
||||||
root.Add(panel);
|
|
||||||
|
|
||||||
_pointer = new Label("▶"); // ▶ right-pointing triangle (rotated toward the target)
|
|
||||||
_pointer.style.position = Position.Absolute;
|
|
||||||
_pointer.style.fontSize = 30;
|
|
||||||
_pointer.style.color = MenuUi.Accent;
|
|
||||||
_pointer.style.unityFontStyleAndWeight = FontStyle.Bold;
|
|
||||||
_pointer.pickingMode = PickingMode.Ignore;
|
|
||||||
_pointer.style.display = DisplayStyle.None;
|
|
||||||
root.Add(_pointer);
|
|
||||||
|
|
||||||
root.style.display = DisplayStyle.None;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
fileFormatVersion: 2
|
|
||||||
guid: b4828f5a68386fa4da379dcddbf629de
|
|
||||||
@@ -144,10 +144,8 @@ namespace ProjectM.Client
|
|||||||
|
|
||||||
// Hide the OS cursor only while aiming AND focused; restore otherwise (focus loss / pre-spawn) so an
|
// 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.
|
// unfocused editor or a windowed session is never stranded with an invisible pointer.
|
||||||
// END-2: while the run is over (terminal banner up) keep the cursor visible so the player can click the
|
// AimReticleSystem is the sole Cursor.visible writer.
|
||||||
// Play Again / Quit buttons, regardless of aim state. AimReticleSystem is the sole Cursor.visible writer.
|
bool wantHidden = haveTarget && Application.isFocused && !AimPresentation.ForceCursorVisible;
|
||||||
bool runOver = SystemAPI.TryGetSingleton<RunOutcome>(out var ro) && ro.Value != RunOutcomeId.InProgress;
|
|
||||||
bool wantHidden = haveTarget && Application.isFocused && !AimPresentation.ForceCursorVisible && !runOver;
|
|
||||||
if (wantHidden != _cursorHidden)
|
if (wantHidden != _cursorHidden)
|
||||||
{
|
{
|
||||||
if (wantHidden) Cursor.lockState = CursorLockMode.None;
|
if (wantHidden) Cursor.lockState = CursorLockMode.None;
|
||||||
|
|||||||
@@ -6,13 +6,12 @@ using UnityEngine;
|
|||||||
namespace ProjectM.Client
|
namespace ProjectM.Client
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Client-only AMBIENT audio + cycle-phase stingers. A managed presentation <see cref="SystemBase"/>
|
/// Client-only AMBIENT audio bed + run cues. A managed presentation <see cref="SystemBase"/>
|
||||||
/// (<see cref="PresentationSystemGroup"/>, main thread, no Burst) that OBSERVES the replicated
|
/// (<see cref="PresentationSystemGroup"/>, main thread, no Burst) that plays a low, seamless-looping
|
||||||
/// <see cref="CycleState"/> and never touches the simulation. On start it plays a low, seamless-looping
|
/// procedural drone (asset-free, <c>AudioClip.Create</c> like <c>CombatFeedbackSystem.MakeClip</c>) plus
|
||||||
/// procedural drone (asset-free, <c>AudioClip.Create</c> like <c>CombatFeedbackSystem.MakeClip</c>); each
|
/// launch-countdown beeps and the boss-arrival roar — replicated-state observations only. Lives only in the
|
||||||
/// time the cycle phase changes it plays a short procedural stinger and eases the drone's intensity by phase
|
/// client world, so the server never creates audio and nothing here affects determinism. Volumes are
|
||||||
/// (calmer at base, tenser during Defend / "wave incoming"). Lives only in the client world, so the server
|
/// deliberately conservative. (The cycle-phase stingers + Core alarm retired with the siege loop — LANTERN purge.)
|
||||||
/// never creates audio and nothing here affects determinism. Volumes are deliberately conservative + tunable.
|
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[WorldSystemFilter(WorldSystemFilterFlags.ClientSimulation)]
|
[WorldSystemFilter(WorldSystemFilterFlags.ClientSimulation)]
|
||||||
[UpdateInGroup(typeof(PresentationSystemGroup))]
|
[UpdateInGroup(typeof(PresentationSystemGroup))]
|
||||||
@@ -20,29 +19,17 @@ namespace ProjectM.Client
|
|||||||
{
|
{
|
||||||
AudioSource _ambient;
|
AudioSource _ambient;
|
||||||
AudioClip _ambientClip;
|
AudioClip _ambientClip;
|
||||||
AudioClip _stingExpedition;
|
|
||||||
AudioClip _stingDefend;
|
|
||||||
AudioClip _stingBuild;
|
|
||||||
GameObject _root;
|
GameObject _root;
|
||||||
|
|
||||||
byte _lastPhase;
|
|
||||||
bool _phaseInit;
|
|
||||||
AudioClip _stingCoreHit;
|
|
||||||
int _lastCore = -1;
|
|
||||||
float _coreStingCooldown;
|
|
||||||
AudioClip _stingBeep, _stingRoar;
|
AudioClip _stingBeep, _stingRoar;
|
||||||
int _lastCountdownSec = -1;
|
int _lastCountdownSec = -1;
|
||||||
bool _bossRoared;
|
bool _bossRoared;
|
||||||
|
|
||||||
const float AmbientBaseVolume = 0.10f; // low bed; Defend eases up to ~1.7x
|
const float AmbientBaseVolume = 0.10f; // low ambient bed
|
||||||
|
|
||||||
protected override void OnCreate()
|
protected override void OnCreate()
|
||||||
{
|
{
|
||||||
_ambientClip = MakeDrone();
|
_ambientClip = MakeDrone();
|
||||||
_stingExpedition = MakeSting(520f, 880f, 0.45f, 0.30f); // airy rising "deploy"
|
|
||||||
_stingDefend = MakeSting(300f, 140f, 0.55f, 0.42f); // tense falling "wave incoming"
|
|
||||||
_stingBuild = MakeSting(440f, 660f, 0.40f, 0.26f); // soft confirm
|
|
||||||
_stingCoreHit = MakeSting(240f, 70f, 0.30f, 0.55f); // harsh falling "Core hit" alarm
|
|
||||||
_stingBeep = MakeSting(880f, 880f, 0.09f, 0.30f); // countdown tick
|
_stingBeep = MakeSting(880f, 880f, 0.09f, 0.30f); // countdown tick
|
||||||
_stingRoar = MakeSting(90f, 38f, 0.90f, 0.60f); // boss-arrival roar (low falling growl)
|
_stingRoar = MakeSting(90f, 38f, 0.90f, 0.60f); // boss-arrival roar (low falling growl)
|
||||||
}
|
}
|
||||||
@@ -68,36 +55,8 @@ namespace ProjectM.Client
|
|||||||
protected override void OnUpdate()
|
protected override void OnUpdate()
|
||||||
{
|
{
|
||||||
if (_ambient == null) return;
|
if (_ambient == null) return;
|
||||||
if (!SystemAPI.TryGetSingleton<CycleState>(out var cyc)) return;
|
|
||||||
|
|
||||||
byte phase = cyc.Phase;
|
_ambient.volume = Mathf.MoveTowards(_ambient.volume, AmbientBaseVolume * GameVolume.Music, SystemAPI.Time.DeltaTime * 0.25f);
|
||||||
if (!_phaseInit)
|
|
||||||
{
|
|
||||||
_lastPhase = phase; // adopt the current phase silently (no stinger on first observe)
|
|
||||||
_phaseInit = true;
|
|
||||||
}
|
|
||||||
else if (phase != _lastPhase)
|
|
||||||
{
|
|
||||||
PlaySting(phase);
|
|
||||||
_lastPhase = phase;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Ease the drone intensity toward the phase target (tenser during Defend).
|
|
||||||
float target = phase == CyclePhase.Siege ? AmbientBaseVolume * 1.7f : AmbientBaseVolume;
|
|
||||||
_ambient.volume = Mathf.MoveTowards(_ambient.volume, target * GameVolume.Music, SystemAPI.Time.DeltaTime * 0.25f);
|
|
||||||
|
|
||||||
// Core-under-attack alarm: a falling sting on each CoreIntegrity drop (rate-limited) so a base
|
|
||||||
// breach is AUDIBLE even when the fight has the player's eyes elsewhere.
|
|
||||||
_coreStingCooldown -= SystemAPI.Time.DeltaTime;
|
|
||||||
if (SystemAPI.TryGetSingleton<CoreIntegrity>(out var core))
|
|
||||||
{
|
|
||||||
if (_lastCore >= 0 && core.Current < _lastCore && _coreStingCooldown <= 0f)
|
|
||||||
{
|
|
||||||
_ambient.PlayOneShot(_stingCoreHit, 0.8f * GameVolume.Sfx);
|
|
||||||
_coreStingCooldown = 0.7f;
|
|
||||||
}
|
|
||||||
_lastCore = core.Current;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Launch countdown beeps (3-2-1) + the boss-arrival roar — replicated-state observations only.
|
// Launch countdown beeps (3-2-1) + the boss-arrival roar — replicated-state observations only.
|
||||||
if (SystemAPI.TryGetSingleton<RunInfo>(out var runAudio))
|
if (SystemAPI.TryGetSingleton<RunInfo>(out var runAudio))
|
||||||
@@ -131,13 +90,6 @@ namespace ProjectM.Client
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void PlaySting(byte phase)
|
|
||||||
{
|
|
||||||
AudioClip clip = phase == CyclePhase.Siege ? _stingDefend : _stingBuild;
|
|
||||||
if (clip != null && _ambient != null)
|
|
||||||
_ambient.PlayOneShot(clip, 0.6f * GameVolume.Music);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- Procedural audio (asset-free; mirrors CombatFeedbackSystem.MakeClip) ----
|
// ---- Procedural audio (asset-free; mirrors CombatFeedbackSystem.MakeClip) ----
|
||||||
|
|
||||||
// A low, seamless-looping pad: each partial completes an integer number of cycles over the buffer
|
// A low, seamless-looping pad: each partial completes an integer number of cycles over the buffer
|
||||||
@@ -176,6 +128,6 @@ namespace ProjectM.Client
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Short one-shot tone sweeping f0->f1 with an exponential decay envelope.
|
// Short one-shot tone sweeping f0->f1 with an exponential decay envelope.
|
||||||
static AudioClip MakeSting(float f0, float f1, float dur, float vol) => FeedbackFx.MakeClip("sting", f0, f1, dur, vol, decay: 3.5f);
|
static AudioClip MakeSting(float f0, float f1, float dur, float vol) => FeedbackFx.MakeClip("sting", f0, f1, dur, vol, decay: 3.5f);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -63,8 +63,6 @@ namespace ProjectM.Client
|
|||||||
}
|
}
|
||||||
|
|
||||||
bool haveRun = SystemAPI.TryGetSingleton<RunInfo>(out var runInfo);
|
bool haveRun = SystemAPI.TryGetSingleton<RunInfo>(out var runInfo);
|
||||||
bool haveCycle = SystemAPI.TryGetSingleton<CycleState>(out var cyc);
|
|
||||||
bool siege = haveCycle && cyc.Phase == CyclePhase.Siege;
|
|
||||||
|
|
||||||
// Resources from the ledger (last entry per type wins, matching the core loop).
|
// Resources from the ledger (last entry per type wins, matching the core loop).
|
||||||
int aether = 0, ore = 0, bio = 0;
|
int aether = 0, ore = 0, bio = 0;
|
||||||
@@ -93,7 +91,7 @@ namespace ProjectM.Client
|
|||||||
// Faithful reproduction of the original `metaShow` gate: class/prep were shown on the SAME condition as
|
// Faithful reproduction of the original `metaShow` gate: class/prep were shown on the SAME condition as
|
||||||
// the meta shop, which requires the meta catalog + tier buffer to exist.
|
// the meta shop, which requires the meta catalog + tier buffer to exist.
|
||||||
DynamicBuffer<MetaTierState> metaRecord = default;
|
DynamicBuffer<MetaTierState> metaRecord = default;
|
||||||
bool metaShow = haveRun && runInfo.Lifecycle == RunLifecycle.Staging && haveLocalPlayer && !siege
|
bool metaShow = haveRun && runInfo.Lifecycle == RunLifecycle.Staging && haveLocalPlayer
|
||||||
&& SystemAPI.TryGetSingleton<MetaUpgradeCatalog>(out var metaCat) && metaCat.Value.IsCreated
|
&& SystemAPI.TryGetSingleton<MetaUpgradeCatalog>(out var metaCat) && metaCat.Value.IsCreated
|
||||||
&& SystemAPI.TryGetSingletonBuffer<MetaTierState>(out metaRecord, true);
|
&& SystemAPI.TryGetSingletonBuffer<MetaTierState>(out metaRecord, true);
|
||||||
|
|
||||||
|
|||||||
@@ -1,91 +0,0 @@
|
|||||||
using System.Collections.Generic;
|
|
||||||
using ProjectM.Simulation;
|
|
||||||
using Unity.Entities;
|
|
||||||
using UnityEngine;
|
|
||||||
using UnityEngine.SceneManagement;
|
|
||||||
|
|
||||||
namespace ProjectM.Client
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// The Engine Core's CRYSTAL answers its replicated <see cref="CoreIntegrity"/> (07-01 backlog: the mesh sat
|
|
||||||
/// static while draining). Client-only observe-only presentation: the cosmetic <c>CoreCrystals</c> /
|
|
||||||
/// <c>CoreMachine</c> GameObjects in Game.unity (classic URP renderers, not entities) are tinted via
|
|
||||||
/// MaterialPropertyBlock — darker + blood-shifted as integrity falls, a white-hot flash on each hit (pairs
|
|
||||||
/// with the AmbientAudioSystem alarm). Property-guarded per the shader-value rule (only materials exposing
|
|
||||||
/// _BaseColor are touched); per-renderer MPBs never mutate the shared material assets (no bleed).
|
|
||||||
/// </summary>
|
|
||||||
[WorldSystemFilter(WorldSystemFilterFlags.ClientSimulation)]
|
|
||||||
[UpdateInGroup(typeof(PresentationSystemGroup))]
|
|
||||||
public partial class CoreVisualFeedbackSystem : SystemBase
|
|
||||||
{
|
|
||||||
static readonly int BaseColorId = Shader.PropertyToID("_BaseColor");
|
|
||||||
static readonly string[] TargetNames = { "CoreCrystals", "CoreMachine" };
|
|
||||||
const float FlashSeconds = 0.35f;
|
|
||||||
|
|
||||||
readonly List<Renderer> _renderers = new();
|
|
||||||
readonly List<Color> _authoredColors = new();
|
|
||||||
MaterialPropertyBlock _mpb;
|
|
||||||
bool _resolved;
|
|
||||||
int _lastCore = -1;
|
|
||||||
float _flashLeft;
|
|
||||||
|
|
||||||
protected override void OnUpdate()
|
|
||||||
{
|
|
||||||
if (!SystemAPI.TryGetSingleton<CoreIntegrity>(out var core) || core.Max <= 0)
|
|
||||||
return;
|
|
||||||
|
|
||||||
// One-time renderer resolve, deferred until Game.unity is actually the active scene (the frontend
|
|
||||||
// path creates this world a frame BEFORE the scene loads — latching early would find nothing).
|
|
||||||
if (!_resolved)
|
|
||||||
{
|
|
||||||
var scene = SceneManager.GetActiveScene();
|
|
||||||
if (!scene.isLoaded || scene.name != "Game") return;
|
|
||||||
Resolve();
|
|
||||||
}
|
|
||||||
if (_renderers.Count == 0) return;
|
|
||||||
|
|
||||||
if (_lastCore >= 0 && core.Current < _lastCore)
|
|
||||||
_flashLeft = FlashSeconds; // hit edge -> white-hot pop (the audio alarm fires beside it)
|
|
||||||
_lastCore = core.Current;
|
|
||||||
_flashLeft -= SystemAPI.Time.DeltaTime;
|
|
||||||
|
|
||||||
float frac = Mathf.Clamp01(core.Current / (float)core.Max);
|
|
||||||
float dim = Mathf.Lerp(0.35f, 1f, frac);
|
|
||||||
float flash = _flashLeft > 0f ? Mathf.Clamp01(_flashLeft / FlashSeconds) : 0f;
|
|
||||||
for (int i = 0; i < _renderers.Count; i++)
|
|
||||||
{
|
|
||||||
var r = _renderers[i];
|
|
||||||
if (r == null) continue;
|
|
||||||
var c0 = _authoredColors[i];
|
|
||||||
// Wounded shift: keep red-ish energy, drain green/blue with integrity (reads as bleeding light).
|
|
||||||
var wounded = new Color(
|
|
||||||
Mathf.Min(1f, c0.r * dim + (1f - frac) * 0.25f),
|
|
||||||
c0.g * dim * (0.45f + 0.55f * frac),
|
|
||||||
c0.b * dim * (0.45f + 0.55f * frac),
|
|
||||||
c0.a);
|
|
||||||
var col = flash > 0f ? Color.Lerp(wounded, Color.white, flash) : wounded;
|
|
||||||
r.GetPropertyBlock(_mpb);
|
|
||||||
_mpb.SetColor(BaseColorId, col);
|
|
||||||
r.SetPropertyBlock(_mpb);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
void Resolve()
|
|
||||||
{
|
|
||||||
_resolved = true;
|
|
||||||
_mpb = new MaterialPropertyBlock();
|
|
||||||
foreach (var name in TargetNames)
|
|
||||||
{
|
|
||||||
var go = GameObject.Find(name);
|
|
||||||
if (go == null) continue;
|
|
||||||
foreach (var r in go.GetComponentsInChildren<Renderer>())
|
|
||||||
{
|
|
||||||
var m = r.sharedMaterial;
|
|
||||||
if (m == null || !m.HasProperty(BaseColorId)) continue;
|
|
||||||
_renderers.Add(r);
|
|
||||||
_authoredColors.Add(m.GetColor(BaseColorId));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
fileFormatVersion: 2
|
|
||||||
guid: 47b2a19145125ac4e98411cab7f9b569
|
|
||||||
@@ -26,14 +26,12 @@ namespace ProjectM.Client
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
[WorldSystemFilter(WorldSystemFilterFlags.ClientSimulation)]
|
[WorldSystemFilter(WorldSystemFilterFlags.ClientSimulation)]
|
||||||
[UpdateInGroup(typeof(PresentationSystemGroup))]
|
[UpdateInGroup(typeof(PresentationSystemGroup))]
|
||||||
[UpdateAfter(typeof(OnboardingSystem))] // read OnboardingState.Active same-frame (single prompt voice)
|
|
||||||
public partial class HudSystem : SystemBase
|
public partial class HudSystem : SystemBase
|
||||||
{
|
{
|
||||||
// ---- palette (Aether language; Synty white skins are tinted into these) ----
|
// ---- palette (Aether language; Synty white skins are tinted into these) ----
|
||||||
static readonly Color AetherCyan = new(0.30f, 0.85f, 1f);
|
static readonly Color AetherCyan = new(0.30f, 0.85f, 1f);
|
||||||
static readonly Color OreAmber = new(1f, 0.72f, 0.35f);
|
static readonly Color OreAmber = new(1f, 0.72f, 0.35f);
|
||||||
static readonly Color BioGreen = new(0.55f, 0.85f, 0.45f);
|
static readonly Color BioGreen = new(0.55f, 0.85f, 0.45f);
|
||||||
static readonly Color CoreRed = new(1f, 0.40f, 0.32f); // END-1 Engine Core integrity bar
|
|
||||||
|
|
||||||
static readonly Color PanelDark = new(0.08f, 0.11f, 0.15f, 0.90f);
|
static readonly Color PanelDark = new(0.08f, 0.11f, 0.15f, 0.90f);
|
||||||
static readonly Color PanelWarm = new(0.16f, 0.09f, 0.09f, 0.88f);
|
static readonly Color PanelWarm = new(0.16f, 0.09f, 0.09f, 0.88f);
|
||||||
@@ -43,7 +41,6 @@ namespace ProjectM.Client
|
|||||||
static readonly Color SlotIdleBg = new(0.09f, 0.11f, 0.15f, 0.92f);
|
static readonly Color SlotIdleBg = new(0.09f, 0.11f, 0.15f, 0.92f);
|
||||||
static readonly Color SlotSelBg = new(0.16f, 0.26f, 0.32f, 0.95f);
|
static readonly Color SlotSelBg = new(0.16f, 0.26f, 0.32f, 0.95f);
|
||||||
static readonly Color SlotIdleBorder = new(1f, 1f, 1f, 0.08f);
|
static readonly Color SlotIdleBorder = new(1f, 1f, 1f, 0.08f);
|
||||||
const int MaxPips = 12;
|
|
||||||
const float ExpeditionRegionXMin = RegionMath.RegionBoundaryX; // camera x past this = the +1000 expedition region (DR-013)
|
const float ExpeditionRegionXMin = RegionMath.RegionBoundaryX; // camera x past this = the +1000 expedition region (DR-013)
|
||||||
|
|
||||||
GameObject _hudGo;
|
GameObject _hudGo;
|
||||||
@@ -59,18 +56,9 @@ namespace ProjectM.Client
|
|||||||
VisualElement _threatPanel, _threatIcon;
|
VisualElement _threatPanel, _threatIcon;
|
||||||
Label _threatNum;
|
Label _threatNum;
|
||||||
|
|
||||||
// macro: banner + location + goal
|
// macro: banner + location line
|
||||||
VisualElement _banner, _goalContainer, _goalPipsRow, _goalBar, _goalFill;
|
VisualElement _banner;
|
||||||
Label _phaseText, _cycleText, _locationText, _goalText;
|
Label _phaseText, _locationText;
|
||||||
|
|
||||||
// END-1: Engine Core integrity (losable base-heart) + overrun flash edge-detector
|
|
||||||
VisualElement _coreContainer, _coreBar, _coreFill;
|
|
||||||
Label _coreText;
|
|
||||||
uint _lastOverrunTick;
|
|
||||||
float _overrunFlashLeft;
|
|
||||||
// END-2: terminal win/loss banner (observes the replicated RunOutcome; latched server-side).
|
|
||||||
VisualElement _runBanner;
|
|
||||||
Label _runBannerText, _runBannerSub;
|
|
||||||
// Demo polish: the clickable READY panel (Staging/Launching).
|
// Demo polish: the clickable READY panel (Staging/Launching).
|
||||||
VisualElement _readyPanel, _readyPipRow;
|
VisualElement _readyPanel, _readyPipRow;
|
||||||
Button _readyBtn;
|
Button _readyBtn;
|
||||||
@@ -84,12 +72,8 @@ namespace ProjectM.Client
|
|||||||
VisualElement _depthPanel;
|
VisualElement _depthPanel;
|
||||||
int _depthShownFor;
|
int _depthShownFor;
|
||||||
bool _depthBuilt;
|
bool _depthBuilt;
|
||||||
VisualElement _outcomeFlash; // one-shot gold/red full-screen flash when the outcome banner first lands
|
|
||||||
float _outcomeFlashLeft;
|
|
||||||
byte _outcomeFlashedFor;
|
|
||||||
|
|
||||||
|
|
||||||
readonly List<VisualElement> _pips = new();
|
|
||||||
|
|
||||||
// resources
|
// resources
|
||||||
Label _aetherNum, _oreNum, _bioNum;
|
Label _aetherNum, _oreNum, _bioNum;
|
||||||
@@ -158,84 +142,34 @@ namespace ProjectM.Client
|
|||||||
bool haveTick = SystemAPI.TryGetSingleton<NetworkTime>(out var nt);
|
bool haveTick = SystemAPI.TryGetSingleton<NetworkTime>(out var nt);
|
||||||
int huskCount = _huskQuery.CalculateEntityCount();
|
int huskCount = _huskQuery.CalculateEntityCount();
|
||||||
|
|
||||||
// ---- Macro: phase + cycle + countdown (center-top banner) ----
|
// ---- Macro banner: run-lifecycle header (the siege/cycle machinery is retired — LANTERN purge) ----
|
||||||
bool haveRun = SystemAPI.TryGetSingleton<RunInfo>(out var runInfo); // hoisted: the phase banner is lifecycle-aware (Phase 0 fix — it read "AT BASE" inside expedition rooms)
|
bool haveRun = SystemAPI.TryGetSingleton<RunInfo>(out var runInfo);
|
||||||
|
bool onRun = haveRun && runInfo.Lifecycle != RunLifecycle.Staging;
|
||||||
// B6: run-failed read (review-confirmed design: NEVER key on Returning — it is a 1-tick transient and
|
|
||||||
// the Charge bank lands a tick after it; detect the (in-run)->Staging edge with a launch-cached Charge.
|
|
||||||
// Lifecycle + Charge ride the SAME director ghost snapshot, so at the Staging edge the bank has arrived).
|
|
||||||
if (haveRun)
|
if (haveRun)
|
||||||
{
|
{
|
||||||
bool haveGoalNow = SystemAPI.TryGetSingleton<GoalProgress>(out var goalSnap);
|
var col = onRun ? new Color(1f, 0.8f, 0.4f) : new Color(0.45f, 0.9f, 0.7f);
|
||||||
byte lcNow = runInfo.Lifecycle;
|
_phaseText.text = onRun ? "ON EXPEDITION" : "AT BASE";
|
||||||
if (lcNow == RunLifecycle.Launching && _prevRunLifecycle == RunLifecycle.Staging)
|
|
||||||
{
|
|
||||||
_chargeAtLaunch = haveGoalNow ? goalSnap.Charge : 0;
|
|
||||||
_wentInRun = false;
|
|
||||||
}
|
|
||||||
if (lcNow == RunLifecycle.InRoom) _wentInRun = true;
|
|
||||||
if (lcNow == RunLifecycle.Staging && _prevRunLifecycle != RunLifecycle.Staging && _wentInRun)
|
|
||||||
{
|
|
||||||
if (haveGoalNow && goalSnap.Charge <= _chargeAtLaunch)
|
|
||||||
_runFailedUntil = (float)SystemAPI.Time.ElapsedTime + 6f; // wipe/abort: nothing banked
|
|
||||||
_wentInRun = false;
|
|
||||||
}
|
|
||||||
_prevRunLifecycle = lcNow;
|
|
||||||
}
|
|
||||||
bool haveCycle = SystemAPI.TryGetSingleton<CycleState>(out var cyc);
|
|
||||||
bool siege = haveCycle && cyc.Phase == CyclePhase.Siege;
|
|
||||||
bool goalFull = SystemAPI.TryGetSingleton<GoalProgress>(out var goalNow) && goalNow.Target > 0 && goalNow.Charge >= goalNow.Target;
|
|
||||||
bool finalSiege = siege && goalFull; // END-2: the climactic final siege (goal cap reached)
|
|
||||||
bool onRun = haveRun && !siege && runInfo.Lifecycle != RunLifecycle.Staging; // mid-run: the base's Calm label is wrong
|
|
||||||
if (haveCycle)
|
|
||||||
{
|
|
||||||
var endTick = new NetworkTick(cyc.PhaseEndTick);
|
|
||||||
bool arming = haveTick && cyc.PhaseEndTick != 0 && endTick.IsValid && endTick.IsNewerThan(nt.ServerTick);
|
|
||||||
bool finalArming = !siege && goalFull && arming; // the cap-reached arming window before the final wave
|
|
||||||
int secs = arming ? (endTick.TicksSince(nt.ServerTick) / 60 + 1) : 0;
|
|
||||||
string detail;
|
|
||||||
if (siege)
|
|
||||||
detail = (finalSiege ? "FINAL SIEGE" : "WAVE " + cyc.WaveNumber) + " - " + huskCount + " HUSKS";
|
|
||||||
else if (arming)
|
|
||||||
detail = (finalArming ? "FINAL SIEGE INCOMING" : "INCURSION") + " - " + secs + "s";
|
|
||||||
else
|
|
||||||
detail = "";
|
|
||||||
// END-2: the climax reads distinct (intense red), not a normal incursion/wave.
|
|
||||||
var col = finalSiege || finalArming ? new Color(1f, 0.28f, 0.22f) : PhaseColor(cyc.Phase);
|
|
||||||
_phaseText.text = (finalSiege ? "HOLD THE ENGINE" : onRun ? "ON EXPEDITION" : PhaseLabel(cyc.Phase)) + (detail.Length > 0 ? " - " + detail : "");
|
|
||||||
_phaseText.style.color = col;
|
_phaseText.style.color = col;
|
||||||
_cycleText.text = "CYCLE " + cyc.CycleNumber;
|
|
||||||
_banner.style.borderBottomColor = col;
|
_banner.style.borderBottomColor = col;
|
||||||
RetintPanel(_banner, siege ? PanelWarm : PanelDark);
|
RetintPanel(_banner, PanelDark);
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
_phaseText.text = "";
|
_phaseText.text = "";
|
||||||
_cycleText.text = "";
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- Location line (banner sub-line) — Step 14: driven by the replicated RunInfo lifecycle FSM ----
|
// ---- Location line (banner sub-line) — Step 14: driven by the replicated RunInfo lifecycle FSM ----
|
||||||
// (the old camera-X + walk-in-gate copy died with the gate; siege/final overrides below still win).
|
|
||||||
var cam = Camera.main; // camera-X region signal still feeds downstream panels (atmosphere/threat)
|
var cam = Camera.main; // camera-X region signal still feeds downstream panels (atmosphere/threat)
|
||||||
bool onExpedition = cam != null && cam.transform.position.x > ExpeditionRegionXMin;
|
bool onExpedition = cam != null && cam.transform.position.x > ExpeditionRegionXMin;
|
||||||
SystemAPI.TryGetSingleton<ExpeditionObjective>(out var obj);
|
SystemAPI.TryGetSingleton<ExpeditionObjective>(out var obj);
|
||||||
if (haveRun && !siege && !finalSiege)
|
if (haveRun)
|
||||||
{
|
{
|
||||||
switch (runInfo.Lifecycle)
|
switch (runInfo.Lifecycle)
|
||||||
{
|
{
|
||||||
case RunLifecycle.Staging:
|
case RunLifecycle.Staging:
|
||||||
// The READY panel (bottom-center) owns the action + N/M count; the top line frames intent.
|
// The READY panel (bottom-center) owns the action + N/M count; the top line frames intent.
|
||||||
if ((float)SystemAPI.Time.ElapsedTime < _runFailedUntil)
|
_locationText.text = "AT THE BASE - build defenses, buy upgrades, READY UP to launch";
|
||||||
{
|
_locationText.style.color = new Color(0.55f, 0.85f, 1f);
|
||||||
// B6: a silent wipe used to land players home with ZERO explanation.
|
|
||||||
_locationText.text = "EXPEDITION FAILED - the party fell; nothing was banked";
|
|
||||||
_locationText.style.color = new Color(1f, 0.35f, 0.3f);
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
_locationText.text = "AT THE BASE - build defenses, buy upgrades, READY UP to launch";
|
|
||||||
_locationText.style.color = new Color(0.55f, 0.85f, 1f);
|
|
||||||
}
|
|
||||||
break;
|
break;
|
||||||
case RunLifecycle.Launching:
|
case RunLifecycle.Launching:
|
||||||
{
|
{
|
||||||
@@ -280,33 +214,18 @@ namespace ProjectM.Client
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
else if (!haveRun)
|
|
||||||
{
|
|
||||||
_locationText.text = finalSiege
|
|
||||||
? "FINAL SIEGE - hold the Engine, this is the last stand"
|
|
||||||
: siege ? "DEFEND THE BASE - hold the line"
|
|
||||||
: "MINE THE CRYSTALS - any attack harvests Ore, then BUILD";
|
|
||||||
_locationText.style.color = finalSiege ? new Color(1f, 0.3f, 0.25f)
|
|
||||||
: siege ? new Color(1f, 0.55f, 0.4f) : new Color(0.6f, 0.95f, 0.7f);
|
|
||||||
}
|
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
_locationText.text = finalSiege
|
_locationText.text = "";
|
||||||
? "FINAL SIEGE - hold the Engine, this is the last stand"
|
|
||||||
: "DEFEND THE BASE - hold the line";
|
|
||||||
_locationText.style.color = finalSiege ? new Color(1f, 0.3f, 0.25f) : new Color(1f, 0.55f, 0.4f);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
// The clickable READY panel (Staging/Launching, hidden once the outcome latched — the banner owns
|
// The clickable READY panel (Staging/Launching). Counts are the replicated send-to-all PlayerReady flags.
|
||||||
// the screen then). Counts are the replicated send-to-all PlayerReady flags.
|
|
||||||
int rTotal = 0, rReady = 0;
|
int rTotal = 0, rReady = 0;
|
||||||
bool localReady = false;
|
bool localReady = false;
|
||||||
int launchSecs = 0;
|
int launchSecs = 0;
|
||||||
bool terminal = SystemAPI.TryGetSingleton<RunOutcome>(out var readyOc)
|
bool readyShow = haveRun
|
||||||
&& readyOc.Value != RunOutcomeId.InProgress;
|
|
||||||
bool readyShow = haveRun && !terminal && !goalFull /* D6: goal full -> final defense armed, launching is refused server-side */
|
|
||||||
&& (runInfo.Lifecycle == RunLifecycle.Staging || runInfo.Lifecycle == RunLifecycle.Launching);
|
&& (runInfo.Lifecycle == RunLifecycle.Staging || runInfo.Lifecycle == RunLifecycle.Launching);
|
||||||
if (readyShow)
|
if (readyShow)
|
||||||
{
|
{
|
||||||
@@ -352,35 +271,6 @@ namespace ProjectM.Client
|
|||||||
// Run-depth dots — keeps the roguelite spine visible while fighting (the map only shows at gates).
|
// Run-depth dots — keeps the roguelite spine visible while fighting (the map only shows at gates).
|
||||||
UpdateRunDepth(haveRun ? runInfo : default, haveRun);
|
UpdateRunDepth(haveRun ? runInfo : default, haveRun);
|
||||||
|
|
||||||
// ---- Goal (hex-pip meter, or a continuous bar for large targets) ----
|
|
||||||
if (SystemAPI.TryGetSingleton<GoalProgress>(out var goal))
|
|
||||||
{
|
|
||||||
_goalContainer.style.display = DisplayStyle.Flex;
|
|
||||||
float gfrac = goal.Target > 0 ? Mathf.Clamp01(goal.Charge / (float)goal.Target) : 0f;
|
|
||||||
_goalText.text = "GOAL " + goal.Charge + " / " + goal.Target;
|
|
||||||
if (goal.Target >= 1 && goal.Target <= MaxPips)
|
|
||||||
{
|
|
||||||
_goalPipsRow.style.display = DisplayStyle.Flex;
|
|
||||||
_goalBar.style.display = DisplayStyle.None;
|
|
||||||
int active = Mathf.Min(goal.Charge, goal.Target); // Charge is the integer pip count; never over-fill
|
|
||||||
for (int i = 0; i < _pips.Count; i++)
|
|
||||||
{
|
|
||||||
bool show = i < goal.Target;
|
|
||||||
_pips[i].style.display = show ? DisplayStyle.Flex : DisplayStyle.None;
|
|
||||||
if (show) SetPip(_pips[i], i < active);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
_goalPipsRow.style.display = DisplayStyle.None;
|
|
||||||
_goalBar.style.display = DisplayStyle.Flex;
|
|
||||||
HudUi.SetFill(_goalFill, gfrac);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
_goalContainer.style.display = DisplayStyle.None;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- Resources (feed palette affordability) ----
|
// ---- Resources (feed palette affordability) ----
|
||||||
int aether = 0, ore = 0, bio = 0;
|
int aether = 0, ore = 0, bio = 0;
|
||||||
@@ -400,104 +290,21 @@ namespace ProjectM.Client
|
|||||||
_bioNum.text = bio.ToString();
|
_bioNum.text = bio.ToString();
|
||||||
|
|
||||||
|
|
||||||
// ---- Engine Core integrity (END-1): a red base-heart bar; an overrun stamps a transient pulse we flash ----
|
|
||||||
if (SystemAPI.TryGetSingleton<CoreIntegrity>(out var core) && core.Max > 0)
|
|
||||||
{
|
|
||||||
_coreContainer.style.display = DisplayStyle.Flex;
|
|
||||||
float cfrac = Mathf.Clamp01(core.Current / (float)core.Max);
|
|
||||||
HudUi.SetFill(_coreFill, cfrac);
|
|
||||||
_coreText.text = "CORE " + core.Current + " / " + core.Max;
|
|
||||||
_coreText.style.color = Color.Lerp(BlightRed, CoreRed, cfrac); // shifts to danger as it drops
|
|
||||||
if (core.OverrunTick != 0 && core.OverrunTick != _lastOverrunTick)
|
|
||||||
{
|
|
||||||
_lastOverrunTick = core.OverrunTick; // edge-detect the replicated breach pulse
|
|
||||||
_overrunFlashLeft = 3.5f;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
_coreContainer.style.display = DisplayStyle.None;
|
|
||||||
}
|
|
||||||
// Overrun flash overrides the location line (runs AFTER the EB-2 cue so it wins; at a breach Phase is Calm).
|
|
||||||
if (_overrunFlashLeft > 0f)
|
|
||||||
{
|
|
||||||
_overrunFlashLeft -= dt;
|
|
||||||
_locationText.text = "BASE OVERRUN - resources lost; the Core will recover";
|
|
||||||
_locationText.style.color = new Color(1f, 0.3f, 0.25f);
|
|
||||||
}
|
|
||||||
// First-run onboarding owns the prompt voice: while a coach-mark step is showing, blank the HUD's own
|
|
||||||
// location/gate hint so the player sees a single prompt (OnboardingSystem drives its own overlay).
|
|
||||||
if (OnboardingState.SuppressLocationLine) _locationText.text = ""; // D4: blank only for the early base-framing steps; room/siege/charge cues survive
|
|
||||||
// D6: goal full but the final siege hasn't spawned yet (the arming gap) -> the READY panel is hidden; tell the
|
|
||||||
// player what's coming instead of a stale base line (goalFull is replicated; RunPhase is server-only).
|
|
||||||
if (haveRun && goalFull && !terminal && !siege && !finalSiege)
|
|
||||||
{
|
|
||||||
_locationText.text = "GOAL REACHED - FINAL DEFENSE INCOMING: hold the Engine!";
|
|
||||||
_locationText.style.color = new Color(1f, 0.35f, 0.28f);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- END-2: terminal run banner (Victory / Loss), observed from the replicated RunOutcome ----
|
|
||||||
if (SystemAPI.TryGetSingleton<RunOutcome>(out var runOutcome) && runOutcome.Value != RunOutcomeId.InProgress)
|
|
||||||
{
|
|
||||||
bool win = runOutcome.Value == RunOutcomeId.Victory;
|
|
||||||
if (_outcomeFlashedFor != runOutcome.Value)
|
|
||||||
{
|
|
||||||
// One-shot landing flourish: full-screen color flash + camera kick — the beat gets a payoff.
|
|
||||||
_outcomeFlashedFor = runOutcome.Value;
|
|
||||||
_outcomeFlashLeft = win ? 0.9f : 0.7f;
|
|
||||||
PrototypeCameraRig.PunchFov(win ? 5f : 3f, win ? 420f : 260f);
|
|
||||||
PrototypeCameraRig.AddShake(win ? 0.25f : 0.5f);
|
|
||||||
}
|
|
||||||
_runBanner.style.display = DisplayStyle.Flex;
|
|
||||||
_runBannerText.text = win ? "THE ENGINE HOLDS" : "OVERRUN";
|
|
||||||
_runBannerText.style.color = win ? new Color(0.45f, 0.95f, 1f) : new Color(1f, 0.35f, 0.3f);
|
|
||||||
_runBannerSub.text = win ? "VICTORY - the final siege is broken" : "THE FINAL STAND FELL";
|
|
||||||
_runBannerSub.style.color = win ? new Color(0.7f, 0.95f, 1f) : new Color(1f, 0.6f, 0.5f);
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
_runBanner.style.display = DisplayStyle.None;
|
|
||||||
_outcomeFlashedFor = 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Outcome flash decay (lazy element; the banner dim sits above it, the world below).
|
|
||||||
if (_outcomeFlashLeft > 0f)
|
|
||||||
{
|
|
||||||
if (_outcomeFlash == null && _doc != null && _doc.rootVisualElement != null)
|
|
||||||
{
|
|
||||||
_outcomeFlash = new VisualElement { pickingMode = PickingMode.Ignore };
|
|
||||||
_outcomeFlash.style.position = Position.Absolute;
|
|
||||||
_outcomeFlash.style.left = 0; _outcomeFlash.style.right = 0;
|
|
||||||
_outcomeFlash.style.top = 0; _outcomeFlash.style.bottom = 0;
|
|
||||||
_doc.rootVisualElement.Add(_outcomeFlash);
|
|
||||||
}
|
|
||||||
_outcomeFlashLeft -= dt;
|
|
||||||
if (_outcomeFlash != null)
|
|
||||||
{
|
|
||||||
bool winFlash = _outcomeFlashedFor == RunOutcomeId.Victory;
|
|
||||||
var fc = winFlash ? new Color(1f, 0.85f, 0.35f) : new Color(1f, 0.20f, 0.15f);
|
|
||||||
_outcomeFlash.style.backgroundColor = new Color(fc.r, fc.g, fc.b, Mathf.Clamp01(_outcomeFlashLeft) * 0.35f);
|
|
||||||
_outcomeFlash.style.display = DisplayStyle.Flex;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
else if (_outcomeFlash != null)
|
|
||||||
{
|
|
||||||
_outcomeFlash.style.display = DisplayStyle.None;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
// ---- Threat readout (top-right) — hidden entirely at base with zero husks; its reappearance is the cue ----
|
// ---- Threat readout (top-right) — hidden entirely with zero husks; its reappearance is the cue ----
|
||||||
bool showThreat = siege || huskCount > 0;
|
bool showThreat = huskCount > 0;
|
||||||
_threatPanel.style.display = showThreat ? DisplayStyle.Flex : DisplayStyle.None;
|
_threatPanel.style.display = showThreat ? DisplayStyle.Flex : DisplayStyle.None;
|
||||||
if (showThreat)
|
if (showThreat)
|
||||||
{
|
{
|
||||||
float intensity = Mathf.Clamp01(huskCount / 30f);
|
float intensity = Mathf.Clamp01(huskCount / 30f);
|
||||||
Color tc = siege ? Color.Lerp(ThreatWarm, BlightRed, intensity) : ThreatWarm;
|
Color tc = Color.Lerp(ThreatWarm, BlightRed, intensity);
|
||||||
_threatNum.text = huskCount.ToString();
|
_threatNum.text = huskCount.ToString();
|
||||||
_threatNum.style.color = tc;
|
_threatNum.style.color = tc;
|
||||||
_threatIcon.style.unityBackgroundImageTintColor = tc;
|
_threatIcon.style.unityBackgroundImageTintColor = tc;
|
||||||
RetintPanel(_threatPanel, siege ? PanelWarm : PanelDark);
|
RetintPanel(_threatPanel, PanelDark);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- Build palette + control hints (bottom-center) ----
|
// ---- Build palette + control hints (bottom-center) ----
|
||||||
@@ -546,7 +353,7 @@ namespace ProjectM.Client
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
_doc.rootVisualElement.style.display = (found || haveCycle) ? DisplayStyle.Flex : DisplayStyle.None;
|
_doc.rootVisualElement.style.display = (found || haveRun) ? DisplayStyle.Flex : DisplayStyle.None;
|
||||||
|
|
||||||
// ---- Low-health vignette + hurt flash (full-screen) ----
|
// ---- Low-health vignette + hurt flash (full-screen) ----
|
||||||
_flash = HudVisualMath.DecayFlash(_flash, dt);
|
_flash = HudVisualMath.DecayFlash(_flash, dt);
|
||||||
@@ -645,22 +452,7 @@ namespace ProjectM.Client
|
|||||||
else p.style.backgroundColor = c;
|
else p.style.backgroundColor = c;
|
||||||
}
|
}
|
||||||
|
|
||||||
void SetPip(VisualElement pip, bool active)
|
|
||||||
{
|
|
||||||
var theme = HudTheme.Get();
|
|
||||||
var spr = active ? theme?.PipActive : theme?.PipInactive;
|
|
||||||
if (spr != null)
|
|
||||||
{
|
|
||||||
pip.style.backgroundImage = new StyleBackground(Background.FromSprite(spr));
|
|
||||||
pip.style.unityBackgroundImageTintColor = active ? AetherCyan : PipDim;
|
|
||||||
pip.style.backgroundSize = new StyleBackgroundSize(new BackgroundSize(BackgroundSizeType.Contain));
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
pip.style.backgroundColor = active ? AetherCyan : PipDim;
|
|
||||||
MenuUi.Round(pip, 3);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// LANTERN purge: the automation buildables are deleted; Pylon stays hidden from the build palette (cosmetic-only).
|
// LANTERN purge: the automation buildables are deleted; Pylon stays hidden from the build palette (cosmetic-only).
|
||||||
static bool IsPaletteType(byte type) => type != StructureType.Pylon;
|
static bool IsPaletteType(byte type) => type != StructureType.Pylon;
|
||||||
@@ -804,7 +596,6 @@ namespace ProjectM.Client
|
|||||||
BuildDiscoveryChip(root);
|
BuildDiscoveryChip(root);
|
||||||
BuildDowned(root);
|
BuildDowned(root);
|
||||||
BuildInventory(root);
|
BuildInventory(root);
|
||||||
BuildRunBanner(root);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
void BuildVignette(VisualElement root)
|
void BuildVignette(VisualElement root)
|
||||||
@@ -932,69 +723,13 @@ namespace ProjectM.Client
|
|||||||
_banner.Add(bIcon);
|
_banner.Add(bIcon);
|
||||||
_phaseText = HudUi.Display("", 30, AetherCyan, TextAnchor.MiddleCenter);
|
_phaseText = HudUi.Display("", 30, AetherCyan, TextAnchor.MiddleCenter);
|
||||||
_banner.Add(_phaseText);
|
_banner.Add(_phaseText);
|
||||||
_cycleText = HudUi.Text("", 14, MenuUi.SubCol, TextAnchor.MiddleCenter);
|
|
||||||
_cycleText.style.marginLeft = 14;
|
|
||||||
_banner.Add(_cycleText);
|
|
||||||
macro.Add(_banner);
|
macro.Add(_banner);
|
||||||
|
|
||||||
_locationText = HudUi.Text("", 15, new Color(0.6f, 0.85f, 1f), TextAnchor.MiddleCenter);
|
_locationText = HudUi.Text("", 15, new Color(0.6f, 0.85f, 1f), TextAnchor.MiddleCenter);
|
||||||
_locationText.style.marginTop = 5;
|
_locationText.style.marginTop = 5;
|
||||||
macro.Add(_locationText);
|
macro.Add(_locationText);
|
||||||
|
|
||||||
// goal: hex-pip meter (or fallback bar) + numeral
|
|
||||||
_goalContainer = HudUi.Group(Align.Center);
|
|
||||||
_goalContainer.style.marginTop = 8;
|
|
||||||
|
|
||||||
var goalLine = new VisualElement();
|
|
||||||
goalLine.style.flexDirection = FlexDirection.Row;
|
|
||||||
goalLine.style.alignItems = Align.Center;
|
|
||||||
goalLine.pickingMode = PickingMode.Ignore;
|
|
||||||
|
|
||||||
_goalPipsRow = new VisualElement();
|
|
||||||
_goalPipsRow.style.flexDirection = FlexDirection.Row;
|
|
||||||
_goalPipsRow.style.alignItems = Align.Center;
|
|
||||||
_goalPipsRow.pickingMode = PickingMode.Ignore;
|
|
||||||
for (int i = 0; i < MaxPips; i++)
|
|
||||||
{
|
|
||||||
var pip = new VisualElement();
|
|
||||||
pip.style.width = 22; pip.style.height = 22;
|
|
||||||
pip.style.marginLeft = 2; pip.style.marginRight = 2;
|
|
||||||
pip.style.flexShrink = 0;
|
|
||||||
pip.pickingMode = PickingMode.Ignore;
|
|
||||||
pip.style.display = DisplayStyle.None;
|
|
||||||
_pips.Add(pip);
|
|
||||||
_goalPipsRow.Add(pip);
|
|
||||||
}
|
|
||||||
goalLine.Add(_goalPipsRow);
|
|
||||||
|
|
||||||
_goalText = HudUi.Display("GOAL 0 / 10", 16, AetherCyan, TextAnchor.MiddleCenter);
|
|
||||||
_goalText.style.marginLeft = 10;
|
|
||||||
goalLine.Add(_goalText);
|
|
||||||
_goalContainer.Add(goalLine);
|
|
||||||
|
|
||||||
// fallback continuous bar (large targets)
|
|
||||||
_goalBar = HudUi.Bar(360, 16, new Color(0.8f, 0.6f, 1f), out _goalFill);
|
|
||||||
_goalBar.style.marginTop = 4;
|
|
||||||
_goalBar.style.display = DisplayStyle.None;
|
|
||||||
_goalContainer.Add(_goalBar);
|
|
||||||
|
|
||||||
macro.Add(_goalContainer);
|
|
||||||
|
|
||||||
// END-1: Engine Core integrity bar (red) — the losable base-heart meter.
|
|
||||||
_coreContainer = HudUi.Group(Align.Center);
|
|
||||||
_coreContainer.style.marginTop = 6;
|
|
||||||
var coreLine = new VisualElement();
|
|
||||||
coreLine.style.flexDirection = FlexDirection.Row;
|
|
||||||
coreLine.style.alignItems = Align.Center;
|
|
||||||
coreLine.pickingMode = PickingMode.Ignore;
|
|
||||||
_coreBar = HudUi.Bar(360, 14, CoreRed, out _coreFill);
|
|
||||||
coreLine.Add(_coreBar);
|
|
||||||
_coreText = HudUi.Text("CORE 100 / 100", 13, CoreRed, TextAnchor.MiddleLeft);
|
|
||||||
_coreText.style.marginLeft = 10;
|
|
||||||
coreLine.Add(_coreText);
|
|
||||||
_coreContainer.Add(coreLine);
|
|
||||||
_coreContainer.style.display = DisplayStyle.None;
|
|
||||||
macro.Add(_coreContainer);
|
|
||||||
|
|
||||||
root.Add(macro);
|
root.Add(macro);
|
||||||
}
|
}
|
||||||
@@ -1108,53 +843,7 @@ namespace ProjectM.Client
|
|||||||
_downed.style.display = DisplayStyle.None;
|
_downed.style.display = DisplayStyle.None;
|
||||||
root.Add(_downed);
|
root.Add(_downed);
|
||||||
}
|
}
|
||||||
void BuildRunBanner(VisualElement root)
|
|
||||||
{
|
|
||||||
_runBanner = new VisualElement();
|
|
||||||
_runBanner.style.position = Position.Absolute;
|
|
||||||
_runBanner.style.left = 0; _runBanner.style.right = 0; _runBanner.style.top = 0; _runBanner.style.bottom = 0;
|
|
||||||
_runBanner.style.alignItems = Align.Center;
|
|
||||||
_runBanner.style.justifyContent = Justify.Center;
|
|
||||||
_runBanner.pickingMode = PickingMode.Ignore;
|
|
||||||
_runBanner.style.backgroundColor = new Color(0.02f, 0.03f, 0.05f, 0.55f);
|
|
||||||
var col = HudUi.Group(Align.Center);
|
|
||||||
_runBannerText = HudUi.Display("", 72, Color.white, TextAnchor.MiddleCenter);
|
|
||||||
col.Add(_runBannerText);
|
|
||||||
_runBannerSub = HudUi.Text("", 22, MenuUi.SubCol, TextAnchor.MiddleCenter);
|
|
||||||
_runBannerSub.style.marginTop = 8;
|
|
||||||
col.Add(_runBannerSub);
|
|
||||||
// END-2 (SL-5): the terminal banner offers a clear action so the player isn't hunting for Esc.
|
|
||||||
// SINGLE: PLAY AGAIN Continues as a fresh campaign (base+meta kept — the terminal save rolls
|
|
||||||
// forward on stage). CO-OP (operator-locked): the honest exit is a clean teardown for everyone —
|
|
||||||
// the host ends the session (each joiner's ConnectionWatchdog returns them to the menu with a
|
|
||||||
// reason), a joiner just leaves. All self-guard on WorldLauncher.Busy. The row picks (Position)
|
|
||||||
// even though the banner root Ignores.
|
|
||||||
var btnRow = new VisualElement();
|
|
||||||
btnRow.style.flexDirection = FlexDirection.Row;
|
|
||||||
btnRow.style.justifyContent = Justify.Center;
|
|
||||||
btnRow.style.marginTop = 28;
|
|
||||||
btnRow.pickingMode = PickingMode.Position;
|
|
||||||
switch (WorldLauncher.LastMode)
|
|
||||||
{
|
|
||||||
case SessionMode.Host:
|
|
||||||
btnRow.Add(MenuUi.Button("END SESSION — ALL TO MENU", WorldLauncher.TeardownToMenu));
|
|
||||||
break;
|
|
||||||
case SessionMode.Join:
|
|
||||||
btnRow.Add(MenuUi.Button("LEAVE TO MENU", WorldLauncher.TeardownToMenu));
|
|
||||||
break;
|
|
||||||
default:
|
|
||||||
var again = MenuUi.Button("PLAY AGAIN",
|
|
||||||
() => WorldLauncher.StartSession(SessionMode.Single, null, SaveService.HasSave()));
|
|
||||||
again.style.marginRight = 12;
|
|
||||||
btnRow.Add(again);
|
|
||||||
btnRow.Add(MenuUi.Button("QUIT TO MENU", WorldLauncher.TeardownToMenu));
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
col.Add(btnRow);
|
|
||||||
_runBanner.Add(col);
|
|
||||||
_runBanner.style.display = DisplayStyle.None;
|
|
||||||
root.Add(_runBanner);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
void BuildInventory(VisualElement root)
|
void BuildInventory(VisualElement root)
|
||||||
@@ -1280,25 +969,9 @@ namespace ProjectM.Client
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
static Color PhaseColor(byte phase)
|
|
||||||
{
|
|
||||||
switch (phase)
|
|
||||||
{
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
static string PhaseLabel(byte phase)
|
|
||||||
{
|
|
||||||
switch (phase)
|
|
||||||
{
|
|
||||||
case CyclePhase.Calm: return "AT BASE";
|
|
||||||
case CyclePhase.Siege: return "UNDER SIEGE";
|
|
||||||
default: return "";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
static string StructureName(byte type)
|
static string StructureName(byte type)
|
||||||
{
|
{
|
||||||
@@ -1509,11 +1182,6 @@ namespace ProjectM.Client
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// B6 run-failed read (client-local edge state; see the tracker near the top of OnUpdate).
|
|
||||||
byte _prevRunLifecycle;
|
|
||||||
int _chargeAtLaunch;
|
|
||||||
bool _wentInRun;
|
|
||||||
float _runFailedUntil;
|
|
||||||
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -60,8 +60,6 @@ namespace ProjectM.Client
|
|||||||
}
|
}
|
||||||
|
|
||||||
bool haveRun = SystemAPI.TryGetSingleton<RunInfo>(out var runInfo);
|
bool haveRun = SystemAPI.TryGetSingleton<RunInfo>(out var runInfo);
|
||||||
bool haveCycle = SystemAPI.TryGetSingleton<CycleState>(out var cyc);
|
|
||||||
bool siege = haveCycle && cyc.Phase == CyclePhase.Siege;
|
|
||||||
|
|
||||||
// Aether from the ledger (the sole meta-shop currency; last entry wins, matching the core loop).
|
// Aether from the ledger (the sole meta-shop currency; last entry wins, matching the core loop).
|
||||||
int aether = 0;
|
int aether = 0;
|
||||||
@@ -86,7 +84,7 @@ namespace ProjectM.Client
|
|||||||
bool metaShow = false;
|
bool metaShow = false;
|
||||||
BlobAssetReference<MetaUpgradeCatalogBlob> metaPool = default;
|
BlobAssetReference<MetaUpgradeCatalogBlob> metaPool = default;
|
||||||
DynamicBuffer<MetaTierState> metaRecord = default;
|
DynamicBuffer<MetaTierState> metaRecord = default;
|
||||||
if (haveRun && runInfo.Lifecycle == RunLifecycle.Staging && haveLocalPlayer && !siege
|
if (haveRun && runInfo.Lifecycle == RunLifecycle.Staging && haveLocalPlayer
|
||||||
&& SystemAPI.TryGetSingleton<MetaUpgradeCatalog>(out var metaCat) && metaCat.Value.IsCreated
|
&& SystemAPI.TryGetSingleton<MetaUpgradeCatalog>(out var metaCat) && metaCat.Value.IsCreated
|
||||||
&& SystemAPI.TryGetSingletonBuffer<MetaTierState>(out metaRecord, true))
|
&& SystemAPI.TryGetSingletonBuffer<MetaTierState>(out metaRecord, true))
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -11,9 +11,8 @@ namespace ProjectM.Client
|
|||||||
/// every AudioSource starts the same frame with loop=true and identical clip length, and per-bar amplitude
|
/// every AudioSource starts the same frame with loop=true and identical clip length, and per-bar amplitude
|
||||||
/// envelopes reach ~zero at each bar boundary so both the chord changes and the loop point are click-free
|
/// envelopes reach ~zero at each bar boundary so both the chord changes and the loop point are click-free
|
||||||
/// (the <c>AmbientAudioSystem.Snap</c> trick generalized to enveloped segments). The MIX is the state
|
/// (the <c>AmbientAudioSystem.Snap</c> trick generalized to enveloped segments). The MIX is the state
|
||||||
/// machine: layer volumes ease toward targets chosen from replicated state only — <see cref="RunInfo"/>
|
/// machine: layer volumes ease toward targets chosen from replicated state only — the <see cref="RunInfo"/>
|
||||||
/// lifecycle (staging / combat / boss / reward lull), <see cref="CycleState"/> siege, and the terminal
|
/// lifecycle (staging / launch / combat / boss / reward lull / return). Observe-only presentation
|
||||||
/// <see cref="RunOutcome"/> (one-shot victory/defeat sting + aftermath bed). Observe-only presentation
|
|
||||||
/// system: no sim writes, no determinism surface; asset-free per the project convention. Sits under SFX at
|
/// system: no sim writes, no determinism surface; asset-free per the project convention. Sits under SFX at
|
||||||
/// <see cref="MasterVolume"/> × <see cref="GameVolume.Music"/>; the low <c>AmbientAudioSystem</c> drone
|
/// <see cref="MasterVolume"/> × <see cref="GameVolume.Music"/>; the low <c>AmbientAudioSystem</c> drone
|
||||||
/// (vol 0.10) remains as texture beneath it.
|
/// (vol 0.10) remains as texture beneath it.
|
||||||
@@ -43,7 +42,6 @@ namespace ProjectM.Client
|
|||||||
GameObject _root;
|
GameObject _root;
|
||||||
AudioSource _bass, _pad, _arp, _pulse;
|
AudioSource _bass, _pad, _arp, _pulse;
|
||||||
float _vBass, _vPad, _vArp, _vPulse; // current smoothed volumes (pre-master)
|
float _vBass, _vPad, _vArp, _vPulse; // current smoothed volumes (pre-master)
|
||||||
byte _outcomePlayed; // which terminal outcome's sting has fired (0 = none)
|
|
||||||
|
|
||||||
protected override void OnStartRunning()
|
protected override void OnStartRunning()
|
||||||
{
|
{
|
||||||
@@ -80,27 +78,7 @@ namespace ProjectM.Client
|
|||||||
// ---- pick the mix from replicated state (defaults = quiet staging bed) ----
|
// ---- pick the mix from replicated state (defaults = quiet staging bed) ----
|
||||||
float tBass = 0.50f, tPad = 0.55f, tArp = 0.12f, tPulse = 0f;
|
float tBass = 0.50f, tPad = 0.55f, tArp = 0.12f, tPulse = 0f;
|
||||||
|
|
||||||
bool haveRun = SystemAPI.TryGetSingleton<RunInfo>(out var run);
|
if (SystemAPI.TryGetSingleton<RunInfo>(out var run))
|
||||||
SystemAPI.TryGetSingleton<CycleState>(out var cyc);
|
|
||||||
bool siege = cyc.Phase == CyclePhase.Siege;
|
|
||||||
bool finalSiege = siege && SystemAPI.TryGetSingleton<GoalProgress>(out var goal)
|
|
||||||
&& goal.Target > 0 && goal.Charge >= goal.Target;
|
|
||||||
|
|
||||||
byte outcome = SystemAPI.TryGetSingleton<RunOutcome>(out var oc) ? oc.Value : RunOutcomeId.InProgress;
|
|
||||||
if (outcome != RunOutcomeId.InProgress)
|
|
||||||
{
|
|
||||||
if (_outcomePlayed != outcome)
|
|
||||||
{
|
|
||||||
_outcomePlayed = outcome;
|
|
||||||
PlayOutcomeSting(outcome == RunOutcomeId.Victory);
|
|
||||||
}
|
|
||||||
tBass = 0.20f; tPad = 0.45f; tArp = 0f; tPulse = 0f; // aftermath bed under the banner
|
|
||||||
}
|
|
||||||
else if (siege)
|
|
||||||
{
|
|
||||||
tBass = 0.70f; tPad = 0.40f; tArp = 0.60f; tPulse = finalSiege ? 0.90f : 0.65f;
|
|
||||||
}
|
|
||||||
else if (haveRun)
|
|
||||||
{
|
{
|
||||||
switch (run.Lifecycle)
|
switch (run.Lifecycle)
|
||||||
{
|
{
|
||||||
@@ -138,15 +116,7 @@ namespace ProjectM.Client
|
|||||||
_pulse.volume = _vPulse * master;
|
_pulse.volume = _vPulse * master;
|
||||||
}
|
}
|
||||||
|
|
||||||
void PlayOutcomeSting(bool victory)
|
|
||||||
{
|
|
||||||
// Victory: rising A-minor->major-feel arpeggio; defeat: falling minor third crawl.
|
|
||||||
float[] notes = victory
|
|
||||||
? new[] { 220f, 277.18f, 329.63f, 440f } // A3 C#4 E4 A4 (picardy lift)
|
|
||||||
: new[] { 220f, 207.65f, 174.61f, 146.83f }; // A3 Ab3 F3 D3
|
|
||||||
var clip = BuildStingClip(notes, victory ? 0.16f : 0.28f, victory ? 0.45f : 0.40f);
|
|
||||||
if (_pad != null) _pad.PlayOneShot(clip, 0.9f * GameVolume.Music);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ================= clip builders (deterministic, asset-free) =================
|
// ================= clip builders (deterministic, asset-free) =================
|
||||||
|
|
||||||
@@ -263,23 +233,6 @@ namespace ProjectM.Client
|
|||||||
return clip;
|
return clip;
|
||||||
}
|
}
|
||||||
|
|
||||||
static AudioClip BuildStingClip(float[] notes, float noteLen, float vol)
|
|
||||||
{
|
|
||||||
int len = (int)(notes.Length * noteLen * SampleRate) + SampleRate / 2; // + half-second tail
|
|
||||||
var clip = AudioClip.Create("music_sting", len, 1, SampleRate, false);
|
|
||||||
var data = new float[len];
|
|
||||||
for (int n = 0; n < notes.Length; n++)
|
|
||||||
{
|
|
||||||
int start = (int)(n * noteLen * SampleRate);
|
|
||||||
int dur = (int)(SampleRate * (noteLen + (n == notes.Length - 1 ? 0.5f : 0.05f)));
|
|
||||||
for (int i = 0; i < dur && start + i < len; i++)
|
|
||||||
{
|
|
||||||
float tn = i / (float)SampleRate;
|
|
||||||
data[start + i] += Mathf.Sin(2f * Mathf.PI * notes[n] * tn) * Mathf.Exp(-4.5f * tn) * vol;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
clip.SetData(data, 0);
|
|
||||||
return clip;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ namespace ProjectM.Client
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public static class HowToPlayPanel
|
public static class HowToPlayPanel
|
||||||
{
|
{
|
||||||
static readonly string[] Tabs = { "Controls", "The Loop", "Build & Economy", "Threats", "Win / Lose" };
|
static readonly string[] Tabs = { "Controls", "The Loop", "Build & Economy", "Threats" };
|
||||||
|
|
||||||
public static VisualElement Build(Action onClose)
|
public static VisualElement Build(Action onClose)
|
||||||
{
|
{
|
||||||
@@ -85,35 +85,24 @@ namespace ProjectM.Client
|
|||||||
Body(c, "Pause — Esc");
|
Body(c, "Pause — Esc");
|
||||||
break;
|
break;
|
||||||
case 1: // The Loop
|
case 1: // The Loop
|
||||||
Head(c, "THE LOOP — expedition RUNS are how you win");
|
Head(c, "THE LOOP — descend, fight, bank");
|
||||||
Body(c, "1. BASE — pick your CLASS + buy PREP buffs (this run only); build Turrets + a Fabricator; spend Aether on PERMANENT class upgrades.");
|
Body(c, "1. BASE — pick your suit-frame, buy PREP buffs (this run only), spend Aether on PERMANENT upgrades.");
|
||||||
Body(c, "2. READY UP [T] — when everyone is ready, the party launches into a multi-room run together.");
|
Body(c, "2. READY UP [T] — when everyone is ready, the party launches into a multi-room run together.");
|
||||||
Body(c, "3. RUN — clear each room, pick 1 of 3 BOONS (this run only), choose your path on the map, mine the crystals.");
|
Body(c, "3. RUN — clear each room, pick 1 of 3 BOONS (this run only), choose your path, mine the crystals.");
|
||||||
Body(c, "4. BOSS — fell it and the party returns with the haul: +1 on the Engine meter. A wipe banks nothing.");
|
Body(c, "4. BOSS — fell it and the party returns with the haul. A wipe banks nothing but the depth record.");
|
||||||
Body(c, "5. SIEGE — a completed run provokes retaliation at base. Defend the Core!");
|
|
||||||
Body(c, "6. WIN — fill the Engine meter, then hold the final siege.");
|
|
||||||
break;
|
break;
|
||||||
case 2: // Build & Economy
|
case 2: // Build & Economy
|
||||||
Head(c, "RESOURCES & BUILDING");
|
Head(c, "RESOURCES & BUILDING");
|
||||||
Body(c, "Ore — main currency. Attack the glowing crystals INSIDE runs and haul them home (they're scarce — spend well).");
|
Body(c, "Ore — main currency. Attack the glowing crystals INSIDE runs and haul them home (they're scarce — spend well).");
|
||||||
Body(c, "Turret (40 Ore) — auto-fires at enemies. Needs Charge as ammo.");
|
|
||||||
Body(c, "Fabricator (30 Ore) — converts Ore → Charge so turrets keep firing.");
|
|
||||||
Body(c, "Wall (Biomass) — a cheap barrier that blocks enemies.");
|
Body(c, "Wall (Biomass) — a cheap barrier that blocks enemies.");
|
||||||
Body(c, "Aether — rare; fuels PERMANENT class upgrades at the base between runs.");
|
Body(c, "Aether — rare; fuels PERMANENT upgrades at the base between runs.");
|
||||||
Body(c, "Open Build with Tab (Y), pick a piece, click a green tile to place it.");
|
Body(c, "Open Build with Tab (Y), pick a piece, click a green tile to place it.");
|
||||||
break;
|
break;
|
||||||
case 3: // Threats
|
default: // Threats
|
||||||
Head(c, "THREATS");
|
Head(c, "THREATS");
|
||||||
Body(c, "Husks assault the base during a Siege — keep them off the Engine Core.");
|
|
||||||
Body(c, "A Husk that reaches the Core drains it. Lose the Core in the final siege and the run ends.");
|
|
||||||
Body(c, "Runs escalate room by room — elites guard the deep paths, a boss guards the end.");
|
Body(c, "Runs escalate room by room — elites guard the deep paths, a boss guards the end.");
|
||||||
Body(c, "Enemy variety scales the deeper you push.");
|
Body(c, "Enemy variety scales the deeper you push.");
|
||||||
break;
|
Body(c, "What the light doesn't hold, the dark takes back.");
|
||||||
default: // Win / Lose
|
|
||||||
Head(c, "WIN / LOSE");
|
|
||||||
Body(c, "WIN — clear expeditions to fill the Engine meter, then hold the final siege.");
|
|
||||||
Body(c, "LOSE — the Engine Core falls during the final siege.");
|
|
||||||
Body(c, "A Core breach mid-run is only a setback: resources lost, the Core recovers in Calm.");
|
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -129,17 +129,11 @@ namespace ProjectM.Client
|
|||||||
static void StagePendingSave(World server)
|
static void StagePendingSave(World server)
|
||||||
{
|
{
|
||||||
var data = SaveService.Load();
|
var data = SaveService.Load();
|
||||||
// A terminal (won/lost) save Continues as a FRESH campaign — base+meta kept, outcome/meter/core reset
|
|
||||||
// (else the dead outcome banner re-latches from the first snapshot).
|
|
||||||
SaveService.RollTerminalCampaignForward(data);
|
|
||||||
|
|
||||||
if (data == null) return;
|
if (data == null) return;
|
||||||
var em = server.EntityManager;
|
var em = server.EntityManager;
|
||||||
var e = em.CreateEntity();
|
var e = em.CreateEntity();
|
||||||
// v5->v6 migration (operator-approved): an old save's Charge counted boss-cleared runs (DR-042), so a
|
em.AddComponentData(e, new PendingSave { RunsCompleted = data.RunsCompleted, MaxDepthReached = data.MaxDepthReached, HasData = 1 });
|
||||||
// missing RunsCompleted floors to it — the HUD never shows "Charge 3/4" beside "Runs completed: 0".
|
|
||||||
int runsCompleted = data.RunsCompleted > data.GoalCharge ? data.RunsCompleted : data.GoalCharge;
|
|
||||||
em.AddComponentData(e, new PendingSave { GoalCharge = data.GoalCharge, GoalTarget = data.GoalTarget, CoreCurrent = data.CoreCurrent, RunOutcome = (byte)data.RunOutcome, RunsCompleted = runsCompleted, MaxDepthReached = data.MaxDepthReached, HasData = 1 });
|
|
||||||
// v6: stage the meta tiers UNCONDITIONALLY (empty OK — the Bursted spawn system GetBuffers it in the
|
// v6: stage the meta tiers UNCONDITIONALLY (empty OK — the Bursted spawn system GetBuffers it in the
|
||||||
// HasData block; a conditional buffer would throw on any v<=5 Continue). Rows verbatim, no clamping.
|
// HasData block; a conditional buffer would throw on any v<=5 Continue). Rows verbatim, no clamping.
|
||||||
var mbuf = em.AddBuffer<PendingMetaRow>(e);
|
var mbuf = em.AddBuffer<PendingMetaRow>(e);
|
||||||
@@ -159,10 +153,6 @@ namespace ProjectM.Client
|
|||||||
var sbuf = em.AddBuffer<PendingStructure>(se);
|
var sbuf = em.AddBuffer<PendingStructure>(se);
|
||||||
foreach (var s in data.Structures)
|
foreach (var s in data.Structures)
|
||||||
sbuf.Add(SaveApply.ToPending(s)); // EB-1: pure mapping (unit-tested, incl. the wounded HP)
|
sbuf.Add(SaveApply.ToPending(s)); // EB-1: pure mapping (unit-tested, incl. the wounded HP)
|
||||||
var iobuf = em.AddBuffer<PendingStructureIo>(se);
|
|
||||||
if (data.StructureIo != null)
|
|
||||||
foreach (var io in data.StructureIo)
|
|
||||||
iobuf.Add(new PendingStructureIo { StructureIndex = io.StructureIndex, Slot = io.Slot, ResourceId = io.ResourceId, Count = io.Count });
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -175,9 +165,6 @@ namespace ProjectM.Client
|
|||||||
using var q = em.CreateEntityQuery(ComponentType.ReadOnly<ResourceLedger>());
|
using var q = em.CreateEntityQuery(ComponentType.ReadOnly<ResourceLedger>());
|
||||||
if (q.IsEmptyIgnoreFilter) return;
|
if (q.IsEmptyIgnoreFilter) return;
|
||||||
var dir = q.GetSingletonEntity();
|
var dir = q.GetSingletonEntity();
|
||||||
var goal = em.HasComponent<GoalProgress>(dir) ? em.GetComponentData<GoalProgress>(dir) : default;
|
|
||||||
var core = em.HasComponent<CoreIntegrity>(dir) ? em.GetComponentData<CoreIntegrity>(dir) : default; // END-1
|
|
||||||
var outcome = em.HasComponent<RunOutcome>(dir) ? em.GetComponentData<RunOutcome>(dir) : default; // END-2
|
|
||||||
|
|
||||||
var buffer = em.GetBuffer<StorageEntry>(dir, true);
|
var buffer = em.GetBuffer<StorageEntry>(dir, true);
|
||||||
var rows = new LedgerRow[buffer.Length];
|
var rows = new LedgerRow[buffer.Length];
|
||||||
@@ -195,21 +182,16 @@ namespace ProjectM.Client
|
|||||||
// v6: the permanent-meta slice via the ONE shared collector — omitting it HERE (the most common
|
// v6: the permanent-meta slice via the ONE shared collector — omitting it HERE (the most common
|
||||||
// exit path) would silently WIPE all meta progression on quit (the meta review's top blocker).
|
// exit path) would silently WIPE all meta progression on quit (the meta review's top blocker).
|
||||||
MetaSaveScan.Collect(em, dir, out var metaRows, out var runsCompleted, out var maxDepth);
|
MetaSaveScan.Collect(em, dir, out var metaRows, out var runsCompleted, out var maxDepth);
|
||||||
SaveStructureScan.Collect(em, nowTick, out var structures, out var structureIo);
|
SaveStructureScan.Collect(em, nowTick, out var structures);
|
||||||
|
|
||||||
SaveService.Save(new SaveData
|
SaveService.Save(new SaveData
|
||||||
{
|
{
|
||||||
GoalCharge = goal.Charge,
|
|
||||||
GoalTarget = goal.Target,
|
|
||||||
CoreCurrent = core.Current,
|
|
||||||
RunsCompleted = runsCompleted,
|
RunsCompleted = runsCompleted,
|
||||||
MaxDepthReached = maxDepth,
|
MaxDepthReached = maxDepth,
|
||||||
MetaUpgrades = metaRows,
|
MetaUpgrades = metaRows,
|
||||||
RunOutcome = outcome.Value,
|
|
||||||
|
|
||||||
Ledger = rows,
|
Ledger = rows,
|
||||||
Structures = structures,
|
Structures = structures,
|
||||||
StructureIo = structureIo,
|
|
||||||
SavedAtMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(),
|
SavedAtMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -85,13 +85,7 @@ namespace ProjectM.Server
|
|||||||
decoyPositions.Add(dx.ValueRO.Position);
|
decoyPositions.Add(dx.ValueRO.Position);
|
||||||
}
|
}
|
||||||
|
|
||||||
// END-1: the Engine Core is a FALLBACK target. When no living player/structure remains, undefended
|
if (playerEntities.Length == 0 && structureEntities.Length == 0 && decoyEntities.Length == 0)
|
||||||
// Husks march on the base heart (PlotCenter) so the base can be overrun instead of the swarm idling.
|
|
||||||
bool coreAlive = SystemAPI.HasSingleton<BaseAnchor>()
|
|
||||||
&& SystemAPI.TryGetSingleton<CoreIntegrity>(out var coreInteg) && coreInteg.Current > 0;
|
|
||||||
float3 corePos = coreAlive ? BaseGridMath.PlotCenter(SystemAPI.GetSingleton<BaseAnchor>()) : float3.zero;
|
|
||||||
|
|
||||||
if (playerEntities.Length == 0 && structureEntities.Length == 0 && decoyEntities.Length == 0 && !coreAlive)
|
|
||||||
{
|
{
|
||||||
playerEntities.Dispose();
|
playerEntities.Dispose();
|
||||||
playerPositions.Dispose();
|
playerPositions.Dispose();
|
||||||
@@ -137,7 +131,6 @@ namespace ProjectM.Server
|
|||||||
{
|
{
|
||||||
float3 pos = xform.ValueRO.Position;
|
float3 pos = xform.ValueRO.Position;
|
||||||
byte huskRegion = region.ValueRO.Region;
|
byte huskRegion = region.ValueRO.Region;
|
||||||
bool huskCoreAlive = coreAlive && huskRegion == RegionId.Base;
|
|
||||||
|
|
||||||
// Knockback overrides seek/strike for its window — EnemyAISystem stays the SOLE writer of Position.
|
// Knockback overrides seek/strike for its window — EnemyAISystem stays the SOLE writer of Position.
|
||||||
var kb = knockback.ValueRO;
|
var kb = knockback.ValueRO;
|
||||||
@@ -178,12 +171,10 @@ namespace ProjectM.Server
|
|||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
if (tgtIdx < 0 && !huskCoreAlive)
|
if (tgtIdx < 0)
|
||||||
continue; // no decoy, no player/structure, and no Core -> nothing to seek
|
continue; // no decoy, no player/structure -> nothing to seek
|
||||||
targetEntity = tgtIdx < 0 ? Entity.Null
|
targetEntity = tgtIsStruct ? structureEntities[tgtIdx] : playerEntities[tgtIdx];
|
||||||
: (tgtIsStruct ? structureEntities[tgtIdx] : playerEntities[tgtIdx]);
|
targetPos = tgtIsStruct ? structurePositions[tgtIdx] : playerPositions[tgtIdx];
|
||||||
targetPos = tgtIdx < 0 ? corePos
|
|
||||||
: (tgtIsStruct ? structurePositions[tgtIdx] : playerPositions[tgtIdx]);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Seek: stop just inside strike range so the Husk holds position to attack.
|
// Seek: stop just inside strike range so the Husk holds position to attack.
|
||||||
@@ -271,7 +262,6 @@ namespace ProjectM.Server
|
|||||||
{
|
{
|
||||||
float3 pos = xform.ValueRO.Position;
|
float3 pos = xform.ValueRO.Position;
|
||||||
byte cHuskRegion = region.ValueRO.Region;
|
byte cHuskRegion = region.ValueRO.Region;
|
||||||
bool cHuskCoreAlive = coreAlive && cHuskRegion == RegionId.Base;
|
|
||||||
|
|
||||||
// 1. Knockback wins (and cancels any in-flight lunge so Position keeps a single writer).
|
// 1. Knockback wins (and cancels any in-flight lunge so Position keeps a single writer).
|
||||||
var kb = knockback.ValueRO;
|
var kb = knockback.ValueRO;
|
||||||
@@ -296,12 +286,10 @@ namespace ProjectM.Server
|
|||||||
|
|
||||||
// EB-1 fortress aggro: same weighted target selection as the Grunt pass (shared helper).
|
// EB-1 fortress aggro: same weighted target selection as the Grunt pass (shared helper).
|
||||||
EnemyAIMath.PickWeightedNearest(pos, playerPositions, playerRegions, structurePositions, structureRegions, cHuskRegion, structAggro, out bool cIsStruct, out int cIdx);
|
EnemyAIMath.PickWeightedNearest(pos, playerPositions, playerRegions, structurePositions, structureRegions, cHuskRegion, structAggro, out bool cIsStruct, out int cIdx);
|
||||||
if (cIdx < 0 && !cHuskCoreAlive)
|
if (cIdx < 0)
|
||||||
continue;
|
continue;
|
||||||
Entity cTargetEntity = cIdx < 0 ? Entity.Null
|
Entity cTargetEntity = cIsStruct ? structureEntities[cIdx] : playerEntities[cIdx];
|
||||||
: (cIsStruct ? structureEntities[cIdx] : playerEntities[cIdx]);
|
float3 cTargetPos = cIsStruct ? structurePositions[cIdx] : playerPositions[cIdx];
|
||||||
float3 cTargetPos = cIdx < 0 ? corePos
|
|
||||||
: (cIsStruct ? structurePositions[cIdx] : playerPositions[cIdx]);
|
|
||||||
|
|
||||||
// 2. Lunge active: travel the locked direction; damage on contact, or stagger on a wall-stop whiff.
|
// 2. Lunge active: travel the locked direction; damage on contact, or stagger on a wall-stop whiff.
|
||||||
var lg = lunge.ValueRO;
|
var lg = lunge.ValueRO;
|
||||||
@@ -414,7 +402,6 @@ namespace ProjectM.Server
|
|||||||
{
|
{
|
||||||
float3 pos = xform.ValueRO.Position;
|
float3 pos = xform.ValueRO.Position;
|
||||||
byte sRegion = region.ValueRO.Region;
|
byte sRegion = region.ValueRO.Region;
|
||||||
bool sCoreAlive = coreAlive && sRegion == RegionId.Base;
|
|
||||||
|
|
||||||
// 1. Knockback overrides everything (sole Position writer preserved).
|
// 1. Knockback overrides everything (sole Position writer preserved).
|
||||||
var kb = knockback.ValueRO;
|
var kb = knockback.ValueRO;
|
||||||
@@ -434,14 +421,12 @@ namespace ProjectM.Server
|
|||||||
knockback.ValueRW.UntilTick = 0;
|
knockback.ValueRW.UntilTick = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Target (region-scoped shared helper); Core fallback like the Grunt/Charger passes.
|
// 2. Target (region-scoped shared helper); no target -> idle.
|
||||||
EnemyAIMath.PickWeightedNearest(pos, playerPositions, playerRegions, structurePositions, structureRegions, sRegion, structAggro, out bool sIsStruct, out int sIdx);
|
EnemyAIMath.PickWeightedNearest(pos, playerPositions, playerRegions, structurePositions, structureRegions, sRegion, structAggro, out bool sIsStruct, out int sIdx);
|
||||||
if (sIdx < 0 && !sCoreAlive)
|
if (sIdx < 0)
|
||||||
continue;
|
continue;
|
||||||
Entity sTargetEntity = sIdx < 0 ? Entity.Null
|
Entity sTargetEntity = sIsStruct ? structureEntities[sIdx] : playerEntities[sIdx];
|
||||||
: (sIsStruct ? structureEntities[sIdx] : playerEntities[sIdx]);
|
float3 sTargetPos = sIsStruct ? structurePositions[sIdx] : playerPositions[sIdx];
|
||||||
float3 sTargetPos = sIdx < 0 ? corePos
|
|
||||||
: (sIsStruct ? structurePositions[sIdx] : playerPositions[sIdx]);
|
|
||||||
|
|
||||||
// 3. Range-band movement: advance if too far, retreat if too close, hold in-band. Face the target.
|
// 3. Range-band movement: advance if too far, retreat if too close, hold in-band. Face the target.
|
||||||
var sp = spitter.ValueRO;
|
var sp = spitter.ValueRO;
|
||||||
@@ -504,7 +489,7 @@ namespace ProjectM.Server
|
|||||||
float sDist = math.length(sToTarget);
|
float sDist = math.length(sToTarget);
|
||||||
bool sInBand = math.abs(sDist - sp.PreferredRange) <= sp.RangeTolerance;
|
bool sInBand = math.abs(sDist - sp.PreferredRange) <= sp.RangeTolerance;
|
||||||
bool sCornered = sDist <= sp.CorneredRange;
|
bool sCornered = sDist <= sp.CorneredRange;
|
||||||
if (sReady && (sInBand || sCornered) && (sTargetEntity != Entity.Null || sCoreAlive))
|
if (sReady && (sInBand || sCornered))
|
||||||
{
|
{
|
||||||
uint wTicks = (uint)math.max(1, sp.WindupTicks);
|
uint wTicks = (uint)math.max(1, sp.WindupTicks);
|
||||||
windup.ValueRW.WindUpUntilTick = TickUtil.NonZero(now + wTicks);
|
windup.ValueRW.WindUpUntilTick = TickUtil.NonZero(now + wTicks);
|
||||||
@@ -625,9 +610,8 @@ namespace ProjectM.Server
|
|||||||
}
|
}
|
||||||
|
|
||||||
EnemyAIMath.PickWeightedNearest(npos, playerPositions, playerRegions, structurePositions, structureRegions, nRegion, structAggro, out bool nIsStruct, out int nIdx);
|
EnemyAIMath.PickWeightedNearest(npos, playerPositions, playerRegions, structurePositions, structureRegions, nRegion, structAggro, out bool nIsStruct, out int nIdx);
|
||||||
bool nCoreAlive = coreAlive && nRegion == RegionId.Base;
|
bool hasTarget = nIdx >= 0;
|
||||||
bool hasTarget = nIdx >= 0 || nCoreAlive;
|
float3 nTarget = nIdx < 0 ? npos : (nIsStruct ? structurePositions[nIdx] : playerPositions[nIdx]);
|
||||||
float3 nTarget = nIdx < 0 ? corePos : (nIsStruct ? structurePositions[nIdx] : playerPositions[nIdx]);
|
|
||||||
bool wantsToClose = hasTarget && !committed
|
bool wantsToClose = hasTarget && !committed
|
||||||
&& math.distance(npos.xz, nTarget.xz) > nstats.ValueRO.AttackRange * 1.15f;
|
&& math.distance(npos.xz, nTarget.xz) > nstats.ValueRO.AttackRange * 1.15f;
|
||||||
|
|
||||||
|
|||||||
@@ -39,9 +39,6 @@ namespace ProjectM.Server
|
|||||||
if (!serverTick.IsValid)
|
if (!serverTick.IsValid)
|
||||||
return;
|
return;
|
||||||
uint now = serverTick.TickIndexForValidTick;
|
uint now = serverTick.TickIndexForValidTick;
|
||||||
// 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>();
|
var director = SystemAPI.GetSingleton<WaveDirector>();
|
||||||
var directorEntity = SystemAPI.GetSingletonEntity<WaveDirector>();
|
var directorEntity = SystemAPI.GetSingletonEntity<WaveDirector>();
|
||||||
|
|||||||
@@ -11,12 +11,12 @@ namespace ProjectM.Server
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// EDITOR-ONLY server receiver for <see cref="DebugCommandRequest"/> dev-tool RPCs (from the DebugOverlay or
|
/// 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
|
/// 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,
|
/// over a live connection too: force/stop waves, clear enemies, grant resources/upgrades, teleport, god-mode,
|
||||||
/// advance the goal. Sender-targeted ops resolve the player via SourceConnection -> NetworkId -> GhostOwner
|
/// heal/kill, class swap, gym enemy spawns. Sender-targeted ops resolve the player via SourceConnection ->
|
||||||
/// (the RegionTransitSystem pattern). Plain server SimulationSystemGroup (NOT the predicted loop). Reuses
|
/// NetworkId -> GhostOwner (the RegionTransitSystem pattern). Plain server SimulationSystemGroup (NOT the
|
||||||
/// StorageMath / StatModifier / RegionMath + the wave/cycle singletons. The whole system is #if UNITY_EDITOR
|
/// predicted loop). The whole system is #if UNITY_EDITOR (stripped from builds); the wire TYPE
|
||||||
/// (stripped from builds); the wire TYPE (<see cref="DebugCommandRequest"/>) is unconditional so the RPC
|
/// (<see cref="DebugCommandRequest"/>) is unconditional so the RPC collection hash matches across peers.
|
||||||
/// collection hash matches across peers. Non-Burst (managed-simple, editor-only) — perf is irrelevant.
|
/// Non-Burst (managed-simple, editor-only) — perf is irrelevant.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[WorldSystemFilter(WorldSystemFilterFlags.ServerSimulation)]
|
[WorldSystemFilter(WorldSystemFilterFlags.ServerSimulation)]
|
||||||
[UpdateInGroup(typeof(SimulationSystemGroup))]
|
[UpdateInGroup(typeof(SimulationSystemGroup))]
|
||||||
@@ -41,7 +41,8 @@ namespace ProjectM.Server
|
|||||||
foreach (var (owner, e) in SystemAPI.Query<RefRO<GhostOwner>>().WithAll<PlayerTag>().WithEntityAccess())
|
foreach (var (owner, e) in SystemAPI.Query<RefRO<GhostOwner>>().WithAll<PlayerTag>().WithEntityAccess())
|
||||||
playerByConn[owner.ValueRO.NetworkId] = e;
|
playerByConn[owner.ValueRO.NetworkId] = e;
|
||||||
|
|
||||||
bool haveCycle = SystemAPI.TryGetSingletonEntity<CycleState>(out var cycleEntity);
|
uint now = SystemAPI.TryGetSingleton<NetworkTime>(out var netTime) && netTime.ServerTick.IsValid
|
||||||
|
? netTime.ServerTick.TickIndexForValidTick : 0u;
|
||||||
|
|
||||||
foreach (var (request, receive, reqEntity) in
|
foreach (var (request, receive, reqEntity) in
|
||||||
SystemAPI.Query<RefRO<DebugCommandRequest>, RefRO<ReceiveRpcCommandRequest>>().WithEntityAccess())
|
SystemAPI.Query<RefRO<DebugCommandRequest>, RefRO<ReceiveRpcCommandRequest>>().WithEntityAccess())
|
||||||
@@ -54,43 +55,29 @@ namespace ProjectM.Server
|
|||||||
|
|
||||||
switch (cmd.Op)
|
switch (cmd.Op)
|
||||||
{
|
{
|
||||||
case DebugOp.SpawnWave:
|
case DebugOp.SpawnWave: // re-meant (LANTERN): force the NEXT wave to start this tick
|
||||||
if (haveCycle && SystemAPI.HasComponent<ThreatState>(cycleEntity))
|
if (SystemAPI.TryGetSingletonEntity<WaveState>(out var forceWaveE))
|
||||||
{
|
{
|
||||||
var ts = SystemAPI.GetComponent<ThreatState>(cycleEntity);
|
var fw = SystemAPI.GetComponent<WaveState>(forceWaveE);
|
||||||
ts.PendingSiegeSize = math.max(1, cmd.ArgA);
|
fw.Phase = WavePhase.Lull;
|
||||||
ts.ArmTick = 0; // fire as soon as CyclePhaseSystem sees it
|
fw.NextActionTick = 0; // due immediately -> WaveSystem starts the next (bigger) wave
|
||||||
SystemAPI.SetComponent(cycleEntity, ts);
|
SystemAPI.SetComponent(forceWaveE, fw);
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case DebugOp.EndSiege:
|
case DebugOp.EndSiege: // re-meant (LANTERN): "quiet the arena" — cull husks + push the next wave far out
|
||||||
case DebugOp.SetCalm:
|
|
||||||
CullHusks(ref ecb);
|
CullHusks(ref ecb);
|
||||||
if (SystemAPI.TryGetSingletonEntity<WaveState>(out var we))
|
if (SystemAPI.TryGetSingletonEntity<WaveState>(out var stopWaveE))
|
||||||
{
|
{
|
||||||
var w = SystemAPI.GetComponent<WaveState>(we);
|
var w = SystemAPI.GetComponent<WaveState>(stopWaveE);
|
||||||
w.Phase = WavePhase.Lull;
|
w.Phase = WavePhase.Lull;
|
||||||
w.RemainingToSpawn = 0;
|
w.RemainingToSpawn = 0;
|
||||||
SystemAPI.SetComponent(we, w);
|
w.NextActionTick = TickUtil.NonZero(now + 216000u); // ~1 h @ 60 Hz: waves stay quiet for the session
|
||||||
}
|
SystemAPI.SetComponent(stopWaveE, 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;
|
break;
|
||||||
|
|
||||||
|
|
||||||
case DebugOp.ClearEnemies:
|
case DebugOp.ClearEnemies:
|
||||||
CullHusks(ref ecb);
|
CullHusks(ref ecb);
|
||||||
break;
|
break;
|
||||||
@@ -147,23 +134,6 @@ namespace ProjectM.Server
|
|||||||
}
|
}
|
||||||
break;
|
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;
|
|
||||||
case DebugOp.SetTuning:
|
case DebugOp.SetTuning:
|
||||||
if (SystemAPI.TryGetSingleton<TuningConfig>(out var tuningCfg))
|
if (SystemAPI.TryGetSingleton<TuningConfig>(out var tuningCfg))
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -7,11 +7,9 @@ namespace ProjectM.Server
|
|||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Host-only autosave writer. A managed <see cref="SystemBase"/> (file IO => NO Burst) that reacts to the
|
/// Host-only autosave writer. A managed <see cref="SystemBase"/> (file IO => NO Burst) that reacts to the
|
||||||
/// <see cref="SaveRequest"/> flag the Bursted <c>CyclePhaseSystem</c> raises on the Siege->Calm checkpoint:
|
/// <see cref="SaveRequest"/> flag <c>RunDirectorSystem</c> raises on the terminal bank: reads the shared
|
||||||
/// reads the authoritative <see cref="GoalProgress"/> + shared resource ledger off the CycleDirector ghost,
|
/// resource ledger + permanent meta off the director ghost, writes the JSON save (<see cref="SaveService"/>),
|
||||||
/// writes the JSON save (<see cref="SaveService"/>), then clears the flag. ServerSimulation-only, so a pure
|
/// then clears the flag. ServerSimulation-only, so a pure (Join) client never writes.
|
||||||
/// (Join) client never writes. Deliberately carries NO <c>[UpdateAfter(CyclePhaseSystem)]</c> (that would risk
|
|
||||||
/// a sort-cycle); a one-tick-late autosave is irrelevant.
|
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[WorldSystemFilter(WorldSystemFilterFlags.ServerSimulation)]
|
[WorldSystemFilter(WorldSystemFilterFlags.ServerSimulation)]
|
||||||
public partial class SaveWriteSystem : SystemBase
|
public partial class SaveWriteSystem : SystemBase
|
||||||
@@ -32,46 +30,26 @@ namespace ProjectM.Server
|
|||||||
req.Pending = 0;
|
req.Pending = 0;
|
||||||
SystemAPI.SetComponent(dir, req);
|
SystemAPI.SetComponent(dir, req);
|
||||||
|
|
||||||
var goal = SystemAPI.HasComponent<GoalProgress>(dir)
|
// The shared ledger lives on this same director ghost (ResourceLedger-tagged StorageEntry buffer).
|
||||||
? SystemAPI.GetComponent<GoalProgress>(dir)
|
|
||||||
: default;
|
|
||||||
|
|
||||||
// END-1: persist the Engine Core integrity (a wounded base stays wounded across save/quit).
|
|
||||||
var core = SystemAPI.HasComponent<CoreIntegrity>(dir)
|
|
||||||
? SystemAPI.GetComponent<CoreIntegrity>(dir)
|
|
||||||
: default;
|
|
||||||
// END-2: persist the terminal run outcome so a won/lost run loads finished (no re-arm on Continue).
|
|
||||||
var outcome = SystemAPI.HasComponent<RunOutcome>(dir)
|
|
||||||
? SystemAPI.GetComponent<RunOutcome>(dir)
|
|
||||||
: default;
|
|
||||||
|
|
||||||
|
|
||||||
// The shared ledger lives on this same CycleDirector ghost (ResourceLedger-tagged StorageEntry buffer).
|
|
||||||
var buffer = SystemAPI.GetBuffer<StorageEntry>(dir);
|
var buffer = SystemAPI.GetBuffer<StorageEntry>(dir);
|
||||||
var rows = new LedgerRow[buffer.Length];
|
var rows = new LedgerRow[buffer.Length];
|
||||||
for (int i = 0; i < buffer.Length; i++)
|
for (int i = 0; i < buffer.Length; i++)
|
||||||
rows[i] = new LedgerRow { ItemId = buffer[i].ItemId, Count = buffer[i].Count };
|
rows[i] = new LedgerRow { ItemId = buffer[i].ItemId, Count = buffer[i].Count };
|
||||||
|
|
||||||
// M7: also persist player-built structures + their production tick-state / inventory (single shared scan).
|
// Persist player-built structures (single shared scan; drift-proof vs the quit-to-menu writer).
|
||||||
uint nowTick = SystemAPI.GetSingleton<NetworkTime>().ServerTick.TickIndexForValidTick;
|
uint nowTick = SystemAPI.GetSingleton<NetworkTime>().ServerTick.TickIndexForValidTick;
|
||||||
SaveStructureScan.Collect(EntityManager, nowTick, out var structures, out var structureIo);
|
SaveStructureScan.Collect(EntityManager, nowTick, out var structures);
|
||||||
// v6: the permanent-meta slice via the ONE shared collector (drift-proof vs the quit-to-menu writer).
|
// v6: the permanent-meta slice via the ONE shared collector.
|
||||||
MetaSaveScan.Collect(EntityManager, dir, out var metaRows, out var runsCompleted, out var maxDepth);
|
MetaSaveScan.Collect(EntityManager, dir, out var metaRows, out var runsCompleted, out var maxDepth);
|
||||||
|
|
||||||
|
|
||||||
SaveService.Save(new SaveData
|
SaveService.Save(new SaveData
|
||||||
{
|
{
|
||||||
GoalCharge = goal.Charge,
|
|
||||||
GoalTarget = goal.Target,
|
|
||||||
CoreCurrent = core.Current,
|
|
||||||
RunOutcome = outcome.Value,
|
|
||||||
RunsCompleted = runsCompleted,
|
RunsCompleted = runsCompleted,
|
||||||
MaxDepthReached = maxDepth,
|
MaxDepthReached = maxDepth,
|
||||||
MetaUpgrades = metaRows,
|
MetaUpgrades = metaRows,
|
||||||
|
|
||||||
Ledger = rows,
|
Ledger = rows,
|
||||||
Structures = structures,
|
Structures = structures,
|
||||||
StructureIo = structureIo,
|
|
||||||
SavedAtMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(),
|
SavedAtMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,79 +0,0 @@
|
|||||||
using ProjectM.Simulation;
|
|
||||||
using Unity.Burst;
|
|
||||||
using Unity.Collections;
|
|
||||||
using Unity.Entities;
|
|
||||||
using Unity.Mathematics;
|
|
||||||
using Unity.NetCode;
|
|
||||||
using Unity.Transforms;
|
|
||||||
|
|
||||||
namespace ProjectM.Server
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// END-1 — the Engine Core takes the hit a siege breaks through to. Server-only, plain
|
|
||||||
/// <see cref="SimulationSystemGroup"/> <c>[UpdateAfter(EnemyAISystem)]</c> so it reads each Husk's POST-move
|
|
||||||
/// position this tick (Husks are interpolated ghosts moved server-only by <see cref="EnemyAISystem"/>; the Core
|
|
||||||
/// integrity rides the GLOBAL CycleDirector ghost). Any living Husk within <see cref="CoreReachRadius"/> of the
|
|
||||||
/// base <see cref="BaseGridMath.PlotCenter"/> BREACHES: it drains <c>CoreDamagePerHusk</c> integrity and is
|
|
||||||
/// consumed (despawned via the ECB — at-most-once, each Husk visited once per tick). Pure planar XZ check
|
|
||||||
/// (<see cref="EnemyAIMath.InAttackRange"/>); the per-Husk damage is the live <see cref="TuningConfig"/> knob with
|
|
||||||
/// the baked fallback. Once <see cref="CoreIntegrity.Current"/> hits 0 this system idles — the SOFT-loss edge in
|
|
||||||
/// <see cref="ProjectM.Simulation"/>'s CyclePhaseSystem owns resolution (the locked DR-029 soft fork).
|
|
||||||
/// </summary>
|
|
||||||
[BurstCompile]
|
|
||||||
[WorldSystemFilter(WorldSystemFilterFlags.ServerSimulation)]
|
|
||||||
[UpdateInGroup(typeof(SimulationSystemGroup))]
|
|
||||||
[UpdateAfter(typeof(EnemyAISystem))]
|
|
||||||
public partial struct CoreDamageSystem : ISystem
|
|
||||||
{
|
|
||||||
/// <summary>How close (planar XZ) a Husk must get to the Engine Core to breach it. A STRUCTURAL reach radius
|
|
||||||
/// (not a per-session feel knob) — generous so a Husk pushing into the base interior reads as a breach.</summary>
|
|
||||||
const float CoreReachRadius = 3f;
|
|
||||||
|
|
||||||
[BurstCompile]
|
|
||||||
public void OnCreate(ref SystemState state)
|
|
||||||
{
|
|
||||||
state.RequireForUpdate<NetworkTime>();
|
|
||||||
state.RequireForUpdate<CoreIntegrity>();
|
|
||||||
state.RequireForUpdate<BaseAnchor>();
|
|
||||||
state.RequireForUpdate(state.GetEntityQuery(ComponentType.ReadOnly<EnemyTag>()));
|
|
||||||
}
|
|
||||||
|
|
||||||
[BurstCompile]
|
|
||||||
public void OnUpdate(ref SystemState state)
|
|
||||||
{
|
|
||||||
var coreEntity = SystemAPI.GetSingletonEntity<CoreIntegrity>();
|
|
||||||
var core = SystemAPI.GetComponent<CoreIntegrity>(coreEntity);
|
|
||||||
if (core.Current <= 0)
|
|
||||||
return; // already breached this beat; the lose-edge (CyclePhaseSystem) owns resolution.
|
|
||||||
|
|
||||||
// END-2: once the run is decided (Victory/Loss latched) the Core takes no more damage. Defensive — the
|
|
||||||
// siege already despawned its Husks on resolution; this mirrors the CoreRestoreSystem terminal-halt guard.
|
|
||||||
if (SystemAPI.TryGetSingleton<RunOutcome>(out var endOutcome) && endOutcome.Value != RunOutcomeId.InProgress)
|
|
||||||
return;
|
|
||||||
|
|
||||||
float3 corePos = BaseGridMath.PlotCenter(SystemAPI.GetSingleton<BaseAnchor>());
|
|
||||||
var tune = SystemAPI.TryGetSingleton<TuningConfig>(out var tcfg) ? tcfg : TuningConfig.Defaults();
|
|
||||||
int dmgPerHusk = (int)math.max(1f, tune.CoreDamagePerHusk);
|
|
||||||
|
|
||||||
var ecb = new EntityCommandBuffer(Allocator.Temp);
|
|
||||||
int drained = 0;
|
|
||||||
foreach (var (xform, entity) in
|
|
||||||
SystemAPI.Query<RefRO<LocalTransform>>().WithAll<EnemyTag>().WithNone<Dying>().WithEntityAccess()) // corpses don't drain the Core (B3)
|
|
||||||
{
|
|
||||||
if (!EnemyAIMath.InAttackRange(xform.ValueRO.Position, corePos, CoreReachRadius))
|
|
||||||
continue;
|
|
||||||
drained += dmgPerHusk;
|
|
||||||
ecb.DestroyEntity(entity); // a breaching Husk is consumed (each Husk visited once -> at-most-once)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (drained > 0)
|
|
||||||
{
|
|
||||||
core.Current = math.max(0, core.Current - drained);
|
|
||||||
SystemAPI.SetComponent(coreEntity, core);
|
|
||||||
}
|
|
||||||
|
|
||||||
ecb.Playback(state.EntityManager);
|
|
||||||
ecb.Dispose();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
fileFormatVersion: 2
|
|
||||||
guid: 9a3eeda43e19f1946abd8e74126c3a62
|
|
||||||
@@ -1,59 +0,0 @@
|
|||||||
using ProjectM.Simulation;
|
|
||||||
using Unity.Burst;
|
|
||||||
using Unity.Entities;
|
|
||||||
using Unity.Mathematics;
|
|
||||||
using Unity.NetCode;
|
|
||||||
|
|
||||||
namespace ProjectM.Server
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// END-1 — a chipped-but-survived Engine Core heals between sieges, so a breach is a SETBACK you recover from,
|
|
||||||
/// not a death spiral. Server-only, plain <see cref="SimulationSystemGroup"/>. Regenerates ONLY in
|
|
||||||
/// <see cref="CyclePhase.Calm"/> (no regen mid-Siege): +1 integrity every <c>CoreRegenIntervalTicks</c> server
|
|
||||||
/// ticks toward <see cref="CoreIntegrity.Max"/>. Deterministic + server-only (no rollback) so the plain
|
|
||||||
/// <c>now % interval</c> tick gate is safe (the server advances exactly one fixed tick per step). The interval is
|
|
||||||
/// the live <see cref="TuningConfig"/> knob with the baked fallback.
|
|
||||||
/// </summary>
|
|
||||||
[BurstCompile]
|
|
||||||
[WorldSystemFilter(WorldSystemFilterFlags.ServerSimulation)]
|
|
||||||
[UpdateInGroup(typeof(SimulationSystemGroup))]
|
|
||||||
public partial struct CoreRestoreSystem : ISystem
|
|
||||||
{
|
|
||||||
[BurstCompile]
|
|
||||||
public void OnCreate(ref SystemState state)
|
|
||||||
{
|
|
||||||
state.RequireForUpdate<NetworkTime>();
|
|
||||||
state.RequireForUpdate<CoreIntegrity>();
|
|
||||||
state.RequireForUpdate<CycleState>();
|
|
||||||
}
|
|
||||||
|
|
||||||
[BurstCompile]
|
|
||||||
public void OnUpdate(ref SystemState state)
|
|
||||||
{
|
|
||||||
// END-2: once the run is decided (Victory/Loss latched), the Core freezes at its terminal value (no regen).
|
|
||||||
if (SystemAPI.TryGetSingleton<RunOutcome>(out var endOutcome) && endOutcome.Value != RunOutcomeId.InProgress)
|
|
||||||
return;
|
|
||||||
|
|
||||||
if (SystemAPI.GetSingleton<CycleState>().Phase != CyclePhase.Calm)
|
|
||||||
return; // heal only between sieges
|
|
||||||
|
|
||||||
var coreEntity = SystemAPI.GetSingletonEntity<CoreIntegrity>();
|
|
||||||
var core = SystemAPI.GetComponent<CoreIntegrity>(coreEntity);
|
|
||||||
if (core.Current >= core.Max)
|
|
||||||
return;
|
|
||||||
|
|
||||||
var serverTick = SystemAPI.GetSingleton<NetworkTime>().ServerTick;
|
|
||||||
if (!serverTick.IsValid)
|
|
||||||
return;
|
|
||||||
uint now = serverTick.TickIndexForValidTick;
|
|
||||||
|
|
||||||
var tune = SystemAPI.TryGetSingleton<TuningConfig>(out var tcfg) ? tcfg : TuningConfig.Defaults();
|
|
||||||
uint interval = (uint)math.max(1f, tune.CoreRegenIntervalTicks);
|
|
||||||
if (now % interval != 0)
|
|
||||||
return;
|
|
||||||
|
|
||||||
core.Current = math.min(core.Max, core.Current + 1);
|
|
||||||
SystemAPI.SetComponent(coreEntity, core);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
fileFormatVersion: 2
|
|
||||||
guid: e3acd11f97b97d240b97e8c0ad096df7
|
|
||||||
@@ -8,13 +8,13 @@ using Unity.Transforms;
|
|||||||
namespace ProjectM.Server
|
namespace ProjectM.Server
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Server-only, one-shot spawner for the GLOBAL cycle-director ghost (mirrors SharedStorageSpawnSystem,
|
/// Server-only, one-shot spawner for the GLOBAL director ghost (mirrors SharedStorageSpawnSystem, but MINUS
|
||||||
/// but MINUS the RegionTag — the director must stay global so GhostRelevancy keeps it relevant to every
|
/// the RegionTag — the director must stay global so GhostRelevancy keeps it relevant to every region). On its
|
||||||
/// region). On its first update it reads the baked <see cref="CycleDirectorSpawner"/> + NetworkTime,
|
/// first update it reads the baked <see cref="CycleDirectorSpawner"/> + NetworkTime, instantiates the ghost
|
||||||
/// instantiates the ghost, initializes <see cref="CycleState"/> (Expedition, cycle 1, PhaseEndTick =
|
/// — the shared-ledger / RunInfo / meta host (its old cycle/siege/goal/core state is retired, LANTERN purge) —
|
||||||
/// now + the initial phase delay), adds the server-only <see cref="CycleRuntime"/>, and
|
/// applies a menu-staged save born-correct, and places it at the base center (preserving the prefab's baked
|
||||||
/// places it at the base center (preserving the prefab's baked LocalTransform scale — FromPosition would
|
/// LocalTransform scale — FromPosition would reset the replicated Scale GhostField), then destroys the
|
||||||
/// reset the replicated Scale GhostField), then destroys the spawner so it idles.
|
/// spawner so it idles.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[BurstCompile]
|
[BurstCompile]
|
||||||
[WorldSystemFilter(WorldSystemFilterFlags.ServerSimulation)]
|
[WorldSystemFilter(WorldSystemFilterFlags.ServerSimulation)]
|
||||||
@@ -49,24 +49,11 @@ namespace ProjectM.Server
|
|||||||
xform.Position = BaseGridMath.PlotCenter(anchor);
|
xform.Position = BaseGridMath.PlotCenter(anchor);
|
||||||
ecb.SetComponent(director, xform);
|
ecb.SetComponent(director, xform);
|
||||||
|
|
||||||
// Boot the run-state in Calm (the persistent default) — no timer; ThreatDirector arms sieges.
|
|
||||||
ecb.SetComponent(director, new CycleState
|
|
||||||
{
|
|
||||||
Phase = CyclePhase.Calm,
|
|
||||||
CycleNumber = 1,
|
|
||||||
PhaseEndTick = 0u,
|
|
||||||
});
|
|
||||||
ecb.AddComponent(director, new CycleRuntime { DefendStartWave = 0 });
|
|
||||||
ecb.AddComponent(director, new ThreatState());
|
|
||||||
// END-2: server-only run-phase marker (Normal until the goal cap arms the final siege). Added at
|
|
||||||
// spawn like CycleRuntime/ThreatState (never on the ghost serializer). RunOutcome is baked on the prefab.
|
|
||||||
ecb.AddComponent(director, new RunPhase { Value = RunPhaseId.Normal });
|
|
||||||
|
|
||||||
// Expedition redesign: run-FSM working state + the co-op route first-commit latch + the persisted
|
// Expedition redesign: run-FSM working state + the co-op route first-commit latch + the persisted
|
||||||
// meta counters — ALL added UNCONDITIONALLY at spawn (the CycleRuntime/ThreatState/RunPhase idiom;
|
// meta counters — ALL added UNCONDITIONALLY at spawn (D-F2: a New-Game boot must have the components
|
||||||
// D-F2: a New-Game boot must have the components the bank block reads; Continue restores VALUES only,
|
// the bank block reads; Continue restores VALUES only, inside the HasData block — Step 12b).
|
||||||
// inside the HasData block — Step 12b). HostSalt starts a fixed non-tick seed lineage (bumped per
|
// HostSalt starts a fixed non-tick seed lineage (bumped per launch); the save folds persisted
|
||||||
// launch); SaveData v6 folds persisted RunsCompleted in at restore so cross-session runs diverge.
|
// RunsCompleted in at restore so cross-session runs diverge.
|
||||||
ecb.AddComponent(director, new RunRuntime { HostSalt = 0x5EED0001u });
|
ecb.AddComponent(director, new RunRuntime { HostSalt = 0x5EED0001u });
|
||||||
ecb.AddComponent(director, default(RouteCommand));
|
ecb.AddComponent(director, default(RouteCommand));
|
||||||
ecb.AddComponent(director, default(PortalCommand)); // DR-046 room-exit portal interact latch
|
ecb.AddComponent(director, default(PortalCommand)); // DR-046 room-exit portal interact latch
|
||||||
@@ -74,47 +61,25 @@ namespace ProjectM.Server
|
|||||||
ecb.AddComponent(director, default(MetaCounters));
|
ecb.AddComponent(director, default(MetaCounters));
|
||||||
|
|
||||||
// Born-correct load: if the menu staged a save (Continue), apply it AT SPAWN so the director
|
// Born-correct load: if the menu staged a save (Continue), apply it AT SPAWN so the director
|
||||||
|
// ghost never serializes an empty ledger to clients (no replication flicker).
|
||||||
// DR-042 C6c: a NEW game seeds starting Ore below; a restored save (Continue) keeps its ledger.
|
// DR-042 C6c: a NEW game seeds starting Ore below; a restored save (Continue) keeps its ledger.
|
||||||
bool restoredLedger = false;
|
bool restoredLedger = false;
|
||||||
// ghost never serializes a default GoalProgress / empty ledger to clients (no replication flicker).
|
|
||||||
if (SystemAPI.TryGetSingletonEntity<PendingSave>(out var pendingEntity))
|
if (SystemAPI.TryGetSingletonEntity<PendingSave>(out var pendingEntity))
|
||||||
{
|
{
|
||||||
var pending = SystemAPI.GetComponent<PendingSave>(pendingEntity);
|
var pending = SystemAPI.GetComponent<PendingSave>(pendingEntity);
|
||||||
if (pending.HasData != 0)
|
if (pending.HasData != 0)
|
||||||
{
|
{
|
||||||
// END-2: clamp the restored Target to the baked run-length so a pre-v5 save carrying the old
|
|
||||||
// Target=10 still honours the slice's baked Target=4 (the final siege stays reachable).
|
|
||||||
int bakedTarget = SystemAPI.HasComponent<GoalProgress>(spawner.Prefab)
|
|
||||||
? SystemAPI.GetComponent<GoalProgress>(spawner.Prefab).Target : pending.GoalTarget;
|
|
||||||
int restoredTarget = pending.GoalTarget > 0 && pending.GoalTarget < bakedTarget
|
|
||||||
? pending.GoalTarget : bakedTarget;
|
|
||||||
ecb.SetComponent(director, new GoalProgress { Charge = pending.GoalCharge, Target = restoredTarget });
|
|
||||||
var srcLedger = SystemAPI.GetBuffer<PendingSaveLedgerRow>(pendingEntity);
|
var srcLedger = SystemAPI.GetBuffer<PendingSaveLedgerRow>(pendingEntity);
|
||||||
var destLedger = ecb.SetBuffer<StorageEntry>(director);
|
var destLedger = ecb.SetBuffer<StorageEntry>(director);
|
||||||
SaveApply.WriteLedger(srcLedger, destLedger);
|
SaveApply.WriteLedger(srcLedger, destLedger);
|
||||||
restoredLedger = true; // a save restored the ledger -> do NOT seed starting Ore (C6c)
|
restoredLedger = true; // a save restored the ledger -> do NOT seed starting Ore (C6c)
|
||||||
|
|
||||||
// END-1: born-correct the Engine Core. Max comes from the BAKED prefab (never the save); a
|
|
||||||
// persisted wounded Current (>0) restores clamped to Max, else (0 = pre-v4 save) born full.
|
|
||||||
if (SystemAPI.HasComponent<CoreIntegrity>(spawner.Prefab))
|
|
||||||
{
|
|
||||||
var bakedCore = SystemAPI.GetComponent<CoreIntegrity>(spawner.Prefab);
|
|
||||||
int restoredCore = pending.CoreCurrent > 0
|
|
||||||
? (pending.CoreCurrent < bakedCore.Max ? pending.CoreCurrent : bakedCore.Max)
|
|
||||||
: bakedCore.Max;
|
|
||||||
ecb.SetComponent(director, new CoreIntegrity { Current = restoredCore, Max = bakedCore.Max, OverrunTick = 0u });
|
|
||||||
}
|
|
||||||
|
|
||||||
// END-2: born-correct the terminal run outcome (a won/lost run loads finished + halted; a pre-v5
|
|
||||||
// save / New Game = 0 -> InProgress). Independent of the Core -> NOT nested in the CoreIntegrity guard.
|
|
||||||
ecb.SetComponent(director, new RunOutcome { Value = pending.RunOutcome });
|
|
||||||
|
|
||||||
// v6: restore the permanent meta — counters (VALUES only; the component was added
|
// v6: restore the permanent meta — counters (VALUES only; the component was added
|
||||||
// unconditionally above, D-F2), the tier record (SetBuffer replaces the baked-empty
|
// unconditionally above, D-F2), the tier record (SetBuffer replaces the baked-empty
|
||||||
// [GhostField] buffer pre-Playback — the StorageEntry idiom — rows VERBATIM incl. unknown
|
// [GhostField] buffer pre-Playback — the StorageEntry idiom — rows VERBATIM incl. unknown
|
||||||
// ids), the born-correct RunInfo HUD mirror (the spawn-time exception to RunDirector's
|
// ids), the born-correct RunInfo HUD mirror (the spawn-time exception to RunDirector's
|
||||||
// sole-writer rule, like CycleState/RunOutcome above), and the HostSalt fold (cross-session
|
// sole-writer rule), and the HostSalt fold (cross-session first-run maps diverge once
|
||||||
// first-run maps diverge once you've banked clears — the promise at the RunRuntime add).
|
// you've banked clears — the promise at the RunRuntime add).
|
||||||
ecb.SetComponent(director, new MetaCounters
|
ecb.SetComponent(director, new MetaCounters
|
||||||
{
|
{
|
||||||
RunsCompleted = pending.RunsCompleted,
|
RunsCompleted = pending.RunsCompleted,
|
||||||
@@ -140,13 +105,13 @@ namespace ProjectM.Server
|
|||||||
ecb.DestroyEntity(pendingEntity);
|
ecb.DestroyEntity(pendingEntity);
|
||||||
}
|
}
|
||||||
|
|
||||||
// DR-042 C6c: NEW game only (no restored ledger) -> seed a little Ore so the build loop isn't a cold
|
// DR-042 C6c: NEW game only (no restored ledger) -> seed a little Ore so the build loop isn't a
|
||||||
// deadlock (a turret needs Charge from a Fabricator that costs Ore you haven't mined yet). Appended
|
// cold start with nothing to place. Appended BEFORE Playback so the ghost first-serializes WITH
|
||||||
// BEFORE Playback so the ghost first-serializes WITH the seed (no empty-ledger replication flicker).
|
// the seed (no empty-ledger replication flicker).
|
||||||
if (!restoredLedger)
|
if (!restoredLedger)
|
||||||
ecb.AppendToBuffer(director, new StorageEntry { ItemId = ResourceId.Ore, Count = Tuning.StartingOre });
|
ecb.AppendToBuffer(director, new StorageEntry { ItemId = ResourceId.Ore, Count = Tuning.StartingOre });
|
||||||
|
|
||||||
// Host-only autosave flag; SaveWriteSystem consumes it on the Siege->Calm checkpoint.
|
// Host-only autosave flag; SaveWriteSystem consumes it (RunDirectorSystem raises it on bank).
|
||||||
ecb.AddComponent(director, new SaveRequest { Pending = 0 });
|
ecb.AddComponent(director, new SaveRequest { Pending = 0 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,195 +0,0 @@
|
|||||||
using ProjectM.Simulation;
|
|
||||||
using Unity.Burst;using Unity.Collections;
|
|
||||||
|
|
||||||
using Unity.Entities;
|
|
||||||
using Unity.Mathematics;
|
|
||||||
using Unity.NetCode;
|
|
||||||
|
|
||||||
namespace ProjectM.Server
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// 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)]
|
|
||||||
[UpdateInGroup(typeof(SimulationSystemGroup))]
|
|
||||||
[UpdateBefore(typeof(WaveSystem))]
|
|
||||||
public partial struct CyclePhaseSystem : ISystem
|
|
||||||
{
|
|
||||||
[BurstCompile]
|
|
||||||
public void OnCreate(ref SystemState state)
|
|
||||||
{
|
|
||||||
state.RequireForUpdate<NetworkTime>();
|
|
||||||
state.RequireForUpdate<CycleState>();
|
|
||||||
}
|
|
||||||
|
|
||||||
[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 runtime = SystemAPI.GetComponent<CycleRuntime>(cycleEntity);
|
|
||||||
|
|
||||||
if (cycle.Phase == CyclePhase.Calm)
|
|
||||||
{
|
|
||||||
// Default calm: no pending siege => no countdown.
|
|
||||||
cycle.PhaseEndTick = 0;
|
|
||||||
|
|
||||||
if (SystemAPI.HasComponent<ThreatState>(cycleEntity))
|
|
||||||
{
|
|
||||||
var threat = SystemAPI.GetComponent<ThreatState>(cycleEntity);
|
|
||||||
if (threat.PendingSiegeSize > 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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
else if (cycle.Phase == CyclePhase.Siege)
|
|
||||||
{
|
|
||||||
// END-2: is this the FINAL siege (the goal cap armed it)? Server-only RunPhase marker; HasComponent-
|
|
||||||
// guarded so EditMode worlds without RunPhase keep the pre-END-2 (normal) soft-loss + survival paths.
|
|
||||||
bool isFinal = SystemAPI.HasComponent<RunPhase>(cycleEntity)
|
|
||||||
&& SystemAPI.GetComponent<RunPhase>(cycleEntity).Value == RunPhaseId.FinalDefense;
|
|
||||||
|
|
||||||
// The Engine Core breached to 0 during the siege (checked BEFORE survival). CyclePhaseSystem stays the
|
|
||||||
// sole Phase/WaveState writer; it is ALSO the sole RunOutcome writer (END-2 single-writer).
|
|
||||||
bool overrun = SystemAPI.HasComponent<CoreIntegrity>(cycleEntity)
|
|
||||||
&& SystemAPI.GetComponent<CoreIntegrity>(cycleEntity).Current <= 0;
|
|
||||||
if (overrun)
|
|
||||||
{
|
|
||||||
cycle.Phase = CyclePhase.Calm;
|
|
||||||
cycle.PhaseEndTick = 0;
|
|
||||||
|
|
||||||
// The siege ends: despawn the base siege Husks (the locked despawn-on-breach fork) + reset the
|
|
||||||
// wave so the NEXT armed siege starts clean (WaveSystem idles in Calm anyway). Shared by both paths.
|
|
||||||
var ecb = new EntityCommandBuffer(Allocator.Temp);
|
|
||||||
// Slice 3: cull the BASE wave only — an Expedition wave runs in its own region and must
|
|
||||||
// survive a base Core breach. A region-blind EnemyTag wipe would also spuriously trip the
|
|
||||||
// zone director's aliveZone==0 clear/reward edge. Mirrors ThreatDirectorSystem + DefendCleared.
|
|
||||||
foreach (var (hr, he) in SystemAPI.Query<RefRO<RegionTag>>().WithAll<EnemyTag>().WithNone<Dying>().WithEntityAccess()) // skip corpses: the B3 expiry pass owns their destroy (cross-ECB double-destroy)
|
|
||||||
if (hr.ValueRO.Region == RegionId.Base)
|
|
||||||
ecb.DestroyEntity(he);
|
|
||||||
ecb.Playback(state.EntityManager);
|
|
||||||
ecb.Dispose();
|
|
||||||
if (SystemAPI.TryGetSingletonEntity<WaveState>(out var waveLost))
|
|
||||||
{
|
|
||||||
var wl = SystemAPI.GetComponent<WaveState>(waveLost);
|
|
||||||
wl.RemainingToSpawn = 0;
|
|
||||||
wl.Phase = WavePhase.Lull;
|
|
||||||
wl.NextActionTick = 0;
|
|
||||||
SystemAPI.SetComponent(waveLost, wl);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isFinal)
|
|
||||||
{
|
|
||||||
// END-2 TERMINAL LOSS: the final stand fell. Latch Loss + halt (the director stops arming). NO
|
|
||||||
// ledger drain and NO OverrunTick stamp -> the client shows the dedicated terminal Loss banner
|
|
||||||
// (from the replicated RunOutcome), not the soft "the Core will recover" flash.
|
|
||||||
SystemAPI.SetComponent(cycleEntity, new RunOutcome { Value = RunOutcomeId.Loss });
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
// END-1 SOFT LOSS (unchanged): drain a fraction of the shared ledger + stamp the transient
|
|
||||||
// overrun pulse; the base persists wounded and the Core regenerates in Calm (the DR-029 fork).
|
|
||||||
var tuneL = SystemAPI.TryGetSingleton<TuningConfig>(out var tcfgL) ? tcfgL : TuningConfig.Defaults();
|
|
||||||
if (SystemAPI.HasBuffer<StorageEntry>(cycleEntity))
|
|
||||||
{
|
|
||||||
var ledger = SystemAPI.GetBuffer<StorageEntry>(cycleEntity);
|
|
||||||
StorageMath.DrainFraction(ledger, tuneL.CoreOverrunDrainPct);
|
|
||||||
}
|
|
||||||
var coreL = SystemAPI.GetComponent<CoreIntegrity>(cycleEntity);
|
|
||||||
coreL.OverrunTick = TickUtil.NonZero(now);
|
|
||||||
SystemAPI.SetComponent(cycleEntity, coreL);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Autosave the checkpoint (a breach / final loss is a meaningful save point).
|
|
||||||
if (SystemAPI.HasComponent<SaveRequest>(cycleEntity))
|
|
||||||
SystemAPI.SetComponent(cycleEntity, new SaveRequest { Pending = 1 });
|
|
||||||
}
|
|
||||||
else if (DefendCleared(ref state, runtime.DefendStartWave))
|
|
||||||
{
|
|
||||||
cycle.Phase = CyclePhase.Calm;
|
|
||||||
cycle.PhaseEndTick = 0;
|
|
||||||
if (isFinal)
|
|
||||||
{
|
|
||||||
// END-2 TERMINAL WIN: the final siege was survived -> the Engine holds. Latch Victory + halt;
|
|
||||||
// do NOT increment the (already-capped) goal.
|
|
||||||
SystemAPI.SetComponent(cycleEntity, new RunOutcome { Value = RunOutcomeId.Victory });
|
|
||||||
if (SystemAPI.HasComponent<SaveRequest>(cycleEntity))
|
|
||||||
SystemAPI.SetComponent(cycleEntity, new SaveRequest { Pending = 1 });
|
|
||||||
}
|
|
||||||
// DR-042: a SURVIVED base siege no longer advances the win meter — that was the AFK/passive win
|
|
||||||
// path (scheduled sieges auto-armed + auto-collapsed on timeout, so standing still won). The win-
|
|
||||||
// driver moved to EXPEDITION CLEARS: GoalProgress.Charge is now credited per cleared expedition by
|
|
||||||
// ExpeditionGateSystem on the player's RETURN. Surviving a normal siege is still its own reward
|
|
||||||
// (resources kept, Core intact) but is not progress toward Victory. The final-siege Victory latch
|
|
||||||
// above is unchanged — GoalReachedSystem still arms the climactic final siege once Charge hits Target.
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Surface the live wave number on the replicated CycleState for the HUD (single writer).
|
|
||||||
if (SystemAPI.TryGetSingleton<WaveState>(out var waveSync))
|
|
||||||
cycle.WaveNumber = waveSync.WaveNumber;
|
|
||||||
|
|
||||||
SystemAPI.SetComponent(cycleEntity, cycle);
|
|
||||||
SystemAPI.SetComponent(cycleEntity, runtime);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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))
|
|
||||||
return false;
|
|
||||||
// Cleared only when no BASE husk remains: expedition zone enemies (EnemyTag + RegionTag{Expedition})
|
|
||||||
// must not hold the base siege open (DR-040 BLOCKER 3 — same global-count soft-lock as WaveSystem).
|
|
||||||
int baseHusks = 0;
|
|
||||||
foreach (var hr in SystemAPI.Query<RefRO<RegionTag>>().WithAll<EnemyTag>().WithNone<Dying>()) // LIVING only (B3)
|
|
||||||
if (hr.ValueRO.Region == RegionId.Base) baseHusks++;
|
|
||||||
return wave.WaveNumber > defendStartWave
|
|
||||||
&& wave.RemainingToSpawn == 0
|
|
||||||
&& baseHusks == 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
fileFormatVersion: 2
|
|
||||||
guid: c325c252dce9fba4a938d5c8db903042
|
|
||||||
@@ -1,90 +0,0 @@
|
|||||||
using ProjectM.Simulation;
|
|
||||||
using Unity.Burst;
|
|
||||||
using Unity.Entities;
|
|
||||||
using Unity.Mathematics;
|
|
||||||
using Unity.NetCode;
|
|
||||||
|
|
||||||
namespace ProjectM.Server
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// END-2 — arms the FINAL siege when the long-arc goal meter fills. Server-only, plain
|
|
||||||
/// <see cref="SimulationSystemGroup"/>, <c>[UpdateAfter(CyclePhaseSystem)]</c> so it reads
|
|
||||||
/// <see cref="GoalProgress.Charge"/> AFTER the survived-siege increment that may have just reached Target.
|
|
||||||
/// On the <c>Charge >= Target</c> rising edge — guarded by <see cref="RunPhaseId.Normal"/> +
|
|
||||||
/// <see cref="RunOutcomeId.InProgress"/> so it fires EXACTLY once — it:
|
|
||||||
/// <list type="bullet">
|
|
||||||
/// <item>arms a bigger siege through the existing single entry point <see cref="ThreatState.PendingSiegeSize"/>:
|
|
||||||
/// the would-be-next normal siege size (<c>SizeBase + ScheduleSizePerWave*wave</c>) times the live
|
|
||||||
/// <see cref="TuningConfig.FinalSiegeMultiplier"/> (floored at 1 so the final siege is never smaller), telegraphed
|
|
||||||
/// via <see cref="ThreatState.ArmTick"/> (wrap-safe <see cref="TickUtil.NonZero"/>);</item>
|
|
||||||
/// <item>flips <see cref="RunPhase"/> to <see cref="RunPhaseId.FinalDefense"/>.</item>
|
|
||||||
/// </list>
|
|
||||||
/// It NEVER writes <see cref="CycleState"/>.Phase / <c>WaveState</c> (CyclePhaseSystem stays the sole writer) nor
|
|
||||||
/// <see cref="GoalProgress"/>.Charge (CyclePhaseSystem clamps it at the increment site) — it only READS the edge.
|
|
||||||
/// CyclePhaseSystem then consumes <see cref="ThreatState.PendingSiegeSize"/> the next tick exactly like any other
|
|
||||||
/// armed siege; <c>ThreatDirectorSystem</c> stops arming once <see cref="RunPhase"/> leaves Normal, so no normal
|
|
||||||
/// siege can stomp the final one. Plain server group => one run per tick, no rollback/predicted exposure.
|
|
||||||
/// Bytes, never enums (Burst-safe).
|
|
||||||
/// </summary>
|
|
||||||
[BurstCompile]
|
|
||||||
[WorldSystemFilter(WorldSystemFilterFlags.ServerSimulation)]
|
|
||||||
[UpdateInGroup(typeof(SimulationSystemGroup))]
|
|
||||||
[UpdateAfter(typeof(CyclePhaseSystem))]
|
|
||||||
public partial struct GoalReachedSystem : ISystem
|
|
||||||
{
|
|
||||||
[BurstCompile]
|
|
||||||
public void OnCreate(ref SystemState state)
|
|
||||||
{
|
|
||||||
state.RequireForUpdate<NetworkTime>();
|
|
||||||
state.RequireForUpdate<CycleState>();
|
|
||||||
state.RequireForUpdate<RunPhase>();
|
|
||||||
}
|
|
||||||
|
|
||||||
[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>();
|
|
||||||
|
|
||||||
// Exactly-once guards: a decided run, or one already in the final siege, arms nothing.
|
|
||||||
if (SystemAPI.HasComponent<RunOutcome>(cycleEntity)
|
|
||||||
&& SystemAPI.GetComponent<RunOutcome>(cycleEntity).Value != RunOutcomeId.InProgress)
|
|
||||||
return;
|
|
||||||
var runPhase = SystemAPI.GetComponent<RunPhase>(cycleEntity);
|
|
||||||
if (runPhase.Value != RunPhaseId.Normal)
|
|
||||||
return;
|
|
||||||
|
|
||||||
// Goal cap reached? (Charge is clamped to Target at the CyclePhaseSystem increment site.)
|
|
||||||
if (!SystemAPI.HasComponent<GoalProgress>(cycleEntity))
|
|
||||||
return;
|
|
||||||
var goal = SystemAPI.GetComponent<GoalProgress>(cycleEntity);
|
|
||||||
if (goal.Target <= 0 || goal.Charge < goal.Target)
|
|
||||||
return;
|
|
||||||
|
|
||||||
if (!SystemAPI.HasComponent<ThreatState>(cycleEntity) || !SystemAPI.HasComponent<ThreatConfig>(cycleEntity))
|
|
||||||
return;
|
|
||||||
var threat = SystemAPI.GetComponent<ThreatState>(cycleEntity);
|
|
||||||
var config = SystemAPI.GetComponent<ThreatConfig>(cycleEntity);
|
|
||||||
|
|
||||||
int wave = SystemAPI.TryGetSingleton<WaveState>(out var ws) ? ws.WaveNumber : 0;
|
|
||||||
float mult = math.max(1f, SystemAPI.TryGetSingleton<TuningConfig>(out var tcfg)
|
|
||||||
? tcfg.FinalSiegeMultiplier
|
|
||||||
: TuningConfig.Defaults().FinalSiegeMultiplier);
|
|
||||||
int normalSize = config.SizeBase + config.ScheduleSizePerWave * wave;
|
|
||||||
int finalSize = math.max(1, (int)(normalSize * mult));
|
|
||||||
|
|
||||||
// Arm the final siege (overwrites any pending normal siege — the final supersedes; at the goal-reach tick
|
|
||||||
// PendingSiegeSize is 0 anyway, the just-cleared siege having consumed it). CyclePhaseSystem consumes it.
|
|
||||||
threat.PendingSiegeSize = finalSize;
|
|
||||||
threat.ArmTick = TickUtil.NonZero(now + config.PostExpeditionDelayTicks);
|
|
||||||
SystemAPI.SetComponent(cycleEntity, threat);
|
|
||||||
|
|
||||||
runPhase.Value = RunPhaseId.FinalDefense;
|
|
||||||
SystemAPI.SetComponent(cycleEntity, runPhase);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
fileFormatVersion: 2
|
|
||||||
guid: 472c137c49b85e141b0ee00b1d1fa076
|
|
||||||
@@ -10,8 +10,7 @@ namespace ProjectM.Server
|
|||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// SOLE writer of the replicated run-lifecycle FSM (<see cref="RunInfo"/>) and its server-only working state
|
/// SOLE writer of the replicated run-lifecycle FSM (<see cref="RunInfo"/>) and its server-only working state
|
||||||
/// (<see cref="RunRuntime"/>) — the expedition redesign's counterpart of CyclePhaseSystem's single-writer
|
/// (<see cref="RunRuntime"/>).
|
||||||
/// discipline (that system stays the sole writer of the BASE Calm↔Siege posture; the two FSMs are distinct).
|
|
||||||
///
|
///
|
||||||
/// Step-7 = the REAL LINEAR traversal: Staging (ready-check) → Launching (3-2-1 telegraph, un-ready aborts) →
|
/// Step-7 = the REAL LINEAR traversal: Staging (ready-check) → Launching (3-2-1 telegraph, un-ready aborts) →
|
||||||
/// InRoom (fight; the clear edge arrives as the replicated <see cref="ExpeditionObjective"/>.State == Cleared,
|
/// InRoom (fight; the clear edge arrives as the replicated <see cref="ExpeditionObjective"/>.State == Cleared,
|
||||||
@@ -24,20 +23,12 @@ namespace ProjectM.Server
|
|||||||
///
|
///
|
||||||
/// The terminal bank (once per RunEpoch, equality-latched): ALWAYS records the honest depth
|
/// The terminal bank (once per RunEpoch, equality-latched): ALWAYS records the honest depth
|
||||||
/// (max(MaxDepthReached, RoomsClearedThisRun) — never the planned RoomCount) and re-stages; ONLY a genuine
|
/// (max(MaxDepthReached, RoomsClearedThisRun) — never the planned RoomCount) and re-stages; ONLY a genuine
|
||||||
/// boss-clear terminal (<see cref="RunRuntime.LastTerminalCleared"/>) credits the win meter
|
/// boss-clear terminal (<see cref="RunRuntime.LastTerminalCleared"/>) credits RunsCompleted and requests a
|
||||||
/// (<see cref="GoalProgress"/>.Charge, clamped), RunsCompleted, the retaliation inputs
|
/// save. An abort/wipe banks NOTHING but the depth high-water (D-F3).
|
||||||
/// (<see cref="ThreatState"/>.PendingReturns/ExpeditionsCompleted — carried from the retired gate, C7) and
|
|
||||||
/// requests a save. An abort/wipe banks NOTHING but the depth high-water (D-F3).
|
|
||||||
///
|
|
||||||
/// Ordering: <c>[UpdateBefore(CyclePhaseSystem)]</c> ONLY (GoalReachedSystem is [UpdateAfter(CyclePhaseSystem)] —
|
|
||||||
/// transitively after this system, so the Charge credit lands before it reads the edge). Per the hard rule,
|
|
||||||
/// NOTHING in the room chain adds another CyclePhase edge (a sort cycle is invisible to EditMode and throws only
|
|
||||||
/// at Play world creation).
|
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[BurstCompile]
|
[BurstCompile]
|
||||||
[WorldSystemFilter(WorldSystemFilterFlags.ServerSimulation)]
|
[WorldSystemFilter(WorldSystemFilterFlags.ServerSimulation)]
|
||||||
[UpdateInGroup(typeof(SimulationSystemGroup))]
|
[UpdateInGroup(typeof(SimulationSystemGroup))]
|
||||||
[UpdateBefore(typeof(CyclePhaseSystem))]
|
|
||||||
public partial struct RunDirectorSystem : ISystem
|
public partial struct RunDirectorSystem : ISystem
|
||||||
{
|
{
|
||||||
/// <summary>"All ready → 3-2-1 → go" telegraph (~3 s @ 60). An un-ready during the countdown aborts.</summary>
|
/// <summary>"All ready → 3-2-1 → go" telegraph (~3 s @ 60). An un-ready during the countdown aborts.</summary>
|
||||||
@@ -95,15 +86,7 @@ namespace ProjectM.Server
|
|||||||
{
|
{
|
||||||
case RunLifecycle.Staging:
|
case RunLifecycle.Staging:
|
||||||
{
|
{
|
||||||
// F2 cross-FSM launch guard: no new run while a final siege arms/runs or the outcome latched.
|
if (allReady && run.WasAllReady == 0)
|
||||||
// Guards default OPEN when the server-only markers are absent (EditMode worlds).
|
|
||||||
bool launchAllowed =
|
|
||||||
(!SystemAPI.HasComponent<RunPhase>(dirEntity)
|
|
||||||
|| SystemAPI.GetComponent<RunPhase>(dirEntity).Value == RunPhaseId.Normal)
|
|
||||||
&& (!SystemAPI.HasComponent<RunOutcome>(dirEntity)
|
|
||||||
|| SystemAPI.GetComponent<RunOutcome>(dirEntity).Value == RunOutcomeId.InProgress);
|
|
||||||
|
|
||||||
if (allReady && run.WasAllReady == 0 && launchAllowed)
|
|
||||||
{
|
{
|
||||||
// Rising edge → Launching. Seed the run: monotonic epoch + per-playthrough salt lineage,
|
// Rising edge → Launching. Seed the run: monotonic epoch + per-playthrough salt lineage,
|
||||||
// never a tick, never 0, equality-compared downstream.
|
// never a tick, never 0, equality-compared downstream.
|
||||||
@@ -364,25 +347,9 @@ case RunLifecycle.RouteSelect:
|
|||||||
info.MaxDepthReached = meta.MaxDepthReached; // HUD mirror
|
info.MaxDepthReached = meta.MaxDepthReached; // HUD mirror
|
||||||
}
|
}
|
||||||
|
|
||||||
// Boss-clear only: the win meter, the retaliation inputs (C7), and a save checkpoint.
|
// Boss-clear only: a save checkpoint (the win-meter/retaliation credits are retired — LANTERN purge).
|
||||||
if (run.LastTerminalCleared != 0)
|
if (run.LastTerminalCleared != 0 && SystemAPI.HasComponent<SaveRequest>(dirEntity))
|
||||||
{
|
SystemAPI.SetComponent(dirEntity, new SaveRequest { Pending = 1 });
|
||||||
if (SystemAPI.HasComponent<GoalProgress>(dirEntity))
|
|
||||||
{
|
|
||||||
var goal = SystemAPI.GetComponent<GoalProgress>(dirEntity);
|
|
||||||
goal.Charge = math.min(goal.Charge + 1, goal.Target);
|
|
||||||
SystemAPI.SetComponent(dirEntity, goal);
|
|
||||||
}
|
|
||||||
if (SystemAPI.HasComponent<ThreatState>(dirEntity))
|
|
||||||
{
|
|
||||||
var threat = SystemAPI.GetComponent<ThreatState>(dirEntity);
|
|
||||||
threat.PendingReturns += 1;
|
|
||||||
threat.ExpeditionsCompleted += 1;
|
|
||||||
SystemAPI.SetComponent(dirEntity, threat);
|
|
||||||
}
|
|
||||||
if (SystemAPI.HasComponent<SaveRequest>(dirEntity))
|
|
||||||
SystemAPI.SetComponent(dirEntity, new SaveRequest { Pending = 1 });
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// TWO-CHANNEL strip (DR-037): run boons EXPIRE at home — one range-strip clears every
|
// TWO-CHANNEL strip (DR-037): run boons EXPIRE at home — one range-strip clears every
|
||||||
|
|||||||
@@ -1,138 +0,0 @@
|
|||||||
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 completed RUN (banked on the boss-clear
|
|
||||||
/// return by <see cref="RunDirectorSystem"/> into <see cref="ThreatState.PendingReturns"/> — the retired
|
|
||||||
/// walk-in ExpeditionGateSystem's carry, Step 11) 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(RunDirectorSystem))]
|
|
||||||
[UpdateBefore(typeof(CyclePhaseSystem))]
|
|
||||||
public partial struct ThreatDirectorSystem : ISystem
|
|
||||||
{
|
|
||||||
|
|
||||||
[BurstCompile]
|
|
||||||
public void OnCreate(ref SystemState state)
|
|
||||||
{
|
|
||||||
state.RequireForUpdate<NetworkTime>();
|
|
||||||
state.RequireForUpdate<CycleState>();
|
|
||||||
state.RequireForUpdate<ThreatState>();
|
|
||||||
state.RequireForUpdate<ThreatConfig>();
|
|
||||||
}
|
|
||||||
|
|
||||||
[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);
|
|
||||||
// END-2: a decided run (Victory/Loss) or one already in the FINAL siege arms NO further sieges. The
|
|
||||||
// SiegeTimeout cull is also disabled during the final siege (a cull -> false Victory). Guarded with
|
|
||||||
// HasComponent so EditMode worlds without RunPhase/RunOutcome keep the pre-END-2 behaviour.
|
|
||||||
byte runPhase = SystemAPI.HasComponent<RunPhase>(cycleEntity)
|
|
||||||
? SystemAPI.GetComponent<RunPhase>(cycleEntity).Value : RunPhaseId.Normal;
|
|
||||||
byte runOutcome = SystemAPI.HasComponent<RunOutcome>(cycleEntity)
|
|
||||||
? SystemAPI.GetComponent<RunOutcome>(cycleEntity).Value : RunOutcomeId.InProgress;
|
|
||||||
bool canArm = runPhase == RunPhaseId.Normal && runOutcome == RunOutcomeId.InProgress;
|
|
||||||
|
|
||||||
|
|
||||||
// ---- 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 && canArm)
|
|
||||||
{
|
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- SOURCE: scheduled base sieges. A timed cadence arms a siege even with NO expedition trip, so
|
|
||||||
// the base-defense loop has stakes on its own. The first fire is one full interval out (a mine/build
|
|
||||||
// grace window); size escalates by the live wave number. All ticks wrap-safe (TickUtil.NonZero). ----
|
|
||||||
if (config.ScheduleEnabled != 0 && config.ScheduleIntervalTicks > 0)
|
|
||||||
{
|
|
||||||
if (threat.NextScheduledTick == 0 || cycle.Phase != CyclePhase.Calm)
|
|
||||||
{
|
|
||||||
// Seed, and DEFER while a siege runs, so the next scheduled siege is always one full interval
|
|
||||||
// AFTER the current one resolves -> a guaranteed calm/build window even if a siege runs long.
|
|
||||||
threat.NextScheduledTick = TickUtil.NonZero(now + config.ScheduleIntervalTicks);
|
|
||||||
}
|
|
||||||
else if (cycle.Phase == CyclePhase.Calm && threat.PendingSiegeSize == 0 && canArm
|
|
||||||
&& !new NetworkTick(threat.NextScheduledTick).IsNewerThan(serverTick))
|
|
||||||
{
|
|
||||||
int wave = SystemAPI.TryGetSingleton<WaveState>(out var ws) ? ws.WaveNumber : 0;
|
|
||||||
threat.PendingSiegeSize = math.max(1, config.SizeBase + config.ScheduleSizePerWave * wave);
|
|
||||||
threat.ArmTick = TickUtil.NonZero(now + config.PostExpeditionDelayTicks);
|
|
||||||
threat.NextScheduledTick = TickUtil.NonZero(now + config.ScheduleIntervalTicks);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- 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 && runPhase != RunPhaseId.FinalDefense)
|
|
||||||
{
|
|
||||||
var start = new NetworkTick(threat.SiegeStartTick);
|
|
||||||
if (start.IsValid && serverTick.TicksSince(start) > (int)config.SiegeTimeoutTicks)
|
|
||||||
{
|
|
||||||
// Collapse the siege: cull every remaining BASE Husk only (expedition zone enemies are also
|
|
||||||
// EnemyTag but RegionTag{Expedition}; the timeout must not destroy them — DR-040 BLOCKER 3).
|
|
||||||
var ecb = new EntityCommandBuffer(Allocator.Temp);
|
|
||||||
foreach (var (hr, he) in SystemAPI.Query<RefRO<RegionTag>>().WithAll<EnemyTag>().WithNone<Dying>().WithEntityAccess()) // skip corpses: the B3 expiry pass owns their destroy
|
|
||||||
if (hr.ValueRO.Region == RegionId.Base)
|
|
||||||
ecb.DestroyEntity(he);
|
|
||||||
ecb.Playback(state.EntityManager);
|
|
||||||
ecb.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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
fileFormatVersion: 2
|
|
||||||
guid: 3cd1beb28c2b1f84398722a95d1ee784
|
|
||||||
@@ -25,17 +25,17 @@ namespace ProjectM.Simulation
|
|||||||
/// <summary>Opcodes for <see cref="DebugCommandRequest.Op"/> (bytes — never an enum on the wire).</summary>
|
/// <summary>Opcodes for <see cref="DebugCommandRequest.Op"/> (bytes — never an enum on the wire).</summary>
|
||||||
public static class DebugOp
|
public static class DebugOp
|
||||||
{
|
{
|
||||||
/// <summary>Arm + immediately fire a siege of ArgA Husks (ArgA = size).</summary>
|
/// <summary>Force the NEXT wave to start this tick (re-meant from the old arm-a-siege op; args unused).</summary>
|
||||||
public const byte SpawnWave = 0;
|
public const byte SpawnWave = 0;
|
||||||
|
|
||||||
/// <summary>Collapse the current siege (cull Husks, stop spawning, clear pending) -> back to Calm.</summary>
|
/// <summary>Quiet the arena: cull Husks + push the next wave ~1 h out (re-meant from the old end-siege op).</summary>
|
||||||
public const byte EndSiege = 1;
|
public const byte EndSiege = 1;
|
||||||
|
|
||||||
/// <summary>Cull every living Husk now (leaves the phase alone).</summary>
|
/// <summary>Cull every living Husk now (leaves wave state alone).</summary>
|
||||||
public const byte ClearEnemies = 2;
|
public const byte ClearEnemies = 2;
|
||||||
|
|
||||||
/// <summary>Hard-reset the run-state to Calm (clears any siege/pending).</summary>
|
// RETIRED (LANTERN purge — keep the byte values reserved, never renumber a wire byte):
|
||||||
public const byte SetCalm = 3;
|
// 3 = SetCalm · 10 = AdvanceGoal · 11 = SetHeat
|
||||||
|
|
||||||
/// <summary>Deposit ArgB of resource ArgA (a <see cref="ResourceId"/>) into the shared ledger.</summary>
|
/// <summary>Deposit ArgB of resource ArgA (a <see cref="ResourceId"/>) into the shared ledger.</summary>
|
||||||
public const byte GrantResource = 4;
|
public const byte GrantResource = 4;
|
||||||
@@ -55,11 +55,6 @@ namespace ProjectM.Simulation
|
|||||||
/// <summary>Kill the sender (Health -> 0; the normal death/respawn loop takes over).</summary>
|
/// <summary>Kill the sender (Health -> 0; the normal death/respawn loop takes over).</summary>
|
||||||
public const byte KillPlayer = 9;
|
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;
|
|
||||||
|
|
||||||
/// <summary>Set the <see cref="TuningKnob"/> ArgA to ArgB/1000f (live dash/Charger feel-tuning; MC-0).</summary>
|
/// <summary>Set the <see cref="TuningKnob"/> ArgA to ArgB/1000f (live dash/Charger feel-tuning; MC-0).</summary>
|
||||||
public const byte SetTuning = 12;
|
public const byte SetTuning = 12;
|
||||||
|
|||||||
@@ -50,16 +50,6 @@ namespace ProjectM.Simulation
|
|||||||
// preferred targets); a closer player 'in the way' still wins. Read server-side by EnemyAISystem.
|
// preferred targets); a closer player 'in the way' still wins. Read server-side by EnemyAISystem.
|
||||||
public float StructureAggroWeight;
|
public float StructureAggroWeight;
|
||||||
|
|
||||||
// END-1 Engine Core (live feel knobs; read server-side by CoreDamageSystem/CoreRestoreSystem/CyclePhaseSystem).
|
|
||||||
// CoreDamagePerHusk + CoreOverrunDrainPct are value knobs (>=0); CoreRegenIntervalTicks is a tick knob (>=1).
|
|
||||||
public float CoreDamagePerHusk; // integrity drained by one breaching Husk (~5 unintercepted = serious dent)
|
|
||||||
public float CoreRegenIntervalTicks; // ticks between +1 regen in Calm (18 -> +1/0.3s -> ~full over one short Calm)
|
|
||||||
public float CoreOverrunDrainPct; // fraction (0..1) of the shared ledger lost on a breach (soft-loss penalty)
|
|
||||||
|
|
||||||
// END-2 final siege: the would-be-next normal siege size is multiplied by this for the FINAL siege so the
|
|
||||||
// climax reads visibly larger. Floors at 1 (the default ClampKnob bucket) — a final siege is never smaller
|
|
||||||
// than a normal one; GoalReachedSystem also math.max(1, ...) at the use-site.
|
|
||||||
public float FinalSiegeMultiplier;
|
|
||||||
|
|
||||||
// Phase 1 B1/B2 (design review wf_fd177263): poise threshold on the EXISTING KnockbackState.Speed channel
|
// Phase 1 B1/B2 (design review wf_fd177263): poise threshold on the EXISTING KnockbackState.Speed channel
|
||||||
// (light melee 6 nudges; finisher 6x1.8=10.8 and cone 8 stagger), and the separation pass's push-speed cap.
|
// (light melee 6 nudges; finisher 6x1.8=10.8 and cone 8 stagger), and the separation pass's push-speed cap.
|
||||||
@@ -89,10 +79,6 @@ namespace ProjectM.Simulation
|
|||||||
MeleeFinisherMult = 1.8f, // finisher (last hit) scales dmg/range/recover/knockback
|
MeleeFinisherMult = 1.8f, // finisher (last hit) scales dmg/range/recover/knockback
|
||||||
MeleeComboLength = 3f, // light, light, finisher
|
MeleeComboLength = 3f, // light, light, finisher
|
||||||
StructureAggroWeight = 0.7f, // EB-1: <1 prefers structures (fortress aggro); live-tunable
|
StructureAggroWeight = 0.7f, // EB-1: <1 prefers structures (fortress aggro); live-tunable
|
||||||
CoreDamagePerHusk = 10f, // END-1: 10 breaching Husks = full loss; ~5 = a serious dent
|
|
||||||
CoreRegenIntervalTicks = 18f, // END-1: +1 integrity / 0.3s in Calm (~30s to refill 100 from 0)
|
|
||||||
CoreOverrunDrainPct = 0.5f, // END-1: a breach costs half the shared ledger (soft-loss penalty)
|
|
||||||
FinalSiegeMultiplier = 2.5f, // END-2: the final siege is ~2.5x the would-be-next normal siege
|
|
||||||
StaggerKnockbackSpeed = 7f, // B2 poise: kb.Speed >= this interrupts windups/lunges; below = nudge only
|
StaggerKnockbackSpeed = 7f, // B2 poise: kb.Speed >= this interrupts windups/lunges; below = nudge only
|
||||||
SeparationMaxSpeed = 3f, // B1: max separation push (units/s) so soft-collision can't fling
|
SeparationMaxSpeed = 3f, // B1: max separation push (units/s) so soft-collision can't fling
|
||||||
};
|
};
|
||||||
@@ -114,8 +100,6 @@ namespace ProjectM.Simulation
|
|||||||
case TuningKnob.MeleeKnockbackSpeed:
|
case TuningKnob.MeleeKnockbackSpeed:
|
||||||
case TuningKnob.MeleeFinisherMult:
|
case TuningKnob.MeleeFinisherMult:
|
||||||
case TuningKnob.StructureAggroWeight:
|
case TuningKnob.StructureAggroWeight:
|
||||||
case TuningKnob.CoreDamagePerHusk:
|
|
||||||
case TuningKnob.CoreOverrunDrainPct:
|
|
||||||
case TuningKnob.StaggerKnockbackSpeed:
|
case TuningKnob.StaggerKnockbackSpeed:
|
||||||
case TuningKnob.SeparationMaxSpeed:
|
case TuningKnob.SeparationMaxSpeed:
|
||||||
return math.max(0f, value);
|
return math.max(0f, value);
|
||||||
@@ -152,10 +136,6 @@ namespace ProjectM.Simulation
|
|||||||
case TuningKnob.MeleeFinisherMult: c.MeleeFinisherMult = value; break;
|
case TuningKnob.MeleeFinisherMult: c.MeleeFinisherMult = value; break;
|
||||||
case TuningKnob.MeleeComboLength: c.MeleeComboLength = value; break;
|
case TuningKnob.MeleeComboLength: c.MeleeComboLength = value; break;
|
||||||
case TuningKnob.StructureAggroWeight: c.StructureAggroWeight = value; break;
|
case TuningKnob.StructureAggroWeight: c.StructureAggroWeight = value; break;
|
||||||
case TuningKnob.CoreDamagePerHusk: c.CoreDamagePerHusk = value; break;
|
|
||||||
case TuningKnob.CoreRegenIntervalTicks: c.CoreRegenIntervalTicks = value; break;
|
|
||||||
case TuningKnob.CoreOverrunDrainPct: c.CoreOverrunDrainPct = value; break;
|
|
||||||
case TuningKnob.FinalSiegeMultiplier: c.FinalSiegeMultiplier = value; break;
|
|
||||||
case TuningKnob.StaggerKnockbackSpeed: c.StaggerKnockbackSpeed = value; break;
|
case TuningKnob.StaggerKnockbackSpeed: c.StaggerKnockbackSpeed = value; break;
|
||||||
case TuningKnob.SeparationMaxSpeed: c.SeparationMaxSpeed = value; break;
|
case TuningKnob.SeparationMaxSpeed: c.SeparationMaxSpeed = value; break;
|
||||||
// unknown index -> no-op (matches the no-default switch convention in DebugCommandReceiveSystem)
|
// unknown index -> no-op (matches the no-default switch convention in DebugCommandReceiveSystem)
|
||||||
@@ -187,10 +167,6 @@ namespace ProjectM.Simulation
|
|||||||
case TuningKnob.MeleeFinisherMult: return c.MeleeFinisherMult;
|
case TuningKnob.MeleeFinisherMult: return c.MeleeFinisherMult;
|
||||||
case TuningKnob.MeleeComboLength: return c.MeleeComboLength;
|
case TuningKnob.MeleeComboLength: return c.MeleeComboLength;
|
||||||
case TuningKnob.StructureAggroWeight: return c.StructureAggroWeight;
|
case TuningKnob.StructureAggroWeight: return c.StructureAggroWeight;
|
||||||
case TuningKnob.CoreDamagePerHusk: return c.CoreDamagePerHusk;
|
|
||||||
case TuningKnob.CoreRegenIntervalTicks: return c.CoreRegenIntervalTicks;
|
|
||||||
case TuningKnob.CoreOverrunDrainPct: return c.CoreOverrunDrainPct;
|
|
||||||
case TuningKnob.FinalSiegeMultiplier: return c.FinalSiegeMultiplier;
|
|
||||||
case TuningKnob.StaggerKnockbackSpeed: return c.StaggerKnockbackSpeed;
|
case TuningKnob.StaggerKnockbackSpeed: return c.StaggerKnockbackSpeed;
|
||||||
case TuningKnob.SeparationMaxSpeed: return c.SeparationMaxSpeed;
|
case TuningKnob.SeparationMaxSpeed: return c.SeparationMaxSpeed;
|
||||||
default: return 0f;
|
default: return 0f;
|
||||||
@@ -220,10 +196,6 @@ namespace ProjectM.Simulation
|
|||||||
MeleeFinisherMult = c.MeleeFinisherMult,
|
MeleeFinisherMult = c.MeleeFinisherMult,
|
||||||
MeleeComboLength = c.MeleeComboLength,
|
MeleeComboLength = c.MeleeComboLength,
|
||||||
StructureAggroWeight = c.StructureAggroWeight,
|
StructureAggroWeight = c.StructureAggroWeight,
|
||||||
CoreDamagePerHusk = c.CoreDamagePerHusk,
|
|
||||||
CoreRegenIntervalTicks = c.CoreRegenIntervalTicks,
|
|
||||||
CoreOverrunDrainPct = c.CoreOverrunDrainPct,
|
|
||||||
FinalSiegeMultiplier = c.FinalSiegeMultiplier,
|
|
||||||
StaggerKnockbackSpeed = c.StaggerKnockbackSpeed,
|
StaggerKnockbackSpeed = c.StaggerKnockbackSpeed,
|
||||||
SeparationMaxSpeed = c.SeparationMaxSpeed,
|
SeparationMaxSpeed = c.SeparationMaxSpeed,
|
||||||
};
|
};
|
||||||
@@ -251,10 +223,6 @@ namespace ProjectM.Simulation
|
|||||||
MeleeFinisherMult = r.MeleeFinisherMult,
|
MeleeFinisherMult = r.MeleeFinisherMult,
|
||||||
MeleeComboLength = r.MeleeComboLength,
|
MeleeComboLength = r.MeleeComboLength,
|
||||||
StructureAggroWeight = r.StructureAggroWeight,
|
StructureAggroWeight = r.StructureAggroWeight,
|
||||||
CoreDamagePerHusk = r.CoreDamagePerHusk,
|
|
||||||
CoreRegenIntervalTicks = r.CoreRegenIntervalTicks,
|
|
||||||
CoreOverrunDrainPct = r.CoreOverrunDrainPct,
|
|
||||||
FinalSiegeMultiplier = r.FinalSiegeMultiplier,
|
|
||||||
StaggerKnockbackSpeed = r.StaggerKnockbackSpeed,
|
StaggerKnockbackSpeed = r.StaggerKnockbackSpeed,
|
||||||
SeparationMaxSpeed = r.SeparationMaxSpeed,
|
SeparationMaxSpeed = r.SeparationMaxSpeed,
|
||||||
};
|
};
|
||||||
@@ -283,10 +251,8 @@ namespace ProjectM.Simulation
|
|||||||
public const byte MeleeFinisherMult = 17;
|
public const byte MeleeFinisherMult = 17;
|
||||||
public const byte MeleeComboLength = 18;
|
public const byte MeleeComboLength = 18;
|
||||||
public const byte StructureAggroWeight = 19;
|
public const byte StructureAggroWeight = 19;
|
||||||
public const byte CoreDamagePerHusk = 20;
|
// RETIRED knob ids (LANTERN purge — keep 20-23 reserved, never renumber):
|
||||||
public const byte CoreRegenIntervalTicks = 21;
|
// 20 = CoreDamagePerHusk · 21 = CoreRegenIntervalTicks · 22 = CoreOverrunDrainPct · 23 = FinalSiegeMultiplier
|
||||||
public const byte CoreOverrunDrainPct = 22;
|
|
||||||
public const byte FinalSiegeMultiplier = 23;
|
|
||||||
public const byte StaggerKnockbackSpeed = 24;
|
public const byte StaggerKnockbackSpeed = 24;
|
||||||
public const byte SeparationMaxSpeed = 25;
|
public const byte SeparationMaxSpeed = 25;
|
||||||
|
|
||||||
@@ -322,10 +288,6 @@ namespace ProjectM.Simulation
|
|||||||
public float MeleeFinisherMult;
|
public float MeleeFinisherMult;
|
||||||
public float MeleeComboLength;
|
public float MeleeComboLength;
|
||||||
public float StructureAggroWeight;
|
public float StructureAggroWeight;
|
||||||
public float CoreDamagePerHusk;
|
|
||||||
public float CoreRegenIntervalTicks;
|
|
||||||
public float CoreOverrunDrainPct;
|
|
||||||
public float FinalSiegeMultiplier;
|
|
||||||
public float StaggerKnockbackSpeed;
|
public float StaggerKnockbackSpeed;
|
||||||
public float SeparationMaxSpeed;
|
public float SeparationMaxSpeed;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -76,24 +76,7 @@ namespace ProjectM.Simulation
|
|||||||
/// <summary>END-1 soft-loss penalty: remove a FRACTION (0..1) of EVERY row, floored per row, dropping any
|
/// <summary>END-1 soft-loss penalty: remove a FRACTION (0..1) of EVERY row, floored per row, dropping any
|
||||||
/// row that hits zero. Pure/deterministic (no RNG, no wall-clock), Burst-safe; iterates back-to-front so a
|
/// row that hits zero. Pure/deterministic (no RNG, no wall-clock), Burst-safe; iterates back-to-front so a
|
||||||
/// dropped row never skips its successor. No-op for fraction <= 0.</summary>
|
/// dropped row never skips its successor. No-op for fraction <= 0.</summary>
|
||||||
public static void DrainFraction(DynamicBuffer<StorageEntry> buffer, float fraction)
|
|
||||||
{
|
|
||||||
fraction = math.clamp(fraction, 0f, 1f);
|
|
||||||
if (fraction <= 0f)
|
|
||||||
return;
|
|
||||||
for (int i = buffer.Length - 1; i >= 0; i--)
|
|
||||||
{
|
|
||||||
var entry = buffer[i];
|
|
||||||
int drop = (int)math.floor(entry.Count * fraction);
|
|
||||||
if (drop <= 0)
|
|
||||||
continue;
|
|
||||||
entry.Count -= drop;
|
|
||||||
if (entry.Count <= 0)
|
|
||||||
buffer.RemoveAt(i);
|
|
||||||
else
|
|
||||||
buffer[i] = entry;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,6 +16,9 @@ namespace ProjectM.Simulation
|
|||||||
dest.Add(new StorageEntry { ItemId = src[i].ItemId, Count = src[i].Count });
|
dest.Add(new StorageEntry { ItemId = src[i].ItemId, Count = src[i].Count });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>EB-1: map a serialized <see cref="StructureSave"/> to the staged <see cref="PendingStructure"/>
|
||||||
|
/// (the menu->ServerWorld copy in WorldLauncher). Pure so the field-for-field copy — including the
|
||||||
|
/// easy-to-miss HP — is unit-tested; an omitted field here silently restores every structure at full HP.</summary>
|
||||||
/// <summary>EB-1: map a serialized <see cref="StructureSave"/> to the staged <see cref="PendingStructure"/>
|
/// <summary>EB-1: map a serialized <see cref="StructureSave"/> to the staged <see cref="PendingStructure"/>
|
||||||
/// (the menu->ServerWorld copy in WorldLauncher). Pure so the field-for-field copy — including the
|
/// (the menu->ServerWorld copy in WorldLauncher). Pure so the field-for-field copy — including the
|
||||||
/// easy-to-miss HP — is unit-tested; an omitted field here silently restores every structure at full HP.</summary>
|
/// easy-to-miss HP — is unit-tested; an omitted field here silently restores every structure at full HP.</summary>
|
||||||
@@ -24,10 +27,6 @@ namespace ProjectM.Simulation
|
|||||||
Type = s.Type,
|
Type = s.Type,
|
||||||
CellX = s.CellX,
|
CellX = s.CellX,
|
||||||
CellZ = s.CellZ,
|
CellZ = s.CellZ,
|
||||||
Direction = s.Direction,
|
|
||||||
RemainingTicks = s.RemainingTicks,
|
|
||||||
ConveyorResId = s.ConveyorResId,
|
|
||||||
ConveyorCount = s.ConveyorCount,
|
|
||||||
HP = s.HP,
|
HP = s.HP,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,23 +5,13 @@ namespace ProjectM.Simulation
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Server-world, UNMANAGED bridge holding a save slice the menu staged for a "Continue" session, applied
|
/// Server-world, UNMANAGED bridge holding a save slice the menu staged for a "Continue" session, applied
|
||||||
/// AT SPAWN by the server CycleDirectorSpawnSystem so the director ghost is BORN correct — it never
|
/// AT SPAWN by the server CycleDirectorSpawnSystem so the director ghost is BORN correct — it never
|
||||||
/// serializes a default <see cref="GoalProgress"/> / empty ledger to clients (no replication flicker). The
|
/// serializes an empty ledger to clients (no replication flicker). The menu creates exactly one of these
|
||||||
/// menu creates exactly one of these (with the <see cref="PendingSaveLedgerRow"/> buffer) in the freshly
|
/// (with the <see cref="PendingSaveLedgerRow"/> buffer) in the freshly created ServerWorld BEFORE the
|
||||||
/// created ServerWorld BEFORE the gameplay subscene streams in; the spawn system consumes + destroys it.
|
/// gameplay subscene streams in; the spawn system consumes + destroys it. Unmanaged so the Bursted spawn
|
||||||
/// Unmanaged so the Bursted spawn system reads it without a managed bridge.
|
/// system reads it without a managed bridge.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public struct PendingSave : IComponentData
|
public struct PendingSave : IComponentData
|
||||||
{
|
{
|
||||||
public int GoalCharge;
|
|
||||||
public int GoalTarget;
|
|
||||||
|
|
||||||
/// <summary>END-1: Engine Core integrity to restore (0 = pre-v4 save / New Game -> born full at baked Max).</summary>
|
|
||||||
public int CoreCurrent;
|
|
||||||
|
|
||||||
/// <summary>END-2: terminal run outcome to restore (0 = InProgress / pre-v5 save / New Game; 1 = Victory,
|
|
||||||
/// 2 = Loss -> the run loads finished + halted, no re-arm). Born-correct at director spawn.</summary>
|
|
||||||
public byte RunOutcome;
|
|
||||||
|
|
||||||
/// <summary>v6: persisted run counters to restore into MetaCounters + the born-correct RunInfo HUD mirror.</summary>
|
/// <summary>v6: persisted run counters to restore into MetaCounters + the born-correct RunInfo HUD mirror.</summary>
|
||||||
public int RunsCompleted;
|
public int RunsCompleted;
|
||||||
public int MaxDepthReached;
|
public int MaxDepthReached;
|
||||||
@@ -40,14 +30,15 @@ namespace ProjectM.Simulation
|
|||||||
/// <summary>One staged PERMANENT meta tier for a Continue session (v6) — copied VERBATIM into the director's
|
/// <summary>One staged PERMANENT meta tier for a Continue session (v6) — copied VERBATIM into the director's
|
||||||
/// replicated MetaTierState buffer at spawn (unknown ids preserved for round-trip; clamping happens only at
|
/// replicated MetaTierState buffer at spawn (unknown ids preserved for round-trip; clamping happens only at
|
||||||
/// seed/shop/spend). The buffer is added UNCONDITIONALLY at staging (empty OK) — the Bursted spawn system
|
/// seed/shop/spend). The buffer is added UNCONDITIONALLY at staging (empty OK) — the Bursted spawn system
|
||||||
/// GetBuffers it inside the HasData block and a missing buffer would throw on any v<=5 Continue.</summary>
|
/// GetBuffers it inside the HasData block and a missing buffer would throw on any Continue.</summary>
|
||||||
public struct PendingMetaRow : IBufferElementData
|
public struct PendingMetaRow : IBufferElementData
|
||||||
{
|
{
|
||||||
public byte ClassId;
|
public byte ClassId;
|
||||||
public byte UpgradeId;
|
public byte UpgradeId;
|
||||||
public byte Tier;
|
public byte Tier;
|
||||||
}
|
}
|
||||||
/// <summary>One staged player-built structure row for a Continue session (M7); BaseRestoreSystem replays it
|
|
||||||
|
/// <summary>One staged player-built structure row for a Continue session; BaseRestoreSystem replays it
|
||||||
/// charge-free into the freshly-streamed base. Mirrors <see cref="StructureSave"/> but as an unmanaged ECS
|
/// charge-free into the freshly-streamed base. Mirrors <see cref="StructureSave"/> but as an unmanaged ECS
|
||||||
/// buffer element (staged in the ServerWorld before the subscene streams).</summary>
|
/// buffer element (staged in the ServerWorld before the subscene streams).</summary>
|
||||||
public struct PendingStructure : IBufferElementData
|
public struct PendingStructure : IBufferElementData
|
||||||
@@ -55,28 +46,13 @@ namespace ProjectM.Simulation
|
|||||||
public byte Type;
|
public byte Type;
|
||||||
public int CellX;
|
public int CellX;
|
||||||
public int CellZ;
|
public int CellZ;
|
||||||
public byte Direction;
|
|
||||||
public uint RemainingTicks;
|
|
||||||
public byte ConveyorResId;
|
|
||||||
public int ConveyorCount;
|
|
||||||
public float HP; // EB-1: staged hit points (BaseRestoreSystem restores 0 -> baked Max)
|
public float HP; // EB-1: staged hit points (BaseRestoreSystem restores 0 -> baked Max)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>One staged machine I/O row (M7), joined to the <see cref="PendingStructure"/> buffer by index.
|
|
||||||
/// Slot 0 = MachineInput, 1 = MachineOutput.</summary>
|
|
||||||
public struct PendingStructureIo : IBufferElementData
|
|
||||||
{
|
|
||||||
public int StructureIndex;
|
|
||||||
public byte Slot;
|
|
||||||
public byte ResourceId;
|
|
||||||
public int Count;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Host-only autosave request flag on the CycleDirector entity (added at spawn). The Bursted CyclePhaseSystem
|
/// Host-only autosave request flag on the director entity (added at spawn). RunDirectorSystem sets
|
||||||
/// sets <see cref="Pending"/>=1 on the Siege->Calm checkpoint; the managed SaveWriteSystem reads it, writes
|
/// <see cref="Pending"/>=1 on the terminal bank; the managed SaveWriteSystem reads it, writes the JSON save,
|
||||||
/// the JSON save, and clears it. A plain byte => Burst-safe (no managed/string/file touch in the sim loop).
|
/// and clears it. A plain byte => Burst-safe (no managed/string/file touch in the sim loop).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public struct SaveRequest : IComponentData
|
public struct SaveRequest : IComponentData
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -21,67 +21,38 @@ namespace ProjectM.Simulation
|
|||||||
public byte Tier;
|
public byte Tier;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>One serialized player-built structure. Flat scalars (JsonUtility has no int2).</summary>
|
||||||
/// One serialized player-built structure (M7). Flat scalars (JsonUtility has no int2). The production
|
|
||||||
/// cooldown is stored as REMAINING ticks (epoch-independent) so it survives the server-tick origin reset on a
|
|
||||||
/// fresh session; the in-flight conveyor item (if any) rides here, while variable-length machine I/O buffers
|
|
||||||
/// live in the flat <see cref="SaveData.StructureIo"/> table keyed by index.
|
|
||||||
/// </summary>
|
|
||||||
[Serializable]
|
[Serializable]
|
||||||
public struct StructureSave
|
public struct StructureSave
|
||||||
{
|
{
|
||||||
public byte Type;
|
public byte Type;
|
||||||
public int CellX;
|
public int CellX;
|
||||||
public int CellZ;
|
public int CellZ;
|
||||||
public byte Direction; // conveyor facing (0 for non-conveyors)
|
public float HP; // EB-1: hit points at save time (0 -> restored to baked Max)
|
||||||
public uint RemainingTicks; // production/cooldown ticks left at save time
|
|
||||||
public byte ConveyorResId; // in-flight conveyor item resource (0 = none)
|
|
||||||
public int ConveyorCount;
|
|
||||||
public float HP; // EB-1: hit points at save time (0 from a pre-v3 save -> restored to baked Max)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// One serialized machine I/O buffer row, joined to <see cref="SaveData.Structures"/> by
|
/// Versioned, host-authoritative save slice: the shared resource ledger + player-built structures + the
|
||||||
/// <see cref="StructureIndex"/>. A flat top-level array (JsonUtility can't nest arrays-of-arrays); Slot 0 =
|
/// permanent meta. JsonUtility-friendly — a class with flat fields and an array FIELD (never a root array).
|
||||||
/// MachineInput, Slot 1 = MachineOutput.
|
/// The schema is ADDITIVE going forward, gated by <see cref="Version"/> migration.
|
||||||
/// </summary>
|
|
||||||
[Serializable]
|
|
||||||
public struct StructureIoRow
|
|
||||||
{
|
|
||||||
public int StructureIndex;
|
|
||||||
public byte Slot;
|
|
||||||
public byte ResourceId;
|
|
||||||
public int Count;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Versioned, host-authoritative save slice (the FOUNDATION): the long-arc goal charge/target + the shared
|
|
||||||
/// resource ledger. JsonUtility-friendly — a class with flat fields and an array FIELD (never a root array).
|
|
||||||
/// The schema is intentionally ADDITIVE: future fields (placed structures, threat, storage) append without
|
|
||||||
/// breaking old saves, gated by <see cref="Version"/> migration.
|
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[Serializable]
|
[Serializable]
|
||||||
public class SaveData
|
public class SaveData
|
||||||
{
|
{
|
||||||
public const int CurrentVersion = 6; // v6: permanent META (per-class upgrade tiers + run counters); v5 added RunOutcome; v4 added CoreCurrent
|
public const int CurrentVersion = 7; // v7: LANTERN fresh epoch — goal/core/outcome + conveyor/machine-I/O fields dropped
|
||||||
|
|
||||||
/// <summary>Oldest save schema the loader accepts (additive); a v2 save loads with structures at full HP.</summary>
|
/// <summary>Oldest save schema the loader accepts. v7 is a FRESH EPOCH (operator-approved): older saves are ignored.</summary>
|
||||||
public const int MinLoadableVersion = 2;
|
public const int MinLoadableVersion = 7;
|
||||||
|
|
||||||
public int Version = CurrentVersion;
|
public int Version = CurrentVersion;
|
||||||
public int GoalCharge;
|
|
||||||
public int GoalTarget;
|
|
||||||
public int CoreCurrent; // END-1: Engine Core integrity at save time (0 from a pre-v4 save -> restored to baked Max)
|
|
||||||
public int RunOutcome; // END-2: 0=InProgress (also any pre-v5 save) / 1=Victory / 2=Loss -> a finished run loads finished
|
|
||||||
|
|
||||||
// v6 — permanent meta-progression (0/empty-defaults on any v<=5 save):
|
// Permanent meta-progression:
|
||||||
public int RunsCompleted; // boss-cleared runs (the HUD counter + the HostSalt fold at restore)
|
public int RunsCompleted; // boss-cleared runs (the HUD counter + the HostSalt fold at restore)
|
||||||
public int MaxDepthReached; // deepest room actually CLEARED across all runs (honest depth, never planned)
|
public int MaxDepthReached; // deepest room actually CLEARED across all runs (honest depth, never planned)
|
||||||
public MetaUpgradeSave[] MetaUpgrades = Array.Empty<MetaUpgradeSave>(); // sparse per-class tiers
|
public MetaUpgradeSave[] MetaUpgrades = Array.Empty<MetaUpgradeSave>(); // sparse per-class tiers
|
||||||
|
|
||||||
public LedgerRow[] Ledger = Array.Empty<LedgerRow>();
|
public LedgerRow[] Ledger = Array.Empty<LedgerRow>();
|
||||||
public StructureSave[] Structures = Array.Empty<StructureSave>();
|
public StructureSave[] Structures = Array.Empty<StructureSave>();
|
||||||
public StructureIoRow[] StructureIo = Array.Empty<StructureIoRow>();
|
|
||||||
public long SavedAtMs;
|
public long SavedAtMs;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -44,13 +44,7 @@ namespace ProjectM.Simulation
|
|||||||
/// placed structures survive untouched. Without this, Continue/PLAY AGAIN after a win re-latches the dead
|
/// placed structures survive untouched. Without this, Continue/PLAY AGAIN after a win re-latches the dead
|
||||||
/// outcome banner on the first replicated snapshot. Pure + idempotent; no-op for in-progress saves.
|
/// outcome banner on the first replicated snapshot. Pure + idempotent; no-op for in-progress saves.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static void RollTerminalCampaignForward(SaveData data)
|
|
||||||
{
|
|
||||||
if (data == null || data.RunOutcome == 0) return;
|
|
||||||
data.RunOutcome = 0;
|
|
||||||
data.GoalCharge = 0;
|
|
||||||
data.CoreCurrent = 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
public static void Save(SaveData data)
|
public static void Save(SaveData data)
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ namespace ProjectM.Simulation
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public static class SaveStructureScan
|
public static class SaveStructureScan
|
||||||
{
|
{
|
||||||
public static void Collect(EntityManager em, uint nowTick, out StructureSave[] structures, out StructureIoRow[] io)
|
public static void Collect(EntityManager em, uint nowTick, out StructureSave[] structures)
|
||||||
{
|
{
|
||||||
var structs = new List<StructureSave>();
|
var structs = new List<StructureSave>();
|
||||||
|
|
||||||
@@ -38,7 +38,6 @@ namespace ProjectM.Simulation
|
|||||||
}
|
}
|
||||||
|
|
||||||
structures = structs.ToArray();
|
structures = structs.ToArray();
|
||||||
io = System.Array.Empty<StructureIoRow>(); // machine I/O retired with the automation chain (row type dies at save v7)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,34 +0,0 @@
|
|||||||
using Unity.Entities;
|
|
||||||
using Unity.NetCode;
|
|
||||||
|
|
||||||
namespace ProjectM.Simulation
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// END-1 — the losable Engine Core. An aggregate base-integrity meter that rides the GLOBAL CycleDirector
|
|
||||||
/// ghost (the untagged ghost already carrying <see cref="CycleState"/>/<see cref="GoalProgress"/>/the resource
|
|
||||||
/// ledger), so it is visible to every player regardless of region with NO new ghost and NO relevancy work — it
|
|
||||||
/// must NEVER be region-tagged (the shared-global-state rule; <c>SetIsIrrelevant</c> would hide it cross-region).
|
|
||||||
/// <para>
|
|
||||||
/// A Husk that breaches to the Core radius drains <see cref="Current"/> and despawns (server-only
|
|
||||||
/// <c>CoreDamageSystem</c>); in Calm the Core regenerates toward <see cref="Max"/> (<c>CoreRestoreSystem</c>) so a
|
|
||||||
/// chipped-but-survived base reads as "we got hurt but we're okay." When <see cref="Current"/> reaches 0 during a
|
|
||||||
/// Siege the SOFT-loss edge fires once in <c>CyclePhaseSystem</c> (the sole Phase writer): the siege ends, the
|
|
||||||
/// shared ledger is drained, the base persists wounded (no rollback — the locked DR-029 fork). <see cref="Max"/> is
|
|
||||||
/// baked from <c>CycleDirectorAuthoring</c>; <see cref="Current"/> is born-correct at spawn (full, or the persisted
|
|
||||||
/// wounded value from a Continue save).
|
|
||||||
/// </para>
|
|
||||||
/// </summary>
|
|
||||||
public struct CoreIntegrity : IComponentData
|
|
||||||
{
|
|
||||||
/// <summary>Current integrity (0 = breached/overrun). Server-authoritative; replicated for the HUD bar.</summary>
|
|
||||||
[GhostField] public int Current;
|
|
||||||
|
|
||||||
/// <summary>Integrity ceiling (baked from authoring; not persisted — a restored Core caps at the baked Max).</summary>
|
|
||||||
[GhostField] public int Max;
|
|
||||||
|
|
||||||
/// <summary>Server tick of the most recent overrun breach (0 = never). A TRANSIENT pulse the client HUD
|
|
||||||
/// edge-detects to flash a "BASE OVERRUN" banner — a SOFT loss is non-terminal ("keep playing"), so this is
|
|
||||||
/// the right shape, not a latching run-outcome (the terminal Victory latch is END-2's job).</summary>
|
|
||||||
[GhostField] public uint OverrunTick;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
fileFormatVersion: 2
|
|
||||||
guid: a933c59d7c550d844b615e3672b333f6
|
|
||||||
@@ -3,75 +3,15 @@ using Unity.NetCode;
|
|||||||
|
|
||||||
namespace ProjectM.Simulation
|
namespace ProjectM.Simulation
|
||||||
{
|
{
|
||||||
/// <summary>
|
// NOTE (LANTERN purge): CycleState/CyclePhase/CycleRuntime (the Calm↔Siege macro-loop) are DELETED.
|
||||||
/// Macro-loop state for "The Aether Cycle": which phase the run is in, the cycle number, and the server
|
// ExpeditionObjective below is the surviving replicated room-objective readout (live consumers:
|
||||||
/// tick the current (timed) phase ends. Server-authoritative, maintained by CyclePhaseSystem. Currently a
|
// RoomEnemyDirectorSystem writes it; RunDirectorSystem/HudSystem read it).
|
||||||
/// server-side singleton; the [GhostField]s below are inert until it is moved onto the runtime-spawned
|
|
||||||
/// CycleDirector ghost (when the client HUD is wired), at which point the same struct replicates unchanged.
|
|
||||||
/// The Defend phase is NOT timed — it ends when the base-defense wave is cleared — so PhaseEndTick is only
|
|
||||||
/// meaningful in Expedition/Build (0 during Defend).
|
|
||||||
/// </summary>
|
|
||||||
public struct CycleState : IComponentData
|
|
||||||
{
|
|
||||||
/// <summary>Current phase (see <see cref="CyclePhase"/>).</summary>
|
|
||||||
[GhostField] public byte Phase;
|
|
||||||
|
|
||||||
/// <summary>1-based cycle counter (increments when a new Expedition begins).</summary>
|
|
||||||
[GhostField] public int CycleNumber;
|
|
||||||
|
|
||||||
/// <summary>Server tick the current timed phase ends (Expedition/Build only; 0 in Defend).</summary>
|
|
||||||
[GhostField] public uint PhaseEndTick;
|
|
||||||
|
|
||||||
/// <summary>Live Husk wave number during Defend, synced from the server-only WaveState by CyclePhaseSystem so the replicated-state-only HUD can show it (holds the last wave number outside Defend; the HUD gates the display to the Defend phase).</summary>
|
|
||||||
[GhostField] public int WaveNumber;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <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
|
|
||||||
{
|
|
||||||
// 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;
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 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 the moment the current Siege started (DefendCleared tests > this).</summary>
|
|
||||||
public int DefendStartWave;
|
|
||||||
|
|
||||||
/// <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>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;
|
|
||||||
|
|
||||||
/// <summary>The <see cref="ExpeditionEpoch"/> a zone-clear Ore reward was last banked for — gates the once-per-epoch reward so two same-tick co-op returners pay once and gate re-entry can't farm (int equality, never tick math).</summary>
|
|
||||||
public int LastRewardedEpoch;
|
|
||||||
|
|
||||||
/// <summary>1 once the current epoch's expedition wave has FULLY spawned and been cleared to zero live zone enemies; reset to 0 on the empty->occupied epoch bump. The reward fires only on a REAL clear.</summary>
|
|
||||||
public byte ClearedThisEpoch;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// DR-042 C7b — a SMALL replicated summary of the current expedition objective so the client HUD can show an
|
/// DR-042 C7b — a SMALL replicated summary of the current expedition objective so the client HUD can show an
|
||||||
/// "enemies remaining / cleared — return to claim" readout. Rides the GLOBAL UNTAGGED CycleDirector ghost
|
/// "enemies remaining / cleared — return to claim" readout. Rides the GLOBAL UNTAGGED director ghost so
|
||||||
/// (alongside <see cref="CycleState"/> / GoalProgress) so GhostRelevancy.SetIsIrrelevant never hides it
|
/// GhostRelevancy.SetIsIrrelevant never hides it
|
||||||
/// cross-region — a base teammate can't see the expedition's own (region-tagged, relevancy-hidden) enemy
|
/// cross-region — a base teammate can't see the expedition's own (region-tagged, relevancy-hidden) enemy
|
||||||
/// ghosts. SOLE writer: ZoneEnemyDirectorSystem (server, plain group), written ABOVE its early-returns
|
/// ghosts. SOLE writer: ZoneEnemyDirectorSystem (server, plain group), written ABOVE its early-returns
|
||||||
/// (snapshot-above-early-return) so the readout never freezes stale. byte/short, never enum (writer is [BurstCompile]).
|
/// (snapshot-above-early-return) so the readout never freezes stale. byte/short, never enum (writer is [BurstCompile]).
|
||||||
|
|||||||
@@ -1,22 +0,0 @@
|
|||||||
using Unity.Entities;
|
|
||||||
using Unity.NetCode;
|
|
||||||
|
|
||||||
namespace ProjectM.Simulation
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// Long-arc progress toward the goal ("reach THEM"). Lives on the GLOBAL CycleDirector ghost (relevant in
|
|
||||||
/// every region, alongside CycleState + the resource ledger), so it is visible to all players regardless
|
|
||||||
/// of region. Sole PRODUCTION writer (DR-042): <c>ExpeditionGateSystem</c> increments <see cref="Charge"/> by
|
|
||||||
/// one per cleared EXPEDITION (on the player's return). <c>GoalReachedSystem</c> only READS the Charge==Target
|
|
||||||
/// edge to arm the climactic final siege. (<c>DebugCommandReceiveSystem</c> is a manual dev-op writer.) The HUD
|
|
||||||
/// observes it for a progress bar.
|
|
||||||
/// </summary>
|
|
||||||
public struct GoalProgress : IComponentData
|
|
||||||
{
|
|
||||||
/// <summary>Accumulated progress.</summary>
|
|
||||||
[GhostField] public int Charge;
|
|
||||||
|
|
||||||
/// <summary>Charge required to reach the goal.</summary>
|
|
||||||
[GhostField] public int Target;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
fileFormatVersion: 2
|
|
||||||
guid: e1f60b3396850074ca0e44b831b5980c
|
|
||||||
@@ -1,61 +0,0 @@
|
|||||||
using Unity.Entities;
|
|
||||||
using Unity.NetCode;
|
|
||||||
|
|
||||||
namespace ProjectM.Simulation
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// END-2 — server-only marker of which run-phase the macro loop is in. Lives on the GLOBAL CycleDirector
|
|
||||||
/// entity beside <see cref="CycleState"/>/<see cref="CycleRuntime"/>/<see cref="ThreatState"/>; NOT replicated
|
|
||||||
/// (the client never needs to distinguish "the final siege is armed" — the larger wave + telegraph already read
|
|
||||||
/// as escalation; the client shows the terminal banner from the replicated <see cref="RunOutcome"/> instead).
|
|
||||||
/// SINGLE writer: <c>GoalReachedSystem</c> flips <see cref="RunPhaseId.Normal"/> ->
|
|
||||||
/// <see cref="RunPhaseId.FinalDefense"/> exactly once when <see cref="GoalProgress.Charge"/> reaches Target.
|
|
||||||
/// Added at spawn by <c>CycleDirectorSpawnSystem</c> (like CycleRuntime/ThreatState), so it is server-world-only
|
|
||||||
/// and never on the ghost serializer (no re-hash). A <c>byte</c> (never an enum) so a Bursted reader can't trip
|
|
||||||
/// the cross-assembly-enum Burst ICE.
|
|
||||||
/// </summary>
|
|
||||||
public struct RunPhase : IComponentData
|
|
||||||
{
|
|
||||||
public byte Value;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>Phase constants for <see cref="RunPhase.Value"/> (bytes — never an enum on a Bursted path).</summary>
|
|
||||||
public static class RunPhaseId
|
|
||||||
{
|
|
||||||
/// <summary>Normal play: scheduled / post-expedition sieges arm; the goal meter climbs +1 per survived siege.</summary>
|
|
||||||
public const byte Normal = 0;
|
|
||||||
|
|
||||||
/// <summary>The goal cap was reached: the larger FINAL siege is armed/running. No further sieges arm.</summary>
|
|
||||||
public const byte FinalDefense = 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// END-2 — the terminal run outcome: the LATCHING win/lose state, REPLICATED so the client HUD shows the
|
|
||||||
/// victory/loss banner by simply observing it (no fragile client-side reconstruction). Rides the GLOBAL
|
|
||||||
/// untagged CycleDirector ghost (relevant to every connection in every region — it must NEVER be region-tagged;
|
|
||||||
/// the shared-global-state rule), one <c>[GhostField] byte</c> alongside <see cref="CoreIntegrity"/>/
|
|
||||||
/// <see cref="GoalProgress"/>. SINGLE writer: <c>CyclePhaseSystem</c> latches <see cref="RunOutcomeId.Victory"/>
|
|
||||||
/// (final siege cleared) or <see cref="RunOutcomeId.Loss"/> (Core breached during the final siege). Once it is
|
|
||||||
/// non-<see cref="RunOutcomeId.InProgress"/> the run HALTS (GoalReachedSystem + ThreatDirectorSystem stop arming;
|
|
||||||
/// CoreRestoreSystem stops regen). Baked onto the prefab so it is part of the ghost (adding this <c>[GhostField]</c>
|
|
||||||
/// re-hashes the CycleDirector ghost -> one re-bake); born-correct at spawn (InProgress for a New Game, or the
|
|
||||||
/// persisted value on Continue — SaveData v5).
|
|
||||||
/// </summary>
|
|
||||||
public struct RunOutcome : IComponentData
|
|
||||||
{
|
|
||||||
[GhostField] public byte Value;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>Outcome constants for <see cref="RunOutcome.Value"/> (bytes — never an enum on a Bursted/serialized path).</summary>
|
|
||||||
public static class RunOutcomeId
|
|
||||||
{
|
|
||||||
/// <summary>The run is live (no terminal result yet).</summary>
|
|
||||||
public const byte InProgress = 0;
|
|
||||||
|
|
||||||
/// <summary>The final siege was survived — the Engine holds. Terminal; the run halts.</summary>
|
|
||||||
public const byte Victory = 1;
|
|
||||||
|
|
||||||
/// <summary>The Core was breached during the final siege — overrun. Terminal; the run halts.</summary>
|
|
||||||
public const byte Loss = 2;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
fileFormatVersion: 2
|
|
||||||
guid: 8ce481dc9a135834fa6d59882895b0f5
|
|
||||||
@@ -1,88 +0,0 @@
|
|||||||
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>Extra Husks per surviving wave for a SCHEDULED base siege (size = SizeBase + this*WaveNumber). 0 = flat SizeBase.</summary>
|
|
||||||
public int ScheduleSizePerWave;
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
fileFormatVersion: 2
|
|
||||||
guid: 2e66b1e7c715ceb418459c9323853271
|
|
||||||
@@ -1,181 +0,0 @@
|
|||||||
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>
|
|
||||||
/// END-1 — plain-Entities EditMode tests for the Engine Core server systems. <see cref="CoreDamageSystem"/>:
|
|
||||||
/// a Husk that reaches the base <see cref="BaseGridMath.PlotCenter"/> drains integrity (the live
|
|
||||||
/// <see cref="TuningConfig"/> default with no singleton) and is consumed; a distant Husk is untouched; at 0
|
|
||||||
/// the system idles (the lose-edge owns resolution). <see cref="CoreRestoreSystem"/>: the Core regenerates
|
|
||||||
/// exactly +1 across one regen interval ONLY in Calm, never mid-Siege, and never past Max. The lose-edge
|
|
||||||
/// itself is covered in <c>CyclePhaseSystemTests</c>. BaseAnchor is configured so PlotCenter == origin.
|
|
||||||
/// </summary>
|
|
||||||
public class CoreSystemsTests
|
|
||||||
{
|
|
||||||
static (World world, SimulationSystemGroup group) MakeWorld<T>(string name, uint serverTick)
|
|
||||||
where T : unmanaged, ISystem
|
|
||||||
{
|
|
||||||
var world = new World(name);
|
|
||||||
var group = world.GetOrCreateSystemManaged<SimulationSystemGroup>();
|
|
||||||
group.AddSystemToUpdateList(world.GetOrCreateSystem<T>());
|
|
||||||
group.SortSystems();
|
|
||||||
world.SetTime(new TimeData(elapsedTime: 0f, deltaTime: 1f / 60f));
|
|
||||||
SetServerTick(world, serverTick);
|
|
||||||
return (world, group);
|
|
||||||
}
|
|
||||||
|
|
||||||
static void SetServerTick(World world, uint tick)
|
|
||||||
{
|
|
||||||
var em = world.EntityManager;
|
|
||||||
using var q = em.CreateEntityQuery(typeof(NetworkTime));
|
|
||||||
Entity e = q.IsEmpty ? em.CreateEntity(typeof(NetworkTime)) : q.GetSingletonEntity();
|
|
||||||
em.SetComponentData(e, new NetworkTime { ServerTick = new NetworkTick(tick) });
|
|
||||||
}
|
|
||||||
|
|
||||||
static Entity MakeCore(EntityManager em, int current, int max)
|
|
||||||
{
|
|
||||||
var e = em.CreateEntity(typeof(CoreIntegrity));
|
|
||||||
em.SetComponentData(e, new CoreIntegrity { Current = current, Max = max });
|
|
||||||
return e;
|
|
||||||
}
|
|
||||||
|
|
||||||
// PlotCenter = GridOrigin.xz + GridDims*CellSize*0.5; origin + zero dims => (0,0,0).
|
|
||||||
static void MakeBaseAnchor(EntityManager em)
|
|
||||||
{
|
|
||||||
var e = em.CreateEntity(typeof(BaseAnchor));
|
|
||||||
em.SetComponentData(e, new BaseAnchor
|
|
||||||
{
|
|
||||||
AnchorPos = float3.zero,
|
|
||||||
GridOrigin = float3.zero,
|
|
||||||
CellSize = 1f,
|
|
||||||
GridDims = int2.zero,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
static void MakeHusk(EntityManager em, float3 pos)
|
|
||||||
{
|
|
||||||
var e = em.CreateEntity(typeof(EnemyTag), typeof(LocalTransform));
|
|
||||||
em.SetComponentData(e, LocalTransform.FromPosition(pos));
|
|
||||||
}
|
|
||||||
|
|
||||||
static Entity MakeCycle(EntityManager em, byte phase)
|
|
||||||
{
|
|
||||||
var e = em.CreateEntity(typeof(CycleState));
|
|
||||||
em.SetComponentData(e, new CycleState { Phase = phase });
|
|
||||||
return e;
|
|
||||||
}
|
|
||||||
|
|
||||||
[Test]
|
|
||||||
public void CoreDamage_Breaching_Husk_Drains_And_Is_Consumed()
|
|
||||||
{
|
|
||||||
var (world, group) = MakeWorld<CoreDamageSystem>("CoreDamage", serverTick: 100);
|
|
||||||
using (world)
|
|
||||||
{
|
|
||||||
var em = world.EntityManager;
|
|
||||||
var core = MakeCore(em, current: 100, max: 100);
|
|
||||||
MakeBaseAnchor(em);
|
|
||||||
MakeHusk(em, new float3(0, 0, 0)); // at the Core -> breaches
|
|
||||||
MakeHusk(em, new float3(20, 0, 20)); // far -> safe
|
|
||||||
|
|
||||||
group.Update();
|
|
||||||
|
|
||||||
Assert.AreEqual(90, em.GetComponentData<CoreIntegrity>(core).Current,
|
|
||||||
"one breaching Husk drains the default 10 integrity.");
|
|
||||||
using var hq = em.CreateEntityQuery(typeof(EnemyTag));
|
|
||||||
Assert.AreEqual(1, hq.CalculateEntityCount(),
|
|
||||||
"the breaching Husk is consumed; the distant one survives.");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
[Test]
|
|
||||||
public void CoreDamage_Idles_When_Already_Breached()
|
|
||||||
{
|
|
||||||
var (world, group) = MakeWorld<CoreDamageSystem>("CoreBreached", serverTick: 100);
|
|
||||||
using (world)
|
|
||||||
{
|
|
||||||
var em = world.EntityManager;
|
|
||||||
MakeCore(em, current: 0, max: 100);
|
|
||||||
MakeBaseAnchor(em);
|
|
||||||
MakeHusk(em, float3.zero);
|
|
||||||
|
|
||||||
group.Update();
|
|
||||||
|
|
||||||
using var hq = em.CreateEntityQuery(typeof(EnemyTag));
|
|
||||||
Assert.AreEqual(1, hq.CalculateEntityCount(),
|
|
||||||
"at 0 integrity CoreDamageSystem idles (the lose-edge owns resolution).");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
[Test]
|
|
||||||
public void CoreRestore_Regens_Exactly_Once_Per_Interval_In_Calm()
|
|
||||||
{
|
|
||||||
var (world, group) = MakeWorld<CoreRestoreSystem>("CoreRegenCalm", serverTick: 100);
|
|
||||||
using (world)
|
|
||||||
{
|
|
||||||
var em = world.EntityManager;
|
|
||||||
var core = MakeCore(em, current: 50, max: 100);
|
|
||||||
MakeCycle(em, CyclePhase.Calm);
|
|
||||||
|
|
||||||
// Across one full default interval (18) of consecutive ticks, exactly ONE is on the regen boundary.
|
|
||||||
const uint interval = 18;
|
|
||||||
for (uint t = 100; t < 100 + interval; t++)
|
|
||||||
{
|
|
||||||
SetServerTick(world, t);
|
|
||||||
group.Update();
|
|
||||||
}
|
|
||||||
|
|
||||||
Assert.AreEqual(51, em.GetComponentData<CoreIntegrity>(core).Current,
|
|
||||||
"Calm regenerates exactly +1 across one regen interval.");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
[Test]
|
|
||||||
public void CoreRestore_Does_Not_Regen_During_Siege()
|
|
||||||
{
|
|
||||||
var (world, group) = MakeWorld<CoreRestoreSystem>("CoreNoRegenSiege", serverTick: 100);
|
|
||||||
using (world)
|
|
||||||
{
|
|
||||||
var em = world.EntityManager;
|
|
||||||
var core = MakeCore(em, current: 50, max: 100);
|
|
||||||
MakeCycle(em, CyclePhase.Siege);
|
|
||||||
|
|
||||||
for (uint t = 100; t < 100 + 18; t++)
|
|
||||||
{
|
|
||||||
SetServerTick(world, t);
|
|
||||||
group.Update();
|
|
||||||
}
|
|
||||||
|
|
||||||
Assert.AreEqual(50, em.GetComponentData<CoreIntegrity>(core).Current,
|
|
||||||
"no regen mid-Siege (a chipped Core heals only between sieges).");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
[Test]
|
|
||||||
public void CoreRestore_Never_Exceeds_Max()
|
|
||||||
{
|
|
||||||
var (world, group) = MakeWorld<CoreRestoreSystem>("CoreCap", serverTick: 100);
|
|
||||||
using (world)
|
|
||||||
{
|
|
||||||
var em = world.EntityManager;
|
|
||||||
var core = MakeCore(em, current: 100, max: 100);
|
|
||||||
MakeCycle(em, CyclePhase.Calm);
|
|
||||||
|
|
||||||
for (uint t = 100; t < 100 + 18; t++)
|
|
||||||
{
|
|
||||||
SetServerTick(world, t);
|
|
||||||
group.Update();
|
|
||||||
}
|
|
||||||
|
|
||||||
Assert.AreEqual(100, em.GetComponentData<CoreIntegrity>(core).Current,
|
|
||||||
"regen clamps at Max.");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
fileFormatVersion: 2
|
|
||||||
guid: 0b316df3c18e66c47b2a29316eeaba0e
|
|
||||||
@@ -1,252 +0,0 @@
|
|||||||
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="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 WITHOUT charging the goal (DR-042: expedition clears drive the win);
|
|
||||||
/// and split co-op presence never produces a non-Calm phase. All timing is wrap-safe NetworkTick math.
|
|
||||||
/// </summary>
|
|
||||||
public class CyclePhaseSystemTests
|
|
||||||
{
|
|
||||||
static (World world, SimulationSystemGroup group) MakeWorld(string name, uint serverTick)
|
|
||||||
{
|
|
||||||
var world = new World(name);
|
|
||||||
var group = world.GetOrCreateSystemManaged<SimulationSystemGroup>();
|
|
||||||
group.AddSystemToUpdateList(world.GetOrCreateSystem<CyclePhaseSystem>());
|
|
||||||
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 Entity MakeCycle(EntityManager em, byte phase, int defendStartWave)
|
|
||||||
{
|
|
||||||
var e = em.CreateEntity(typeof(CycleState), typeof(CycleRuntime));
|
|
||||||
em.SetComponentData(e, new CycleState { Phase = phase, PhaseEndTick = 0u, CycleNumber = 1 });
|
|
||||||
em.SetComponentData(e, new CycleRuntime { DefendStartWave = defendStartWave });
|
|
||||||
return e;
|
|
||||||
}
|
|
||||||
|
|
||||||
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, Phase = phase, RemainingToSpawn = remainingToSpawn });
|
|
||||||
return e;
|
|
||||||
}
|
|
||||||
|
|
||||||
[Test]
|
|
||||||
public void Calm_Holds_When_No_PendingSiege()
|
|
||||||
{
|
|
||||||
var (world, group) = MakeWorld("CalmHolds", serverTick: 200);
|
|
||||||
using (world)
|
|
||||||
{
|
|
||||||
var em = world.EntityManager;
|
|
||||||
var cycle = MakeCycle(em, CyclePhase.Calm, defendStartWave: 0);
|
|
||||||
AddThreat(em, cycle, pendingSiegeSize: 0, armTick: 0);
|
|
||||||
|
|
||||||
group.Update();
|
|
||||||
|
|
||||||
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 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 Siege_Exits_To_Calm_On_DefendCleared_Does_Not_Charge_Goal()
|
|
||||||
{
|
|
||||||
var (world, group) = MakeWorld("SiegeClears", serverTick: 200);
|
|
||||||
using (world)
|
|
||||||
{
|
|
||||||
var em = world.EntityManager;
|
|
||||||
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();
|
|
||||||
|
|
||||||
Assert.AreEqual(CyclePhase.Calm, em.GetComponentData<CycleState>(cycle).Phase,
|
|
||||||
"A cleared siege returns to Calm.");
|
|
||||||
Assert.AreEqual(0, em.GetComponentData<GoalProgress>(cycle).Charge,
|
|
||||||
"DR-042: surviving a base siege does NOT charge the goal (the AFK win path is closed).");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
[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("WaveSync", serverTick: 200);
|
|
||||||
using (world)
|
|
||||||
{
|
|
||||||
var em = world.EntityManager;
|
|
||||||
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 for the replicated-state-only HUD.");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
[Test]
|
|
||||||
public void Siege_Overrun_Ends_Siege_Drains_Ledger_Despawns_Husks_No_Goal_Charge()
|
|
||||||
{
|
|
||||||
var (world, group) = MakeWorld("SiegeOverrun", serverTick: 200);
|
|
||||||
using (world)
|
|
||||||
{
|
|
||||||
var em = world.EntityManager;
|
|
||||||
var cycle = MakeCycle(em, CyclePhase.Siege, defendStartWave: 5);
|
|
||||||
em.AddComponentData(cycle, new GoalProgress { Charge = 3, Target = 10 });
|
|
||||||
em.AddComponentData(cycle, new CoreIntegrity { Current = 0, Max = 100, OverrunTick = 0 }); // breached
|
|
||||||
var ledger = em.AddBuffer<StorageEntry>(cycle);
|
|
||||||
ledger.Add(new StorageEntry { ItemId = 2, Count = 100 });
|
|
||||||
ledger.Add(new StorageEntry { ItemId = 4, Count = 40 });
|
|
||||||
MakeWaveState(em, waveNumber: 6, phase: WavePhase.Spawning, remainingToSpawn: 3);
|
|
||||||
// two live BASE husks the team failed to clear (RegionTag defaults to Region 0 = Base)
|
|
||||||
em.CreateEntity(typeof(EnemyTag), typeof(RegionTag));
|
|
||||||
em.CreateEntity(typeof(EnemyTag), typeof(RegionTag));
|
|
||||||
|
|
||||||
group.Update();
|
|
||||||
|
|
||||||
Assert.AreEqual(CyclePhase.Calm, em.GetComponentData<CycleState>(cycle).Phase,
|
|
||||||
"an overrun ends the siege -> Calm (soft loss).");
|
|
||||||
Assert.AreEqual(3, em.GetComponentData<GoalProgress>(cycle).Charge,
|
|
||||||
"NO goal charge on a loss (you were overrun, not survived).");
|
|
||||||
var l = em.GetBuffer<StorageEntry>(cycle);
|
|
||||||
Assert.AreEqual(50, l[0].Count, "ledger row 1 drained 50% (100 -> 50).");
|
|
||||||
Assert.AreEqual(20, l[1].Count, "ledger row 2 drained 50% (40 -> 20).");
|
|
||||||
Assert.AreNotEqual(0u, em.GetComponentData<CoreIntegrity>(cycle).OverrunTick,
|
|
||||||
"the overrun pulse is stamped for the HUD flash.");
|
|
||||||
using var huskQ = em.CreateEntityQuery(typeof(EnemyTag));
|
|
||||||
Assert.AreEqual(0, huskQ.CalculateEntityCount(),
|
|
||||||
"remaining husks are despawned (the siege disperses).");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
[Test]
|
|
||||||
public void Base_Overrun_Disperses_Base_Husks_But_Spares_Expedition_Husks()
|
|
||||||
{
|
|
||||||
// Slice 3 regression: a BASE Core breach must NOT wipe an in-progress EXPEDITION wave (both share
|
|
||||||
// EnemyTag but live in different regions). A region-blind cull would also spuriously trip the zone
|
|
||||||
// director's aliveZone==0 clear/reward edge on the player's return.
|
|
||||||
var (world, group) = MakeWorld("BaseOverrunSparesExpedition", serverTick: 200);
|
|
||||||
using (world)
|
|
||||||
{
|
|
||||||
var em = world.EntityManager;
|
|
||||||
var cycle = MakeCycle(em, CyclePhase.Siege, defendStartWave: 5);
|
|
||||||
em.AddComponentData(cycle, new GoalProgress { Charge = 3, Target = 10 });
|
|
||||||
em.AddComponentData(cycle, new CoreIntegrity { Current = 0, Max = 100, OverrunTick = 0 }); // breached
|
|
||||||
var ledger = em.AddBuffer<StorageEntry>(cycle);
|
|
||||||
ledger.Add(new StorageEntry { ItemId = 2, Count = 100 });
|
|
||||||
ledger.Add(new StorageEntry { ItemId = 4, Count = 40 });
|
|
||||||
MakeWaveState(em, waveNumber: 6, phase: WavePhase.Spawning, remainingToSpawn: 3);
|
|
||||||
em.CreateEntity(typeof(EnemyTag), typeof(RegionTag)); // BASE husk (RegionTag defaults to Region 0 = Base)
|
|
||||||
var exp = em.CreateEntity(typeof(EnemyTag), typeof(RegionTag));
|
|
||||||
em.SetComponentData(exp, new RegionTag { Region = RegionId.Expedition }); // a husk out on the expedition
|
|
||||||
|
|
||||||
group.Update();
|
|
||||||
|
|
||||||
Assert.IsTrue(em.Exists(exp), "the expedition husk survives a base Core breach.");
|
|
||||||
using var huskQ = em.CreateEntityQuery(typeof(EnemyTag));
|
|
||||||
Assert.AreEqual(1, huskQ.CalculateEntityCount(),
|
|
||||||
"only the BASE husk is dispersed by the breach; the in-progress expedition wave is untouched.");
|
|
||||||
Assert.AreEqual(RegionId.Expedition, em.GetComponentData<RegionTag>(exp).Region,
|
|
||||||
"the survivor is the expedition-region husk.");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
[Test]
|
|
||||||
public void Overrun_Resolves_Once_Then_Stays_Calm_Without_Recharging()
|
|
||||||
{
|
|
||||||
var (world, group) = MakeWorld("OverrunOnce", serverTick: 200);
|
|
||||||
using (world)
|
|
||||||
{
|
|
||||||
var em = world.EntityManager;
|
|
||||||
var cycle = MakeCycle(em, CyclePhase.Siege, defendStartWave: 5);
|
|
||||||
em.AddComponentData(cycle, new GoalProgress { Charge = 0, Target = 10 });
|
|
||||||
em.AddComponentData(cycle, new CoreIntegrity { Current = 0, Max = 100 });
|
|
||||||
em.AddBuffer<StorageEntry>(cycle);
|
|
||||||
MakeWaveState(em, waveNumber: 6, phase: WavePhase.Spawning, remainingToSpawn: 0);
|
|
||||||
|
|
||||||
group.Update();
|
|
||||||
group.Update(); // second tick: Calm branch -> must not re-resolve or charge
|
|
||||||
|
|
||||||
Assert.AreEqual(CyclePhase.Calm, em.GetComponentData<CycleState>(cycle).Phase);
|
|
||||||
Assert.AreEqual(0, em.GetComponentData<GoalProgress>(cycle).Charge,
|
|
||||||
"the loss never charges the goal across ticks.");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
fileFormatVersion: 2
|
|
||||||
guid: def6f8080b5a28d4eb9ee4781b283752
|
|
||||||
@@ -57,33 +57,31 @@ namespace ProjectM.Tests
|
|||||||
}
|
}
|
||||||
|
|
||||||
[Test]
|
[Test]
|
||||||
public void SpawnWave_Arms_PendingSiege()
|
public void SpawnWave_Forces_Next_Wave_Now()
|
||||||
{
|
{
|
||||||
var (world, group) = MakeWorld("DebugSpawnWave");
|
var (world, group) = MakeWorld("DebugSpawnWave");
|
||||||
using (world)
|
using (world)
|
||||||
{
|
{
|
||||||
var em = world.EntityManager;
|
var em = world.EntityManager;
|
||||||
var dir = em.CreateEntity(typeof(CycleState), typeof(ThreatState));
|
var wave = em.CreateEntity(typeof(WaveState));
|
||||||
em.SetComponentData(dir, new CycleState { Phase = CyclePhase.Calm });
|
em.SetComponentData(wave, new WaveState { Phase = WavePhase.Spawning, NextActionTick = 999999, RemainingToSpawn = 0 });
|
||||||
MakeRequest(em, DebugOp.SpawnWave, 8, 0, Entity.Null);
|
MakeRequest(em, DebugOp.SpawnWave, 0, 0, Entity.Null);
|
||||||
|
|
||||||
group.Update();
|
group.Update();
|
||||||
|
|
||||||
Assert.AreEqual(8, em.GetComponentData<ThreatState>(dir).PendingSiegeSize,
|
var w = em.GetComponentData<WaveState>(wave);
|
||||||
"SpawnWave arms a pending siege of the requested size.");
|
Assert.AreEqual(WavePhase.Lull, w.Phase, "SpawnWave resets to a due Lull so WaveSystem starts the next wave.");
|
||||||
|
Assert.AreEqual(0u, w.NextActionTick, "SpawnWave makes the next wave due immediately.");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
[Test]
|
[Test]
|
||||||
public void EndSiege_Forces_WaveState_Lull_And_Clears_Pending()
|
public void EndSiege_Quiets_The_Arena()
|
||||||
{
|
{
|
||||||
var (world, group) = MakeWorld("DebugEndSiege");
|
var (world, group) = MakeWorld("DebugEndSiege");
|
||||||
using (world)
|
using (world)
|
||||||
{
|
{
|
||||||
var em = world.EntityManager;
|
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));
|
var wave = em.CreateEntity(typeof(WaveState));
|
||||||
em.SetComponentData(wave, new WaveState { Phase = WavePhase.Spawning, RemainingToSpawn = 3 });
|
em.SetComponentData(wave, new WaveState { Phase = WavePhase.Spawning, RemainingToSpawn = 3 });
|
||||||
for (int i = 0; i < 2; i++)
|
for (int i = 0; i < 2; i++)
|
||||||
@@ -96,9 +94,9 @@ namespace ProjectM.Tests
|
|||||||
var w = em.GetComponentData<WaveState>(wave);
|
var w = em.GetComponentData<WaveState>(wave);
|
||||||
Assert.AreEqual(WavePhase.Lull, w.Phase, "EndSiege drives the wave to Lull.");
|
Assert.AreEqual(WavePhase.Lull, w.Phase, "EndSiege drives the wave to Lull.");
|
||||||
Assert.AreEqual(0, w.RemainingToSpawn, "EndSiege stops further spawning.");
|
Assert.AreEqual(0, w.RemainingToSpawn, "EndSiege stops further spawning.");
|
||||||
Assert.AreEqual(0, em.GetComponentData<ThreatState>(dir).PendingSiegeSize, "EndSiege clears any pending siege.");
|
Assert.AreNotEqual(0u, w.NextActionTick, "EndSiege pushes the next wave far out (quiet arena).");
|
||||||
using var husks = em.CreateEntityQuery(typeof(EnemyTag));
|
using (var husks = em.CreateEntityQuery(typeof(EnemyTag)))
|
||||||
Assert.AreEqual(0, husks.CalculateEntityCount(), "EndSiege culls the remaining Husks.");
|
Assert.AreEqual(0, husks.CalculateEntityCount(), "EndSiege culls the remaining Husks.");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,399 +0,0 @@
|
|||||||
using NUnit.Framework;
|
|
||||||
using ProjectM.Server;
|
|
||||||
using ProjectM.Simulation;
|
|
||||||
using Unity.Core;
|
|
||||||
using Unity.Entities;
|
|
||||||
using Unity.NetCode;
|
|
||||||
|
|
||||||
namespace ProjectM.Tests
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// END-2 (SL-3) — plain-Entities EditMode tests for the final-siege win/lose spine: <see cref="GoalReachedSystem"/>
|
|
||||||
/// arming + <see cref="CyclePhaseSystem"/>'s FinalDefense-gated Victory/Loss latches + the
|
|
||||||
/// <see cref="ThreatDirectorSystem"/> SiegeTimeout guard. A bare world is seeded with a NetworkTime singleton and a
|
|
||||||
/// CycleDirector entity carrying the full run-state set (CycleState/CycleRuntime/ThreatState/ThreatConfig/
|
|
||||||
/// GoalProgress/CoreIntegrity/RunPhase/RunOutcome/SaveRequest + a ledger). These pin: the goal cap arms a bigger
|
|
||||||
/// final siege EXACTLY once and CyclePhaseSystem enters it once; a survived NORMAL siege no longer charges the goal (DR-042); a survived final siege
|
|
||||||
/// latches Victory (no extra charge); a Core breach during the final siege latches Loss with NONE of the END-1
|
|
||||||
/// soft-loss side effects (no ledger drain, no OverrunTick); a NORMAL-phase overrun STILL takes the END-1 soft
|
|
||||||
/// path (the key regression); a restored Victory does not re-arm; and the SiegeTimeout cull is disabled during the
|
|
||||||
/// final siege so a timeout can't fake a Victory. All timing is wrap-safe NetworkTick math.
|
|
||||||
/// </summary>
|
|
||||||
public class EndgameWinLoseTests
|
|
||||||
{
|
|
||||||
// ---- harness ----
|
|
||||||
|
|
||||||
static (World world, SimulationSystemGroup group) MakeWorld(string name, uint serverTick)
|
|
||||||
{
|
|
||||||
var world = new World(name);
|
|
||||||
var group = world.GetOrCreateSystemManaged<SimulationSystemGroup>();
|
|
||||||
// CyclePhaseSystem then GoalReachedSystem ([UpdateAfter(CyclePhaseSystem)] is honored by SortSystems).
|
|
||||||
group.AddSystemToUpdateList(world.GetOrCreateSystem<CyclePhaseSystem>());
|
|
||||||
group.AddSystemToUpdateList(world.GetOrCreateSystem<GoalReachedSystem>());
|
|
||||||
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 (World world, SimulationSystemGroup group) MakeThreatWorld(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);
|
|
||||||
}
|
|
||||||
|
|
||||||
// SizeBase 5 / ScheduleSizePerWave 1 / immediate (delay 0) arm / no timeout — the END-2 arming math is
|
|
||||||
// (5 + 1*wave) * FinalSiegeMultiplier.
|
|
||||||
static ThreatConfig Cfg() => new ThreatConfig
|
|
||||||
{
|
|
||||||
PostExpeditionEnabled = 0,
|
|
||||||
ScheduleEnabled = 0,
|
|
||||||
PostExpeditionDelayTicks = 0,
|
|
||||||
SizeBase = 5,
|
|
||||||
ScheduleSizePerWave = 1,
|
|
||||||
StartCondition = ThreatStartCondition.Immediate,
|
|
||||||
SiegeTimeoutTicks = 0,
|
|
||||||
};
|
|
||||||
|
|
||||||
static Entity MakeDirector(EntityManager em, byte phase, int defendStartWave, int charge, int target,
|
|
||||||
int core, byte runPhase, byte runOutcome)
|
|
||||||
{
|
|
||||||
var e = em.CreateEntity();
|
|
||||||
em.AddComponentData(e, new CycleState { Phase = phase, PhaseEndTick = 0u, CycleNumber = 1 });
|
|
||||||
em.AddComponentData(e, new CycleRuntime { DefendStartWave = defendStartWave });
|
|
||||||
em.AddComponentData(e, new ThreatState());
|
|
||||||
em.AddComponentData(e, Cfg());
|
|
||||||
em.AddComponentData(e, new GoalProgress { Charge = charge, Target = target });
|
|
||||||
em.AddComponentData(e, new CoreIntegrity { Current = core, Max = 100, OverrunTick = 0u });
|
|
||||||
em.AddComponentData(e, new RunPhase { Value = runPhase });
|
|
||||||
em.AddComponentData(e, new RunOutcome { Value = runOutcome });
|
|
||||||
em.AddComponentData(e, new SaveRequest { Pending = 0 });
|
|
||||||
em.AddBuffer<StorageEntry>(e);
|
|
||||||
return e;
|
|
||||||
}
|
|
||||||
|
|
||||||
static Entity MakeWave(EntityManager em, int waveNumber, byte phase, int remaining)
|
|
||||||
{
|
|
||||||
var e = em.CreateEntity(typeof(WaveState));
|
|
||||||
em.SetComponentData(e, new WaveState { WaveNumber = waveNumber, Phase = phase, RemainingToSpawn = remaining });
|
|
||||||
return e;
|
|
||||||
}
|
|
||||||
|
|
||||||
static int ExpectedFinalSize(int sizeBase, int perWave, int wave)
|
|
||||||
=> (int)((sizeBase + perWave * wave) * TuningConfig.Defaults().FinalSiegeMultiplier);
|
|
||||||
|
|
||||||
// ---- tests ----
|
|
||||||
|
|
||||||
[Test]
|
|
||||||
public void GoalReached_Arms_Final_Siege_Then_CyclePhase_Enters_It_Once()
|
|
||||||
{
|
|
||||||
var (world, group) = MakeWorld("End2Arm", serverTick: 200);
|
|
||||||
using (world)
|
|
||||||
{
|
|
||||||
var em = world.EntityManager;
|
|
||||||
var dir = MakeDirector(em, CyclePhase.Calm, defendStartWave: 0, charge: 4, target: 4,
|
|
||||||
core: 100, RunPhaseId.Normal, RunOutcomeId.InProgress);
|
|
||||||
var wave = MakeWave(em, waveNumber: 4, phase: WavePhase.Lull, remaining: 0);
|
|
||||||
int expected = ExpectedFinalSize(5, 1, 4); // (5 + 4) * 2.5 = 22
|
|
||||||
|
|
||||||
// Tick 1: CyclePhase Calm (nothing pending) -> GoalReached arms the FINAL siege + flips FinalDefense.
|
|
||||||
group.Update();
|
|
||||||
Assert.AreEqual(expected, em.GetComponentData<ThreatState>(dir).PendingSiegeSize,
|
|
||||||
"final siege armed at (SizeBase + perWave*wave) * FinalSiegeMultiplier (visibly bigger than a normal siege).");
|
|
||||||
Assert.Greater(expected, 5 + 1 * 4, "the final siege is strictly larger than the would-be normal siege.");
|
|
||||||
Assert.AreEqual(RunPhaseId.FinalDefense, em.GetComponentData<RunPhase>(dir).Value,
|
|
||||||
"RunPhase flips to FinalDefense exactly when the goal cap is reached.");
|
|
||||||
Assert.AreEqual(CyclePhase.Calm, em.GetComponentData<CycleState>(dir).Phase,
|
|
||||||
"still Calm on the arm tick (CyclePhase consumes the pending siege the next tick).");
|
|
||||||
|
|
||||||
// Tick 2: CyclePhase Calm consumes the armed siege -> Siege; GoalReached no-ops (RunPhase != Normal).
|
|
||||||
group.Update();
|
|
||||||
Assert.AreEqual(CyclePhase.Siege, em.GetComponentData<CycleState>(dir).Phase,
|
|
||||||
"the final siege starts.");
|
|
||||||
Assert.AreEqual(expected, em.GetComponentData<WaveState>(wave).RemainingToSpawn,
|
|
||||||
"WaveState is seeded with the EXACT multiplied final-siege size.");
|
|
||||||
Assert.AreEqual(0, em.GetComponentData<ThreatState>(dir).PendingSiegeSize,
|
|
||||||
"the final siege is consumed exactly once (no re-arm by GoalReached while in FinalDefense).");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
[Test]
|
|
||||||
public void Survived_Normal_Siege_Neither_Charges_Goal_Nor_Arms_Final()
|
|
||||||
{
|
|
||||||
var (world, group) = MakeWorld("End2Clamp", serverTick: 200);
|
|
||||||
using (world)
|
|
||||||
{
|
|
||||||
var em = world.EntityManager;
|
|
||||||
// DR-042: surviving a NORMAL siege one short of the cap must neither charge the goal nor arm the final.
|
|
||||||
var dir = MakeDirector(em, CyclePhase.Siege, defendStartWave: 5, charge: 3, target: 4,
|
|
||||||
core: 100, RunPhaseId.Normal, RunOutcomeId.InProgress);
|
|
||||||
MakeWave(em, waveNumber: 6, phase: WavePhase.Spawning, remaining: 0); // DefendCleared
|
|
||||||
|
|
||||||
group.Update();
|
|
||||||
|
|
||||||
Assert.AreEqual(3, em.GetComponentData<GoalProgress>(dir).Charge,
|
|
||||||
"a survived normal siege does NOT charge the goal (DR-042: base-siege survival is not win-progress).");
|
|
||||||
Assert.AreEqual(RunPhaseId.Normal, em.GetComponentData<RunPhase>(dir).Value,
|
|
||||||
"the final siege is NOT armed by a survived siege near the cap.");
|
|
||||||
Assert.AreEqual(0, em.GetComponentData<ThreatState>(dir).PendingSiegeSize,
|
|
||||||
"nothing is armed (the cap is only crossed by an expedition clear).");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
[Test]
|
|
||||||
public void Victory_Latches_Once_On_Final_DefendCleared()
|
|
||||||
{
|
|
||||||
var (world, group) = MakeWorld("End2Victory", serverTick: 200);
|
|
||||||
using (world)
|
|
||||||
{
|
|
||||||
var em = world.EntityManager;
|
|
||||||
var dir = MakeDirector(em, CyclePhase.Siege, defendStartWave: 5, charge: 4, target: 4,
|
|
||||||
core: 100, RunPhaseId.FinalDefense, RunOutcomeId.InProgress);
|
|
||||||
MakeWave(em, waveNumber: 6, phase: WavePhase.Spawning, remaining: 0); // cleared, no husks alive
|
|
||||||
|
|
||||||
group.Update();
|
|
||||||
|
|
||||||
Assert.AreEqual(RunOutcomeId.Victory, em.GetComponentData<RunOutcome>(dir).Value,
|
|
||||||
"surviving the final siege latches Victory.");
|
|
||||||
Assert.AreEqual(CyclePhase.Calm, em.GetComponentData<CycleState>(dir).Phase, "the run ends in Calm.");
|
|
||||||
Assert.AreEqual(4, em.GetComponentData<GoalProgress>(dir).Charge,
|
|
||||||
"a Victory does NOT increment the already-capped goal.");
|
|
||||||
|
|
||||||
// A second tick must not change the latched outcome (GoalReached + the branch are inert once decided).
|
|
||||||
group.Update();
|
|
||||||
Assert.AreEqual(RunOutcomeId.Victory, em.GetComponentData<RunOutcome>(dir).Value,
|
|
||||||
"Victory is latched (stable across ticks).");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
[Test]
|
|
||||||
public void Loss_Latches_On_Final_Core_Breach_Without_Soft_Side_Effects()
|
|
||||||
{
|
|
||||||
var (world, group) = MakeWorld("End2Loss", serverTick: 200);
|
|
||||||
using (world)
|
|
||||||
{
|
|
||||||
var em = world.EntityManager;
|
|
||||||
var dir = MakeDirector(em, CyclePhase.Siege, defendStartWave: 5, charge: 4, target: 4,
|
|
||||||
core: 0, RunPhaseId.FinalDefense, RunOutcomeId.InProgress); // Core breached during the final siege
|
|
||||||
var ledger = em.GetBuffer<StorageEntry>(dir);
|
|
||||||
ledger.Add(new StorageEntry { ItemId = ResourceId.Ore, Count = 100 });
|
|
||||||
ledger.Add(new StorageEntry { ItemId = ResourceId.Charge, Count = 40 });
|
|
||||||
MakeWave(em, waveNumber: 6, phase: WavePhase.Spawning, remaining: 3);
|
|
||||||
em.CreateEntity(typeof(EnemyTag), typeof(RegionTag));
|
|
||||||
em.CreateEntity(typeof(EnemyTag), typeof(RegionTag));
|
|
||||||
|
|
||||||
group.Update();
|
|
||||||
|
|
||||||
Assert.AreEqual(RunOutcomeId.Loss, em.GetComponentData<RunOutcome>(dir).Value,
|
|
||||||
"a Core breach during the FINAL siege latches a terminal Loss.");
|
|
||||||
Assert.AreEqual(CyclePhase.Calm, em.GetComponentData<CycleState>(dir).Phase, "the run ends.");
|
|
||||||
var l = em.GetBuffer<StorageEntry>(dir);
|
|
||||||
Assert.AreEqual(100, l[0].Count, "terminal Loss does NOT drain the ledger (unlike the soft overrun).");
|
|
||||||
Assert.AreEqual(40, l[1].Count, "terminal Loss does NOT drain the ledger.");
|
|
||||||
Assert.AreEqual(0u, em.GetComponentData<CoreIntegrity>(dir).OverrunTick,
|
|
||||||
"terminal Loss does NOT stamp OverrunTick (the dedicated Loss banner shows instead of the soft flash).");
|
|
||||||
using var huskQ = em.CreateEntityQuery(typeof(EnemyTag));
|
|
||||||
Assert.AreEqual(0, huskQ.CalculateEntityCount(), "the siege disperses (remaining husks despawned).");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
[Test]
|
|
||||||
public void Normal_Overrun_Stays_Soft_When_RunPhase_Normal()
|
|
||||||
{
|
|
||||||
// REGRESSION: END-2 must not change END-1's soft-loss for a NORMAL (non-final) siege overrun.
|
|
||||||
var (world, group) = MakeWorld("End2NormalSoft", serverTick: 200);
|
|
||||||
using (world)
|
|
||||||
{
|
|
||||||
var em = world.EntityManager;
|
|
||||||
var dir = MakeDirector(em, CyclePhase.Siege, defendStartWave: 5, charge: 3, target: 10,
|
|
||||||
core: 0, RunPhaseId.Normal, RunOutcomeId.InProgress); // breached, but NOT the final siege
|
|
||||||
var ledger = em.GetBuffer<StorageEntry>(dir);
|
|
||||||
ledger.Add(new StorageEntry { ItemId = ResourceId.Ore, Count = 100 });
|
|
||||||
ledger.Add(new StorageEntry { ItemId = ResourceId.Charge, Count = 40 });
|
|
||||||
MakeWave(em, waveNumber: 6, phase: WavePhase.Spawning, remaining: 0);
|
|
||||||
em.CreateEntity(typeof(EnemyTag), typeof(RegionTag));
|
|
||||||
em.CreateEntity(typeof(EnemyTag), typeof(RegionTag));
|
|
||||||
|
|
||||||
group.Update();
|
|
||||||
|
|
||||||
Assert.AreEqual(CyclePhase.Calm, em.GetComponentData<CycleState>(dir).Phase, "the soft loss ends the siege -> Calm.");
|
|
||||||
Assert.AreEqual(RunOutcomeId.InProgress, em.GetComponentData<RunOutcome>(dir).Value,
|
|
||||||
"a NORMAL overrun must NOT latch a terminal outcome (END-1 soft-loss preserved).");
|
|
||||||
var l = em.GetBuffer<StorageEntry>(dir);
|
|
||||||
Assert.AreEqual(50, l[0].Count, "the soft loss drains the ledger 50% (END-1 behaviour, unchanged).");
|
|
||||||
Assert.AreEqual(20, l[1].Count, "the soft loss drains the ledger 50%.");
|
|
||||||
Assert.AreNotEqual(0u, em.GetComponentData<CoreIntegrity>(dir).OverrunTick,
|
|
||||||
"the soft loss stamps OverrunTick for the HUD flash (END-1 behaviour, unchanged).");
|
|
||||||
Assert.AreEqual(3, em.GetComponentData<GoalProgress>(dir).Charge, "no goal charge on a loss.");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
[Test]
|
|
||||||
public void Restored_Victory_Does_Not_Rearm_Final_Siege()
|
|
||||||
{
|
|
||||||
// Born-correct of a finished-run Continue (SaveData v5): RunOutcome=Victory restored; RunPhase boots Normal
|
|
||||||
// (server-only, not persisted). The RunOutcome guard must keep GoalReached inert so the win is durable.
|
|
||||||
var (world, group) = MakeWorld("End2Restore", serverTick: 200);
|
|
||||||
using (world)
|
|
||||||
{
|
|
||||||
var em = world.EntityManager;
|
|
||||||
var dir = MakeDirector(em, CyclePhase.Calm, defendStartWave: 0, charge: 4, target: 4,
|
|
||||||
core: 100, RunPhaseId.Normal, RunOutcomeId.Victory);
|
|
||||||
MakeWave(em, waveNumber: 4, phase: WavePhase.Lull, remaining: 0);
|
|
||||||
|
|
||||||
group.Update();
|
|
||||||
|
|
||||||
Assert.AreEqual(0, em.GetComponentData<ThreatState>(dir).PendingSiegeSize,
|
|
||||||
"a restored Victory does NOT re-arm the final siege (Continue loads finished).");
|
|
||||||
Assert.AreEqual(RunPhaseId.Normal, em.GetComponentData<RunPhase>(dir).Value, "RunPhase stays Normal (no flip).");
|
|
||||||
Assert.AreEqual(RunOutcomeId.Victory, em.GetComponentData<RunOutcome>(dir).Value, "the win persists.");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
[Test]
|
|
||||||
public void Final_Siege_Is_Not_Culled_By_SiegeTimeout()
|
|
||||||
{
|
|
||||||
// F5: the SiegeTimeout cull must be disabled during the final siege — otherwise a timeout-cull trips
|
|
||||||
// DefendCleared and fakes a Victory. (The NORMAL-phase timeout cull is covered by ThreatDirectorSystemTests.)
|
|
||||||
var (world, group) = MakeThreatWorld("End2NoTimeoutCull", serverTick: 1000);
|
|
||||||
using (world)
|
|
||||||
{
|
|
||||||
var em = world.EntityManager;
|
|
||||||
var e = em.CreateEntity();
|
|
||||||
em.AddComponentData(e, new CycleState { Phase = CyclePhase.Siege, CycleNumber = 1 });
|
|
||||||
var cfg = Cfg();
|
|
||||||
cfg.SiegeTimeoutTicks = 10; // would normally fire: 1000 - 900 = 100 ticks elapsed >> 10
|
|
||||||
em.AddComponentData(e, cfg);
|
|
||||||
em.AddComponentData(e, new ThreatState { SiegeStartTick = 900 });
|
|
||||||
em.AddComponentData(e, new RunPhase { Value = RunPhaseId.FinalDefense });
|
|
||||||
em.AddComponentData(e, new RunOutcome { Value = RunOutcomeId.InProgress });
|
|
||||||
var w = em.CreateEntity(typeof(WaveState));
|
|
||||||
em.SetComponentData(w, new WaveState { RemainingToSpawn = 5, Phase = WavePhase.Spawning });
|
|
||||||
for (int i = 0; i < 3; i++)
|
|
||||||
em.CreateEntity(typeof(EnemyTag), typeof(RegionTag)); // base husks (RegionTag defaults to Base)
|
|
||||||
|
|
||||||
group.Update();
|
|
||||||
|
|
||||||
using var huskQ = em.CreateEntityQuery(typeof(EnemyTag));
|
|
||||||
Assert.AreEqual(3, huskQ.CalculateEntityCount(),
|
|
||||||
"the final siege is NOT culled by SiegeTimeout (a cull would fake a Victory).");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- review-driven additions: M-3/N-4 (full-pipeline arming) + M-4 (multiplier) ----
|
|
||||||
|
|
||||||
static (World world, SimulationSystemGroup group) MakeFullWorld(string name, uint serverTick)
|
|
||||||
{
|
|
||||||
var world = new World(name);
|
|
||||||
var group = world.GetOrCreateSystemManaged<SimulationSystemGroup>();
|
|
||||||
// Sorted by attributes: ThreatDirector [UpdateBefore CyclePhase] -> CyclePhase -> GoalReached [UpdateAfter].
|
|
||||||
group.AddSystemToUpdateList(world.GetOrCreateSystem<ThreatDirectorSystem>());
|
|
||||||
group.AddSystemToUpdateList(world.GetOrCreateSystem<CyclePhaseSystem>());
|
|
||||||
group.AddSystemToUpdateList(world.GetOrCreateSystem<GoalReachedSystem>());
|
|
||||||
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 void SetServerTick(World world, uint tick)
|
|
||||||
{
|
|
||||||
var em = world.EntityManager;
|
|
||||||
using var q = em.CreateEntityQuery(typeof(NetworkTime));
|
|
||||||
em.SetComponentData(q.GetSingletonEntity(), new NetworkTime { ServerTick = new NetworkTick(tick) });
|
|
||||||
}
|
|
||||||
|
|
||||||
[Test]
|
|
||||||
public void Final_Siege_Arms_On_Goal_Edge_Through_Pipeline_Not_Stomped_By_Scheduler()
|
|
||||||
{
|
|
||||||
// M-3 + N-4: drive the REAL cross-system handoff (ThreatDirector -> CyclePhase -> GoalReached) over the
|
|
||||||
// Charge edge (now crossed by an EXPEDITION CLEAR in production; PRE-SEEDED at Target here), then prove a DUE scheduled source can't stomp the armed final
|
|
||||||
// siege (the FinalDefense + PendingSiegeSize!=0 guards) and the FINAL size flows through to the wave.
|
|
||||||
var (world, group) = MakeFullWorld("End2Pipeline", serverTick: 200);
|
|
||||||
using (world)
|
|
||||||
{
|
|
||||||
var em = world.EntityManager;
|
|
||||||
var dir = MakeDirector(em, CyclePhase.Siege, defendStartWave: 5, charge: 4, target: 4,
|
|
||||||
core: 100, RunPhaseId.Normal, RunOutcomeId.InProgress);
|
|
||||||
var cfg = Cfg(); cfg.ScheduleEnabled = 1; cfg.ScheduleIntervalTicks = 100;
|
|
||||||
em.SetComponentData(dir, cfg);
|
|
||||||
em.SetComponentData(dir, new ThreatState { NextScheduledTick = 150 }); // a scheduled siege is pending
|
|
||||||
var wave = MakeWave(em, waveNumber: 6, phase: WavePhase.Spawning, remaining: 0); // DefendCleared this tick
|
|
||||||
int expected = ExpectedFinalSize(5, 1, 6); // (5 + 6) * 2.5 = 27
|
|
||||||
|
|
||||||
// Tick 1: ThreatDirector (Siege -> no arm) -> CyclePhase (survive -> Calm, Charge stays at cap) -> GoalReached (arm).
|
|
||||||
group.Update();
|
|
||||||
Assert.AreEqual(4, em.GetComponentData<GoalProgress>(dir).Charge,
|
|
||||||
"Charge sits at the cap (crossed by an expedition clear in production; survived sieges no longer credit — DR-042).");
|
|
||||||
Assert.AreEqual(RunPhaseId.FinalDefense, em.GetComponentData<RunPhase>(dir).Value,
|
|
||||||
"GoalReached flips FinalDefense the same tick the Charge edge is crossed.");
|
|
||||||
Assert.AreEqual(expected, em.GetComponentData<ThreatState>(dir).PendingSiegeSize,
|
|
||||||
"the final siege is armed at the multiplied size.");
|
|
||||||
|
|
||||||
// Advance the clock so the scheduled source is DUE, then tick: it must NOT stomp the armed final siege;
|
|
||||||
// CyclePhase consumes it into the wave at the FINAL size (not a scheduled SizeBase).
|
|
||||||
SetServerTick(world, 400);
|
|
||||||
group.Update();
|
|
||||||
Assert.AreEqual(CyclePhase.Siege, em.GetComponentData<CycleState>(dir).Phase, "the final siege starts.");
|
|
||||||
Assert.AreEqual(expected, em.GetComponentData<WaveState>(wave).RemainingToSpawn,
|
|
||||||
"the FINAL size (not a scheduled SizeBase) is seeded -> the due scheduler did not stomp it.");
|
|
||||||
Assert.AreEqual(0, em.GetComponentData<ThreatState>(dir).PendingSiegeSize, "consumed exactly once.");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
[Test]
|
|
||||||
public void FinalSiegeMultiplier_LiveOverride_Scales_Final_Size()
|
|
||||||
{
|
|
||||||
var (world, group) = MakeWorld("End2MultOverride", serverTick: 200);
|
|
||||||
using (world)
|
|
||||||
{
|
|
||||||
var em = world.EntityManager;
|
|
||||||
var dir = MakeDirector(em, CyclePhase.Calm, defendStartWave: 0, charge: 4, target: 4,
|
|
||||||
core: 100, RunPhaseId.Normal, RunOutcomeId.InProgress);
|
|
||||||
MakeWave(em, waveNumber: 4, phase: WavePhase.Lull, remaining: 0);
|
|
||||||
var tc = em.CreateEntity(typeof(TuningConfig));
|
|
||||||
var cfg = TuningConfig.Defaults(); cfg.FinalSiegeMultiplier = 1.5f;
|
|
||||||
em.SetComponentData(tc, cfg);
|
|
||||||
|
|
||||||
group.Update();
|
|
||||||
|
|
||||||
int normal = 5 + 1 * 4; // 9
|
|
||||||
Assert.AreEqual((int)(normal * 1.5f), em.GetComponentData<ThreatState>(dir).PendingSiegeSize,
|
|
||||||
"the final size scales by the LIVE FinalSiegeMultiplier (1.5x), not the 2.5 default.");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
[Test]
|
|
||||||
public void FinalSiegeMultiplier_Below_One_Floors_To_Normal_Size()
|
|
||||||
{
|
|
||||||
var (world, group) = MakeWorld("End2MultFloor", serverTick: 200);
|
|
||||||
using (world)
|
|
||||||
{
|
|
||||||
var em = world.EntityManager;
|
|
||||||
var dir = MakeDirector(em, CyclePhase.Calm, defendStartWave: 0, charge: 4, target: 4,
|
|
||||||
core: 100, RunPhaseId.Normal, RunOutcomeId.InProgress);
|
|
||||||
MakeWave(em, waveNumber: 4, phase: WavePhase.Lull, remaining: 0);
|
|
||||||
var tc = em.CreateEntity(typeof(TuningConfig));
|
|
||||||
var cfg = TuningConfig.Defaults(); cfg.FinalSiegeMultiplier = 0.5f; // degenerate sub-1
|
|
||||||
em.SetComponentData(tc, cfg);
|
|
||||||
|
|
||||||
group.Update();
|
|
||||||
|
|
||||||
int normal = 5 + 1 * 4; // 9
|
|
||||||
Assert.AreEqual(normal, em.GetComponentData<ThreatState>(dir).PendingSiegeSize,
|
|
||||||
"a sub-1 multiplier floors at 1x (math.max(1,...)) -> the final siege is never smaller than a normal one.");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
fileFormatVersion: 2
|
|
||||||
guid: 51539ff68b0fdfe4da4b0210ac19afb5
|
|
||||||
@@ -1,223 +0,0 @@
|
|||||||
using NUnit.Framework;
|
|
||||||
using ProjectM.Client;
|
|
||||||
using ProjectM.Simulation;
|
|
||||||
|
|
||||||
namespace ProjectM.Tests
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// Pure-logic coverage for the first-run onboarding step machine (<see cref="OnboardingStepMath"/>) — the
|
|
||||||
/// testable core of the client-only <c>OnboardingSystem</c>. No World/ECS needed: each case builds a
|
|
||||||
/// <see cref="OnboardingStepMath.Snapshot"/> and asserts the deterministic advance rule, the mask helpers,
|
|
||||||
/// the scheme-aware prompts, and the pointer kinds.
|
|
||||||
/// </summary>
|
|
||||||
public class OnboardingStepTests
|
|
||||||
{
|
|
||||||
static OnboardingStepMath.Snapshot Empty() => new OnboardingStepMath.Snapshot();
|
|
||||||
|
|
||||||
// ---- mask helpers (resume point + dormant detection) ----
|
|
||||||
|
|
||||||
[Test]
|
|
||||||
public void FirstIncomplete_EmptyMask_IsWelcome()
|
|
||||||
=> Assert.AreEqual(OnboardingStepMath.Welcome, OnboardingStepMath.FirstIncomplete(0));
|
|
||||||
|
|
||||||
[Test]
|
|
||||||
public void FirstIncomplete_SkipsCompletedPrefix()
|
|
||||||
{
|
|
||||||
int mask = (1 << OnboardingStepMath.Welcome) | (1 << OnboardingStepMath.Move) | (1 << OnboardingStepMath.Build);
|
|
||||||
Assert.AreEqual(OnboardingStepMath.Fabricator, OnboardingStepMath.FirstIncomplete(mask));
|
|
||||||
}
|
|
||||||
|
|
||||||
[Test]
|
|
||||||
public void AllComplete_TrueForFullMaskAndMigrationSentinel()
|
|
||||||
{
|
|
||||||
Assert.IsFalse(OnboardingStepMath.AllComplete(0));
|
|
||||||
int full = (1 << OnboardingStepMath.StepCount) - 1;
|
|
||||||
Assert.IsTrue(OnboardingStepMath.AllComplete(full));
|
|
||||||
Assert.IsTrue(OnboardingStepMath.AllComplete(int.MaxValue)); // the v1->v2 migration sentinel reads as done
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- per-step completion rules ----
|
|
||||||
|
|
||||||
[Test]
|
|
||||||
public void Welcome_AdvancesOnTimer()
|
|
||||||
{
|
|
||||||
var s = Empty(); s.StepElapsed = OnboardingStepMath.WelcomeSeconds - 0.1f;
|
|
||||||
Assert.IsFalse(OnboardingStepMath.IsSatisfied(OnboardingStepMath.Welcome, s));
|
|
||||||
s.StepElapsed = OnboardingStepMath.WelcomeSeconds;
|
|
||||||
Assert.IsTrue(OnboardingStepMath.IsSatisfied(OnboardingStepMath.Welcome, s));
|
|
||||||
}
|
|
||||||
|
|
||||||
[Test]
|
|
||||||
public void Move_AdvancesAfterThreshold()
|
|
||||||
{
|
|
||||||
var s = Empty(); s.MoveDistance = OnboardingStepMath.MoveThreshold - 0.1f;
|
|
||||||
Assert.IsFalse(OnboardingStepMath.IsSatisfied(OnboardingStepMath.Move, s));
|
|
||||||
s.MoveDistance = OnboardingStepMath.MoveThreshold;
|
|
||||||
Assert.IsTrue(OnboardingStepMath.IsSatisfied(OnboardingStepMath.Move, s));
|
|
||||||
}
|
|
||||||
|
|
||||||
[Test]
|
|
||||||
public void ReadyUp_AdvancesOnLocalReadyOrLaunch()
|
|
||||||
{
|
|
||||||
var s = Empty(); s.Lifecycle = RunLifecycle.Staging;
|
|
||||||
Assert.IsFalse(OnboardingStepMath.IsSatisfied(OnboardingStepMath.ReadyUp, s));
|
|
||||||
var ready = Empty(); ready.LocalReady = true;
|
|
||||||
Assert.IsTrue(OnboardingStepMath.IsSatisfied(OnboardingStepMath.ReadyUp, ready));
|
|
||||||
var launched = Empty(); launched.Lifecycle = RunLifecycle.Launching;
|
|
||||||
Assert.IsTrue(OnboardingStepMath.IsSatisfied(OnboardingStepMath.ReadyUp, launched));
|
|
||||||
}
|
|
||||||
|
|
||||||
[Test]
|
|
||||||
public void Build_AbsoluteTurretCount_AutoSuppressesAtBuiltBase()
|
|
||||||
{
|
|
||||||
var s = Empty(); s.TurretCount = 0;
|
|
||||||
Assert.IsFalse(OnboardingStepMath.IsSatisfied(OnboardingStepMath.Build, s));
|
|
||||||
s.TurretCount = 1; // a join-client landing at an already-built base satisfies it on entry
|
|
||||||
Assert.IsTrue(OnboardingStepMath.IsSatisfied(OnboardingStepMath.Build, s));
|
|
||||||
}
|
|
||||||
|
|
||||||
[Test]
|
|
||||||
public void Fabricator_SoftBeat_AdvancesOnBuildOrTimeout()
|
|
||||||
{
|
|
||||||
var none = Empty();
|
|
||||||
Assert.IsFalse(OnboardingStepMath.IsSatisfied(OnboardingStepMath.Fabricator, none));
|
|
||||||
var built = Empty(); built.FabricatorCount = 1;
|
|
||||||
Assert.IsTrue(OnboardingStepMath.IsSatisfied(OnboardingStepMath.Fabricator, built));
|
|
||||||
var timedOut = Empty(); timedOut.StepElapsed = OnboardingStepMath.FabricatorSoftSeconds;
|
|
||||||
Assert.IsTrue(OnboardingStepMath.IsSatisfied(OnboardingStepMath.Fabricator, timedOut));
|
|
||||||
}
|
|
||||||
|
|
||||||
[Test]
|
|
||||||
public void Rooms_AdvancesInRoomAfterMinimumDwell()
|
|
||||||
{
|
|
||||||
var s = Empty();
|
|
||||||
Assert.IsFalse(OnboardingStepMath.IsSatisfied(OnboardingStepMath.Rooms, s));
|
|
||||||
var onExpTooSoon = Empty(); onExpTooSoon.OnExpedition = true; onExpTooSoon.StepElapsed = OnboardingStepMath.RoomsSeconds - 0.1f;
|
|
||||||
Assert.IsFalse(OnboardingStepMath.IsSatisfied(OnboardingStepMath.Rooms, onExpTooSoon)); // D2: don't advance the instant we teleport in
|
|
||||||
var dwelled = Empty(); dwelled.OnExpedition = true; dwelled.StepElapsed = OnboardingStepMath.RoomsSeconds;
|
|
||||||
Assert.IsTrue(OnboardingStepMath.IsSatisfied(OnboardingStepMath.Rooms, dwelled)); // the mine prompt + node pointer showed IN the room
|
|
||||||
var elapsedAtBase = Empty(); elapsedAtBase.StepElapsed = OnboardingStepMath.RoomsSeconds + 5f;
|
|
||||||
Assert.IsFalse(OnboardingStepMath.IsSatisfied(OnboardingStepMath.Rooms, elapsedAtBase)); // elapsed alone (still at base) must NOT satisfy
|
|
||||||
}
|
|
||||||
|
|
||||||
[Test]
|
|
||||||
public void Boon_AdvancesOnClearedObjectiveOrRewardLifecycle()
|
|
||||||
{
|
|
||||||
var s = Empty(); s.ObjectiveState = ExpeditionObjectiveState.Active;
|
|
||||||
Assert.IsFalse(OnboardingStepMath.IsSatisfied(OnboardingStepMath.Boon, s));
|
|
||||||
s.ObjectiveState = ExpeditionObjectiveState.Cleared;
|
|
||||||
Assert.IsTrue(OnboardingStepMath.IsSatisfied(OnboardingStepMath.Boon, s));
|
|
||||||
var reward = Empty(); reward.Lifecycle = RunLifecycle.RoomReward;
|
|
||||||
Assert.IsTrue(OnboardingStepMath.IsSatisfied(OnboardingStepMath.Boon, reward));
|
|
||||||
var route = Empty(); route.Lifecycle = RunLifecycle.RouteSelect;
|
|
||||||
Assert.IsTrue(OnboardingStepMath.IsSatisfied(OnboardingStepMath.Boon, route));
|
|
||||||
}
|
|
||||||
|
|
||||||
[Test]
|
|
||||||
public void Return_AdvancesOnComingHomeAfterExpeditionOrTimeout()
|
|
||||||
{
|
|
||||||
var onExp = Empty(); onExp.WasOnExpedition = true; onExp.OnExpedition = true;
|
|
||||||
Assert.IsFalse(OnboardingStepMath.IsSatisfied(OnboardingStepMath.Return, onExp)); // still out on expedition
|
|
||||||
var neverLeftBase = Empty(); neverLeftBase.OnExpedition = false; // at base but never observed on expedition this step
|
|
||||||
Assert.IsFalse(OnboardingStepMath.IsSatisfied(OnboardingStepMath.Return, neverLeftBase)); // D3: a start-at-base Return must NOT instantly satisfy
|
|
||||||
var cameHome = Empty(); cameHome.WasOnExpedition = true; cameHome.OnExpedition = false;
|
|
||||||
Assert.IsTrue(OnboardingStepMath.IsSatisfied(OnboardingStepMath.Return, cameHome)); // was on expedition, now home
|
|
||||||
var timedOut = Empty(); timedOut.StepElapsed = OnboardingStepMath.ReturnMaxSeconds;
|
|
||||||
Assert.IsTrue(OnboardingStepMath.IsSatisfied(OnboardingStepMath.Return, timedOut)); // D3 soft backstop (never a 1-tick edge)
|
|
||||||
}
|
|
||||||
|
|
||||||
[Test]
|
|
||||||
public void Defend_WaitsForSiegeEndButTimesOutWithoutOne()
|
|
||||||
{
|
|
||||||
var mid = Empty(); mid.SawSiege = true; mid.Phase = CyclePhase.Siege;
|
|
||||||
Assert.IsFalse(OnboardingStepMath.IsSatisfied(OnboardingStepMath.Defend, mid));
|
|
||||||
var survived = Empty(); survived.SawSiege = true; survived.Phase = CyclePhase.Calm;
|
|
||||||
Assert.IsTrue(OnboardingStepMath.IsSatisfied(OnboardingStepMath.Defend, survived));
|
|
||||||
var noSiege = Empty(); noSiege.StepElapsed = OnboardingStepMath.DefendNoSiegeSeconds;
|
|
||||||
Assert.IsTrue(OnboardingStepMath.IsSatisfied(OnboardingStepMath.Defend, noSiege));
|
|
||||||
}
|
|
||||||
|
|
||||||
[Test]
|
|
||||||
public void Done_LingersThenCompletes()
|
|
||||||
{
|
|
||||||
var s = Empty(); s.StepElapsed = OnboardingStepMath.DoneSeconds - 0.1f;
|
|
||||||
Assert.IsFalse(OnboardingStepMath.IsSatisfied(OnboardingStepMath.Done, s));
|
|
||||||
s.StepElapsed = OnboardingStepMath.DoneSeconds;
|
|
||||||
Assert.IsTrue(OnboardingStepMath.IsSatisfied(OnboardingStepMath.Done, s));
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- prompts (scheme-aware, never empty) ----
|
|
||||||
|
|
||||||
[Test]
|
|
||||||
public void Prompts_NonEmptyForEveryStep()
|
|
||||||
{
|
|
||||||
for (byte i = 0; i < OnboardingStepMath.StepCount; i++)
|
|
||||||
{
|
|
||||||
Assert.IsNotEmpty(OnboardingStepMath.Prompt(i, false), "kbm step " + i);
|
|
||||||
Assert.IsNotEmpty(OnboardingStepMath.Prompt(i, true), "pad step " + i);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
[Test]
|
|
||||||
public void Prompts_AreSchemeAware()
|
|
||||||
{
|
|
||||||
StringAssert.Contains("WASD", OnboardingStepMath.Prompt(OnboardingStepMath.Move, false));
|
|
||||||
StringAssert.Contains("Tab", OnboardingStepMath.Prompt(OnboardingStepMath.Build, false));
|
|
||||||
StringAssert.Contains("Y", OnboardingStepMath.Prompt(OnboardingStepMath.Build, true));
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- pointer kinds ----
|
|
||||||
|
|
||||||
[Test]
|
|
||||||
public void PointerKinds_OnlyTheRoomsStepPoints()
|
|
||||||
{
|
|
||||||
Assert.AreEqual(OnboardingStepMath.PointerOreNode, OnboardingStepMath.PointerKind(OnboardingStepMath.Rooms));
|
|
||||||
Assert.AreEqual(OnboardingStepMath.PointerNone, OnboardingStepMath.PointerKind(OnboardingStepMath.ReadyUp));
|
|
||||||
Assert.AreEqual(OnboardingStepMath.PointerNone, OnboardingStepMath.PointerKind(OnboardingStepMath.Return));
|
|
||||||
Assert.AreEqual(OnboardingStepMath.PointerNone, OnboardingStepMath.PointerKind(OnboardingStepMath.Move));
|
|
||||||
Assert.AreEqual(OnboardingStepMath.PointerNone, OnboardingStepMath.PointerKind(OnboardingStepMath.Defend));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Public-surface coverage of the onboarding settings fields + their interaction with the dormant check
|
|
||||||
/// (the v1->v2 migration itself runs through the private SettingsService.Migrate at load — its EFFECT is
|
|
||||||
/// pinned here via the all-done sentinel + Defaults/Clamped, and end-to-end in the Play smoke).
|
|
||||||
/// </summary>
|
|
||||||
public class OnboardingSettingsTests
|
|
||||||
{
|
|
||||||
[Test]
|
|
||||||
public void Defaults_TutorialOn_MaskEmpty()
|
|
||||||
{
|
|
||||||
var d = GameSettings.Defaults();
|
|
||||||
Assert.AreEqual(1, d.TutorialHints);
|
|
||||||
Assert.AreEqual(0, d.OnboardingMask);
|
|
||||||
Assert.AreEqual(GameSettings.CurrentVersion, d.Version);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Test]
|
|
||||||
public void Clamped_NormalizesHints_PreservesMask()
|
|
||||||
{
|
|
||||||
var s = GameSettings.Defaults();
|
|
||||||
s.TutorialHints = 5; // out of the 0/1 range
|
|
||||||
s.OnboardingMask = 0x55; // an arbitrary bitmask must survive untouched
|
|
||||||
var c = s.Clamped();
|
|
||||||
Assert.AreEqual(1, c.TutorialHints);
|
|
||||||
Assert.AreEqual(0x55, c.OnboardingMask);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Test]
|
|
||||||
public void Defaults_ForceEachLaunch_Off()
|
|
||||||
=> Assert.AreEqual(0, GameSettings.Defaults().ForceOnboardingEachLaunch);
|
|
||||||
|
|
||||||
[Test]
|
|
||||||
public void Clamped_NormalizesForceEachLaunchToBool()
|
|
||||||
{
|
|
||||||
var s = GameSettings.Defaults();
|
|
||||||
s.ForceOnboardingEachLaunch = 7; // any non-zero collapses to the 0/1 dev flag
|
|
||||||
Assert.AreEqual(1, s.Clamped().ForceOnboardingEachLaunch);
|
|
||||||
s.ForceOnboardingEachLaunch = 0;
|
|
||||||
Assert.AreEqual(0, s.Clamped().ForceOnboardingEachLaunch);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
fileFormatVersion: 2
|
|
||||||
guid: 6b7226d166f601d43add545e1532c3e1
|
|
||||||
@@ -200,23 +200,6 @@ namespace ProjectM.Tests
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
[Test]
|
|
||||||
public void LaunchGuard_BlocksWhenOutcomeLatched()
|
|
||||||
{
|
|
||||||
var (world, group, dir) = MakeWorld();
|
|
||||||
using (world)
|
|
||||||
{
|
|
||||||
var em = world.EntityManager;
|
|
||||||
MakePlayer(em, 1);
|
|
||||||
var conn = MakeConnection(em, 1);
|
|
||||||
em.AddComponentData(dir, new RunOutcome { Value = RunOutcomeId.Victory });
|
|
||||||
|
|
||||||
SendToggle(em, conn, 1);
|
|
||||||
group.Update();
|
|
||||||
|
|
||||||
Assert.AreEqual(RunLifecycle.Staging, em.GetComponentData<RunInfo>(dir).Lifecycle,
|
|
||||||
"a decided run must not launch (F2 guard)");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ namespace ProjectM.Tests
|
|||||||
|
|
||||||
map = RunMapMath.Generate(Seed);
|
map = RunMapMath.Generate(Seed);
|
||||||
var dir = em.CreateEntity(typeof(RunInfo), typeof(RunRuntime), typeof(ExpeditionObjective),
|
var dir = em.CreateEntity(typeof(RunInfo), typeof(RunRuntime), typeof(ExpeditionObjective),
|
||||||
typeof(RouteCommand), typeof(PortalCommand), typeof(MetaCounters), typeof(GoalProgress), typeof(ThreatState), typeof(SaveRequest));
|
typeof(RouteCommand), typeof(PortalCommand), typeof(MetaCounters), typeof(SaveRequest));
|
||||||
em.SetComponentData(dir, new RunInfo
|
em.SetComponentData(dir, new RunInfo
|
||||||
{
|
{
|
||||||
Lifecycle = RunLifecycle.InRoom,
|
Lifecycle = RunLifecycle.InRoom,
|
||||||
@@ -52,7 +52,6 @@ namespace ProjectM.Tests
|
|||||||
ActiveSubSlot = (byte)(currentRoom & 1),
|
ActiveSubSlot = (byte)(currentRoom & 1),
|
||||||
RoomsClearedThisRun = currentRoom, // rooms before this one were cleared
|
RoomsClearedThisRun = currentRoom, // rooms before this one were cleared
|
||||||
});
|
});
|
||||||
em.SetComponentData(dir, new GoalProgress { Charge = 0, Target = 4 });
|
|
||||||
|
|
||||||
// Mid-run fixture: the launch edge would have stamped the roster tag (RunParticipant) — fabricate it.
|
// Mid-run fixture: the launch edge would have stamped the roster tag (RunParticipant) — fabricate it.
|
||||||
var player = em.CreateEntity(typeof(PlayerTag), typeof(PlayerReady), typeof(RegionTag),
|
var player = em.CreateEntity(typeof(PlayerTag), typeof(PlayerReady), typeof(RegionTag),
|
||||||
@@ -164,19 +163,14 @@ static int RoomEntities(EntityManager em)
|
|||||||
var info = em.GetComponentData<RunInfo>(dir);
|
var info = em.GetComponentData<RunInfo>(dir);
|
||||||
Assert.AreEqual(RunLifecycle.Staging, info.Lifecycle);
|
Assert.AreEqual(RunLifecycle.Staging, info.Lifecycle);
|
||||||
Assert.AreEqual(RegionId.Base, em.GetComponentData<RegionTag>(player).Region, "party home");
|
Assert.AreEqual(RegionId.Base, em.GetComponentData<RegionTag>(player).Region, "party home");
|
||||||
Assert.AreEqual(1, em.GetComponentData<GoalProgress>(dir).Charge, "win meter +1 on a boss clear");
|
|
||||||
var meta = em.GetComponentData<MetaCounters>(dir);
|
var meta = em.GetComponentData<MetaCounters>(dir);
|
||||||
Assert.AreEqual(1, meta.RunsCompleted, "run completed");
|
Assert.AreEqual(1, meta.RunsCompleted, "run completed");
|
||||||
Assert.AreEqual(bossLayer + 1, meta.MaxDepthReached, "honest depth = rooms actually cleared");
|
Assert.AreEqual(bossLayer + 1, meta.MaxDepthReached, "honest depth = rooms actually cleared");
|
||||||
var threat = em.GetComponentData<ThreatState>(dir);
|
|
||||||
Assert.AreEqual(1, threat.PendingReturns, "retaliation input carried (C7)");
|
|
||||||
Assert.AreEqual(1, threat.ExpeditionsCompleted);
|
|
||||||
Assert.AreEqual(1, em.GetComponentData<SaveRequest>(dir).Pending, "save checkpoint requested");
|
Assert.AreEqual(1, em.GetComponentData<SaveRequest>(dir).Pending, "save checkpoint requested");
|
||||||
Assert.AreEqual(1, info.RunsCompleted, "HUD mirror updated");
|
Assert.AreEqual(1, info.RunsCompleted, "HUD mirror updated");
|
||||||
|
|
||||||
group.Update();
|
group.Update();
|
||||||
group.Update();
|
group.Update();
|
||||||
Assert.AreEqual(1, em.GetComponentData<GoalProgress>(dir).Charge, "no double credit (F7)");
|
|
||||||
Assert.AreEqual(1, em.GetComponentData<MetaCounters>(dir).RunsCompleted);
|
Assert.AreEqual(1, em.GetComponentData<MetaCounters>(dir).RunsCompleted);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -195,12 +189,9 @@ static int RoomEntities(EntityManager em)
|
|||||||
group.Update(); // Returning: depth-only bank -> Staging
|
group.Update(); // Returning: depth-only bank -> Staging
|
||||||
|
|
||||||
Assert.AreEqual(RunLifecycle.Staging, em.GetComponentData<RunInfo>(dir).Lifecycle);
|
Assert.AreEqual(RunLifecycle.Staging, em.GetComponentData<RunInfo>(dir).Lifecycle);
|
||||||
Assert.AreEqual(0, em.GetComponentData<GoalProgress>(dir).Charge, "no win credit on an abort (D-F3)");
|
|
||||||
var meta = em.GetComponentData<MetaCounters>(dir);
|
var meta = em.GetComponentData<MetaCounters>(dir);
|
||||||
Assert.AreEqual(0, meta.RunsCompleted, "no completed-run credit");
|
Assert.AreEqual(0, meta.RunsCompleted, "no completed-run credit");
|
||||||
Assert.AreEqual(2, meta.MaxDepthReached, "honest depth: the 2 rooms actually cleared, not the plan");
|
Assert.AreEqual(2, meta.MaxDepthReached, "honest depth: the 2 rooms actually cleared, not the plan");
|
||||||
var threat = em.GetComponentData<ThreatState>(dir);
|
|
||||||
Assert.AreEqual(0, threat.PendingReturns, "no retaliation provoked by an abort");
|
|
||||||
Assert.AreEqual(0, em.GetComponentData<SaveRequest>(dir).Pending, "no save spam on abort");
|
Assert.AreEqual(0, em.GetComponentData<SaveRequest>(dir).Pending, "no save spam on abort");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ namespace ProjectM.Tests
|
|||||||
|
|
||||||
map = RunMapMath.Generate(Seed);
|
map = RunMapMath.Generate(Seed);
|
||||||
var dir = em.CreateEntity(typeof(RunInfo), typeof(RunRuntime), typeof(ExpeditionObjective),
|
var dir = em.CreateEntity(typeof(RunInfo), typeof(RunRuntime), typeof(ExpeditionObjective),
|
||||||
typeof(RouteCommand), typeof(MetaCounters), typeof(GoalProgress), typeof(ThreatState), typeof(SaveRequest));
|
typeof(RouteCommand), typeof(MetaCounters), typeof(SaveRequest));
|
||||||
return (world, group, dir);
|
return (world, group, dir);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,9 +6,10 @@ using UnityEngine;
|
|||||||
namespace ProjectM.Tests
|
namespace ProjectM.Tests
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Pure tests for the save FOUNDATION: the JSON schema round-trips (JsonUtility), version handling is safe,
|
/// Pure tests for the save FOUNDATION: the JSON schema round-trips (JsonUtility), version handling is safe
|
||||||
/// and the born-correct ledger apply (<see cref="SaveApply.WriteLedger"/>) the server spawn system uses to
|
/// (v7 is a FRESH EPOCH — older saves are rejected at MinLoadableVersion), and the born-correct ledger apply
|
||||||
/// overwrite a director's StorageEntry buffer from a staged PendingSave.
|
/// (<see cref="SaveApply.WriteLedger"/>) the server spawn system uses to overwrite a director's StorageEntry
|
||||||
|
/// buffer from a staged PendingSave.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public class SavePersistenceTests
|
public class SavePersistenceTests
|
||||||
{
|
{
|
||||||
@@ -17,8 +18,8 @@ namespace ProjectM.Tests
|
|||||||
{
|
{
|
||||||
var data = new SaveData
|
var data = new SaveData
|
||||||
{
|
{
|
||||||
GoalCharge = 42,
|
RunsCompleted = 7,
|
||||||
GoalTarget = 10,
|
MaxDepthReached = 9,
|
||||||
Ledger = new[]
|
Ledger = new[]
|
||||||
{
|
{
|
||||||
new LedgerRow { ItemId = 1, Count = 5 },
|
new LedgerRow { ItemId = 1, Count = 5 },
|
||||||
@@ -31,8 +32,8 @@ namespace ProjectM.Tests
|
|||||||
var back = JsonUtility.FromJson<SaveData>(json);
|
var back = JsonUtility.FromJson<SaveData>(json);
|
||||||
|
|
||||||
Assert.AreEqual(SaveData.CurrentVersion, back.Version);
|
Assert.AreEqual(SaveData.CurrentVersion, back.Version);
|
||||||
Assert.AreEqual(42, back.GoalCharge);
|
Assert.AreEqual(7, back.RunsCompleted);
|
||||||
Assert.AreEqual(10, back.GoalTarget);
|
Assert.AreEqual(9, back.MaxDepthReached);
|
||||||
Assert.AreEqual(2, back.Ledger.Length);
|
Assert.AreEqual(2, back.Ledger.Length);
|
||||||
Assert.AreEqual(1, back.Ledger[0].ItemId);
|
Assert.AreEqual(1, back.Ledger[0].ItemId);
|
||||||
Assert.AreEqual(5, back.Ledger[0].Count);
|
Assert.AreEqual(5, back.Ledger[0].Count);
|
||||||
@@ -46,20 +47,27 @@ namespace ProjectM.Tests
|
|||||||
{
|
{
|
||||||
Assert.DoesNotThrow(() => JsonUtility.FromJson<SaveData>("{}"));
|
Assert.DoesNotThrow(() => JsonUtility.FromJson<SaveData>("{}"));
|
||||||
|
|
||||||
var empty = new SaveData { GoalCharge = 0, GoalTarget = 10 };
|
var empty = new SaveData();
|
||||||
var back = JsonUtility.FromJson<SaveData>(JsonUtility.ToJson(empty));
|
var back = JsonUtility.FromJson<SaveData>(JsonUtility.ToJson(empty));
|
||||||
Assert.IsNotNull(back.Ledger);
|
Assert.IsNotNull(back.Ledger);
|
||||||
Assert.AreEqual(0, back.Ledger.Length);
|
Assert.AreEqual(0, back.Ledger.Length);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Test]
|
[Test]
|
||||||
public void SaveData_OldVersion_IsDetectable()
|
public void SaveData_Is_At_V7_Fresh_Epoch()
|
||||||
{
|
{
|
||||||
// A stale-version blob round-trips with its Version intact, so SaveService.Load rejects it (-> New Game).
|
Assert.AreEqual(7, SaveData.CurrentVersion, "SaveData is at v7 (the LANTERN fresh epoch).");
|
||||||
var old = new SaveData { Version = 0, GoalCharge = 7 };
|
Assert.AreEqual(7, SaveData.MinLoadableVersion, "v7 is a fresh epoch: no older save loads.");
|
||||||
var back = JsonUtility.FromJson<SaveData>(JsonUtility.ToJson(old));
|
}
|
||||||
Assert.AreEqual(0, back.Version);
|
|
||||||
Assert.AreNotEqual(SaveData.CurrentVersion, back.Version);
|
[Test]
|
||||||
|
public void Pre_V7_Save_Is_Below_The_Load_Floor()
|
||||||
|
{
|
||||||
|
// A v6 (co-op-Hades era) save round-trips with its Version intact and sits BELOW MinLoadableVersion,
|
||||||
|
// so SaveService.Load rejects it (-> New Game). Fresh epoch, operator-approved (LANTERN purge).
|
||||||
|
var back = JsonUtility.FromJson<SaveData>("{\"Version\":6,\"RunsCompleted\":3}");
|
||||||
|
Assert.AreEqual(6, back.Version);
|
||||||
|
Assert.Less(back.Version, SaveData.MinLoadableVersion, "a v6 save is rejected under the v7 fresh epoch.");
|
||||||
}
|
}
|
||||||
|
|
||||||
[Test]
|
[Test]
|
||||||
@@ -103,99 +111,29 @@ namespace ProjectM.Tests
|
|||||||
|
|
||||||
Assert.AreEqual(0, em.GetBuffer<StorageEntry>(e).Length);
|
Assert.AreEqual(0, em.GetBuffer<StorageEntry>(e).Length);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Test]
|
[Test]
|
||||||
public void StructureSave_HP_RoundTrips_And_Writes_V3()
|
public void StructureSave_HP_RoundTrips()
|
||||||
{
|
{
|
||||||
var data = new SaveData { Structures = new[] { new StructureSave { Type = 1, CellX = 1, CellZ = 2, HP = 37f } } };
|
var data = new SaveData { Structures = new[] { new StructureSave { Type = 1, CellX = 1, CellZ = 2, HP = 37f } } };
|
||||||
var back = JsonUtility.FromJson<SaveData>(JsonUtility.ToJson(data));
|
var back = JsonUtility.FromJson<SaveData>(JsonUtility.ToJson(data));
|
||||||
Assert.AreEqual(SaveData.CurrentVersion, back.Version, "new saves write the current version (v5 since END-2; v4 added Core, v3 HP).");
|
Assert.AreEqual(SaveData.CurrentVersion, back.Version, "new saves write the current version.");
|
||||||
Assert.AreEqual(1, back.Structures.Length);
|
Assert.AreEqual(1, back.Structures.Length);
|
||||||
Assert.AreEqual(37f, back.Structures[0].HP, 1e-4f, "the wounded HP round-trips through JSON.");
|
Assert.AreEqual(37f, back.Structures[0].HP, 1e-4f, "the wounded HP round-trips through JSON.");
|
||||||
}
|
}
|
||||||
|
|
||||||
[Test]
|
|
||||||
public void V2_Save_IsWithinLoadableRange_And_ZeroHp_Restores_Full()
|
|
||||||
{
|
|
||||||
// A pre-EB-1 v2 save sits inside the additive load floor [Min,Current], so SaveService.Load accepts it;
|
|
||||||
// an unset HP (0) is mapped by BaseRestoreSystem to the baked Max (structures come back at full HP).
|
|
||||||
var v2 = new SaveData { Version = 2, GoalCharge = 3, GoalTarget = 10, Structures = new[] { new StructureSave { Type = 1, CellX = 2, CellZ = 4 } } };
|
|
||||||
var back = JsonUtility.FromJson<SaveData>(JsonUtility.ToJson(v2));
|
|
||||||
Assert.AreEqual(2, back.Version);
|
|
||||||
Assert.GreaterOrEqual(back.Version, SaveData.MinLoadableVersion, "v2 is at/above the load floor.");
|
|
||||||
Assert.LessOrEqual(back.Version, SaveData.CurrentVersion);
|
|
||||||
Assert.AreEqual(0f, back.Structures[0].HP, 1e-4f, "unset HP (0) -> restore maps to baked Max.");
|
|
||||||
}
|
|
||||||
|
|
||||||
[Test]
|
[Test]
|
||||||
public void ToPending_Maps_All_Fields_Including_The_Wounded_HP()
|
public void ToPending_Maps_All_Fields_Including_The_Wounded_HP()
|
||||||
{
|
{
|
||||||
// The WorldLauncher save->stage copy: omitting any field here silently restores at full HP (review-caught).
|
// The WorldLauncher save->stage copy: omitting any field here silently restores at full HP (review-caught).
|
||||||
var s = new StructureSave { Type = 1, CellX = 3, CellZ = -2, Direction = 2, RemainingTicks = 50, ConveyorResId = 1, ConveyorCount = 4, HP = 37f };
|
var s = new StructureSave { Type = 1, CellX = 3, CellZ = -2, HP = 37f };
|
||||||
var p = SaveApply.ToPending(s);
|
var p = SaveApply.ToPending(s);
|
||||||
Assert.AreEqual(1, p.Type);
|
Assert.AreEqual(1, p.Type);
|
||||||
Assert.AreEqual(3, p.CellX);
|
Assert.AreEqual(3, p.CellX);
|
||||||
Assert.AreEqual(-2, p.CellZ);
|
Assert.AreEqual(-2, p.CellZ);
|
||||||
Assert.AreEqual(2, p.Direction);
|
|
||||||
Assert.AreEqual(50u, p.RemainingTicks);
|
|
||||||
Assert.AreEqual(1, p.ConveyorResId);
|
|
||||||
Assert.AreEqual(4, p.ConveyorCount);
|
|
||||||
Assert.AreEqual(37f, p.HP, 1e-4f, "the wounded HP survives the save->staging copy.");
|
Assert.AreEqual(37f, p.HP, 1e-4f, "the wounded HP survives the save->staging copy.");
|
||||||
}
|
}
|
||||||
|
|
||||||
[Test]
|
|
||||||
public void CoreCurrent_RoundTrips_And_Writes_Current_Version()
|
|
||||||
{
|
|
||||||
var data = new SaveData { GoalCharge = 1, GoalTarget = 10, CoreCurrent = 63 };
|
|
||||||
var back = JsonUtility.FromJson<SaveData>(JsonUtility.ToJson(data));
|
|
||||||
Assert.AreEqual(SaveData.CurrentVersion, back.Version, "new saves write the current version (v5 since END-2).");
|
|
||||||
Assert.AreEqual(63, back.CoreCurrent, "the wounded Core integrity round-trips through JSON.");
|
|
||||||
}
|
|
||||||
|
|
||||||
[Test]
|
|
||||||
public void Pre_END1_Save_Missing_CoreCurrent_Defaults_To_Zero()
|
|
||||||
{
|
|
||||||
// A pre-END-1 save JSON lacks the CoreCurrent field -> JsonUtility defaults it to 0, which the
|
|
||||||
// born-correct spawn maps to the baked Max (the Core comes back full). Additive: no field, no break.
|
|
||||||
var back = JsonUtility.FromJson<SaveData>("{\"Version\":3,\"GoalCharge\":2,\"GoalTarget\":10}");
|
|
||||||
Assert.AreEqual(0, back.CoreCurrent, "missing CoreCurrent -> 0 -> restored full at baked Max.");
|
|
||||||
Assert.GreaterOrEqual(back.Version, SaveData.MinLoadableVersion, "v3 stays within the additive load floor.");
|
|
||||||
Assert.LessOrEqual(back.Version, SaveData.CurrentVersion);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Test]
|
|
||||||
public void RunOutcome_RoundTrips_And_Writes_Current_Version()
|
|
||||||
{
|
|
||||||
var data = new SaveData { GoalCharge = 1, GoalTarget = 4, RunOutcome = RunOutcomeId.Victory };
|
|
||||||
var back = JsonUtility.FromJson<SaveData>(JsonUtility.ToJson(data));
|
|
||||||
Assert.AreEqual(SaveData.CurrentVersion, back.Version, "new saves write the current version.");
|
|
||||||
Assert.AreEqual(6, SaveData.CurrentVersion, "SaveData is at v6 (permanent meta: tier rows + run counters).");
|
|
||||||
Assert.AreEqual((int)RunOutcomeId.Victory, back.RunOutcome, "the latched terminal outcome round-trips through JSON.");
|
|
||||||
}
|
|
||||||
|
|
||||||
[Test]
|
|
||||||
public void Pre_END2_Save_Missing_RunOutcome_Defaults_To_InProgress()
|
|
||||||
{
|
|
||||||
// A pre-END-2 (v4) save JSON lacks RunOutcome -> JsonUtility defaults it to 0 (InProgress) -> the run loads
|
|
||||||
// as in-progress, NOT a finished run. Additive: no field, no break; v4 stays within the load floor.
|
|
||||||
var back = JsonUtility.FromJson<SaveData>("{\"Version\":4,\"GoalCharge\":2,\"GoalTarget\":10,\"CoreCurrent\":50}");
|
|
||||||
Assert.AreEqual((int)RunOutcomeId.InProgress, back.RunOutcome, "missing RunOutcome -> 0 (InProgress).");
|
|
||||||
Assert.GreaterOrEqual(back.Version, SaveData.MinLoadableVersion, "v4 stays within the additive load floor.");
|
|
||||||
Assert.LessOrEqual(back.Version, SaveData.CurrentVersion);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Test]
|
|
||||||
public void Pre_v6_Save_Missing_Meta_Defaults_To_Empty()
|
|
||||||
{
|
|
||||||
// A v5 save JSON lacks MetaUpgrades/RunsCompleted/MaxDepthReached -> the field initializer keeps the
|
|
||||||
// array EMPTY (never null) and the counters 0-default. Additive: no field, no break; v5 loads.
|
|
||||||
var back = JsonUtility.FromJson<SaveData>("{\"Version\":5,\"GoalCharge\":3,\"GoalTarget\":4}");
|
|
||||||
Assert.IsNotNull(back.MetaUpgrades, "missing MetaUpgrades -> empty array, never null.");
|
|
||||||
Assert.AreEqual(0, back.MetaUpgrades.Length);
|
|
||||||
Assert.AreEqual(0, back.RunsCompleted, "missing RunsCompleted -> 0 (StagePendingSave floors it to GoalCharge).");
|
|
||||||
Assert.AreEqual(0, back.MaxDepthReached);
|
|
||||||
Assert.GreaterOrEqual(back.Version, SaveData.MinLoadableVersion, "v5 stays within the additive load floor.");
|
|
||||||
}
|
|
||||||
|
|
||||||
[Test]
|
[Test]
|
||||||
public void MetaUpgrades_And_Counters_RoundTrip()
|
public void MetaUpgrades_And_Counters_RoundTrip()
|
||||||
{
|
{
|
||||||
@@ -218,6 +156,5 @@ namespace ProjectM.Tests
|
|||||||
Assert.AreEqual(3, back.MetaUpgrades[0].Tier);
|
Assert.AreEqual(3, back.MetaUpgrades[0].Tier);
|
||||||
Assert.AreEqual(200, back.MetaUpgrades[1].UpgradeId, "an unknown upgrade id is preserved on disk, not clamped at save time (preserve-don't-crash).");
|
Assert.AreEqual(200, back.MetaUpgrades[1].UpgradeId, "an unknown upgrade id is preserved on disk, not clamped at save time (preserve-don't-crash).");
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,49 +18,11 @@ namespace ProjectM.Tests
|
|||||||
return (world, e);
|
return (world, e);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Test]
|
|
||||||
public void DrainFraction_Removes_Floored_Fraction_Of_Each_Row()
|
|
||||||
{
|
|
||||||
var (world, e) = MakeWorld();
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var buf = world.EntityManager.GetBuffer<StorageEntry>(e);
|
|
||||||
StorageMath.Deposit(buf, 2, 100); // Ore
|
|
||||||
StorageMath.Deposit(buf, 4, 51); // Charge
|
|
||||||
StorageMath.DrainFraction(buf, 0.5f);
|
|
||||||
Assert.AreEqual(50, StorageMath.TotalOf(buf, 2), "100 -> floor(50) drained -> 50 left");
|
|
||||||
Assert.AreEqual(26, StorageMath.TotalOf(buf, 4), "51 -> floor(25) drained -> 26 left");
|
|
||||||
}
|
|
||||||
finally { world.Dispose(); }
|
|
||||||
}
|
|
||||||
|
|
||||||
[Test]
|
|
||||||
public void DrainFraction_Drops_Rows_That_Hit_Zero_And_Clamps_Above_One()
|
|
||||||
{
|
|
||||||
var (world, e) = MakeWorld();
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var buf = world.EntityManager.GetBuffer<StorageEntry>(e);
|
|
||||||
StorageMath.Deposit(buf, 2, 4);
|
|
||||||
StorageMath.DrainFraction(buf, 1.5f); // clamps to 1.0 -> removes all -> row dropped
|
|
||||||
Assert.AreEqual(0, buf.Length, "a fully-drained row is removed");
|
|
||||||
}
|
|
||||||
finally { world.Dispose(); }
|
|
||||||
}
|
|
||||||
|
|
||||||
[Test]
|
|
||||||
public void DrainFraction_Zero_Is_NoOp()
|
|
||||||
{
|
|
||||||
var (world, e) = MakeWorld();
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var buf = world.EntityManager.GetBuffer<StorageEntry>(e);
|
|
||||||
StorageMath.Deposit(buf, 2, 10);
|
|
||||||
StorageMath.DrainFraction(buf, 0f);
|
|
||||||
Assert.AreEqual(10, StorageMath.TotalOf(buf, 2), "0 fraction drains nothing");
|
|
||||||
}
|
|
||||||
finally { world.Dispose(); }
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
[Test]
|
[Test]
|
||||||
|
|||||||
@@ -27,13 +27,13 @@ namespace ProjectM.Tests
|
|||||||
// RPC-receive systems ordered before the run director
|
// RPC-receive systems ordered before the run director
|
||||||
Add<ReadyToggleSystem>(); Add<RouteSelectSystem>(); Add<PortalInteractReceiveSystem>();
|
Add<ReadyToggleSystem>(); Add<RouteSelectSystem>(); Add<PortalInteractReceiveSystem>();
|
||||||
Add<MetaSpendSystem>(); Add<ClassSelectReceiveSystem>(); Add<BoonApplySystem>(); Add<PrepPurchaseSystem>();
|
Add<MetaSpendSystem>(); Add<ClassSelectReceiveSystem>(); Add<BoonApplySystem>(); Add<PrepPurchaseSystem>();
|
||||||
// Run director + the systems ordered around it and the cycle phase
|
// Run director + the systems ordered around it (the cycle/siege spine is deleted — LANTERN purge)
|
||||||
Add<RunDirectorSystem>(); Add<ThreatDirectorSystem>(); Add<RoomFieldSystem>();
|
Add<RunDirectorSystem>(); Add<RoomFieldSystem>();
|
||||||
Add<RoomEnemyDirectorSystem>(); Add<BoonOfferSystem>(); Add<CyclePhaseSystem>();
|
Add<RoomEnemyDirectorSystem>(); Add<BoonOfferSystem>();
|
||||||
Add<GoalReachedSystem>(); Add<WaveSystem>();
|
Add<WaveSystem>();
|
||||||
// Combat sub-chain in the same group
|
// Combat sub-chain in the same group
|
||||||
Add<EnemyAISystem>(); Add<BossAISystem>(); Add<CoreDamageSystem>();
|
Add<EnemyAISystem>(); Add<BossAISystem>();
|
||||||
Add<CoreRestoreSystem>(); Add<EnemyProjectileMoveSystem>(); Add<EnemyProjectileDamageSystem>();
|
Add<EnemyProjectileMoveSystem>(); Add<EnemyProjectileDamageSystem>();
|
||||||
|
|
||||||
Assert.DoesNotThrow(() => group.SortSystems(),
|
Assert.DoesNotThrow(() => group.SortSystems(),
|
||||||
"A [UpdateBefore/After] cycle in the run/cycle/combat chain throws here instead of only at Play world-creation.");
|
"A [UpdateBefore/After] cycle in the run/cycle/combat chain throws here instead of only at Play world-creation.");
|
||||||
|
|||||||
@@ -1,50 +0,0 @@
|
|||||||
using NUnit.Framework;
|
|
||||||
using ProjectM.Simulation;
|
|
||||||
|
|
||||||
namespace ProjectM.Tests
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// Pins <see cref="SaveService.RollTerminalCampaignForward"/>: a TERMINAL save (Victory/Loss latched)
|
|
||||||
/// Continues as a fresh campaign — outcome / goal meter / core reset to 0 (the spawn restore guards re-map
|
|
||||||
/// 0 to InProgress / empty / baked-full) — while the permanent channel (meta tiers, run counters, ledger,
|
|
||||||
/// structures) survives untouched. In-progress saves and null are no-ops.
|
|
||||||
/// </summary>
|
|
||||||
public class TerminalCampaignRollTests
|
|
||||||
{
|
|
||||||
[Test]
|
|
||||||
public void TerminalSave_RollsForward_KeepingBaseAndMeta()
|
|
||||||
{
|
|
||||||
var data = new SaveData
|
|
||||||
{
|
|
||||||
RunOutcome = 1, // Victory latched
|
|
||||||
GoalCharge = 4,
|
|
||||||
GoalTarget = 4,
|
|
||||||
CoreCurrent = 37,
|
|
||||||
RunsCompleted = 4,
|
|
||||||
MaxDepthReached = 7,
|
|
||||||
MetaUpgrades = new[] { new MetaUpgradeSave { ClassId = 0, UpgradeId = 1, Tier = 2 } },
|
|
||||||
Ledger = new[] { new LedgerRow { ItemId = ResourceId.Ore, Count = 123 } },
|
|
||||||
};
|
|
||||||
|
|
||||||
SaveService.RollTerminalCampaignForward(data);
|
|
||||||
|
|
||||||
Assert.AreEqual(0, data.RunOutcome, "outcome latch reset");
|
|
||||||
Assert.AreEqual(0, data.GoalCharge, "goal meter reset");
|
|
||||||
Assert.AreEqual(0, data.CoreCurrent, "core resets to 0 -> restore guard re-maps to baked-full");
|
|
||||||
Assert.AreEqual(4, data.RunsCompleted, "permanent counters survive");
|
|
||||||
Assert.AreEqual(7, data.MaxDepthReached, "permanent counters survive");
|
|
||||||
Assert.AreEqual(2, data.MetaUpgrades[0].Tier, "meta tiers survive");
|
|
||||||
Assert.AreEqual(123, data.Ledger[0].Count, "the ledger survives");
|
|
||||||
}
|
|
||||||
|
|
||||||
[Test]
|
|
||||||
public void InProgressSave_AndNull_AreNoOps()
|
|
||||||
{
|
|
||||||
var data = new SaveData { RunOutcome = 0, GoalCharge = 2, CoreCurrent = 50 };
|
|
||||||
SaveService.RollTerminalCampaignForward(data);
|
|
||||||
Assert.AreEqual(2, data.GoalCharge);
|
|
||||||
Assert.AreEqual(50, data.CoreCurrent);
|
|
||||||
Assert.DoesNotThrow(() => SaveService.RollTerminalCampaignForward(null));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
fileFormatVersion: 2
|
|
||||||
guid: 085790610f52ea347aa4ccd208849316
|
|
||||||
@@ -1,205 +0,0 @@
|
|||||||
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++)
|
|
||||||
{
|
|
||||||
var h = em.CreateEntity(typeof(EnemyTag));
|
|
||||||
em.AddComponentData(h, new RegionTag { Region = RegionId.Base });
|
|
||||||
}
|
|
||||||
|
|
||||||
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.");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
[Test]
|
|
||||||
public void Schedule_First_Pass_Seeds_NextTick_Without_Firing()
|
|
||||||
{
|
|
||||||
var (world, group) = MakeWorld("ThreatScheduleSeed", serverTick: 200);
|
|
||||||
using (world)
|
|
||||||
{
|
|
||||||
var em = world.EntityManager;
|
|
||||||
var config = DefaultConfig();
|
|
||||||
config.PostExpeditionEnabled = 0;
|
|
||||||
config.ScheduleEnabled = 1;
|
|
||||||
config.ScheduleIntervalTicks = 100;
|
|
||||||
var dir = MakeDirector(em, CyclePhase.Calm, new ThreatState { NextScheduledTick = 0 }, config);
|
|
||||||
|
|
||||||
group.Update();
|
|
||||||
|
|
||||||
var ts = em.GetComponentData<ThreatState>(dir);
|
|
||||||
Assert.AreEqual(300u, ts.NextScheduledTick, "The first pass seeds the next scheduled tick one interval out (200 + 100).");
|
|
||||||
Assert.AreEqual(0, ts.PendingSiegeSize, "The first pass only seeds — it does not arm a siege immediately.");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
[Test]
|
|
||||||
public void Schedule_Arms_Siege_On_Cadence_Without_An_Expedition()
|
|
||||||
{
|
|
||||||
var (world, group) = MakeWorld("ThreatScheduleFire", serverTick: 400);
|
|
||||||
using (world)
|
|
||||||
{
|
|
||||||
var em = world.EntityManager;
|
|
||||||
var config = DefaultConfig();
|
|
||||||
config.PostExpeditionEnabled = 0; // isolate the schedule source
|
|
||||||
config.ScheduleEnabled = 1;
|
|
||||||
config.ScheduleIntervalTicks = 100;
|
|
||||||
config.ScheduleSizePerWave = 0;
|
|
||||||
config.SizeBase = 5;
|
|
||||||
config.PostExpeditionDelayTicks = 10;
|
|
||||||
// NextScheduledTick 300 <= now 400 => the scheduled siege is due.
|
|
||||||
var dir = MakeDirector(em, CyclePhase.Calm, new ThreatState { NextScheduledTick = 300 }, config);
|
|
||||||
|
|
||||||
group.Update();
|
|
||||||
|
|
||||||
var ts = em.GetComponentData<ThreatState>(dir);
|
|
||||||
Assert.AreEqual(5, ts.PendingSiegeSize, "A due scheduled tick arms a SizeBase siege with NO expedition trip.");
|
|
||||||
Assert.AreEqual(410u, ts.ArmTick, "The scheduled siege telegraphs at now + delay (400 + 10).");
|
|
||||||
Assert.AreEqual(500u, ts.NextScheduledTick, "The next scheduled siege is one interval out (400 + 100).");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
fileFormatVersion: 2
|
|
||||||
guid: 351a99057b08e3847b239782bfef893e
|
|
||||||
@@ -35,10 +35,6 @@ namespace ProjectM.Tests
|
|||||||
// GruntWindup must stay the canonical Tuning const (TelegraphTests couples to it).
|
// GruntWindup must stay the canonical Tuning const (TelegraphTests couples to it).
|
||||||
Assert.AreEqual((float)Tuning.AttackWindupTicks, d.GruntWindupTicks, 1e-6f, "GruntWindupTicks == Tuning.AttackWindupTicks");
|
Assert.AreEqual((float)Tuning.AttackWindupTicks, d.GruntWindupTicks, 1e-6f, "GruntWindupTicks == Tuning.AttackWindupTicks");
|
||||||
Assert.AreEqual(0.7f, d.StructureAggroWeight, 1e-6f, "EB-1 StructureAggroWeight default (<1 prefers structures)");
|
Assert.AreEqual(0.7f, d.StructureAggroWeight, 1e-6f, "EB-1 StructureAggroWeight default (<1 prefers structures)");
|
||||||
Assert.AreEqual(10f, d.CoreDamagePerHusk, 1e-6f, "END-1 CoreDamagePerHusk default");
|
|
||||||
Assert.AreEqual(18f, d.CoreRegenIntervalTicks, 1e-6f, "END-1 CoreRegenIntervalTicks default");
|
|
||||||
Assert.AreEqual(0.5f, d.CoreOverrunDrainPct, 1e-6f, "END-1 CoreOverrunDrainPct default (half the ledger on a breach)");
|
|
||||||
Assert.AreEqual(2.5f, d.FinalSiegeMultiplier, 1e-6f, "END-2 FinalSiegeMultiplier default (~2.5x a normal siege)");
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -47,6 +43,7 @@ namespace ProjectM.Tests
|
|||||||
{
|
{
|
||||||
for (byte knob = 0; knob < TuningKnob.Count; knob++)
|
for (byte knob = 0; knob < TuningKnob.Count; knob++)
|
||||||
{
|
{
|
||||||
|
if (knob >= 20 && knob <= 23) continue; // retired END-1/END-2 knob ids (LANTERN purge; reserved)
|
||||||
var c = TuningConfig.Defaults();
|
var c = TuningConfig.Defaults();
|
||||||
float baseline = TuningConfig.Get(c, knob);
|
float baseline = TuningConfig.Get(c, knob);
|
||||||
float target = baseline + 7f; // survives both clamps (positive)
|
float target = baseline + 7f; // survives both clamps (positive)
|
||||||
@@ -56,7 +53,7 @@ namespace ProjectM.Tests
|
|||||||
// every OTHER knob is untouched
|
// every OTHER knob is untouched
|
||||||
var d = TuningConfig.Defaults();
|
var d = TuningConfig.Defaults();
|
||||||
for (byte other = 0; other < TuningKnob.Count; other++)
|
for (byte other = 0; other < TuningKnob.Count; other++)
|
||||||
if (other != knob)
|
if (other != knob && !(other >= 20 && other <= 23))
|
||||||
Assert.AreEqual(TuningConfig.Get(d, other), TuningConfig.Get(c, other), 1e-4f,
|
Assert.AreEqual(TuningConfig.Get(d, other), TuningConfig.Get(c, other), 1e-4f,
|
||||||
$"knob {other} unchanged while editing {knob}");
|
$"knob {other} unchanged while editing {knob}");
|
||||||
}
|
}
|
||||||
@@ -126,7 +123,10 @@ namespace ProjectM.Tests
|
|||||||
TuningConfig.Apply(ref c, TuningKnob.ChargerWhiffStaggerTicks, 50f);
|
TuningConfig.Apply(ref c, TuningKnob.ChargerWhiffStaggerTicks, 50f);
|
||||||
var c2 = TuningConfig.FromReport(TuningConfig.ToReport(c));
|
var c2 = TuningConfig.FromReport(TuningConfig.ToReport(c));
|
||||||
for (byte knob = 0; knob < TuningKnob.Count; knob++)
|
for (byte knob = 0; knob < TuningKnob.Count; knob++)
|
||||||
|
{
|
||||||
|
if (knob >= 20 && knob <= 23) continue; // retired knob ids (LANTERN purge)
|
||||||
Assert.AreEqual(TuningConfig.Get(c, knob), TuningConfig.Get(c2, knob), 1e-6f, $"knob {knob} survives ToReport/FromReport");
|
Assert.AreEqual(TuningConfig.Get(c, knob), TuningConfig.Get(c2, knob), 1e-6f, $"knob {knob} survives ToReport/FromReport");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- consumption (world) ----
|
// ---- consumption (world) ----
|
||||||
|
|||||||
@@ -9,16 +9,17 @@ using Unity.Transforms;
|
|||||||
namespace ProjectM.Tests
|
namespace ProjectM.Tests
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Plain-Entities EditMode tests for the server-only <see cref="WaveSystem"/> (Husk wave/threat director).
|
/// Plain-Entities EditMode tests for the server-only <see cref="WaveSystem"/> (Husk wave director).
|
||||||
/// A bare world is seeded with NetworkTime + CycleState singletons and a director entity carrying
|
/// A bare world is seeded with a NetworkTime singleton and a director entity carrying WaveDirector +
|
||||||
/// WaveDirector + WaveState + a WaveEnemyPrefab buffer (whose prefab is a real <c>Prefab</c>-tagged entity so
|
/// WaveState + a WaveEnemyPrefab buffer (whose prefab is a real <c>Prefab</c>-tagged entity so it is
|
||||||
/// it is excluded from the alive-Husk query and Instantiate yields plain Husk instances). Pins: a due Lull
|
/// excluded from the alive-Husk query and Instantiate yields plain Husk instances). Waves are UNGATED
|
||||||
/// starts the next (escalating) wave; Spawning emits one Husk per interval; the director is gated off outside
|
/// (LANTERN purge: the old CycleState Siege gate is deleted — placement of a WaveDirector decides).
|
||||||
/// Defend; a fully-spawned, cleared wave returns to Lull.
|
/// Pins: a due Lull starts the next (escalating) wave; Spawning emits one Husk per interval; a
|
||||||
|
/// fully-spawned, cleared wave returns to Lull.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public class WaveSystemTests
|
public class WaveSystemTests
|
||||||
{
|
{
|
||||||
static (World world, SimulationSystemGroup group) MakeWorld(string name, uint serverTick, byte cyclePhase)
|
static (World world, SimulationSystemGroup group) MakeWorld(string name, uint serverTick)
|
||||||
{
|
{
|
||||||
var world = new World(name);
|
var world = new World(name);
|
||||||
var group = world.GetOrCreateSystemManaged<SimulationSystemGroup>();
|
var group = world.GetOrCreateSystemManaged<SimulationSystemGroup>();
|
||||||
@@ -28,8 +29,6 @@ namespace ProjectM.Tests
|
|||||||
var em = world.EntityManager;
|
var em = world.EntityManager;
|
||||||
var nt = em.CreateEntity(typeof(NetworkTime));
|
var nt = em.CreateEntity(typeof(NetworkTime));
|
||||||
em.SetComponentData(nt, new NetworkTime { ServerTick = new NetworkTick(serverTick) });
|
em.SetComponentData(nt, new NetworkTime { ServerTick = new NetworkTick(serverTick) });
|
||||||
var cyc = em.CreateEntity(typeof(CycleState));
|
|
||||||
em.SetComponentData(cyc, new CycleState { Phase = cyclePhase });
|
|
||||||
return (world, group);
|
return (world, group);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -68,7 +67,7 @@ namespace ProjectM.Tests
|
|||||||
[Test]
|
[Test]
|
||||||
public void Due_Lull_Starts_Wave_With_Escalating_Count()
|
public void Due_Lull_Starts_Wave_With_Escalating_Count()
|
||||||
{
|
{
|
||||||
var (world, group) = MakeWorld("WaveLullStart", serverTick: 100, cyclePhase: CyclePhase.Siege);
|
var (world, group) = MakeWorld("WaveLullStart", serverTick: 100);
|
||||||
using (world)
|
using (world)
|
||||||
{
|
{
|
||||||
var em = world.EntityManager;
|
var em = world.EntityManager;
|
||||||
@@ -88,7 +87,7 @@ namespace ProjectM.Tests
|
|||||||
[Test]
|
[Test]
|
||||||
public void Spawning_Emits_One_Husk_And_Decrements_Remaining()
|
public void Spawning_Emits_One_Husk_And_Decrements_Remaining()
|
||||||
{
|
{
|
||||||
var (world, group) = MakeWorld("WaveSpawnOne", serverTick: 100, cyclePhase: CyclePhase.Siege);
|
var (world, group) = MakeWorld("WaveSpawnOne", serverTick: 100);
|
||||||
using (world)
|
using (world)
|
||||||
{
|
{
|
||||||
var em = world.EntityManager;
|
var em = world.EntityManager;
|
||||||
@@ -104,28 +103,10 @@ namespace ProjectM.Tests
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
[Test]
|
|
||||||
public void Director_Is_Gated_Off_Outside_Defend()
|
|
||||||
{
|
|
||||||
var (world, group) = MakeWorld("WaveGated", serverTick: 100, cyclePhase: CyclePhase.Calm);
|
|
||||||
using (world)
|
|
||||||
{
|
|
||||||
var em = world.EntityManager;
|
|
||||||
var prefab = MakeHuskPrefab(em);
|
|
||||||
var dir = MakeDirector(em, prefab, WavePhase.Lull, waveNumber: 0, nextActionTick: 100, remainingToSpawn: 0, spawnCounter: 0);
|
|
||||||
|
|
||||||
group.Update();
|
|
||||||
|
|
||||||
var w = em.GetComponentData<WaveState>(dir);
|
|
||||||
Assert.AreEqual(WavePhase.Lull, w.Phase, "Outside Defend the director does not run.");
|
|
||||||
Assert.AreEqual(0, w.WaveNumber, "Wave number stays put outside Defend.");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
[Test]
|
[Test]
|
||||||
public void Fully_Spawned_Cleared_Wave_Returns_To_Lull()
|
public void Fully_Spawned_Cleared_Wave_Returns_To_Lull()
|
||||||
{
|
{
|
||||||
var (world, group) = MakeWorld("WaveCleared", serverTick: 100, cyclePhase: CyclePhase.Siege);
|
var (world, group) = MakeWorld("WaveCleared", serverTick: 100);
|
||||||
using (world)
|
using (world)
|
||||||
{
|
{
|
||||||
var em = world.EntityManager;
|
var em = world.EntityManager;
|
||||||
|
|||||||
Reference in New Issue
Block a user