This commit is contained in:
2026-07-04 16:57:40 -07:00
parent 16e396841e
commit c6b7890212
47 changed files with 1713 additions and 209 deletions
+42 -28
View File
@@ -1,23 +1,25 @@
# the name by which the project can be referenced within Serena # the name by which the project can be referenced within Serena/when chatting with the LLM.
project_name: "Project-M" project_name: "Project-M"
# list of languages for which language servers are started (LSP backend only); choose from:
# list of languages for which language servers are started; choose from: # ada al angular ansible bash
# al angular ansible bash clojure # bsl clojure cpp cpp_ccls crystal
# cpp cpp_ccls crystal csharp csharp_omnisharp # csharp csharp_omnisharp cue dart elixir
# dart elixir elm erlang fortran # elm erlang fortran fsharp gdscript
# fsharp go groovy haskell haxe # go groovy haskell haxe hlsl
# hlsl html java json julia # html java json julia kotlin
# kotlin lean4 lua luau markdown # latex lean4 lua luau markdown
# matlab msl nix ocaml pascal # matlab msl nix ocaml pascal
# perl php php_phpactor powershell python # perl php php_phpactor php_phpantom powershell
# python_jedi python_ty r rego ruby # python python_jedi python_pyrefly python_ty r
# ruby_solargraph rust scala scss solidity # rego ruby ruby_solargraph rust scala
# svelte swift systemverilog terraform toml # scss solidity svelte swift systemverilog
# typescript typescript_vts vue yaml zig # terraform toml typescript typescript_vts vue
# (This list may be outdated. For the current list, see values of Language enum here: # yaml zig
# https://github.com/oraios/serena/blob/main/src/solidlsp/ls_config.py # (This list may be outdated; generated with scripts/print_language_list.py;
# For some languages, there are alternative language servers, e.g. csharp_omnisharp, ruby_solargraph.) # For the current list, see values of Language enum here:
# https://github.com/oraios/serena/blob/main/src/solidlsp/ls_config.py)
# For some languages, there are alternative language servers, e.g. csharp_omnisharp, ruby_solargraph.)
# Note: # Note:
# - For C, use cpp # - For C, use cpp
# - For JavaScript, use typescript # - For JavaScript, use typescript
@@ -59,17 +61,6 @@ ignore_all_files_in_gitignore: true
# See https://oraios.github.io/serena/02-usage/050_configuration.html#language-server-specific-settings # See https://oraios.github.io/serena/02-usage/050_configuration.html#language-server-specific-settings
ls_specific_settings: {} ls_specific_settings: {}
# list of additional workspace folder paths for cross-package reference support (e.g. in monorepos).
# Paths can be absolute or relative to the project root.
# Each folder is registered as an LSP workspace folder, enabling language servers to discover
# symbols and references across package boundaries.
# Currently supported for: TypeScript.
# Example:
# additional_workspace_folders:
# - ../sibling-package
# - ../shared-lib
additional_workspace_folders: []
# list of additional paths to ignore in this project. # list of additional paths to ignore in this project.
# Same syntax as gitignore, so you can use * and **. # Same syntax as gitignore, so you can use * and **.
# Note: global ignored_paths from serena_config.yml are also applied additively. # Note: global ignored_paths from serena_config.yml are also applied additively.
@@ -144,3 +135,26 @@ activation_command:
# maximum time in seconds to wait for activation_command to complete before killing it (default 180s). # maximum time in seconds to wait for activation_command to complete before killing it (default 180s).
# must be a positive number. # must be a positive number.
activation_command_timeout: 180.0 activation_command_timeout: 180.0
# list of additional workspace folder paths for cross-package reference support.
# Paths can be absolute or relative to the project root.
# Each folder is registered as an LSP workspace folder, enabling language servers to discover
# symbols and references across package boundaries, but these folders are not indexed by Serena,
# i.e. the respective symbols will not be found using Serena's symbol search tools.
# Example:
# additional_workspace_folders:
# - ../sibling-package
# - ../shared-lib
ls_additional_workspace_folders: []
# list of workspace folder paths (LSP backend only).
# These folders will be used to build up Serena's symbol index.
# Paths must be within the project root and should thus be relative to the project root.
# Furthermore, the paths should not be filtered by ignore settings.
# Default setting: The entire project root folder (".") is considered.
# In (large) monorepos, this can be used to index only subfolders of the project root, e.g.
# ls_workspace_folders:
# - "./subproject1"
# - "./subproject2"
ls_workspace_folders:
- .
-1
View File
@@ -74,7 +74,6 @@ MonoBehaviour:
m_MixedLightingSupported: 1 m_MixedLightingSupported: 1
m_SupportsLightCookies: 1 m_SupportsLightCookies: 1
m_SupportsLightLayers: 1 m_SupportsLightLayers: 1
m_DebugLevel: 0
m_StoreActionsOptimization: 0 m_StoreActionsOptimization: 0
m_UseAdaptivePerformance: 1 m_UseAdaptivePerformance: 1
m_ColorGradingMode: 1 m_ColorGradingMode: 1
@@ -73,7 +73,22 @@ MonoBehaviour:
- rid: 6670095651985424593 - rid: 6670095651985424593
- rid: 6670095651985424594 - rid: 6670095651985424594
m_RuntimeSettings: m_RuntimeSettings:
m_List: [] m_List:
- rid: 6852985685364965378
- rid: 6852985685364965379
- rid: 6852985685364965380
- rid: 6852985685364965381
- rid: 6852985685364965384
- rid: 6852985685364965392
- rid: 6852985685364965394
- rid: 8712630790384254976
- rid: 6670095613664165896
- rid: 6670095613664165897
- rid: 6670095613664165898
- rid: 6670095613664165902
- rid: 6670095613664165904
- rid: 6670095651985424593
- rid: 6670095651985424594
m_AssetVersion: 10 m_AssetVersion: 10
m_ObsoleteDefaultVolumeProfile: {fileID: 0} m_ObsoleteDefaultVolumeProfile: {fileID: 0}
m_RenderingLayerNames: m_RenderingLayerNames:
@@ -23,6 +23,7 @@ namespace ProjectM.Authoring
[Min(1)] public int PeriodTicks = 30; [Min(1)] public int PeriodTicks = 30;
[Tooltip("EB-2: 1 = ledger-fed (consume the input from the shared ledger; no conveyor). 0 = M7 MachineInput chain.")] [Tooltip("EB-2: 1 = ledger-fed (consume the input from the shared ledger; no conveyor). 0 = M7 MachineInput chain.")]
public byte InputFromLedger = 1; public byte InputFromLedger = 1;
[Min(1f)] public float MaxHp = 120f;
private class FabricatorBaker : Baker<FabricatorAuthoring> private class FabricatorBaker : Baker<FabricatorAuthoring>
{ {
@@ -46,6 +47,14 @@ namespace ProjectM.Authoring
InputFromLedger = authoring.InputFromLedger, InputFromLedger = authoring.InputFromLedger,
}); });
AddBuffer<MachineInput>(entity); AddBuffer<MachineInput>(entity);
// EB-1 parity (DR-032): machines can die. Without Health this baker left the Fabricator OUT of
// the EnemyAISystem fortress-aggro snapshot entirely (its query keys on Health) — an invulnerable
// machine, diverging from DR-032. Now that it IS targetable, the DamageEvent buffer must exist
// (an AI strike appends into it; absent = ECB-playback throw) + Destructible lets
// HealthApplyDamageSystem destroy it at 0 (occupancy auto-frees).
AddComponent(entity, new Health { Current = authoring.MaxHp, Max = authoring.MaxHp });
AddBuffer<DamageEvent>(entity);
AddComponent<Destructible>(entity);
} }
} }
} }
@@ -45,7 +45,7 @@ namespace ProjectM.Client
byte _lastSelected; // skip placing on the frame a palette click changes the selection byte _lastSelected; // skip placing on the frame a palette click changes the selection
// (Step 11: UpgradeAbility/s_PendingUpgrades RETIRED with the AbilityUpgradeRequest wire — in-run boons + // (Step 11: UpgradeAbility/s_PendingUpgrades RETIRED with the AbilityUpgradeRequest wire — in-run boons +
// the base meta-shop replaced the Aether damage upgrade.)des++; // the base meta-shop replaced the Aether damage upgrade.)
#if UNITY_EDITOR #if UNITY_EDITOR
struct PendingBuild { public byte Type; public int CellX; public int CellZ; public byte Direction; } struct PendingBuild { public byte Type; public int CellX; public int CellZ; public byte Direction; }
@@ -177,7 +177,7 @@ namespace ProjectM.Client
byte reason = BuildPreviewMath.Evaluate(anchor, targetCell, occupied, LedgerOre(), cost); byte reason = BuildPreviewMath.Evaluate(anchor, targetCell, occupied, LedgerOre(), cost);
bool valid = reason == BuildPreviewMath.Valid; bool valid = reason == BuildPreviewMath.Valid;
ShowGhost(BaseGridMath.CellToWorld(anchor, targetCell), anchor.CellSize, valid, sel);; ShowGhost(BaseGridMath.CellToWorld(anchor, targetCell), anchor.CellSize, valid, sel);
// Place on a left-click (valid, not the selecting click). // Place on a left-click (valid, not the selecting click).
if (valid && !justSelected && mouse.leftButton.wasPressedThisFrame) if (valid && !justSelected && mouse.leftButton.wasPressedThisFrame)
@@ -36,6 +36,8 @@ namespace ProjectM.Client
if (!NetworkEndpoint.TryParse(address.ToString(), port, out var endpoint, NetworkFamily.Ipv4)) if (!NetworkEndpoint.TryParse(address.ToString(), port, out var endpoint, NetworkFamily.Ipv4))
{ {
Debug.LogError($"[ClientConnectionControlSystem] Invalid join address '{address}'."); Debug.LogError($"[ClientConnectionControlSystem] Invalid join address '{address}'.");
SessionEnd.Reason = "INVALID JOIN ADDRESS — enter a valid IPv4 like 192.168.1.20.";
SessionEnd.Abort = true; // ConnectionWatchdogSystem tears down to the menu at a frame boundary
return; return;
} }
@@ -0,0 +1,91 @@
using Unity.Entities;
using Unity.NetCode;
using UnityEngine;
namespace ProjectM.Client
{
/// <summary>
/// Why the last session ended, surfaced once on the main menu (client-local). Set by
/// <see cref="ConnectionWatchdogSystem"/> (host vanished / join never connected) or by the connect path
/// (unparseable address); <see cref="MainMenuController"/> shows + clears it. Statics reset on play-enter
/// (fast-enter-playmode safe, the VFXConfig precedent).
/// </summary>
public static class SessionEnd
{
public static string Reason;
/// <summary>Force an immediate teardown-to-menu (e.g. a bad join address) without waiting for the
/// watchdog's connect timeout. Set <see cref="Reason"/> first.</summary>
public static bool Abort;
[RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.SubsystemRegistration)]
static void Reset() { Reason = null; Abort = false; }
}
/// <summary>
/// Hands-off co-op fail-safe: watches this client world's connection and returns the player to the main
/// menu (reason on <see cref="SessionEnd"/>) instead of leaving them in a dead world with a live HUD.
/// Exits: (1) the accepted connection VANISHED on a pure join client — host quit or the network died (the
/// host's own in-proc client is excluded: its server outliving user-driven teardown is
/// <see cref="WorldLauncher.Busy"/>-guarded); (2) a join was requested but never accepted within
/// <see cref="JoinTimeoutSeconds"/> (wrong IP / host not hosting — covers the silent
/// NetworkStreamRequestConnect failure modes); (3) <see cref="SessionEnd.Abort"/> was flagged. Managed
/// observe-only client system — never writes sim state; world disposal runs on the WorldLauncher /
/// SessionRunner frame-boundary path, never here.
/// </summary>
[WorldSystemFilter(WorldSystemFilterFlags.ClientSimulation)]
public partial class ConnectionWatchdogSystem : SystemBase
{
const float JoinTimeoutSeconds = 12f;
bool _sawConnection;
float _unconnectedElapsed;
protected override void OnUpdate()
{
if (WorldLauncher.Busy) return; // a start/teardown routine owns world lifecycle right now
if (SessionEnd.Abort)
{
SessionEnd.Abort = false;
WorldLauncher.TeardownToMenu();
return;
}
// An ACCEPTED connection carries NetworkId (at most one per client world).
bool accepted = SystemAPI.HasSingleton<NetworkId>();
if (accepted)
{
_sawConnection = true;
_unconnectedElapsed = 0f;
return;
}
var serverWorld = ClientServerBootstrap.ServerWorld;
bool pureJoiner = serverWorld == null || !serverWorld.IsCreated;
if (_sawConnection)
{
_sawConnection = false;
if (pureJoiner)
{
SessionEnd.Reason = "CONNECTION LOST — the host ended the session.";
Debug.Log("[ConnectionWatchdog] " + SessionEnd.Reason); // build-log trace for headless smokes
WorldLauncher.TeardownToMenu();
}
// Host/single: losing the loopback connection outside teardown is transient noise; re-arm.
return;
}
// Never accepted yet — only a real remote join gets the timeout escape hatch.
if (!pureJoiner) return;
_unconnectedElapsed += SystemAPI.Time.DeltaTime;
if (_unconnectedElapsed >= JoinTimeoutSeconds)
{
SessionEnd.Reason = "COULD NOT REACH HOST — check the IP and that the host is hosting.";
Debug.Log("[ConnectionWatchdog] " + SessionEnd.Reason); // build-log trace for headless smokes
WorldLauncher.TeardownToMenu();
}
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: ac9a33fcc24a6e945927c185a16b9e30
@@ -9,6 +9,12 @@ namespace ProjectM.Client
/// observable client state each step reads, the deterministic per-step completion test, prompt copy, the /// observable client state each step reads, the deterministic per-step completion test, prompt copy, the
/// spatial-cue kind, and the persisted-mask helpers. /// 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), /// 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 /// 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 /// the timed <see cref="Welcome"/> strip. Veteran / co-op auto-suppress falls out for free: the count-based
@@ -20,12 +26,12 @@ namespace ProjectM.Client
// ---- ordered steps (byte ids; bit i of GameSettings.OnboardingMask = step i complete) ---- // ---- 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 Welcome = 0; // tiny win-condition framing strip (timed)
public const byte Move = 1; public const byte Move = 1;
public const byte Mine = 2; // attack an Ore node — mining IS combat at the base (Calm = no enemies yet) public const byte Build = 2; // open palette + place a Turret (the 50-Ore grubstake covers it)
public const byte Build = 3; // open palette + place a Turret public const byte Fabricator = 3; // Ore -> Charge (soft info beat)
public const byte Fabricator = 4; // Ore -> Charge (soft info beat) public const byte ReadyUp = 4; // press T / click READY — the party launches together
public const byte Gate = 5; // reach the Expedition Gate public const byte Rooms = 5; // fight the rooms; attack crystal nodes to haul resources
public const byte Clear = 6; // clear the expedition wave (the first real enemies) public const byte Boon = 6; // clear a room -> pick 1 of 3 boons + choose the path
public const byte Return = 7; // walk back to base to bank +1 charge 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 Defend = 8; // survive the retaliation siege (soft info beat)
public const byte Done = 9; // closing beat public const byte Done = 9; // closing beat
public const byte StepCount = 10; public const byte StepCount = 10;
@@ -40,18 +46,18 @@ namespace ProjectM.Client
// ---- spatial-cue kinds the System resolves to a live world target ---- // ---- spatial-cue kinds the System resolves to a live world target ----
public const byte PointerNone = 0; public const byte PointerNone = 0;
public const byte PointerOreNode = 1; public const byte PointerOreNode = 1;
public const byte PointerBaseGate = 2; // the base-region gate (go to the expedition) public const byte PointerBaseGate = 2; // RETIRED target (walk-in gate died with the redesign); byte kept
public const byte PointerExpeditionGate = 3; // the expedition-region gate (return home) 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> /// <summary>Observable client state for one evaluation. Built by the System from ECS + input each frame.</summary>
public struct Snapshot public struct Snapshot
{ {
public float StepElapsed; // seconds the current step has been shown public float StepElapsed; // seconds the current step has been shown
public float MoveDistance; // accumulated player movement since the Move step began public float MoveDistance; // accumulated player movement since the Move step began
public int OreNow; // shared-ledger Ore right now
public int OreBaseline; // ledger Ore captured when the Mine step began
public int TurretCount; // live Turret structures (absolute) public int TurretCount; // live Turret structures (absolute)
public int FabricatorCount; // live Fabricator 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 bool OnExpedition; // local player is in the expedition region
public byte ObjectiveState; // ExpeditionObjective.State (Idle/Active/Cleared) public byte ObjectiveState; // ExpeditionObjective.State (Idle/Active/Cleared)
public bool SawSiege; // a Siege phase was observed while the Defend step was showing public bool SawSiege; // a Siege phase was observed while the Defend step was showing
@@ -65,12 +71,14 @@ namespace ProjectM.Client
{ {
case Welcome: return s.StepElapsed >= WelcomeSeconds; case Welcome: return s.StepElapsed >= WelcomeSeconds;
case Move: return s.MoveDistance >= MoveThreshold; case Move: return s.MoveDistance >= MoveThreshold;
case Mine: return s.OreNow > s.OreBaseline;
case Build: return s.TurretCount >= 1; case Build: return s.TurretCount >= 1;
case Fabricator: return s.FabricatorCount >= 1 || s.StepElapsed >= FabricatorSoftSeconds; case Fabricator: return s.FabricatorCount >= 1 || s.StepElapsed >= FabricatorSoftSeconds;
case Gate: return s.OnExpedition || s.ObjectiveState == ExpeditionObjectiveState.Active; case ReadyUp: return s.LocalReady || s.Lifecycle != RunLifecycle.Staging;
case Clear: return s.ObjectiveState == ExpeditionObjectiveState.Cleared; case Rooms: return s.OnExpedition || s.ObjectiveState == ExpeditionObjectiveState.Active;
case Return: return !s.OnExpedition; // entered while on expedition; satisfied on crossing home case Boon: return s.ObjectiveState == ExpeditionObjectiveState.Cleared
|| s.Lifecycle == RunLifecycle.RoomReward
|| s.Lifecycle == RunLifecycle.RouteSelect;
case Return: return !s.OnExpedition; // entered mid-run; satisfied on coming home
case Defend: return s.SawSiege ? s.Phase == CyclePhase.Calm : s.StepElapsed >= DefendNoSiegeSeconds; case Defend: return s.SawSiege ? s.Phase == CyclePhase.Calm : s.StepElapsed >= DefendNoSiegeSeconds;
case Done: return s.StepElapsed >= DoneSeconds; case Done: return s.StepElapsed >= DoneSeconds;
default: return true; default: return true;
@@ -82,10 +90,8 @@ namespace ProjectM.Client
{ {
switch (step) switch (step)
{ {
case Mine: return PointerOreNode; case Rooms: return PointerOreNode; // in-run crystal nodes (base nodes no longer exist)
case Gate: return PointerBaseGate; default: return PointerNone;
case Return: return PointerExpeditionGate;
default: return PointerNone;
} }
} }
@@ -93,19 +99,18 @@ namespace ProjectM.Client
public static string Prompt(byte step, bool gamepad) public static string Prompt(byte step, bool gamepad)
{ {
string move = gamepad ? "Left Stick" : "WASD"; string move = gamepad ? "Left Stick" : "WASD";
string attack = gamepad ? "RT" : "LMB";
string build = gamepad ? "Y" : "Tab"; // matches the existing HUD build-discovery chip glyph string build = gamepad ? "Y" : "Tab"; // matches the existing HUD build-discovery chip glyph
switch (step) switch (step)
{ {
case Welcome: return "CLEAR EXPEDITIONS to charge the Engine — defend the Core while you do. (Esc → Pause → How to Play)"; case Welcome: return "CLEAR EXPEDITIONS to charge the Engine — defend the Core while you do. (Esc → Pause → How to Play)";
case Move: return move + " — Move"; case Move: return move + " — Move";
case Mine: return attack + " — Attack the glowing Ore to mine it"; case Build: return build + " — open Build, place a Turret by your Core (your 50 starting Ore covers it)";
case Build: return build + " — open Build, then place a Turret by your Core";
case Fabricator: return "Build a Fabricator — turrets need Charge (Ore → ammo)"; case Fabricator: return "Build a Fabricator — turrets need Charge (Ore → ammo)";
case Gate: return "Press T to READY UP — when everyone is ready, the run launches"; case ReadyUp: return "Press T (or click READY UP) — when everyone is ready, the party launches";
case Clear: return "Clear each room — pick a boon, choose your path, reach the boss"; case Rooms: return "Fight through the roomsattack the glowing crystals to haul resources home";
case Return: return "Fell the bossyou return home and the Engine charges (+1)";; case Boon: return "Clear the roompick 1 of 3 BOONS, then choose your path on the map";
case Defend: return "Defend the Core! — hold the line through the siege"; case Return: return "Reach the boss and fell it — the haul comes home and the Engine charges (+1)";
case Defend: return "Defend the Core! — a completed run provokes a retaliation siege";
case Done: return "You've got it. Clear expeditions to fill the Engine and win."; case Done: return "You've got it. Clear expeditions to fill the Engine and win.";
default: return ""; default: return "";
} }
@@ -41,7 +41,6 @@ namespace ProjectM.Client
float _stepElapsed; float _stepElapsed;
float _moveAccum; float _moveAccum;
float3 _lastPos; float3 _lastPos;
int _oreBaseline;
bool _sawSiege; bool _sawSiege;
protected override void OnStartRunning() protected override void OnStartRunning()
@@ -92,7 +91,11 @@ namespace ProjectM.Client
} }
// ---- remaining observable state ---- // ---- remaining observable state ----
int ore = LedgerOre(); 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); CountStructures(out int turrets, out int fabs);
byte phase = CyclePhase.Calm; byte phase = CyclePhase.Calm;
if (SystemAPI.TryGetSingleton<CycleState>(out var cyc)) phase = cyc.Phase; if (SystemAPI.TryGetSingleton<CycleState>(out var cyc)) phase = cyc.Phase;
@@ -104,7 +107,7 @@ namespace ProjectM.Client
if (!_stepInit) if (!_stepInit)
{ {
_stepElapsed = 0f; _moveAccum = 0f; _sawSiege = false; _stepElapsed = 0f; _moveAccum = 0f; _sawSiege = false;
_oreBaseline = ore; _lastPos = playerPos; _lastPos = playerPos;
_stepInit = true; _stepInit = true;
} }
@@ -120,8 +123,8 @@ namespace ProjectM.Client
{ {
StepElapsed = _stepElapsed, StepElapsed = _stepElapsed,
MoveDistance = _moveAccum, MoveDistance = _moveAccum,
OreNow = ore, LocalReady = localReady,
OreBaseline = _oreBaseline, Lifecycle = lifecycle,
TurretCount = turrets, TurretCount = turrets,
FabricatorCount = fabs, FabricatorCount = fabs,
OnExpedition = onExp, OnExpedition = onExp,
@@ -159,16 +162,7 @@ namespace ProjectM.Client
// ---- state gathering helpers ---- // ---- state gathering helpers ----
int LedgerOre()
{
if (SystemAPI.TryGetSingletonEntity<ResourceLedger>(out var e))
{
var buf = SystemAPI.GetBuffer<StorageEntry>(e);
for (int i = 0; i < buf.Length; i++)
if (buf[i].ItemId == ResourceId.Ore) return buf[i].Count;
}
return 0;
}
void CountStructures(out int turrets, out int fabs) void CountStructures(out int turrets, out int fabs)
{ {
@@ -27,6 +27,12 @@ namespace ProjectM.Client
byte _lastPhase; byte _lastPhase;
bool _phaseInit; bool _phaseInit;
AudioClip _stingCoreHit;
int _lastCore = -1;
float _coreStingCooldown;
AudioClip _stingBeep, _stingRoar;
int _lastCountdownSec = -1;
bool _bossRoared;
const float AmbientBaseVolume = 0.10f; // low bed; Defend eases up to ~1.7x const float AmbientBaseVolume = 0.10f; // low bed; Defend eases up to ~1.7x
@@ -36,6 +42,9 @@ namespace ProjectM.Client
_stingExpedition = MakeSting(520f, 880f, 0.45f, 0.30f); // airy rising "deploy" _stingExpedition = MakeSting(520f, 880f, 0.45f, 0.30f); // airy rising "deploy"
_stingDefend = MakeSting(300f, 140f, 0.55f, 0.42f); // tense falling "wave incoming" _stingDefend = MakeSting(300f, 140f, 0.55f, 0.42f); // tense falling "wave incoming"
_stingBuild = MakeSting(440f, 660f, 0.40f, 0.26f); // soft confirm _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
_stingRoar = MakeSting(90f, 38f, 0.90f, 0.60f); // boss-arrival roar (low falling growl)
} }
protected override void OnStartRunning() protected override void OnStartRunning()
@@ -76,6 +85,50 @@ namespace ProjectM.Client
// Ease the drone intensity toward the phase target (tenser during Defend). // Ease the drone intensity toward the phase target (tenser during Defend).
float target = phase == CyclePhase.Siege ? AmbientBaseVolume * 1.7f : AmbientBaseVolume; float target = phase == CyclePhase.Siege ? AmbientBaseVolume * 1.7f : AmbientBaseVolume;
_ambient.volume = Mathf.MoveTowards(_ambient.volume, target * GameVolume.Music, SystemAPI.Time.DeltaTime * 0.25f); _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.
if (SystemAPI.TryGetSingleton<RunInfo>(out var runAudio))
{
int sec = -1;
if (runAudio.Lifecycle == RunLifecycle.Launching && runAudio.LaunchTick != 0
&& SystemAPI.TryGetSingleton<NetworkTime>(out var antime) && antime.ServerTick.IsValid)
{
int tl = new NetworkTick(runAudio.LaunchTick).TicksSince(antime.ServerTick);
if (tl > 0) sec = tl / 60 + 1;
}
if (sec > 0 && sec != _lastCountdownSec && sec <= 3)
_ambient.PlayOneShot(_stingBeep, 0.5f * GameVolume.Sfx);
_lastCountdownSec = sec;
bool inBossRoom = runAudio.Lifecycle == RunLifecycle.InRoom
&& runAudio.CurrentRoomType == RoomTypeId.Boss;
if (!inBossRoom)
{
_bossRoared = false; // re-arm for the next boss room
}
else if (!_bossRoared)
{
foreach (var _ in SystemAPI.Query<RefRO<Health>>().WithAll<EnemyTag>())
{
_ambient.PlayOneShot(_stingRoar, 0.9f * GameVolume.Sfx);
_bossRoared = true;
break;
}
}
}
} }
void PlaySting(byte phase) void PlaySting(byte phase)
@@ -62,6 +62,8 @@ namespace ProjectM.Client
bool _slashActive; bool _slashActive;
float _slashRange, _slashHalf; // live cone geometry re-sampled each frame for the per-frame sweep rebuild float _slashRange, _slashHalf; // live cone geometry re-sampled each frame for the per-frame sweep rebuild
int _slashSweepSign = 1; // alternate sweep direction per combo step (reads as alternating strikes) int _slashSweepSign = 1; // alternate sweep direction per combo step (reads as alternating strikes)
uint _lastConeFireTick; // own latch — the muzzle block owns _lastLocalFireTick and runs first
bool _coneTickInit;
Material _dangerMat; Material _dangerMat;
readonly Dictionary<Entity, GameObject> _dangerZones = new(); readonly Dictionary<Entity, GameObject> _dangerZones = new();
readonly HashSet<Entity> _dangerSeen = new(); readonly HashSet<Entity> _dangerSeen = new();
@@ -367,6 +369,11 @@ namespace ProjectM.Client
bool finisher = step >= comboLen; bool finisher = step >= comboLen;
float slashRange = tcfg.MeleeRange > 0f ? tcfg.MeleeRange : 2.6f; float slashRange = tcfg.MeleeRange > 0f ? tcfg.MeleeRange : 2.6f;
float slashHalf = tcfg.MeleeConeHalfAngleRad > 0f ? tcfg.MeleeConeHalfAngleRad : 0.9f; float slashHalf = tcfg.MeleeConeHalfAngleRad > 0f ? tcfg.MeleeConeHalfAngleRad : 0.9f;
// Slice-2 deferred reach fix: the SERVER folds per-player StatModifiers into melee range
// (class seed +0.8, boons, meta tiers) — the arc must sweep the REAL reach, not the base.
if (EntityManager.HasBuffer<StatModifier>(_localPlayer))
slashRange = math.max(0f, StatMath.Apply(slashRange, StatTarget.MeleeRange,
EntityManager.GetBuffer<StatModifier>(_localPlayer, true)));
if (finisher) slashRange *= tcfg.MeleeFinisherMult > 0f ? tcfg.MeleeFinisherMult : 1.8f; if (finisher) slashRange *= tcfg.MeleeFinisherMult > 0f ? tcfg.MeleeFinisherMult : 1.8f;
// MC-4 connect-vs-whiff: client-side cone overlap over the cached enemy snapshot gives an IMMEDIATE // MC-4 connect-vs-whiff: client-side cone overlap over the cached enemy snapshot gives an IMMEDIATE
// "you bit" read (the authoritative server damage spark/number arrives a few ticks later). // "you bit" read (the authoritative server damage spark/number arrives a few ticks later).
@@ -397,6 +404,40 @@ namespace ProjectM.Client
_swingTickInit = true; _swingTickInit = true;
} }
// Slice-2 deferred: the Warrior CONE ability had no client VFX (server-only cleave). Edge-detect the
// PREDICTED AbilityCooldown (raw uint, like the dash) and reuse the slash-arc sweep with the ability's
// own effective geometry — an aimed cone IS an arc.
if (_localPlayer != Entity.Null
&& EntityManager.HasComponent<AbilityCooldown>(_localPlayer)
&& EntityManager.HasComponent<AbilityRef>(_localPlayer)
&& EntityManager.HasComponent<EffectiveAbilityStats>(_localPlayer))
{
uint nextFire = EntityManager.GetComponentData<AbilityCooldown>(_localPlayer).NextFireTick;
if (_coneTickInit && nextFire != 0 && nextFire != _lastConeFireTick
&& SystemAPI.TryGetSingleton<AbilityDatabase>(out var coneDb) && coneDb.Value.IsCreated)
{
ref var coneAdb = ref coneDb.Value.Value;
if (coneAdb.TryGetAbility(EntityManager.GetComponentData<AbilityRef>(_localPlayer).Id, out var coneDef)
&& coneDef.Archetype == (byte)AbilityArchetype.Cone)
{
var ceff = EntityManager.GetComponentData<EffectiveAbilityStats>(_localPlayer);
Vector3 cface = Vector3.forward;
if (EntityManager.HasComponent<PlayerFacing>(_localPlayer))
{
var cfd = EntityManager.GetComponentData<PlayerFacing>(_localPlayer).Direction;
if (math.lengthsq(cfd) > 1e-6f) cface = new Vector3(cfd.x, 0f, cfd.y).normalized;
}
float coneRange = Mathf.Max(0.1f, ceff.Range);
float coneHalf = Mathf.Clamp(ceff.AutoTargetConeRadians, 0.01f, 3.14159f);
TriggerSlash((Vector3)localPos, new float2(cface.x, cface.z), coneRange, coneHalf, 1, 1, false);
PlayClip(_swingClip, (Vector3)localPos, 0.5f);
PrototypeCameraRig.AddShake(0.06f);
}
}
_lastConeFireTick = nextFire;
_coneTickInit = true;
}
// Footsteps (combat feel): edge-detect local locomotion from the position delta; a soft step at a cadence. // Footsteps (combat feel): edge-detect local locomotion from the position delta; a soft step at a cadence.
if (_localPlayer != Entity.Null) if (_localPlayer != Entity.Null)
{ {
@@ -820,7 +861,11 @@ namespace ProjectM.Client
{ {
int step = math.max(1, (int)mc.ValueRO.Step); int step = math.max(1, (int)mc.ValueRO.Step);
bool finisher = step >= comboLen; bool finisher = step >= comboLen;
rs.Range = finisher ? baseRange * finisherMult : baseRange; float rRange = baseRange; // same reach fix for teammates' arcs (their buffer is OwnerSendType.All)
if (EntityManager.HasBuffer<StatModifier>(entity))
rRange = math.max(0f, StatMath.Apply(baseRange, StatTarget.MeleeRange,
EntityManager.GetBuffer<StatModifier>(entity, true)));
rs.Range = finisher ? rRange * finisherMult : rRange;
rs.Half = baseHalf; rs.Half = baseHalf;
rs.SweepSign = (step % 2 == 0) ? -1 : 1; rs.SweepSign = (step % 2 == 0) ? -1 : 1;
rs.Tint = FeelConfig.RemoteSlashColor * (finisher ? 1.5f : 1f); rs.Tint = FeelConfig.RemoteSlashColor * (finisher ? 1.5f : 1f);
@@ -0,0 +1,91 @@
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));
}
}
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 47b2a19145125ac4e98411cab7f9b569
@@ -72,9 +72,9 @@ namespace ProjectM.Client
// observe-only readers of replicated state (BoonOffer via GhostOwnerIsLocal; RunInfo RouteOpt*); clicks // observe-only readers of replicated state (BoonOffer via GhostOwnerIsLocal; RunInfo RouteOpt*); clicks
// enqueue through the client send-systems' statics. Built lazily on first show. // enqueue through the client send-systems' statics. Built lazily on first show.
VisualElement _boonModal, _boonCardRow; VisualElement _boonModal, _boonCardRow;
VisualElement _routePanel, _routeBtnRow; VisualElement _routePanel;
Label _routeTitle; Label _routeTitle;
byte _boonShownFor; // last (Option0^Option1^Option2 ^ room) signature the modal was built for int _boonShownFor; // last exact (Option0|Option1<<8|Option2<<16)+1 signature the modal was built for
bool _boonModalBuilt, _routePanelBuilt; bool _boonModalBuilt, _routePanelBuilt;
// Step 14 (meta shop): Staging-only permanent-upgrade shop (replicated MetaTierState + ledger Aether; // Step 14 (meta shop): Staging-only permanent-upgrade shop (replicated MetaTierState + ledger Aether;
// row clicks enqueue MetaSpendSendSystem.RequestPurchase — the server re-validates everything). // row clicks enqueue MetaSpendSendSystem.RequestPurchase — the server re-validates everything).
@@ -82,6 +82,28 @@ namespace ProjectM.Client
Label _metaShopTitle; Label _metaShopTitle;
bool _metaShopBuilt; bool _metaShopBuilt;
int _metaShownFor; // last (class, tiers, aether) signature the shop rows were built for int _metaShownFor; // last (class, tiers, aether) signature the shop rows were built for
// Demo polish: the clickable READY panel (Staging/Launching) + the drawn branching route map
// (RouteSelect) — the map is regenerated client-side from RunInfo.RunSeed for DISPLAY only; the
// clickable next-layer nodes bind to the authoritative RouteOpt* bytes (never the regen).
VisualElement _readyPanel, _readyPipRow;
Button _readyBtn;
Label _readyTitle;
bool _readyPanelBuilt;
int _readyShownFor; // (ready, total, localReady, secs, launching) rebuild signature
VisualElement _routeMapHost; // node circles + Painter2D edges
int _routeMapSig; // (seed, room, col, options) signature the map was drawn for
readonly List<int> _routeVisited = new(); // client-local path trace (nodeIds), reset per RunSeed
uint _routeVisitedSeed;
// Demo polish round 2: boss presence bar, run-depth dots, outcome flash.
VisualElement _bossPanel, _bossFill;
Label _bossText;
bool _bossBarBuilt;
VisualElement _depthPanel;
int _depthShownFor;
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(); readonly List<VisualElement> _pips = new();
@@ -97,6 +119,8 @@ namespace ProjectM.Client
// overlays // overlays
VisualElement _vignette, _downed; VisualElement _vignette, _downed;
Label _downedText; // "RESPAWNING IN N" countdown (client-local: baked DelayTicks + death-edge latch)
float _downedSince = -1f;
float _prevHp, _flash; float _prevHp, _flash;
bool _haveHp; bool _haveHp;
// personal inventory panel (read-only; toggled with I) // personal inventory panel (read-only; toggled with I)
@@ -194,25 +218,21 @@ namespace ProjectM.Client
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.
// Client counts the party's replicated ready flags (send-to-all) for the N/M readout. _locationText.text = "AT THE BASE - build defenses, buy upgrades, READY UP to launch";
int total = 0, readyCount = 0;
foreach (var pr in SystemAPI.Query<RefRO<PlayerReady>>().WithAll<PlayerTag>())
{
total++;
if (pr.ValueRO.Value != 0) readyCount++;
}
_locationText.text = "READY UP [T] - " + readyCount + "/" + Mathf.Max(total, 1)
+ " ready - launch a run to advance the Engine";
_locationText.style.color = new Color(0.55f, 0.85f, 1f); _locationText.style.color = new Color(0.55f, 0.85f, 1f);
break; break;
}
case RunLifecycle.Launching: case RunLifecycle.Launching:
{ {
uint nowTick = SystemAPI.TryGetSingleton<NetworkTime>(out var ntime) && ntime.ServerTick.IsValid // Wrap-safe countdown (post-impl review): the client's PREDICTED tick passes LaunchTick
? ntime.ServerTick.TickIndexForValidTick : 0u; // near zero while Lifecycle is still Launching — signed TicksSince, never raw uint math.
int secs = runInfo.LaunchTick != 0 && nowTick != 0 int secs = 0;
? Mathf.Max(0, (int)((runInfo.LaunchTick - nowTick) / 60u) + 1) : 0; if (runInfo.LaunchTick != 0 && SystemAPI.TryGetSingleton<NetworkTime>(out var ntime)
&& ntime.ServerTick.IsValid)
{
int ticksLeft = new NetworkTick(runInfo.LaunchTick).TicksSince(ntime.ServerTick);
if (ticksLeft > 0) secs = ticksLeft / 60 + 1;
}
_locationText.text = "LAUNCHING IN " + secs + " - un-ready [T] to abort"; _locationText.text = "LAUNCHING IN " + secs + " - un-ready [T] to abort";
_locationText.style.color = new Color(1f, 0.9f, 0.4f); _locationText.style.color = new Color(1f, 0.9f, 0.4f);
break; break;
@@ -272,9 +292,72 @@ namespace ProjectM.Client
BlobAssetReference<BoonCatalogBlob> boonPool = default; BlobAssetReference<BoonCatalogBlob> boonPool = default;
if (SystemAPI.TryGetSingleton<BoonCatalog>(out var bcat)) if (SystemAPI.TryGetSingleton<BoonCatalog>(out var bcat))
boonPool = bcat.Value; boonPool = bcat.Value;
UpdateBoonModal(localOffer, hasOffer && localOffer.Pending == 1, boonPool); // Lifecycle gate (post-impl review): even a stale replicated Pending never shows the modal outside
// the reward window.
UpdateBoonModal(localOffer, hasOffer && localOffer.Pending == 1
&& haveRun && runInfo.Lifecycle == RunLifecycle.RoomReward, boonPool);
UpdateRoutePanel(haveRun ? runInfo : default); UpdateRoutePanel(haveRun ? runInfo : default);
// Client-local path trace for the route map (nodeIds visited this run; display-only).
if (haveRun && runInfo.RunSeed != _routeVisitedSeed)
{
_routeVisited.Clear();
_routeVisitedSeed = runInfo.RunSeed;
}
if (haveRun && runInfo.Lifecycle == RunLifecycle.InRoom)
{
int visitedNode = RunMap.NodeId(runInfo.CurrentRoom, runInfo.CurrentCol);
if (_routeVisited.Count == 0 || _routeVisited[^1] != visitedNode) _routeVisited.Add(visitedNode);
}
// The clickable READY panel (Staging/Launching, hidden once the outcome latched — the banner owns
// the screen then). Counts are the replicated send-to-all PlayerReady flags.
int rTotal = 0, rReady = 0;
bool localReady = false;
int launchSecs = 0;
bool terminal = SystemAPI.TryGetSingleton<RunOutcome>(out var readyOc)
&& readyOc.Value != RunOutcomeId.InProgress;
bool readyShow = haveRun && !terminal
&& (runInfo.Lifecycle == RunLifecycle.Staging || runInfo.Lifecycle == RunLifecycle.Launching);
if (readyShow)
{
foreach (var pr in SystemAPI.Query<RefRO<PlayerReady>>().WithAll<PlayerTag>())
{
rTotal++;
if (pr.ValueRO.Value != 0) rReady++;
}
foreach (var pr in SystemAPI.Query<RefRO<PlayerReady>>().WithAll<PlayerTag, GhostOwnerIsLocal>())
localReady = pr.ValueRO.Value != 0;
if (runInfo.Lifecycle == RunLifecycle.Launching && runInfo.LaunchTick != 0
&& SystemAPI.TryGetSingleton<NetworkTime>(out var lnt) && lnt.ServerTick.IsValid)
{
int tl = new NetworkTick(runInfo.LaunchTick).TicksSince(lnt.ServerTick);
if (tl > 0) launchSecs = tl / 60 + 1;
}
}
UpdateReadyPanel(readyShow, runInfo, rTotal, rReady, localReady, launchSecs);
// Boss presence bar — client heuristic: a Boss room spawns exactly ONE enemy (the boss), so any live
// EnemyTag Health in a Boss-type room is it. Replicated state only; no netcode surface.
bool bossAlive = false;
float bossHp = 0f, bossMax = 0f;
if (haveRun && runInfo.Lifecycle == RunLifecycle.InRoom && runInfo.CurrentRoomType == RoomTypeId.Boss)
{
foreach (var bhq in SystemAPI.Query<RefRO<Health>>().WithAll<EnemyTag>())
{
if (bhq.ValueRO.Max > bossMax)
{
bossMax = bhq.ValueRO.Max;
bossHp = bhq.ValueRO.Current;
bossAlive = bhq.ValueRO.Current > 0f;
}
}
}
UpdateBossBar(bossAlive, bossHp, bossMax);
// Run-depth dots — keeps the roguelite spine visible while fighting (the map only shows at gates).
UpdateRunDepth(haveRun ? runInfo : default, haveRun);
// ---- Goal (hex-pip meter, or a continuous bar for large targets) ---- // ---- Goal (hex-pip meter, or a continuous bar for large targets) ----
if (SystemAPI.TryGetSingleton<GoalProgress>(out var goal)) if (SystemAPI.TryGetSingleton<GoalProgress>(out var goal))
{ {
@@ -388,6 +471,14 @@ namespace ProjectM.Client
if (SystemAPI.TryGetSingleton<RunOutcome>(out var runOutcome) && runOutcome.Value != RunOutcomeId.InProgress) if (SystemAPI.TryGetSingleton<RunOutcome>(out var runOutcome) && runOutcome.Value != RunOutcomeId.InProgress)
{ {
bool win = runOutcome.Value == RunOutcomeId.Victory; 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; _runBanner.style.display = DisplayStyle.Flex;
_runBannerText.text = win ? "THE ENGINE HOLDS" : "OVERRUN"; _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); _runBannerText.style.color = win ? new Color(0.45f, 0.95f, 1f) : new Color(1f, 0.35f, 0.3f);
@@ -397,6 +488,32 @@ namespace ProjectM.Client
else else
{ {
_runBanner.style.display = DisplayStyle.None; _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;
} }
@@ -487,6 +604,18 @@ namespace ProjectM.Client
HudUi.SetFill(_cooldownFill, cdFrac); HudUi.SetFill(_cooldownFill, cdFrac);
// A READY weapon (full bar) recedes; a CHARGING one is bright — so the inverted-vs-health polarity reads. // A READY weapon (full bar) recedes; a CHARGING one is bright — so the inverted-vs-health polarity reads.
if (_cdRow != null) _cdRow.style.opacity = cdFrac >= 1f ? 0.4f : 1f; if (_cdRow != null) _cdRow.style.opacity = cdFrac >= 1f ? 0.4f : 1f;
if (dead)
{
// Client-local countdown: latch the death edge; the baked (non-replicated) DelayTicks is the
// honest duration — RespawnTick itself is server-only.
if (_downedSince < 0f) _downedSince = (float)SystemAPI.Time.ElapsedTime;
int delayTicks = 180;
foreach (var rs in SystemAPI.Query<RefRO<RespawnState>>().WithAll<PlayerTag, GhostOwnerIsLocal>())
{ delayTicks = Mathf.Max(1, rs.ValueRO.DelayTicks); break; }
float left = delayTicks / 60f - ((float)SystemAPI.Time.ElapsedTime - _downedSince);
_downedText.text = left > 0.05f ? "RESPAWNING IN " + Mathf.CeilToInt(left) : "RESPAWNING...";
}
else _downedSince = -1f;
_downed.style.display = dead ? DisplayStyle.Flex : DisplayStyle.None; _downed.style.display = dead ? DisplayStyle.Flex : DisplayStyle.None;
} }
else else
@@ -1022,7 +1151,12 @@ namespace ProjectM.Client
{ {
_downed.style.backgroundColor = new Color(0.35f, 0f, 0f, 0.35f); _downed.style.backgroundColor = new Color(0.35f, 0f, 0f, 0.35f);
} }
_downed.Add(HudUi.Display("DOWNED - RESPAWNING", 52, new Color(1f, 0.45f, 0.4f), TextAnchor.MiddleCenter)); var downedCol = HudUi.Group(Align.Center);
downedCol.Add(HudUi.Display("DOWNED", 52, new Color(1f, 0.45f, 0.4f), TextAnchor.MiddleCenter));
_downedText = HudUi.Text("RESPAWNING...", 24, new Color(1f, 0.75f, 0.7f), TextAnchor.MiddleCenter);
_downedText.style.marginTop = 6;
downedCol.Add(_downedText);
_downed.Add(downedCol);
_downed.style.display = DisplayStyle.None; _downed.style.display = DisplayStyle.None;
root.Add(_downed); root.Add(_downed);
} }
@@ -1042,17 +1176,32 @@ namespace ProjectM.Client
_runBannerSub.style.marginTop = 8; _runBannerSub.style.marginTop = 8;
col.Add(_runBannerSub); col.Add(_runBannerSub);
// END-2 (SL-5): the terminal banner offers a clear action so the player isn't hunting for Esc. // END-2 (SL-5): the terminal banner offers a clear action so the player isn't hunting for Esc.
// PLAY AGAIN restarts a fresh Single run via the proven menu lifecycle; QUIT TO MENU reuses the teardown. // SINGLE: PLAY AGAIN Continues as a fresh campaign (base+meta kept — the terminal save rolls
// Both self-guard on WorldLauncher.Busy. The row picks (Position) even though the banner root Ignores. // 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(); var btnRow = new VisualElement();
btnRow.style.flexDirection = FlexDirection.Row; btnRow.style.flexDirection = FlexDirection.Row;
btnRow.style.justifyContent = Justify.Center; btnRow.style.justifyContent = Justify.Center;
btnRow.style.marginTop = 28; btnRow.style.marginTop = 28;
btnRow.pickingMode = PickingMode.Position; btnRow.pickingMode = PickingMode.Position;
var again = MenuUi.Button("PLAY AGAIN", () => WorldLauncher.StartSession(SessionMode.Single, null, false)); switch (WorldLauncher.LastMode)
again.style.marginRight = 12; {
btnRow.Add(again); case SessionMode.Host:
btnRow.Add(MenuUi.Button("QUIT TO MENU", WorldLauncher.TeardownToMenu)); 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); col.Add(btnRow);
_runBanner.Add(col); _runBanner.Add(col);
_runBanner.style.display = DisplayStyle.None; _runBanner.style.display = DisplayStyle.None;
@@ -1230,7 +1379,6 @@ namespace ProjectM.Client
// ==== Step 14: boon modal + route panel (lazy-built overlays; clicks -> client send statics) ==== // ==== Step 14: boon modal + route panel (lazy-built overlays; clicks -> client send statics) ====
byte _routeShownFor; // last (room ^ options) signature the route buttons were built for
static string RoomTypeLabel(byte roomType) => roomType == RoomTypeId.Boss ? "[BOSS]" static string RoomTypeLabel(byte roomType) => roomType == RoomTypeId.Boss ? "[BOSS]"
: roomType == RoomTypeId.Elite ? "[ELITE]" : roomType == RoomTypeId.Elite ? "[ELITE]"
@@ -1253,8 +1401,9 @@ namespace ProjectM.Client
} }
// Rebuild the three cards only when the offer actually changes (a new room's deal). // Rebuild the three cards only when the offer actually changes (a new room's deal).
byte sig = (byte)(offer.Option0 ^ (offer.Option1 * 3) ^ (offer.Option2 * 7)); // Exact signature (post-impl review): the old lossy byte XOR could collide across consecutive
if (sig == 0) sig = 1; // rooms and leave stale card labels. +1 keeps 0 as the hidden/reset sentinel.
int sig = 1 + (offer.Option0 | (offer.Option1 << 8) | (offer.Option2 << 16));
if (_boonShownFor != sig) if (_boonShownFor != sig)
{ {
_boonCardRow.Clear(); _boonCardRow.Clear();
@@ -1265,10 +1414,18 @@ namespace ProjectM.Client
int idx = BoonMath.FindDef(ref defs, id); int idx = BoonMath.FindDef(ref defs, id);
string title = idx >= 0 ? defs.Defs[idx].Name.ToString() : ("BOON " + id); string title = idx >= 0 ? defs.Defs[idx].Name.ToString() : ("BOON " + id);
string desc = idx >= 0 ? defs.Defs[idx].Desc.ToString() : ""; string desc = idx >= 0 ? defs.Defs[idx].Desc.ToString() : "";
byte weight = idx >= 0 ? defs.Defs[idx].Weight : (byte)100;
byte pick = k; // capture a COPY into the closure, never the loop variable byte pick = k; // capture a COPY into the closure, never the loop variable
var card = MenuUi.Button(title + "\n" + desc, () => BoonSendSystem.PickBoon(pick)); var card = MenuUi.Button(title + "\n" + desc, () => BoonSendSystem.PickBoon(pick));
card.style.width = 200; card.style.width = 200;
card.style.height = 96; card.style.height = StyleKeyword.Auto; // long descs grow the card
card.style.minHeight = 96;
// Rarity from the draw weight (100 common / 60 uncommon / 30 rare / 10 epic).
var rare = weight <= 10 ? new Color(1f, 0.82f, 0.30f)
: weight <= 30 ? new Color(0.65f, 0.50f, 1f)
: weight <= 60 ? new Color(0.45f, 0.95f, 0.55f)
: new Color(1f, 1f, 1f, 0.30f);
MenuUi.Border(card, rare, weight <= 30 ? 2.5f : 1.5f);
card.style.marginLeft = 8; card.style.marginLeft = 8;
card.style.marginRight = 8; card.style.marginRight = 8;
card.style.whiteSpace = WhiteSpace.Normal; card.style.whiteSpace = WhiteSpace.Normal;
@@ -1312,6 +1469,25 @@ namespace ProjectM.Client
root.Add(_boonModal); root.Add(_boonModal);
} }
// ---- the drawn branching route map (Slay-the-Spire style; display regen from RunSeed, clicks bind
// to the authoritative RouteOpt* bytes) ----
const float MapStrideX = 58f, MapStrideY = 46f, MapNodeSize = 34f, MapPad = 14f;
static Vector2 MapNodePos(int layer, int col, byte layerWidth)
{
float x = MapPad + layer * MapStrideX;
float y = MapPad + MapStrideY + (col - (layerWidth - 1) * 0.5f) * MapStrideY;
return new Vector2(x, y);
}
static string RoomGlyph(byte t) => t == RoomTypeId.Boss ? "B"
: t == RoomTypeId.Elite ? "E" : t == RoomTypeId.Reward ? "R" : "C";
static Color RoomColor(byte t) => t == RoomTypeId.Boss ? new Color(0.92f, 0.28f, 0.22f)
: t == RoomTypeId.Elite ? new Color(0.80f, 0.45f, 1f)
: t == RoomTypeId.Reward ? new Color(0.45f, 0.95f, 0.55f) : new Color(1f, 0.72f, 0.35f);
void UpdateRoutePanel(RunInfo runInfo) void UpdateRoutePanel(RunInfo runInfo)
{ {
// Keyed on the LIFECYCLE (never RouteOptionCount alone — the review's D-F6 criterion). // Keyed on the LIFECYCLE (never RouteOptionCount alone — the review's D-F6 criterion).
@@ -1319,7 +1495,7 @@ namespace ProjectM.Client
if (!show) if (!show)
{ {
if (_routePanel != null) _routePanel.style.display = DisplayStyle.None; if (_routePanel != null) _routePanel.style.display = DisplayStyle.None;
_routeShownFor = 0; _routeMapSig = 0;
return; return;
} }
var root = _doc != null ? _doc.rootVisualElement : null; var root = _doc != null ? _doc.rootVisualElement : null;
@@ -1330,23 +1506,15 @@ namespace ProjectM.Client
_routePanelBuilt = true; _routePanelBuilt = true;
} }
byte sig = (byte)((runInfo.CurrentRoom + 1) ^ (runInfo.RouteOpt0Type * 3) int sig = (int)runInfo.RunSeed ^ (runInfo.CurrentRoom + 1) * 131 ^ runInfo.CurrentCol * 31
^ (runInfo.RouteOpt1Type * 5) ^ (runInfo.RouteOpt2Type * 7) ^ runInfo.RouteOptionCount); ^ (runInfo.RouteOptionCount << 24) ^ (runInfo.RouteOpt0Col << 16)
^ (runInfo.RouteOpt1Col << 18) ^ (runInfo.RouteOpt2Col << 20);
if (sig == 0) sig = 1; if (sig == 0) sig = 1;
if (_routeShownFor != sig) if (_routeMapSig != sig)
{ {
_routeBtnRow.Clear(); RebuildRouteMap(runInfo);
for (byte k = 0; k < runInfo.RouteOptionCount && k < 3; k++)
{
byte type = k == 2 ? runInfo.RouteOpt2Type : k == 1 ? runInfo.RouteOpt1Type : runInfo.RouteOpt0Type;
byte pick = k; // closure copy
var b = MenuUi.Button("→ " + RoomTypeLabel(type), () => RouteSendSystem.PickRoute(pick));
b.style.marginLeft = 6;
b.style.marginRight = 6;
_routeBtnRow.Add(b);
}
_routeTitle.text = "CHOOSE YOUR PATH — room " + (runInfo.CurrentRoom + 2) + "/" + runInfo.RoomCount; _routeTitle.text = "CHOOSE YOUR PATH — room " + (runInfo.CurrentRoom + 2) + "/" + runInfo.RoomCount;
_routeShownFor = sig; _routeMapSig = sig;
} }
_routePanel.style.display = DisplayStyle.Flex; _routePanel.style.display = DisplayStyle.Flex;
} }
@@ -1356,33 +1524,299 @@ namespace ProjectM.Client
_routePanel = new VisualElement { pickingMode = PickingMode.Ignore }; _routePanel = new VisualElement { pickingMode = PickingMode.Ignore };
_routePanel.style.position = Position.Absolute; _routePanel.style.position = Position.Absolute;
_routePanel.style.left = 0; _routePanel.style.right = 0; _routePanel.style.left = 0; _routePanel.style.right = 0;
_routePanel.style.bottom = 120; _routePanel.style.top = 0; _routePanel.style.bottom = 0;
_routePanel.style.alignItems = Align.Center; _routePanel.style.alignItems = Align.Center;
_routePanel.style.justifyContent = Justify.Center;
_routePanel.style.display = DisplayStyle.None; _routePanel.style.display = DisplayStyle.None;
var box = new VisualElement(); var box = new VisualElement { pickingMode = PickingMode.Position }; // swallow world clicks under the map
box.style.backgroundColor = new Color(0.07f, 0.09f, 0.12f, 0.94f); box.style.backgroundColor = new Color(0.07f, 0.09f, 0.12f, 0.95f);
box.style.borderTopLeftRadius = 10; box.style.borderTopRightRadius = 10; box.style.borderTopLeftRadius = 10; box.style.borderTopRightRadius = 10;
box.style.borderBottomLeftRadius = 10; box.style.borderBottomRightRadius = 10; box.style.borderBottomLeftRadius = 10; box.style.borderBottomRightRadius = 10;
box.style.paddingLeft = 16; box.style.paddingRight = 16; box.style.paddingLeft = 18; box.style.paddingRight = 18;
box.style.paddingTop = 10; box.style.paddingBottom = 12; box.style.paddingTop = 12; box.style.paddingBottom = 12;
box.style.alignItems = Align.Center; box.style.alignItems = Align.Center;
_routeTitle = new Label("CHOOSE YOUR PATH"); _routeTitle = new Label("CHOOSE YOUR PATH");
_routeTitle.style.color = new Color(0.55f, 0.85f, 1f); _routeTitle.style.color = new Color(0.55f, 0.85f, 1f);
_routeTitle.style.fontSize = 15; _routeTitle.style.fontSize = 16;
_routeTitle.style.unityFontStyleAndWeight = FontStyle.Bold; _routeTitle.style.unityFontStyleAndWeight = FontStyle.Bold;
_routeTitle.style.marginBottom = 8; _routeTitle.style.marginBottom = 10;
box.Add(_routeTitle); box.Add(_routeTitle);
_routeBtnRow = new VisualElement(); _routeMapHost = new VisualElement { pickingMode = PickingMode.Ignore };
_routeBtnRow.style.flexDirection = FlexDirection.Row; _routeMapHost.style.position = Position.Relative;
box.Add(_routeBtnRow); box.Add(_routeMapHost);
var cap = HudUi.Text("your path is lit — click a highlighted room to commit the party", 13,
MenuUi.SubCol, TextAnchor.MiddleCenter);
cap.style.marginTop = 10;
box.Add(cap);
_routePanel.Add(box); _routePanel.Add(box);
root.Add(_routePanel); root.Add(_routePanel);
} }
void RebuildRouteMap(RunInfo runInfo)
{
_routeMapHost.Clear();
var map = RunMapMath.Generate(runInfo.RunSeed);
_routeMapHost.style.width = MapPad * 2f + (map.LayerCount - 1) * MapStrideX + MapNodeSize;
_routeMapHost.style.height = MapPad * 2f + 2f * MapStrideY + MapNodeSize;
// Edges under the nodes (Painter2D); walked segments glow, the rest are faint.
var edges = new VisualElement { pickingMode = PickingMode.Ignore };
edges.style.position = Position.Absolute;
edges.style.left = 0; edges.style.top = 0; edges.style.right = 0; edges.style.bottom = 0;
var mapCopy = map;
var visited = new List<int>(_routeVisited);
edges.generateVisualContent += ctx =>
{
var p = ctx.painter2D;
p.lineWidth = 2f;
var c = new Vector2(MapNodeSize * 0.5f, MapNodeSize * 0.5f);
for (int layer = 0; layer < mapCopy.LayerCount - 1; layer++)
for (int col = 0; col < mapCopy.LayerWidths[layer]; col++)
{
var node = mapCopy.Node(layer, col);
if (node.NextMask == 0) continue;
var a = MapNodePos(layer, col, mapCopy.LayerWidths[layer]);
for (int j = 0; j < mapCopy.LayerWidths[layer + 1]; j++)
{
if ((node.NextMask & (1 << j)) == 0) continue;
var b = MapNodePos(layer + 1, j, mapCopy.LayerWidths[layer + 1]);
bool walked = visited.Contains(RunMap.NodeId(layer, col))
&& visited.Contains(RunMap.NodeId(layer + 1, j));
p.strokeColor = walked ? new Color(0.55f, 0.85f, 1f, 0.9f) : new Color(1f, 1f, 1f, 0.16f);
p.BeginPath();
p.MoveTo(a + c);
p.LineTo(b + c);
p.Stroke();
}
}
};
_routeMapHost.Add(edges);
int nextLayer = runInfo.CurrentRoom + 1;
for (int layer = 0; layer < map.LayerCount; layer++)
for (int col = 0; col < map.LayerWidths[layer]; col++)
{
var node = map.Node(layer, col);
bool isCurrent = layer == runInfo.CurrentRoom && col == runInfo.CurrentCol;
bool wasVisited = _routeVisited.Contains(RunMap.NodeId(layer, col));
byte opt = 255;
if (layer == nextLayer)
{
if (runInfo.RouteOptionCount > 0 && col == runInfo.RouteOpt0Col) opt = 0;
else if (runInfo.RouteOptionCount > 1 && col == runInfo.RouteOpt1Col) opt = 1;
else if (runInfo.RouteOptionCount > 2 && col == runInfo.RouteOpt2Col) opt = 2;
}
_routeMapHost.Add(MakeMapNode(node.RoomType,
MapNodePos(layer, col, map.LayerWidths[layer]), isCurrent, wasVisited, opt, layer <= runInfo.CurrentRoom));
}
}
VisualElement MakeMapNode(byte roomType, Vector2 pos, bool isCurrent, bool visited, byte optionIndex, bool past)
{
bool clickable = optionIndex != 255;
var n = new VisualElement { pickingMode = clickable ? PickingMode.Position : PickingMode.Ignore };
n.style.position = Position.Absolute;
n.style.left = pos.x; n.style.top = pos.y;
n.style.width = MapNodeSize; n.style.height = MapNodeSize;
MenuUi.Round(n, MapNodeSize * 0.5f);
var c = RoomColor(roomType);
float bgA = clickable ? 0.95f : visited || isCurrent ? 0.85f : past ? 0.20f : 0.40f;
var restBg = new Color(c.r * 0.35f, c.g * 0.35f, c.b * 0.35f, bgA);
n.style.backgroundColor = restBg;
MenuUi.Border(n, isCurrent ? new Color(0.55f, 0.85f, 1f) : clickable ? c : new Color(1f, 1f, 1f, 0.18f),
isCurrent || clickable ? 2.5f : 1.2f);
var lbl = new Label(RoomGlyph(roomType)) { pickingMode = PickingMode.Ignore };
lbl.style.unityTextAlign = TextAnchor.MiddleCenter;
lbl.style.flexGrow = 1;
lbl.style.color = clickable || visited || isCurrent ? c : new Color(1f, 1f, 1f, 0.35f);
lbl.style.fontSize = 15;
lbl.style.unityFontStyleAndWeight = FontStyle.Bold;
n.Add(lbl);
if (clickable)
{
byte pick = optionIndex; // closure copy, never the loop variable
n.RegisterCallback<ClickEvent>(_ => RouteSendSystem.PickRoute(pick));
n.RegisterCallback<MouseEnterEvent>(_ =>
n.style.backgroundColor = new Color(c.r * 0.55f, c.g * 0.55f, c.b * 0.55f, 1f));
n.RegisterCallback<MouseLeaveEvent>(_ => n.style.backgroundColor = restBg);
}
return n;
}
// ---- the clickable READY panel (Staging: toggle + party pips; Launching: countdown + abort) ----
void UpdateReadyPanel(bool show, RunInfo runInfo, int total, int ready, bool localReady, int launchSecs)
{
if (!show)
{
if (_readyPanel != null) _readyPanel.style.display = DisplayStyle.None;
_readyShownFor = 0;
return;
}
var root = _doc != null ? _doc.rootVisualElement : null;
if (root == null) return;
if (!_readyPanelBuilt)
{
BuildReadyPanel(root);
_readyPanelBuilt = true;
}
bool launching = runInfo.Lifecycle == RunLifecycle.Launching;
int sig = 1 + ready + (total << 4) + (localReady ? 1 << 8 : 0) + (launchSecs << 9) + (launching ? 1 << 16 : 0);
if (_readyShownFor != sig)
{
_readyTitle.text = launching
? "LAUNCHING IN " + launchSecs
: "EXPEDITION — " + ready + "/" + Mathf.Max(total, 1) + " READY";
_readyTitle.style.color = launching ? new Color(1f, 0.9f, 0.4f) : new Color(0.55f, 0.85f, 1f);
_readyBtn.text = launching ? "ABORT [T]" : localReady ? "UNREADY [T]" : "READY UP [T]";
_readyPipRow.Clear();
for (int i = 0; i < total; i++)
{
var pip = new VisualElement();
pip.style.width = 14; pip.style.height = 14;
pip.style.marginLeft = 3; pip.style.marginRight = 3;
MenuUi.Round(pip, 7f);
pip.style.backgroundColor = i < ready
? new Color(0.45f, 0.95f, 0.55f) : new Color(1f, 1f, 1f, 0.15f);
_readyPipRow.Add(pip);
}
_readyShownFor = sig;
}
_readyPanel.style.display = DisplayStyle.Flex;
}
void BuildReadyPanel(VisualElement root)
{
_readyPanel = new VisualElement { pickingMode = PickingMode.Ignore };
_readyPanel.style.position = Position.Absolute;
_readyPanel.style.left = 0; _readyPanel.style.right = 0;
_readyPanel.style.bottom = 170; // clear of the build palette row + hint bar
_readyPanel.style.alignItems = Align.Center;
_readyPanel.style.display = DisplayStyle.None;
var box = new VisualElement { pickingMode = PickingMode.Position };
box.style.backgroundColor = new Color(0.07f, 0.09f, 0.12f, 0.92f);
box.style.borderTopLeftRadius = 10; box.style.borderTopRightRadius = 10;
box.style.borderBottomLeftRadius = 10; box.style.borderBottomRightRadius = 10;
box.style.paddingLeft = 18; box.style.paddingRight = 18;
box.style.paddingTop = 10; box.style.paddingBottom = 12;
box.style.alignItems = Align.Center;
_readyTitle = new Label("EXPEDITION");
_readyTitle.style.fontSize = 16;
_readyTitle.style.unityFontStyleAndWeight = FontStyle.Bold;
box.Add(_readyTitle);
_readyPipRow = new VisualElement();
_readyPipRow.style.flexDirection = FlexDirection.Row;
_readyPipRow.style.justifyContent = Justify.Center;
_readyPipRow.style.marginTop = 6; _readyPipRow.style.marginBottom = 8;
box.Add(_readyPipRow);
_readyBtn = MenuUi.Button("READY UP [T]", ReadySendSystem.ToggleReady);
box.Add(_readyBtn);
_readyPanel.Add(box);
root.Add(_readyPanel);
}
// ---- boss presence bar (Boss rooms only; red, top-center, under the macro banner) ----
void UpdateBossBar(bool alive, float hp, float max)
{
if (!alive || max <= 0f)
{
if (_bossPanel != null) _bossPanel.style.display = DisplayStyle.None;
return;
}
var root = _doc != null ? _doc.rootVisualElement : null;
if (root == null) return;
if (!_bossBarBuilt)
{
BuildBossBar(root);
_bossBarBuilt = true;
}
HudUi.SetFill(_bossFill, Mathf.Clamp01(hp / max));
_bossText.text = "ALPHA HUSK " + Mathf.CeilToInt(Mathf.Max(0f, hp)) + " / " + Mathf.CeilToInt(max);
_bossPanel.style.display = DisplayStyle.Flex;
}
void BuildBossBar(VisualElement root)
{
_bossPanel = new VisualElement { pickingMode = PickingMode.Ignore };
_bossPanel.style.position = Position.Absolute;
_bossPanel.style.left = 0; _bossPanel.style.right = 0;
_bossPanel.style.top = 168;
_bossPanel.style.alignItems = Align.Center;
_bossPanel.style.display = DisplayStyle.None;
var col = HudUi.Group(Align.Center);
_bossText = HudUi.Display("ALPHA HUSK", 22, new Color(1f, 0.35f, 0.3f), TextAnchor.MiddleCenter);
col.Add(_bossText);
var bar = HudUi.Bar(420, 12, new Color(0.92f, 0.22f, 0.18f), out _bossFill);
bar.style.marginTop = 4;
col.Add(bar);
_bossPanel.Add(col);
root.Add(_bossPanel);
}
// ---- run-depth dots (visible through the whole run; the current room pulses bigger) ----
void UpdateRunDepth(RunInfo runInfo, bool haveRun)
{
bool show = haveRun && runInfo.RoomCount > 0
&& (runInfo.Lifecycle == RunLifecycle.InRoom
|| runInfo.Lifecycle == RunLifecycle.RoomReward
|| runInfo.Lifecycle == RunLifecycle.RouteSelect);
if (!show)
{
if (_depthPanel != null) _depthPanel.style.display = DisplayStyle.None;
_depthShownFor = 0;
return;
}
var root = _doc != null ? _doc.rootVisualElement : null;
if (root == null) return;
if (!_depthBuilt)
{
_depthPanel = new VisualElement { pickingMode = PickingMode.Ignore };
_depthPanel.style.position = Position.Absolute;
_depthPanel.style.left = 0; _depthPanel.style.right = 0;
_depthPanel.style.top = 208; // below the macro cluster (goal/core) AND the boss bar (168)
_depthPanel.style.flexDirection = FlexDirection.Row;
_depthPanel.style.justifyContent = Justify.Center;
root.Add(_depthPanel);
_depthBuilt = true;
}
int sig = 1 + runInfo.CurrentRoom * 37 + runInfo.RoomCount * 3;
if (_depthShownFor != sig)
{
_depthPanel.Clear();
for (int i = 0; i < runInfo.RoomCount; i++)
{
bool current = i == runInfo.CurrentRoom;
bool done = i < runInfo.CurrentRoom;
var dot = new VisualElement { pickingMode = PickingMode.Ignore };
float size = current ? 12f : 8f;
dot.style.width = size; dot.style.height = size;
dot.style.marginLeft = 3; dot.style.marginRight = 3;
dot.style.alignSelf = Align.Center;
MenuUi.Round(dot, size * 0.5f);
dot.style.backgroundColor = current ? new Color(0.55f, 0.85f, 1f)
: done ? new Color(0.55f, 0.85f, 1f, 0.55f)
: new Color(1f, 1f, 1f, 0.16f);
_depthPanel.Add(dot);
}
_depthShownFor = sig;
}
_depthPanel.style.display = DisplayStyle.Flex;
}
void UpdateMetaShop(bool show, byte classId, int aether, void UpdateMetaShop(bool show, byte classId, int aether,
BlobAssetReference<MetaUpgradeCatalogBlob> pool, DynamicBuffer<MetaTierState> record) BlobAssetReference<MetaUpgradeCatalogBlob> pool, DynamicBuffer<MetaTierState> record)
{ {
@@ -1417,18 +1851,35 @@ namespace ProjectM.Client
if ((defs.Defs[d].ClassMask & classBit) == 0) continue; if ((defs.Defs[d].ClassMask & classBit) == 0) continue;
byte id = defs.Defs[d].Id; byte id = defs.Defs[d].Id;
byte owned = MetaMath.TierOf(record, classId, id); byte owned = MetaMath.TierOf(record, classId, id);
if (owned > defs.Defs[d].MaxTier) owned = defs.Defs[d].MaxTier; // D-F5 display clamp (seed AND spend AND shop)
bool maxed = owned >= defs.Defs[d].MaxTier; bool maxed = owned >= defs.Defs[d].MaxTier;
int cost = MetaMath.CostForTier(in defs.Defs[d], owned); int cost = MetaMath.CostForTier(in defs.Defs[d], owned);
string label = defs.Defs[d].Name.ToString() + " [" + owned + "/" + defs.Defs[d].MaxTier + "]" string label = defs.Defs[d].Name.ToString()
+ (maxed ? " MAXED" : " - " + cost + " Aether") + (maxed ? " MAXED" : " - " + cost + " Aether")
+ "\n" + defs.Defs[d].Desc.ToString(); + "\n" + defs.Defs[d].Desc.ToString();
byte buyId = id; // closure copy, never the loop variable byte buyId = id; // closure copy, never the loop variable
var row = MenuUi.Button(label, () => MetaSpendSendSystem.RequestPurchase(buyId)); var row = MenuUi.Button(label, () => MetaSpendSendSystem.RequestPurchase(buyId));
row.style.width = 290; row.style.width = 290;
row.style.height = StyleKeyword.Auto; // two-line labels must grow the row (overlap fix)
row.style.paddingTop = 6; row.style.paddingBottom = 6;
row.style.marginBottom = 4; row.style.marginBottom = 4;
row.style.whiteSpace = WhiteSpace.Normal; row.style.whiteSpace = WhiteSpace.Normal;
row.style.unityTextAlign = TextAnchor.MiddleLeft; row.style.unityTextAlign = TextAnchor.MiddleLeft;
row.SetEnabled(!maxed && aether >= cost); // honest UI; the server re-validates everything anyway row.SetEnabled(!maxed && aether >= cost); // honest UI; the server re-validates everything anyway
// Owned-tier pips (replaces the "[2/5]" text — reads at a glance).
var pipRow = new VisualElement { pickingMode = PickingMode.Ignore };
pipRow.style.flexDirection = FlexDirection.Row;
pipRow.style.marginTop = 3;
for (int p = 0; p < defs.Defs[d].MaxTier; p++)
{
var tp = new VisualElement { pickingMode = PickingMode.Ignore };
tp.style.width = 9; tp.style.height = 9;
tp.style.marginRight = 3;
MenuUi.Round(tp, 4.5f);
tp.style.backgroundColor = p < owned ? AetherCyan : new Color(1f, 1f, 1f, 0.14f);
pipRow.Add(tp);
}
row.Add(pipRow);
_metaRowsHost.Add(row); _metaRowsHost.Add(row);
} }
_metaShownFor = sig; _metaShownFor = sig;
@@ -0,0 +1,285 @@
using ProjectM.Simulation;
using Unity.Entities;
using Unity.NetCode;
using UnityEngine;
namespace ProjectM.Client
{
/// <summary>
/// Client-only PROCEDURAL MUSIC — four synchronized loop layers (bass / chord pad / arpeggio / percussion
/// pulse), all generated over the SAME 4-bar buffer (100 BPM, AmFCG) so they stay phase-locked forever:
/// 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
/// (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"/>
/// lifecycle (staging / combat / boss / reward lull), <see cref="CycleState"/> siege, and the terminal
/// <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
/// <see cref="MasterVolume"/> × <see cref="GameVolume.Music"/>; the low <c>AmbientAudioSystem</c> drone
/// (vol 0.10) remains as texture beneath it.
/// </summary>
[WorldSystemFilter(WorldSystemFilterFlags.ClientSimulation)]
[UpdateInGroup(typeof(PresentationSystemGroup))]
public partial class MusicSystem : SystemBase
{
const float MasterVolume = 0.45f;
const float FadePerSecond = 0.30f;
// ---- shared musical grid: 100 BPM, 4/4, one chord per bar, 4 bars ----
const int SampleRate = 44100;
const float BarSeconds = 2.4f; // 4 beats @ 0.6 s
const int Bars = 4;
const float LoopSeconds = BarSeconds * Bars; // 9.6 s
// Am F C G (natural-minor loop; index = bar): root/third/fifth per chord.
static readonly float[][] ChordHz =
{
new[] { 110.00f, 130.81f, 164.81f }, // Am: A2 C3 E3
new[] { 87.31f, 110.00f, 130.81f }, // F: F2 A2 C3
new[] { 130.81f, 164.81f, 196.00f }, // C: C3 E3 G3
new[] { 98.00f, 123.47f, 146.83f }, // G: G2 B2 D3
};
GameObject _root;
AudioSource _bass, _pad, _arp, _pulse;
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()
{
if (_root != null) return;
_root = new GameObject("~Music");
_bass = MakeSource(BuildBassLoop());
_pad = MakeSource(BuildPadLoop());
_arp = MakeSource(BuildArpLoop());
_pulse = MakeSource(BuildPulseLoop());
// Same frame, same length, loop=true -> phase-locked for the whole session.
_bass.Play(); _pad.Play(); _arp.Play(); _pulse.Play();
}
protected override void OnDestroy()
{
if (_root != null) Object.Destroy(_root);
}
AudioSource MakeSource(AudioClip clip)
{
var src = _root.AddComponent<AudioSource>();
src.clip = clip;
src.loop = true;
src.playOnAwake = false;
src.spatialBlend = 0f;
src.volume = 0f;
return src;
}
protected override void OnUpdate()
{
if (_bass == null) return;
// ---- pick the mix from replicated state (defaults = quiet staging bed) ----
float tBass = 0.50f, tPad = 0.55f, tArp = 0.12f, tPulse = 0f;
bool haveRun = 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)
{
case RunLifecycle.Launching:
tBass = 0.55f; tPad = 0.50f; tArp = 0.35f; tPulse = 0.20f; // anticipation swell
break;
case RunLifecycle.InRoom:
bool boss = run.CurrentRoomType == RoomTypeId.Boss;
tBass = boss ? 0.75f : 0.65f;
tPad = 0.40f;
tArp = boss ? 0.80f : 0.65f;
tPulse = boss ? 0.75f : 0.35f;
break;
case RunLifecycle.RoomReward:
case RunLifecycle.RouteSelect:
tBass = 0.45f; tPad = 0.55f; tArp = 0.25f; tPulse = 0.08f; // between-rooms lull
break;
case RunLifecycle.Returning:
tBass = 0.45f; tPad = 0.60f; tArp = 0.15f; tPulse = 0f; // resolution
break;
// Staging keeps the defaults.
}
}
float dt = SystemAPI.Time.DeltaTime * FadePerSecond;
_vBass = Mathf.MoveTowards(_vBass, tBass, dt);
_vPad = Mathf.MoveTowards(_vPad, tPad, dt);
_vArp = Mathf.MoveTowards(_vArp, tArp, dt);
_vPulse = Mathf.MoveTowards(_vPulse, tPulse, dt);
float master = MasterVolume * GameVolume.Music;
_bass.volume = _vBass * master;
_pad.volume = _vPad * master;
_arp.volume = _vArp * 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) =================
static AudioClip NewLoopClip(string name, out float[] data)
{
int len = (int)(LoopSeconds * SampleRate);
data = new float[len];
return AudioClip.Create(name, len, 1, SampleRate, false);
}
/// <summary>Bar-local envelope that is ~0 at both bar edges (click-free chord changes + loop point).</summary>
static float BarEnv(float tInBar, float attack, float release)
{
float up = Mathf.Clamp01(tInBar / attack);
float down = Mathf.Clamp01((BarSeconds - tInBar) / release);
return up * up * down;
}
static AudioClip BuildBassLoop()
{
var clip = NewLoopClip("music_bass", out var data);
for (int i = 0; i < data.Length; i++)
{
float t = i / (float)SampleRate;
int bar = Mathf.Min(Bars - 1, (int)(t / BarSeconds));
float tb = t - bar * BarSeconds;
float f = ChordHz[bar][0] * 0.5f; // root, an octave down
float s = Mathf.Sin(2f * Mathf.PI * f * t) * 0.8f
+ Mathf.Sin(2f * Mathf.PI * f * 2f * t) * 0.2f;
data[i] = s * BarEnv(tb, 0.03f, 0.12f) * 0.50f;
}
clip.SetData(data, 0);
return clip;
}
static AudioClip BuildPadLoop()
{
var clip = NewLoopClip("music_pad", out var data);
for (int i = 0; i < data.Length; i++)
{
float t = i / (float)SampleRate;
int bar = Mathf.Min(Bars - 1, (int)(t / BarSeconds));
float tb = t - bar * BarSeconds;
var chord = ChordHz[bar];
float s = 0f;
for (int n = 0; n < chord.Length; n++)
s += Mathf.Sin(2f * Mathf.PI * chord[n] * t + n * 1.7f);
float trem = 0.85f + 0.15f * Mathf.Sin(2f * Mathf.PI * 0.8f * t);
data[i] = s / 3f * BarEnv(tb, 0.45f, 0.50f) * trem * 0.34f;
}
clip.SetData(data, 0);
return clip;
}
static AudioClip BuildArpLoop()
{
var clip = NewLoopClip("music_arp", out var data);
const float noteLen = BarSeconds / 8f; // 8th notes
for (int i = 0; i < data.Length; i++)
{
float t = i / (float)SampleRate;
int bar = Mathf.Min(Bars - 1, (int)(t / BarSeconds));
float tb = t - bar * BarSeconds;
int step = Mathf.Min(7, (int)(tb / noteLen));
float tn = tb - step * noteLen;
var chord = ChordHz[bar];
// up-and-over pattern: root, fifth, octave, tenth, back down
float f = step switch
{
0 => chord[0] * 2f,
1 => chord[2] * 2f,
2 => chord[0] * 4f,
3 => chord[1] * 4f,
4 => chord[2] * 4f,
5 => chord[0] * 4f,
6 => chord[2] * 2f,
_ => chord[1] * 2f,
};
float env = Mathf.Exp(-9f * tn);
data[i] = Mathf.Sin(2f * Mathf.PI * f * tn) * env * 0.32f;
}
clip.SetData(data, 0);
return clip;
}
static AudioClip BuildPulseLoop()
{
var clip = NewLoopClip("music_pulse", out var data);
const float beat = BarSeconds / 4f;
uint rng = 0x5EED5EEDu; // deterministic hat noise
for (int i = 0; i < data.Length; i++)
{
float t = i / (float)SampleRate;
float tBeat = t % beat;
// Low thump on every beat: 55->38 Hz sweep, tight decay.
float thump = 0f;
if (tBeat < 0.14f)
{
float f = Mathf.Lerp(55f, 38f, tBeat / 0.14f);
thump = Mathf.Sin(2f * Mathf.PI * f * tBeat) * Mathf.Exp(-22f * tBeat) * 0.85f;
}
// Off-beat hat: a 30 ms noise tick halfway through each beat.
float tHat = (t + beat * 0.5f) % beat;
float hat = 0f;
if (tHat < 0.03f)
{
rng ^= rng << 13; rng ^= rng >> 17; rng ^= rng << 5; // xorshift
float n = (rng & 0xFFFF) / 32768f - 1f;
hat = n * Mathf.Exp(-120f * tHat) * 0.22f;
}
data[i] = thump + hat;
}
clip.SetData(data, 0);
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;
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 0674dbc6d7e676f4cb88b18a67cdca99
@@ -76,21 +76,24 @@ namespace ProjectM.Client
Body(c, "Build menu — Tab / Y"); Body(c, "Build menu — Tab / Y");
Body(c, "Place / Cancel (in build) — LMB / RMB (A / B on gamepad)"); Body(c, "Place / Cancel (in build) — LMB / RMB (A / B on gamepad)");
Body(c, "Deposit at base — G"); Body(c, "Deposit at base — G");
Body(c, "Ready up (launch a run) — T");
Body(c, "Boons / route map / shop — mouse click");
Body(c, "Inventory — I"); Body(c, "Inventory — I");
Body(c, "Pause — Esc"); Body(c, "Pause — Esc");
break; break;
case 1: // The Loop case 1: // The Loop
Head(c, "THE LOOP — expeditions are how you win"); Head(c, "THE LOOP — expedition RUNS are how you win");
Body(c, "1. BASE (Calm) — mine Ore, build Turrets + a Fabricator to defend your Engine Core."); Body(c, "1. BASE build Turrets + a Fabricator around your Engine Core; spend Aether on PERMANENT class upgrades.");
Body(c, "2. EXPEDITION — walk to the Gate and fight the wave. CLEARING it is the progress beat."); Body(c, "2. READY UP [T] — when everyone is ready, the party launches into a multi-room run together.");
Body(c, "3. RETURN — come home to bank the clear: +1 on the Engine meter."); 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, "4. SIEGE — returning provokes a retaliation attack. Defend the Core!"); Body(c, "4. BOSS — fell it and the party returns with the haul: +1 on the Engine meter. A wipe banks nothing.");
Body(c, "5. WIN — fill the Engine meter, then hold the final siege."); Body(c, "5. SIEGE — a completed run provokes retaliation at base. Defend the Core!");
Body(c, "The Engine meter (top of screen) is the goal — base sieges are a consequence, not the win."); 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. Mine it by attacking the glowing nodes at base."); 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, "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, "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.");
@@ -101,7 +104,7 @@ namespace ProjectM.Client
Head(c, "THREATS"); Head(c, "THREATS");
Body(c, "Husks assault the base during a Siege — keep them off the Engine Core."); 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, "A Husk that reaches the Core drains it. Lose the Core in the final siege and the run ends.");
Body(c, "Expeditions throw waves at you — clear them all to bank the win."); 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; break;
default: // Win / Lose default: // Win / Lose
@@ -80,6 +80,17 @@ namespace ProjectM.Client
_mainPanel = MenuUi.FullScreenRoot(true); _mainPanel = MenuUi.FullScreenRoot(true);
var card = MenuUi.Card("PROJECT M"); var card = MenuUi.Card("PROJECT M");
card.Add(MenuUi.Caption("Frontier colony — co-op")); card.Add(MenuUi.Caption("Frontier colony — co-op"));
// Why the last session ended (connection watchdog / bad join address) — shown once, then cleared.
if (!string.IsNullOrEmpty(SessionEnd.Reason))
{
var reason = MenuUi.Caption(SessionEnd.Reason);
reason.style.color = new Color(1f, 0.55f, 0.35f);
reason.style.whiteSpace = WhiteSpace.Normal;
reason.style.maxWidth = 340;
SessionEnd.Reason = null;
card.Add(reason);
}
// Slice 2: class picker -> sets WorldLauncher.SelectedClass for the next session (Warrior melee / Ranger ranged). // Slice 2: class picker -> sets WorldLauncher.SelectedClass for the next session (Warrior melee / Ranger ranged).
_classLabel = new Label(ClassName(WorldLauncher.SelectedClass)); _classLabel = new Label(ClassName(WorldLauncher.SelectedClass));
_classLabel.style.unityTextAlign = TextAnchor.MiddleCenter; _classLabel.style.unityTextAlign = TextAnchor.MiddleCenter;
@@ -31,10 +31,16 @@ namespace ProjectM.Client
/// <summary>Slice 2: the class chosen in the menu (a CharacterId byte), seeded into the client world at session start.</summary> /// <summary>Slice 2: the class chosen in the menu (a CharacterId byte), seeded into the client world at session start.</summary>
public static byte SelectedClass = (byte)CharacterId.Warrior; public static byte SelectedClass = (byte)CharacterId.Warrior;
/// <summary>The mode of the most recently started session — the HUD's outcome banner branches on it
/// (single: PLAY AGAIN restarts; co-op: the honest exit is a clean teardown for everyone).</summary>
public static SessionMode LastMode { get; private set; } = SessionMode.Single;
public static void StartSession(SessionMode mode, string joinIp, bool loadSave) public static void StartSession(SessionMode mode, string joinIp, bool loadSave)
{ {
if (Busy) return; if (Busy) return;
Busy = true; Busy = true;
LastMode = mode;
SessionRunner.Run(StartRoutine(mode, joinIp, loadSave)); SessionRunner.Run(StartRoutine(mode, joinIp, loadSave));
} }
@@ -47,6 +53,12 @@ namespace ProjectM.Client
static IEnumerator StartRoutine(SessionMode mode, string joinIp, bool loadSave) static IEnumerator StartRoutine(SessionMode mode, string joinIp, bool loadSave)
{ {
// A LIVE session restarting in place (the banner's PLAY AGAIN) checkpoints first — the teardown
// path saves, so this path must too or the terminal state (outcome, last meta buys) is dropped.
var liveServer = ClientServerBootstrap.ServerWorld;
if (liveServer is { IsCreated: true })
TrySaveFromServer(liveServer);
// Dispose the idle menu world so a netcode world can own DefaultGameObjectInjectionWorld (the // Dispose the idle menu world so a netcode world can own DefaultGameObjectInjectionWorld (the
// subscene then streams into the netcode worlds, exactly as in the always-on bootstrap flow). // subscene then streams into the netcode worlds, exactly as in the always-on bootstrap flow).
World.DisposeAllWorlds(); World.DisposeAllWorlds();
@@ -117,6 +129,10 @@ 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();
@@ -56,8 +56,8 @@ namespace ProjectM.Server
playerRegions.Add(region.ValueRO.Region); playerRegions.Add(region.ValueRO.Region);
} }
// EB-1 fortress aggro: also snapshot live structures (Turret/Wall/Pylon carry Health; automation // EB-1 fortress aggro: also snapshot live structures (Turret/Wall/Pylon AND the Fabricator all carry
// machines lack it so the query excludes them). Snapshot ABOVE the early-return so Husks keep razing // Health since the DR-032 parity fix; the query keys on it). Snapshot ABOVE the early-return so Husks keep razing
// the base even with every player dead/away (the locked 'push for structures' fork). // the base even with every player dead/away (the locked 'push for structures' fork).
var structureEntities = new NativeList<Entity>(Allocator.Temp); var structureEntities = new NativeList<Entity>(Allocator.Temp);
var structurePositions = new NativeList<float3>(Allocator.Temp); var structurePositions = new NativeList<float3>(Allocator.Temp);
@@ -45,7 +45,6 @@ namespace ProjectM.Server
float3 center = haveAnchor ? BaseGridMath.PlotCenter(baseAnchor) : spawner.SpawnPoint; float3 center = haveAnchor ? BaseGridMath.PlotCenter(baseAnchor) : spawner.SpawnPoint;
float ringRadius = haveSpawner ? spawner.SpawnRingRadius : 2f; float ringRadius = haveSpawner ? spawner.SpawnRingRadius : 2f;
int ringSlots = haveSpawner ? spawner.RingSlots : 4; int ringSlots = haveSpawner ? spawner.RingSlots : 4;
center = BaseGridMath.PlotCenter(baseAnchor);
foreach (var (health, respawn, invuln, xform, region, owner, eff) in foreach (var (health, respawn, invuln, xform, region, owner, eff) in
SystemAPI.Query<RefRW<Health>, RefRW<RespawnState>, RefRW<RespawnInvuln>, RefRW<LocalTransform>, SystemAPI.Query<RefRW<Health>, RefRW<RespawnState>, RefRW<RespawnInvuln>, RefRW<LocalTransform>,
@@ -123,7 +123,7 @@ namespace ProjectM.Server
zs.SeededEpoch = run.RoomEpoch; zs.SeededEpoch = run.RoomEpoch;
zs.SpawnCounter = 0; zs.SpawnCounter = 0;
zs.RemainingToSpawn = bossRoom ? 1 : ZoneEnemyMath.WaveSlots(plan.DifficultyEpoch, bands); zs.RemainingToSpawn = bossRoom ? 1 : ZoneEnemyMath.WaveSlots(plan.DifficultyEpoch, bands);
zs.NextSpawnTick = TickUtil.NonZero(now); // first slot this tick zs.NextSpawnTick = TickUtil.NonZero(now + Tuning.RoomEntryGraceTicks); // landing grace — let the party orient
} }
if (zs.RemainingToSpawn > 0) if (zs.RemainingToSpawn > 0)
@@ -139,6 +139,16 @@ namespace ProjectM.Server
bool due = info.LaunchTick == 0u || !new NetworkTick(info.LaunchTick).IsNewerThan(serverTick); bool due = info.LaunchTick == 0u || !new NetworkTick(info.LaunchTick).IsNewerThan(serverTick);
if (due) if (due)
{ {
// Conscript the party: the launch roster is EVERYONE connected (N7 co-location — all at
// base, all ready). Room advances teleport ONLY RunParticipants, so a mid-run late joiner
// is never yanked into the fight; the tag is released on the Returning edge.
var conscript = new EntityCommandBuffer(Allocator.Temp);
foreach (var (_, playerE) in
SystemAPI.Query<RefRO<PlayerReady>>().WithAll<PlayerTag>().WithEntityAccess())
conscript.AddComponent<RunParticipant>(playerE);
conscript.Playback(state.EntityManager);
conscript.Dispose();
// Enter room 0 (the guaranteed Combat landing at column 0, sub-slot 0). // Enter room 0 (the guaranteed Combat landing at column 0, sub-slot 0).
var map = RunMapMath.Generate(run.RunSeed); var map = RunMapMath.Generate(run.RunSeed);
EnterRoom(ref state, ref info, ref run, in map, layer: 0, col: 0, baseCenter, bumpEpoch: true); EnterRoom(ref state, ref info, ref run, in map, layer: 0, col: 0, baseCenter, bumpEpoch: true);
@@ -187,15 +197,24 @@ namespace ProjectM.Server
// Exit gate: every SURVIVING player has picked (BoonOffer.Pending==0 — inert until Step 10) // Exit gate: every SURVIVING player has picked (BoonOffer.Pending==0 — inert until Step 10)
// OR the grace elapsed (wrap-safe IsNewerThan, never raw uint — F4). // OR the grace elapsed (wrap-safe IsNewerThan, never raw uint — F4).
// Only EXPEDITION players hold the gate (BoonOfferSystem's documented contract): a player who
// died and respawned to base must not stall the party (post-impl review, confirmed major).
bool anyPending = false; bool anyPending = false;
foreach (var offer in SystemAPI.Query<RefRO<BoonOffer>>().WithAll<PlayerTag>()) foreach (var (offer, pregion) in
if (offer.ValueRO.Pending != 0) { anyPending = true; break; } SystemAPI.Query<RefRO<BoonOffer>, RefRO<RegionTag>>().WithAll<PlayerTag>())
if (pregion.ValueRO.Region == RegionId.Expedition && offer.ValueRO.Pending != 0)
{ anyPending = true; break; }
bool graceElapsed = run.RewardGraceTick == 0u bool graceElapsed = run.RewardGraceTick == 0u
|| !new NetworkTick(run.RewardGraceTick).IsNewerThan(serverTick); || !new NetworkTick(run.RewardGraceTick).IsNewerThan(serverTick);
if (anyPending && !graceElapsed) if (anyPending && !graceElapsed)
break; break;
run.RewardGraceTick = 0u; run.RewardGraceTick = 0u;
// NO offer survives this gate: zero every straggler (a dead-respawned base player the
// auto-pick deliberately skips, a grace-expired AFK) so a stale Pending can never wedge the
// HUD modal open or stall a later reward gate (post-impl review, confirmed major).
foreach (var offer in SystemAPI.Query<RefRW<BoonOffer>>().WithAll<PlayerTag>())
offer.ValueRW = default;
if (run.LastTerminalCleared != 0) if (run.LastTerminalCleared != 0)
{ {
info.Lifecycle = RunLifecycle.Returning; // boss cleared — go home a winner info.Lifecycle = RunLifecycle.Returning; // boss cleared — go home a winner
@@ -279,18 +298,25 @@ namespace ProjectM.Server
case RunLifecycle.Returning: case RunLifecycle.Returning:
{ {
// Party teleport HOME + region flip back to Base. // PARTICIPANT teleport home + region flip + roster release. Only the launch roster comes
// home (a mid-run joiner already at base keeps its position); the tag removal re-opens the
// next run's conscription cleanly (post-impl review, confirmed medium).
int idx = 0; int idx = 0;
foreach (var (region, xform) in var homebound = new EntityCommandBuffer(Allocator.Temp);
SystemAPI.Query<RefRW<RegionTag>, RefRW<LocalTransform>>().WithAll<PlayerTag>()) foreach (var (region, xform, playerE) in
SystemAPI.Query<RefRW<RegionTag>, RefRW<LocalTransform>>()
.WithAll<PlayerTag, RunParticipant>().WithEntityAccess())
{ {
region.ValueRW.Region = RegionId.Base; region.ValueRW.Region = RegionId.Base;
var p = baseCenter; var p = baseCenter;
p.x += 1.5f * idx; p.x += 1.5f * idx;
p.y = xform.ValueRO.Position.y; p.y = xform.ValueRO.Position.y;
xform.ValueRW.Position = p; xform.ValueRW.Position = p;
homebound.RemoveComponent<RunParticipant>(playerE);
idx++; idx++;
} }
homebound.Playback(state.EntityManager);
homebound.Dispose();
// THE terminal bank — once per RunEpoch (equality latch, F7), CLEAR-GATED (D-F3). // THE terminal bank — once per RunEpoch (equality latch, F7), CLEAR-GATED (D-F3).
if (run.LastBankedRunEpoch != run.RunEpoch) if (run.LastBankedRunEpoch != run.RunEpoch)
@@ -393,7 +419,7 @@ namespace ProjectM.Server
float3 roomOrigin = RegionMath.ExpeditionRoomOrigin(baseCenter, run.ActiveSubSlot); float3 roomOrigin = RegionMath.ExpeditionRoomOrigin(baseCenter, run.ActiveSubSlot);
int idx = 0; int idx = 0;
foreach (var (region, xform) in foreach (var (region, xform) in
SystemAPI.Query<RefRW<RegionTag>, RefRW<LocalTransform>>().WithAll<PlayerTag>()) SystemAPI.Query<RefRW<RegionTag>, RefRW<LocalTransform>>().WithAll<PlayerTag, RunParticipant>())
{ {
region.ValueRW.Region = RegionId.Expedition; region.ValueRW.Region = RegionId.Expedition;
var p = roomOrigin; var p = roomOrigin;
@@ -13,7 +13,7 @@ namespace ProjectM.Server
/// hand-off is <see cref="ThreatState.PendingSiegeSize"/> (this system sets it; CyclePhaseSystem consumes it). /// 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 /// 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 /// return by <see cref="RunDirectorSystem"/> into <see cref="ThreatState.PendingReturns"/> — the retired
/// walk-in ExpeditionGateSystem's carry, Step 11) arms a siege ofe of /// walk-in ExpeditionGateSystem's carry, Step 11) arms a siege of
/// <see cref="ThreatConfig.SizeBase"/> Husks after a <see cref="ThreatConfig.PostExpeditionDelayTicks"/> /// <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 /// 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 /// no re-bake. It also enforces a BOUNDED siege lifetime (<see cref="ThreatConfig.SiegeTimeoutTicks"/>): an
Binary file not shown.
@@ -63,7 +63,7 @@ namespace ProjectM.Simulation
[Serializable] [Serializable]
public class SaveData public class SaveData
{ {
public const int CurrentVersion = 6; // v6: permanent META (per-class upgrade tiers + run counters); v5 added RunOutcomeoreCurrent public const int CurrentVersion = 6; // v6: permanent META (per-class upgrade tiers + run counters); v5 added RunOutcome; v4 added CoreCurrent
/// <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 (additive); a v2 save loads with structures at full HP.</summary>
public const int MinLoadableVersion = 2; public const int MinLoadableVersion = 2;
@@ -27,7 +27,7 @@ namespace ProjectM.Simulation
// field 0-defaults and the restore guard maps 0 -> baked Max); v0/v1 garbage is still rejected. // field 0-defaults and the restore guard maps 0 -> baked Max); v0/v1 garbage is still rejected.
if (data == null || data.Version < SaveData.MinLoadableVersion || data.Version > SaveData.CurrentVersion) return null; if (data == null || data.Version < SaveData.MinLoadableVersion || data.Version > SaveData.CurrentVersion) return null;
data.Ledger ??= Array.Empty<LedgerRow>(); data.Ledger ??= Array.Empty<LedgerRow>();
data.MetaUpgrades ??= Array.Empty<MetaUpgradeSave>(); // v6: null on any v<=5 file(); data.MetaUpgrades ??= Array.Empty<MetaUpgradeSave>(); // v6: null on any v<=5 file
return data; return data;
} }
catch (Exception e) catch (Exception e)
@@ -37,6 +37,22 @@ namespace ProjectM.Simulation
} }
} }
/// <summary>
/// A TERMINAL save (Victory/Loss latched) Continues as a FRESH CAMPAIGN that keeps the base + permanent
/// meta: the outcome latch, goal meter, and core integrity reset to 0 (the spawn restore guards re-map
/// 0 -> InProgress / empty meter / baked-full Core), while meta tiers, run counters, the ledger, and
/// 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.
/// </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)
{ {
if (data == null) return; if (data == null) return;
@@ -90,6 +90,11 @@ namespace ProjectM.Simulation
/// at spawn on the baked value, legible at a glance).</summary> /// at spawn on the baked value, legible at a glance).</summary>
public const float BossScaleMultiplier = 1.6f; public const float BossScaleMultiplier = 1.6f;
/// <summary>Room-landing grace before the FIRST enemy slot spawns (~1.7 s @ 60): the party just teleported
/// in and needs a beat to orient — without it the wave engages while the camera is still settling (demo
/// polish; an idle player died in ~2 s of landing). Subsequent slots keep the normal drip cadence.</summary>
public const uint RoomEntryGraceTicks = 100;
// ---- Inventory (per-player bag; InventoryMath / ResourceHarvestSystem / InventoryDepositSystem) ---- // ---- Inventory (per-player bag; InventoryMath / ResourceHarvestSystem / InventoryDepositSystem) ----
/// <summary>Max stacks a player can carry; InventoryMath rejects deposits past this and the harvest remainder spills to the global ledger.</summary> /// <summary>Max stacks a player can carry; InventoryMath rejects deposits past this and the harvest remainder spills to the global ledger.</summary>
@@ -0,0 +1,16 @@
using Unity.Entities;
namespace ProjectM.Simulation
{
/// <summary>
/// SERVER-ONLY roster tag for the players conscripted into the CURRENT run — stamped on every connected
/// player at the launch edge (Launching → room 0), removed on the Returning edge. Room advances teleport
/// ONLY participants: a dead-respawned member (back at base) is re-conscripted on the next advance (the
/// operator-locked default), while a mid-run late JOINER — who never readied — stays safely at base until
/// the next Staging (spec §2.2 closed-party rule; post-impl review, confirmed medium). NOT a [GhostField];
/// disconnect cleanup is free (the tag dies with the player ghost's LinkedEntityGroup despawn).
/// </summary>
public struct RunParticipant : IComponentData
{
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 92795e88279d04e42af70f8ec1032cc7
@@ -23,8 +23,8 @@ namespace ProjectM.Tests
[Test] [Test]
public void FirstIncomplete_SkipsCompletedPrefix() public void FirstIncomplete_SkipsCompletedPrefix()
{ {
int mask = (1 << OnboardingStepMath.Welcome) | (1 << OnboardingStepMath.Move) | (1 << OnboardingStepMath.Mine); int mask = (1 << OnboardingStepMath.Welcome) | (1 << OnboardingStepMath.Move) | (1 << OnboardingStepMath.Build);
Assert.AreEqual(OnboardingStepMath.Build, OnboardingStepMath.FirstIncomplete(mask)); Assert.AreEqual(OnboardingStepMath.Fabricator, OnboardingStepMath.FirstIncomplete(mask));
} }
[Test] [Test]
@@ -57,12 +57,14 @@ namespace ProjectM.Tests
} }
[Test] [Test]
public void Mine_AdvancesOnlyWhenLedgerOreRises() public void ReadyUp_AdvancesOnLocalReadyOrLaunch()
{ {
var s = Empty(); s.OreBaseline = 50; s.OreNow = 50; // starts at the seeded baseline var s = Empty(); s.Lifecycle = RunLifecycle.Staging;
Assert.IsFalse(OnboardingStepMath.IsSatisfied(OnboardingStepMath.Mine, s)); Assert.IsFalse(OnboardingStepMath.IsSatisfied(OnboardingStepMath.ReadyUp, s));
s.OreNow = 51; var ready = Empty(); ready.LocalReady = true;
Assert.IsTrue(OnboardingStepMath.IsSatisfied(OnboardingStepMath.Mine, s)); Assert.IsTrue(OnboardingStepMath.IsSatisfied(OnboardingStepMath.ReadyUp, ready));
var launched = Empty(); launched.Lifecycle = RunLifecycle.Launching;
Assert.IsTrue(OnboardingStepMath.IsSatisfied(OnboardingStepMath.ReadyUp, launched));
} }
[Test] [Test]
@@ -86,23 +88,27 @@ namespace ProjectM.Tests
} }
[Test] [Test]
public void Gate_AdvancesOnExpeditionEntryOrActiveObjective() public void Rooms_AdvancesOnExpeditionEntryOrActiveObjective()
{ {
var s = Empty(); var s = Empty();
Assert.IsFalse(OnboardingStepMath.IsSatisfied(OnboardingStepMath.Gate, s)); Assert.IsFalse(OnboardingStepMath.IsSatisfied(OnboardingStepMath.Rooms, s));
var onExp = Empty(); onExp.OnExpedition = true; var onExp = Empty(); onExp.OnExpedition = true;
Assert.IsTrue(OnboardingStepMath.IsSatisfied(OnboardingStepMath.Gate, onExp)); Assert.IsTrue(OnboardingStepMath.IsSatisfied(OnboardingStepMath.Rooms, onExp));
var active = Empty(); active.ObjectiveState = ExpeditionObjectiveState.Active; var active = Empty(); active.ObjectiveState = ExpeditionObjectiveState.Active;
Assert.IsTrue(OnboardingStepMath.IsSatisfied(OnboardingStepMath.Gate, active)); Assert.IsTrue(OnboardingStepMath.IsSatisfied(OnboardingStepMath.Rooms, active));
} }
[Test] [Test]
public void Clear_AdvancesOnClearedObjective() public void Boon_AdvancesOnClearedObjectiveOrRewardLifecycle()
{ {
var s = Empty(); s.ObjectiveState = ExpeditionObjectiveState.Active; var s = Empty(); s.ObjectiveState = ExpeditionObjectiveState.Active;
Assert.IsFalse(OnboardingStepMath.IsSatisfied(OnboardingStepMath.Clear, s)); Assert.IsFalse(OnboardingStepMath.IsSatisfied(OnboardingStepMath.Boon, s));
s.ObjectiveState = ExpeditionObjectiveState.Cleared; s.ObjectiveState = ExpeditionObjectiveState.Cleared;
Assert.IsTrue(OnboardingStepMath.IsSatisfied(OnboardingStepMath.Clear, s)); 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] [Test]
@@ -157,11 +163,11 @@ namespace ProjectM.Tests
// ---- pointer kinds ---- // ---- pointer kinds ----
[Test] [Test]
public void PointerKinds_MatchSpatialStepsOnly() public void PointerKinds_OnlyTheRoomsStepPoints()
{ {
Assert.AreEqual(OnboardingStepMath.PointerOreNode, OnboardingStepMath.PointerKind(OnboardingStepMath.Mine)); Assert.AreEqual(OnboardingStepMath.PointerOreNode, OnboardingStepMath.PointerKind(OnboardingStepMath.Rooms));
Assert.AreEqual(OnboardingStepMath.PointerBaseGate, OnboardingStepMath.PointerKind(OnboardingStepMath.Gate)); Assert.AreEqual(OnboardingStepMath.PointerNone, OnboardingStepMath.PointerKind(OnboardingStepMath.ReadyUp));
Assert.AreEqual(OnboardingStepMath.PointerExpeditionGate, OnboardingStepMath.PointerKind(OnboardingStepMath.Return)); Assert.AreEqual(OnboardingStepMath.PointerNone, OnboardingStepMath.PointerKind(OnboardingStepMath.Return));
Assert.AreEqual(OnboardingStepMath.PointerNone, OnboardingStepMath.PointerKind(OnboardingStepMath.Move)); Assert.AreEqual(OnboardingStepMath.PointerNone, OnboardingStepMath.PointerKind(OnboardingStepMath.Move));
Assert.AreEqual(OnboardingStepMath.PointerNone, OnboardingStepMath.PointerKind(OnboardingStepMath.Defend)); Assert.AreEqual(OnboardingStepMath.PointerNone, OnboardingStepMath.PointerKind(OnboardingStepMath.Defend));
} }
@@ -82,6 +82,12 @@ namespace ProjectM.Tests
return q.CalculateEntityCount(); return q.CalculateEntityCount();
} }
static void AdvanceTick(EntityManager em, uint tick)
{
using var q = em.CreateEntityQuery(typeof(NetworkTime));
em.SetComponentData(q.GetSingletonEntity(), new NetworkTime { ServerTick = new NetworkTick(tick) });
}
[Test] [Test]
public void Seeds_ByRoomDifficulty_AndSpawnsTaggedAtActiveSlotOrigin() public void Seeds_ByRoomDifficulty_AndSpawnsTaggedAtActiveSlotOrigin()
{ {
@@ -91,7 +97,11 @@ namespace ProjectM.Tests
var plan = RoomLayoutMath.Plan(map.NodeAt(RunMap.NodeId(0, 0)), 0, map.LayerCount); var plan = RoomLayoutMath.Plan(map.NodeAt(RunMap.NodeId(0, 0)), 0, map.LayerCount);
int slots = ZoneEnemyMath.WaveSlots(plan.DifficultyEpoch, Bands()); int slots = ZoneEnemyMath.WaveSlots(plan.DifficultyEpoch, Bands());
group.Update(); // seeds + first slot spawns this tick group.Update(); // seeds; the landing grace holds the first slot (demo polish)
Assert.AreEqual(slots, em.GetComponentData<ZoneEnemyState>(zoneDir).RemainingToSpawn,
"grace: nothing spawns on the seed tick");
AdvanceTick(em, T0 + Tuning.RoomEntryGraceTicks + 1);
group.Update(); // grace elapsed -> the first slot drips
var zs = em.GetComponentData<ZoneEnemyState>(zoneDir); var zs = em.GetComponentData<ZoneEnemyState>(zoneDir);
Assert.AreEqual(1, zs.SeededEpoch, "seeded for RoomEpoch 1"); Assert.AreEqual(1, zs.SeededEpoch, "seeded for RoomEpoch 1");
@@ -121,7 +131,9 @@ namespace ProjectM.Tests
var (world, group, runDir, zoneDir) = MakeWorld(bossLayer, map.BossNodeId, activeSubSlot: (byte)(bossLayer & 1)); var (world, group, runDir, zoneDir) = MakeWorld(bossLayer, map.BossNodeId, activeSubSlot: (byte)(bossLayer & 1));
var em = world.EntityManager; var em = world.EntityManager;
group.Update(); group.Update(); // seed tick (the landing grace holds the boss)
AdvanceTick(em, T0 + Tuning.RoomEntryGraceTicks + 1);
group.Update(); // grace elapsed -> the boss spawns
var zs = em.GetComponentData<ZoneEnemyState>(zoneDir); var zs = em.GetComponentData<ZoneEnemyState>(zoneDir);
Assert.AreEqual(0, zs.RemainingToSpawn, "a boss room is a single-slot wave, fully spawned"); Assert.AreEqual(0, zs.RemainingToSpawn, "a boss room is a single-slot wave, fully spawned");
@@ -54,7 +54,9 @@ namespace ProjectM.Tests
}); });
em.SetComponentData(dir, new GoalProgress { Charge = 0, Target = 4 }); em.SetComponentData(dir, new GoalProgress { Charge = 0, Target = 4 });
var player = em.CreateEntity(typeof(PlayerTag), typeof(PlayerReady), typeof(RegionTag), typeof(LocalTransform)); // 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),
typeof(LocalTransform), typeof(RunParticipant));
em.SetComponentData(player, new RegionTag { Region = RegionId.Expedition }); em.SetComponentData(player, new RegionTag { Region = RegionId.Expedition });
em.SetComponentData(player, LocalTransform.Identity); em.SetComponentData(player, LocalTransform.Identity);
return (world, group, dir, player); return (world, group, dir, player);
@@ -0,0 +1,136 @@
using NUnit.Framework;
using ProjectM.Server;
using ProjectM.Simulation;
using Unity.Core;
using Unity.Entities;
using Unity.NetCode;
using Unity.Transforms;
namespace ProjectM.Tests
{
/// <summary>
/// Regression pins for the two post-impl-review fixes on <see cref="RunDirectorSystem"/>:
/// (1) a dead-respawned (base-region) player's stale <see cref="BoonOffer.Pending"/> neither holds the
/// RoomReward exit gate nor survives it (the wedge that stalled every reward 30 s and left the modal
/// pickable through the next fight); (2) room advances teleport ONLY <see cref="RunParticipant"/>s —
/// a dead-respawned participant is re-conscripted (operator-locked default) while a mid-run late joiner
/// stays at base (spec §2.2 closed party).
/// </summary>
public class RunRosterRegressionTests
{
const uint Seed = 777u;
const uint T0 = 2000;
static (World world, SimulationSystemGroup group, Entity dir) MakeWorld(out RunMap map)
{
var world = new World("RosterTest");
var group = world.GetOrCreateSystemManaged<SimulationSystemGroup>();
group.AddSystemToUpdateList(world.GetOrCreateSystem<RunDirectorSystem>());
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(T0) });
map = RunMapMath.Generate(Seed);
var dir = em.CreateEntity(typeof(RunInfo), typeof(RunRuntime), typeof(ExpeditionObjective),
typeof(RouteCommand), typeof(MetaCounters), typeof(GoalProgress), typeof(ThreatState), typeof(SaveRequest));
return (world, group, dir);
}
static Entity MakePlayer(EntityManager em, byte region, bool participant, byte pending)
{
var player = participant
? em.CreateEntity(typeof(PlayerTag), typeof(PlayerReady), typeof(RegionTag),
typeof(LocalTransform), typeof(BoonOffer), typeof(RunParticipant))
: em.CreateEntity(typeof(PlayerTag), typeof(PlayerReady), typeof(RegionTag),
typeof(LocalTransform), typeof(BoonOffer));
em.SetComponentData(player, new RegionTag { Region = region });
em.SetComponentData(player, LocalTransform.Identity);
em.SetComponentData(player, new BoonOffer { Pending = pending, Option0 = 1, Option1 = 2, Option2 = 3 });
return player;
}
[Test]
public void StalePendingAtBase_DoesNotHoldGate_AndIsStrippedOnExit()
{
var (world, group, dir) = MakeWorld(out var map);
var em = world.EntityManager;
em.SetComponentData(dir, new RunInfo
{
Lifecycle = RunLifecycle.RoomReward,
CurrentRoom = 1,
CurrentCol = 0,
RoomCount = map.LayerCount,
RunSeed = Seed,
});
em.SetComponentData(dir, new RunRuntime
{
RunSeed = Seed,
RunEpoch = 1,
RoomEpoch = 2,
// Grace far in the future: ONLY the all-picked path can advance this tick.
RewardGraceTick = TickUtil.NonZero(T0 + 100000),
});
var alive = MakePlayer(em, RegionId.Expedition, participant: true, pending: 0); // picked already
var deadAtBase = MakePlayer(em, RegionId.Base, participant: true, pending: 1); // the wedge
group.Update();
var info = em.GetComponentData<RunInfo>(dir);
Assert.AreNotEqual(RunLifecycle.RoomReward, info.Lifecycle,
"a base-region player's stale Pending must not hold the reward gate");
Assert.AreEqual(0, em.GetComponentData<BoonOffer>(deadAtBase).Pending,
"the stale offer is stripped on the gate exit");
Assert.AreEqual(0, em.GetComponentData<BoonOffer>(alive).Pending,
"no offer survives the gate");
world.Dispose();
}
[Test]
public void Advance_TeleportsOnlyParticipants_LateJoinerStaysAtBase()
{
var (world, group, dir) = MakeWorld(out var map);
var em = world.EntityManager;
em.SetComponentData(dir, new RunInfo
{
Lifecycle = RunLifecycle.RouteSelect,
CurrentRoom = 0,
CurrentCol = 0,
RoomCount = map.LayerCount,
RunSeed = Seed,
RouteOptionCount = 1,
RouteOpt0Col = 0,
});
em.SetComponentData(dir, new RunRuntime
{
RunSeed = Seed,
RunEpoch = 1,
RoomEpoch = 1,
RouteGraceTick = TickUtil.NonZero(T0 + 100000),
});
em.SetComponentData(dir, new RouteCommand { HasPick = 1, OptionIndex = 0, ForRunEpoch = 1, ForLayer = 0 });
var inRoom = MakePlayer(em, RegionId.Expedition, participant: true, pending: 0); // fighting on
var deadAtBase = MakePlayer(em, RegionId.Base, participant: true, pending: 0); // re-conscripted
var lateJoiner = MakePlayer(em, RegionId.Base, participant: false, pending: 0); // stays home
group.Update();
var info = em.GetComponentData<RunInfo>(dir);
Assert.AreEqual(RunLifecycle.InRoom, info.Lifecycle, "the committed pick advances the run");
Assert.GreaterOrEqual(em.GetComponentData<LocalTransform>(inRoom).Position.x, 1000f,
"the in-room participant rides the advance");
Assert.GreaterOrEqual(em.GetComponentData<LocalTransform>(deadAtBase).Position.x, 1000f,
"a dead-respawned participant is re-conscripted onto the new room");
Assert.AreEqual(0f, em.GetComponentData<LocalTransform>(lateJoiner).Position.x,
"a non-participant late joiner is never yanked into the fight");
Assert.AreEqual(RegionId.Base, em.GetComponentData<RegionTag>(lateJoiner).Region,
"the late joiner stays in the Base relevancy bucket");
world.Dispose();
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 31b988b0527c7c247ad1976aab05d470
@@ -0,0 +1,50 @@
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));
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 085790610f52ea347aa4ccd208849316
+7 -9
View File
@@ -9,7 +9,7 @@ Multiplayer game on **Unity DOTS (Entities) + Netcode for Entities** — server-
- **Size check** — bash: `wc -c CLAUDE.md` · PowerShell: `(Get-Item CLAUDE.md).Length`. Must be `< 40960`. - **Size check** — bash: `wc -c CLAUDE.md` · PowerShell: `(Get-Item CLAUDE.md).Length`. Must be `< 40960`.
- **Archive, don't delete.** When trimming, append the verbose / least-hot detail to the obsidian reference note `Docs/Vault/_Meta/CLAUDE_Build_Gotchas_Archive.md` under a **new dated heading** (never overwrite an older snapshot), and leave a one-line pointer + the relevant `[[DR-###]]` link here. Design rationale already lives in the per-milestone DRs (`Docs/Vault/07_Sessions/_Decisions/DR-###`). - **Archive, don't delete.** When trimming, append the verbose / least-hot detail to the obsidian reference note `Docs/Vault/_Meta/CLAUDE_Build_Gotchas_Archive.md` under a **new dated heading** (never overwrite an older snapshot), and leave a one-line pointer + the relevant `[[DR-###]]` link here. Design rationale already lives in the per-milestone DRs (`Docs/Vault/07_Sessions/_Decisions/DR-###`).
- **Net-zero rule:** every addition is paid for by a condensation elsewhere. Keep only the hottest, highest-recurrence operational rules inline (flag them **★**); depth lives in the archive + DRs. - **Net-zero rule:** every addition is paid for by a condensation elsewhere. Keep only the hottest, highest-recurrence operational rules inline (flag them **★**); depth lives in the archive + DRs.
- Condensation history: 06-04 → 06-17 (M1END-2 long-form + 6.5 stack swap → archive). - Condensation history: 06-04 → 06-17 (M1END-2 long-form + 6.5 stack swap → archive) · 07-04 (base-mining core-loop bullet + stack-history sentence → archive; DR-044 supersedes).
## Stack — Unity 6.5.1 (`6000.5.1f1`, stable) as of 2026-06-27 ## Stack — Unity 6.5.1 (`6000.5.1f1`, stable) as of 2026-06-27
@@ -26,7 +26,7 @@ Multiplayer game on **Unity DOTS (Entities) + Netcode for Entities** — server-
| `com.unity.mathematics` | 1.4.0 | (transitive) | | `com.unity.mathematics` | 1.4.0 | (transitive) |
| `com.rukhanka.animation` | **2.9.0** | Local pkg (`Packages/com.rukhanka.animation`). ECS skeletal animation (Burst CPU/GPU skinning). Resolves on 6.5.0 via SemVer floor. Netcode replication **OFF** → client-derived. See [[DR-022_Animation_Pipeline_Rukhanka_Synty]]. | | `com.rukhanka.animation` | **2.9.0** | Local pkg (`Packages/com.rukhanka.animation`). ECS skeletal animation (Burst CPU/GPU skinning). Resolves on 6.5.0 via SemVer floor. Netcode replication **OFF** → client-derived. See [[DR-022_Animation_Pipeline_Rukhanka_Synty]]. |
Values match `packages-lock.json` (reconciled 2026-06-17; URP 17.5.0, test-framework 1.7.0, ugui 2.5.0, multiplayer.center 1.0.1). **History:** 6.4.7→6.5.0 (2026-06-17) put DOTS on unified 6.5 versioning (netcode/physics/transport left their old 1.x/2.x lines) — validated green (342/342 EditMode + a clean netcode Play boot: connect/ghost-sync/player-spawn), so the 6.6.0a6 "invalid wrapped network interface" transport bug does NOT hit 6.5-stable. See [[DR-002_Unity66_Alpha_Netcode_Transport]] + the gotchas archive. Values match `packages-lock.json` (reconciled 2026-06-17; URP 17.5.0, test-framework 1.7.0, ugui 2.5.0, multiplayer.center 1.0.1; `com.unity.ai.assistant` REMOVED 07-04 — console noise). **History:** the 6.4.7→6.5.0 unified-versioning swap (2026-06-17) validated green; the 6.6.0a6 transport bug does NOT hit 6.5-stable — detail in [[DR-002_Unity66_Alpha_Netcode_Transport]] + the gotchas archive.
## Namespaces & assembly split ## Namespaces & assembly split
@@ -88,13 +88,13 @@ Long-form originals + the milestone each came from: `Docs/Vault/_Meta/CLAUDE_Bui
- **Build-grid math must be deterministic + integer-stable:** corner-origin, center-returning, **half-open** cell bounds, `math.floor`. Lock `CellSize`/`PlotSize` as a coordinate space once (`BaseGridMath`) — changing them invalidates placed structures. - **Build-grid math must be deterministic + integer-stable:** corner-origin, center-returning, **half-open** cell bounds, `math.floor`. Lock `CellSize`/`PlotSize` as a coordinate space once (`BaseGridMath`) — changing them invalidates placed structures.
- **`PlacedStructure{[GhostField] byte Type; int2 Cell (server-only); uint NextTick; uint LastProcessedTick}`** on an ownerless interpolated ghost. **Bake the tick fields** (catch-up linchpin); only `Type` replicates (client derives `Cell`). **Occupancy is DERIVED** by scanning live ghosts into a Temp `NativeHashSet<int2>`, never baked. See [[DR-014_M6_Build_Structures_Automation_Foundation]]. - **`PlacedStructure{[GhostField] byte Type; int2 Cell (server-only); uint NextTick; uint LastProcessedTick}`** on an ownerless interpolated ghost. **Bake the tick fields** (catch-up linchpin); only `Type` replicates (client derives `Cell`). **Occupancy is DERIVED** by scanning live ghosts into a Temp `NativeHashSet<int2>`, never baked. See [[DR-014_M6_Build_Structures_Automation_Foundation]].
- **Co-op placement atomicity:** commit `StorageMath.Withdraw` + cell-reservation **in-place in the RPC foreach** (only `Instantiate` via ECB) so two same-tick requests for one cell can't both pass. - **Co-op placement atomicity:** commit `StorageMath.Withdraw` + cell-reservation **in-place in the RPC foreach** (only `Instantiate` via ECB) so two same-tick requests for one cell can't both pass.
- **EB-1 machines can die ★ (DR-032):** structures bake `Health`(`[GhostField]`)+`DamageEvent`+a `Destructible` tag; `HealthApplyDamageSystem` destroys a `Destructible` at 0 (NOT bare `PlacedStructure`; occupancy auto-frees). `EnemyAISystem` fortress-targets weighted-nearest players+structures (`EnemyAIMath.PickWeightedNearest`; snapshot ABOVE the early-return; `StructureAggroWeight`<1, SQUARED). See [[DR-032_EB1_Machines_Can_Die]]. - **EB-1 machines can die ★ (DR-032; Fabricator parity restored 07-04 — a Health-less machine silently drops OUT of the AI aggro snapshot):** structures bake `Health`(`[GhostField]`)+`DamageEvent`+a `Destructible` tag; `HealthApplyDamageSystem` destroys a `Destructible` at 0 (NOT bare `PlacedStructure`; occupancy auto-frees). `EnemyAISystem` fortress-targets weighted-nearest players+structures (`EnemyAIMath.PickWeightedNearest`; snapshot ABOVE the early-return; `StructureAggroWeight`<1, SQUARED). See [[DR-032_EB1_Machines_Can_Die]].
- **EB-2 felt spend ★ (DR-033):** turret ammo = shared `Charge`(`ResourceId` **4**) on the `[GhostField] StorageEntry` ledger. `TurretFireSystem` spends from the ONE `GetSingletonEntity<ResourceLedger>` (NEVER `GetSingleton<StorageEntry>`): afford→fire+cooldown, else **SOFT-FAIL** (no cooldown-burn). `Fabricator.InputFromLedger` reads the ledger **LIVE in-loop** (no hoist → machines split a finite pool). See [[DR-033_EB2_Felt_Spend_Charge_Economy]]. - **EB-2 felt spend ★ (DR-033):** turret ammo = shared `Charge`(`ResourceId` **4**) on the `[GhostField] StorageEntry` ledger. `TurretFireSystem` spends from the ONE `GetSingletonEntity<ResourceLedger>` (NEVER `GetSingleton<StorageEntry>`): afford→fire+cooldown, else **SOFT-FAIL** (no cooldown-burn). `Fabricator.InputFromLedger` reads the ledger **LIVE in-loop** (no hoist → machines split a finite pool). See [[DR-033_EB2_Felt_Spend_Charge_Economy]].
- **END-1 losable Core ★ (DR-034):** `CoreIntegrity{[GhostField] int Current,Max; uint OverrunTick}` on the GLOBAL CycleDirector ghost. `CoreDamageSystem`/`CoreRestoreSystem` (server): a Husk near `PlotCenter` drains+despawns; regen ONLY in Calm. SOFT-loss edge IN `CyclePhaseSystem` (sole Phase writer): `Current<=0` in Siege → Calm (**NO** reward; drain+despawn; transient `OverrunTick`, NOT latching). Core = `EnemyAISystem` **FALLBACK** target. SaveData **v4**. See [[DR-034_END1_Losable_Core]]. - **END-1 losable Core ★ (DR-034):** `CoreIntegrity{[GhostField] int Current,Max; uint OverrunTick}` on the GLOBAL CycleDirector ghost. `CoreDamageSystem`/`CoreRestoreSystem` (server): a Husk near `PlotCenter` drains+despawns; regen ONLY in Calm. SOFT-loss edge IN `CyclePhaseSystem` (sole Phase writer): `Current<=0` in Siege → Calm (**NO** reward; drain+despawn; transient `OverrunTick`, NOT latching). Core = `EnemyAISystem` **FALLBACK** target. SaveData **v4**. See [[DR-034_END1_Losable_Core]].
- **END-2 win/lose ★ (DR-036):** terminal run on the CycleDirector — server-only `RunPhase` (writer `GoalReachedSystem`, after CyclePhase) + **REPLICATED `RunOutcome{[GhostField] byte}`** (writer `CyclePhaseSystem`; replicate for the banner, do NOT client-derive). `GoalReached` arms a final siege ×`FinalSiegeMultiplier` at `Charge>=Target` (once)+FinalDefense; latch Victory/Loss+halt; **SiegeTimeout OFF in the final**; SaveData **v5**. See [[DR-036_END2_Final_Siege_Win_Lose]]. - **END-2 win/lose ★ (DR-036):** terminal run on the CycleDirector — server-only `RunPhase` (writer `GoalReachedSystem`, after CyclePhase) + **REPLICATED `RunOutcome{[GhostField] byte}`** (writer `CyclePhaseSystem`; replicate for the banner, do NOT client-derive). `GoalReached` arms a final siege ×`FinalSiegeMultiplier` at `Charge>=Target` (once)+FinalDefense; latch Victory/Loss+halt; **SiegeTimeout OFF in the final**; SaveData **v5**. See [[DR-036_END2_Final_Siege_Win_Lose]].
- **`GoalProgress{[GhostField] int Charge,Target}`** (the goal meter — ≠ EB-2 `ResourceId.Charge` ammo) rides the CycleDirector ghost. Resource-gated ability tiers/buffs reuse `StatModifier` (`StatRecomputeSystem``EffectiveAbilityStats`). - **`GoalProgress{[GhostField] int Charge,Target}`** (the goal meter — ≠ EB-2 `ResourceId.Charge` ammo) rides the CycleDirector ghost. Resource-gated ability tiers/buffs reuse `StatModifier` (`StatRecomputeSystem``EffectiveAbilityStats`).
- **M7 Automation (server-only) ★:** `Harvester`/`Conveyor` TRIMMED from the palette (code intact), `Fabricator` LIVE (EB-2); plain server group; catch-up `ProductionMath.CyclesDue` (**lower-bound 0**); `RuntimePlacedTag`=player-built. See [[DR-020_M7_Automation_Production_Chains]]. - **M7 Automation (server-only) ★:** `Harvester`/`Conveyor` TRIMMED from the palette (code intact), `Fabricator` LIVE (EB-2); plain server group; catch-up `ProductionMath.CyclesDue` (**lower-bound 0**); `RuntimePlacedTag`=player-built. See [[DR-020_M7_Automation_Production_Chains]].
- **Harvest routes by node region (DR-031) ★; inventory/equipment PAUSED:** BASE→shared `ResourceLedger`, Expedition/un-tagged→PERSONAL `InventorySlot` (`[GhostField] OwnerSendType.All`, spill→ledger); `G`=deposit. Items/equip (`ItemDatabase` blob, `EquipSystem`)**full detail in the gotchas archive (2026-06-12)**. See [[DR-026_Inventory_Equipment_Progression_Foundation]] · [[DR-031_Base_Mining_Loop_Cohesion]]. - **Harvest routes by node region ★; inventory/equipment PAUSED:** in-run (Expedition) nodes→PERSONAL `InventorySlot` (`[GhostField] OwnerSendType.All`, spill→ledger); `G`=deposit at base. (Base nodes are GONE — DR-044.) Items/equip**full detail in the gotchas archive (2026-06-12)**. See [[DR-026_Inventory_Equipment_Progression_Foundation]].
- **Disk persistence (`SaveData`, single-slot atomic JSON, versioned/additive) ★:** **born-correct load**`CycleDirectorSpawnSystem` stages `PendingSave` AT SPAWN; `BaseRestoreSystem` replays structures charge-free + REMAINING-tick cooldowns + per-structure HP. `SaveService.Load` = additive floor `[MinLoadableVersion=2, Current]` (old saves load; missing field 0-defaults). See [[DR-019_Frontend_Menu_Settings_Saves_Build]]. - **Disk persistence (`SaveData`, single-slot atomic JSON, versioned/additive) ★:** **born-correct load**`CycleDirectorSpawnSystem` stages `PendingSave` AT SPAWN; `BaseRestoreSystem` replays structures charge-free + REMAINING-tick cooldowns + per-structure HP. `SaveService.Load` = additive floor `[MinLoadableVersion=2, Current]` (old saves load; missing field 0-defaults). See [[DR-019_Frontend_Menu_Settings_Saves_Build]].
### Presentation / juice / VFX ### Presentation / juice / VFX
@@ -115,9 +115,7 @@ Long-form originals + the milestone each came from: `Docs/Vault/_Meta/CLAUDE_Bui
- **A GA "projectile" prefab self-propels** (non-kinematic `Rigidbody`+collider+`ProjectileMoveScript`) — strip to particles before `Start` (`CombatFeedbackSystem.StripCosmetic`). Verify *components*, not the name. - **A GA "projectile" prefab self-propels** (non-kinematic `Rigidbody`+collider+`ProjectileMoveScript`) — strip to particles before `Start` (`CombatFeedbackSystem.StripCosmetic`). Verify *components*, not the name.
### Aim controls ### Aim controls
- **Client-derived aim rides the EXISTING `PlayerInput.Aim` `[GhostField]`** — mouse-cursor aim computed in `PlayerInputGatherSystem` (managed `SystemBase`, `GhostInputSystemGroup`): `Mouse.current.position``Camera.main.ScreenPointToRay``AimMath.PlanarAimFromRay` (pure, unit-tested) → player→cursor direction. Only the direction crosses the wire; strafe-while-aiming is free (`Move` already decoupled from `Aim`). - **Client-derived aim rides the EXISTING `PlayerInput.Aim` `[GhostField]`** (`PlayerInputGatherSystem`, managed, `GhostInputSystemGroup`: cursor ray → `AimMath.PlanarAimFromRay`); only the direction crosses the wire. Scheme = last-meaningful-actuation-wins **`byte`** (`PlayerInput.Scheme`, KBM=0/Gamepad=1 — compared in Bursted `AbilityFireSystem`); server gates `AutoTarget` to gamepad only. `AimReticleSystem` (client presentation, observe-only) RE-raycasts the KBM ground point INSIDE itself (it runs after the follow-cam LateUpdate; latching from the gather drifts a frame); hardware cursor hidden while aiming+focused, restored on focus-loss.
- **Active scheme = last-meaningful-actuation-wins, replicated as `byte`** (`PlayerInput.Scheme`, KBM=0/Gamepad=1 — byte because compared in Bursted `AbilityFireSystem`). Server gates the `AutoTarget` cone to gamepad only → precise mouse, gamepad-only assist.
- **Cursor/reticle = client `PresentationSystemGroup` `SystemBase` (`AimReticleSystem`) that OBSERVES.** Re-raycast the KBM ground point INSIDE it (it runs after the follow-cam's LateUpdate; latching from the gather drifts a frame behind). Hardware cursor hidden while aiming + focused, restored on focus-loss.
### Animation (Rukhanka) ★ ### Animation (Rukhanka) ★
Full rationale: [[DR-022_Animation_Pipeline_Rukhanka_Synty]] · [[DR-023_Enemy_Animation_MonsterMash]] · [[Synty_Asset_Inventory]]. Skeletal animation = **Rukhanka 2.9** (the only maintained Entities-native option on 6.4). **Netcode replication OFF** (`RUKHANKA_WITH_NETCODE` undefined) → **client-derived**: `PlayerAnimationDriveSystem` (client-only `SystemBase`, `[WorldSystemFilter(LocalSimulation|ClientSimulation)]` + `[UpdateBefore(RukhankaAnimationSystemGroup)]`) reads replicated state and writes params via `AnimatorParametersAspect`/`FastAnimatorParameter`. No new `[GhostField]`s; no `DefaultVariant` strip (define off → ghost hash unchanged). Full rationale: [[DR-022_Animation_Pipeline_Rukhanka_Synty]] · [[DR-023_Enemy_Animation_MonsterMash]] · [[Synty_Asset_Inventory]]. Skeletal animation = **Rukhanka 2.9** (the only maintained Entities-native option on 6.4). **Netcode replication OFF** (`RUKHANKA_WITH_NETCODE` undefined) → **client-derived**: `PlayerAnimationDriveSystem` (client-only `SystemBase`, `[WorldSystemFilter(LocalSimulation|ClientSimulation)]` + `[UpdateBefore(RukhankaAnimationSystemGroup)]`) reads replicated state and writes params via `AnimatorParametersAspect`/`FastAnimatorParameter`. No new `[GhostField]`s; no `DefaultVariant` strip (define off → ghost hash unchanged).
@@ -143,7 +141,7 @@ Full rationale: [[DR-022_Animation_Pipeline_Rukhanka_Synty]] · [[DR-023_Enemy_A
- `ProjectM.Simulation.GameBootstrap : ClientServerBootstrap` overrides `Initialize` with `AutoConnectPort = 0` (M4 — listen/connect is explicit via the `ConnectionConfig` singleton + per-world ConnectionControlSystems). **Editor default = instant-into-game + MPPM** (creates `ServerWorld` (`WorldFlags.GameServer`) + `ClientWorld` (`WorldFlags.GameClient`)); the `ProjectM/Boot Into Menu (Editor)` EditorPref flips the MAIN editor to the frontend path. **Player builds boot the UITK frontend menu** (`return false` → one menu world, no netcode worlds until a menu choice). See [[DR-019_Frontend_Menu_Settings_Saves_Build]]. - `ProjectM.Simulation.GameBootstrap : ClientServerBootstrap` overrides `Initialize` with `AutoConnectPort = 0` (M4 — listen/connect is explicit via the `ConnectionConfig` singleton + per-world ConnectionControlSystems). **Editor default = instant-into-game + MPPM** (creates `ServerWorld` (`WorldFlags.GameServer`) + `ClientWorld` (`WorldFlags.GameClient`)); the `ProjectM/Boot Into Menu (Editor)` EditorPref flips the MAIN editor to the frontend path. **Player builds boot the UITK frontend menu** (`return false` → one menu world, no netcode worlds until a menu choice). See [[DR-019_Frontend_Menu_Settings_Saves_Build]].
- **Scenes:** `Assets/Scenes/MainMenu.unity` (build index 0) boots the UITK frontend (menu world only); `Assets/Scenes/Game.unity` (index 1) holds gameplay with `Assets/_Project/Subscenes/Gameplay.unity` wired in as the baked subscene (GameObject `GameplaySubScene`). `SampleScene`/`DevSandbox` are kept as reference/dev scenes. The on-demand lifecycle (`WorldLauncher`/`SessionRunner`/`MainMenuController`) creates the right worlds per menu choice (Single/Host/Join), THEN `LoadScene(Game)` (subscene-streaming rule above). - **Scenes:** `Assets/Scenes/MainMenu.unity` (build index 0) boots the UITK frontend (menu world only); `Assets/Scenes/Game.unity` (index 1) holds gameplay with `Assets/_Project/Subscenes/Gameplay.unity` wired in as the baked subscene (GameObject `GameplaySubScene`). `SampleScene`/`DevSandbox` are kept as reference/dev scenes. The on-demand lifecycle (`WorldLauncher`/`SessionRunner`/`MainMenuController`) creates the right worlds per menu choice (Single/Host/Join), THEN `LoadScene(Game)` (subscene-streaming rule above).
- **Core loop is base-local ★ (DR-031 · DR-042):** `BaseFieldSpawnSystem` (server) tops up `RegionTag{Base}` Ore nodes around `BaseGridMath.PlotCenter` (DISTINCT `BaseFieldSpawner`; `SetComponent`-override Region+Ore — Add throws; Ore-only). **Win = expedition CLEARS (DR-042):** `ExpeditionGateSystem` is sole writer of `GoalProgress.Charge` (+1/clear on RETURN); base sieges = **retaliation only**, blind `ScheduleEnabled` baked OFF (★ a serialized prefab bool ignores the C# initializer — flip `CycleDirector.prefab`, not the authoring default). `ExpeditionFieldSystem` teardown Expedition-filtered (else wipes base field); dormant expedition at `base+(1000,0,0)` hidden via `GhostRelevancy`. See [[DR-031_Base_Mining_Loop_Cohesion]] · [[DR-013_M6_Aether_Cycle_Region_Split]] · [[DR-042_Loop_Reshape_Expedition_Driven]]. - **Core loop = ready-check multi-room RUNS ★ (DR-044; DR-031/042 base-mining text archived 07-04):** base = pure SPEND hub (no base nodes; `Tuning.StartingOre` grubstake). `RunDirectorSystem` = sole `RunInfo` writer (`[UpdateBefore(CyclePhaseSystem)]`; NOTHING else in the room chain touches CyclePhase); banks Charge+retaliation once-per-`RunEpoch` on boss-clear ONLY; strips the boon band + every `BoonOffer` on exit; teleports ONLY launch-stamped **`RunParticipant`s** (released on Returning dead-respawned re-conscript, late joiners stay home). Rooms spawn per `RoomEpoch` at ping-pong sub-slots, `RoomTag`-torn-down; first slot waits `Tuning.RoomEntryGraceTicks`. ★ a serialized prefab bool ignores the C# initializer — flip `CycleDirector.prefab`. See [[DR-044_Expedition_Redesign_Shipped_Demo_Polish]].
## DOTS / ECS conventions (authoritative summary) ## DOTS / ECS conventions (authoritative summary)
@@ -179,4 +177,4 @@ Full protocol + per-layer detail: [[Documentation_Protocol]] (`Docs/Vault/_Meta/
## Per-machine setup (NOT in git — redo on each machine) ## Per-machine setup (NOT in git — redo on each machine)
`.mcp.json` is committed + portable (`${CLAUDE_PROJECT_DIR}`); the **`dots-dev` skill travels with the repo** (`.claude/skills/dots-dev/`). Each machine still needs: (1) `uv`/`uvx` + Obsidian app + `obsidian-cli` (the `unity-mcp-skill` + native `memory/` are machine-local, don't sync); (2) basic-memory registration — `uvx basic-memory project add gamevault "<repo>/Docs/Vault" --default` then `uvx basic-memory reindex --full --search --embeddings --project gamevault`; (3) Unity 6.4 open + the Unity-MCP bridge connected (`mcpforunity://editor/state``ready_for_tools`). `.mcp.json` is committed + portable (`${CLAUDE_PROJECT_DIR}`); the **`dots-dev` skill travels with the repo** (`.claude/skills/dots-dev/`). Each machine still needs: (1) `uv`/`uvx` + Obsidian app + `obsidian-cli` (the `unity-mcp-skill` + native `memory/` are machine-local, don't sync); (2) basic-memory registration — `uvx basic-memory project add gamevault "<repo>/Docs/Vault" --default` then `uvx basic-memory reindex --full --search --embeddings --project gamevault`; (3) Unity 6.5 open + the Unity-MCP bridge connected (`mcpforunity://editor/state``ready_for_tools`).
@@ -0,0 +1,54 @@
---
title: Demo-Readiness Polish Pass — redesign closeout review + full four-area polish
date: 2026-07-04
tags: [session, polish, demo, review, netcode, hud, audio, onboarding, dots-dev]
permalink: gamevault/07-sessions/2026/2026-07-04-demo-polish-pass
---
# Demo-Readiness Polish Pass (/dots-dev, ultracode)
Operator brief: *"overall complete polish pass, ready for a game demo"* → intake locked: hands-off public demo,
standalone Windows co-op build, finish-the-redesign baseline, all four polish areas. Full decisions + finding
table: [[DR-044_Expedition_Redesign_Shipped_Demo_Polish]].
## How it went
1. **Ground fan-out** (`wf_d3f1890a-8ca`, 3 read-only agents) — headline: the 07-02 log said "steps 111" but
`Run Re-Do` already contained 12a/12b/13/14. Only the post-impl review + doc bookend were missing.
2. **Baseline verify** — 452/452 EditMode + clean netcode Play boot (born-correct Staging on both worlds).
3. **Post-impl adversarial review** (`wf_38ef58c2-8bd`, 3 lenses → per-finding critics, 15 agents, 0 failures)
— 7 confirmed / 3 refuted. All 7 fixed same-session + pinned (`RunRosterRegressionTests`,
`TerminalCampaignRollTests`); the two contradictory critic verdicts on the respawn stray line were
adjudicated by reading the code (artifact confirmed, deleted).
4. **Stability slice** — ConnectionWatchdog/SessionEnd/menu-reason plumbing; mode-aware outcome banner;
terminal-save roll-forward; save-before-restart.
5. **UI slice** — READY panel, drawn route map (Painter2D DAG + client path trace), onboarding re-author
(the dead at-base Mine step was a first-run soft-lock), How-to-Play rewrite, DOWNED countdown.
6. **Feel/audio slice** — 4-layer phase-locked procedural music, Fabricator can-die parity, core-hit alarm,
landing grace, slash-arc reach fold (local+remote), Warrior cone VFX, boss bar+roar, rarity borders,
depth dots, outcome flourish, shop pips, core-crystal tint system.
7. **Sweep** — build scenes → MainMenu+Game only; `com.unity.ai.assistant` removed; corrupt whitespace-only
`Simulation/Meta.meta` (committed in Run Re-Do; surfaced on the package re-resolve) deleted → regenerated.
## Validation
456/456 EditMode · repeated Play smokes (launch conscription sampled tick-by-tick; wipe-abort self-validated;
zero exceptions) · L3 screenshots: staging READY panel + meta shop, route map with lit path + depth dots,
rarity boon modal. The route-map/boon states were forced via the disable-the-driver idiom
(`ResolveSystemStateRef(handle).Enabled = false` on `RunDirectorSystem`, then write `RunInfo` server-side).
## Gotchas worth remembering
- **A session log can lag the commit** — ground the working tree, not the log's "Remaining" section.
- **Two critics can contradict each other** (one refuted, one confirmed the same stray line) — adjudicate at
the source before acting.
- **A committed `.meta` can be whitespace-corrupt and stay latent** until a package re-resolve forces a full
reimport ("GUID cannot be extracted"); Unity's own advice applies — delete it, let it regenerate (folder
GUIDs are unreferenced; script GUIDs live in their own metas).
- **`CombatFeedbackSystem` already owns the `AbilityCooldown` fire-edge latch** (muzzle flash) — a second
consumer needs its OWN latch field or it never sees the edge (CS0102 caught the collision).
- The idle-player death→respawn→wipe-abort arc is the system working, not a bug — but it exposed the missing
landing grace.
## Operator-side next steps
Standalone build (`ProjectM/Build/Windows Player`) + 2-instance smoke: `-mhost` / `-mjoin 127.0.0.1`, then kill
the host and expect `[ConnectionWatchdog] CONNECTION LOST…` in the joiner log (that line also proves the join
connected); a dead-IP join should log the 12 s timeout. Onboarding fun-gate via Settings → Force Each Launch.
Explicitly deferred to operator art curation: HudTheme structure icons, projectile/pickup placeholder meshes.
@@ -0,0 +1,91 @@
---
id: DR-044
title: Expedition Redesign SHIPPED (steps 114) + Demo-Readiness Polish Pass
date: 2026-07-04
status: locked
tags: [decision, expedition, roguelite, netcode, demo, polish, onboarding, audio]
supersedes: parts of DR-031/DR-042 (base-mining loop text); completes the [[2026-06-29_Expedition_Redesign_Build_Spec]]
---
# DR-044 — Expedition Redesign SHIPPED + Demo-Readiness Polish
## 1. The redesign is COMPLETE and reviewed
All 14 steps of the [[2026-06-29_Expedition_Redesign_Build_Spec]] (§§16 + §7 addendum) are built and live in
commit `16e396841` "Run Re-Do" — the 2026-07-02 interim log stopped at "steps 111" but the commit itself
contains 12a/12b/13 (permanent meta: `MetaCatalog` blob + born-correct seeding + SaveData **v6** +
`MetaSpendSystem`) and 14 (HUD panels + biome + screenshots). **Lesson recorded: verify the working tree
against the session log before planning — logs can lag the commit.**
The never-run **post-impl adversarial review** (3 lenses + per-finding critics, `wf_38ef58c2-8bd`, 15 agents)
confirmed 7 distinct findings — ALL fixed + regression-pinned this session (456/456 EditMode):
| # | Finding (severity) | Fix |
|---|---|---|
| 1 | Stale `BoonOffer.Pending` on a dead-respawned (base-region) player stalls every reward gate 30 s and wedges the modal into the next fight (major) | RoomReward gate counts EXPEDITION players only; NO offer survives the gate exit (both paths zero all offers); HUD modal gated on `Lifecycle==RoomReward`. Pinned in `RunRosterRegressionTests`. |
| 2 | `EnterRoom` conscripts EVERY `PlayerTag` — a mid-run late joiner is yanked into the fight (medium) | Server-only **`RunParticipant`** roster tag: stamped on all players at the launch edge, gates every advance/return teleport, released on Returning. Dead-respawned members keep the tag → re-conscripted (operator-locked default); late joiners stay home. |
| 3 | HUD launch countdown used raw uint tick subtraction → wraps to ~71 M when the client's predicted tick passes `LaunchTick` (medium) | Signed `NetworkTick.TicksSince` with a >0 clamp (the ★ tick rule). |
| 4 | `PlayerRespawnSystem` stray line unconditionally overwrote the spawn-center fallback with `PlotCenter(default)` (medium; edit-tool artifact) | Stray line deleted. |
| 5 | Boon modal keyed on a lossy byte XOR — 1/256 stale-label collision on the unpicked-offer path (minor) | Exact `(o0\|o1<<8\|o2<<16)+1` int signature. |
| 6 | HUD meta shop missed the D-F5 tier display clamp (minor) | Clamped. |
| 7 | Edit-tool text artifacts in 5 files (comment mangles, stray `;;`) (minor) | All cleaned. Corrupt whitespace-only `Simulation/Meta.meta` (committed in Run Re-Do) deleted → Unity regenerated. |
## 2. Demo-readiness decisions (operator-locked this session)
Demo = **hands-off public play, standalone Windows co-op build**. Baseline = finish redesign, then polish all
four areas (stability · combat feel · UI/session flow · audio/atmosphere).
- **Connection watchdog (`ConnectionWatchdogSystem` + `SessionEnd`):** a pure-join client whose ACCEPTED
connection vanishes tears down to the menu with "CONNECTION LOST — the host ended the session."; a join that
never connects times out at 12 s; an unparseable IP aborts immediately (`SessionEnd.Abort`, reason set by
`ClientConnectionControlSystem`). `MainMenuController` shows the reason once. `[ConnectionWatchdog]` log
lines double as join-success evidence in headless build smokes.
- **PLAY AGAIN = clean teardown for all (operator fork):** the outcome banner branches on
`WorldLauncher.LastMode` — Host: "END SESSION — ALL TO MENU"; Join: "LEAVE TO MENU"; Single: PLAY AGAIN
Continues (loadSave). `StartSession` now checkpoints a live server before disposal.
- **Terminal-save roll-forward (`SaveService.RollTerminalCampaignForward`):** a Victory/Loss save Continues as
a FRESH campaign — outcome/goal/core reset to 0 (restore guards re-map), meta tiers/counters/ledger/
structures survive. Without it Continue re-latched the dead banner from the first snapshot. EditMode-pinned.
- **Procedural music (operator fork → asset-free):** `MusicSystem` — four phase-locked 9.6 s loop layers
(bass/pad/arp/pulse; AmFCG @100 BPM; bar-edge envelopes = click-free loops & chord changes), mixed by
replicated state (Staging/launch/combat/boss/lull/siege/final-siege/outcome) + victory/defeat stings.
- **Drawn route map (operator fork):** Slay-the-Spire node-map panel in `HudSystem` — client regenerates the
full DAG from `RunInfo.RunSeed` (display only; Painter2D edges), walked path traced client-side, clickable
next-layer nodes bind ONLY to the authoritative `RouteOpt*` bytes.
- **Onboarding re-authored:** the old at-base **Mine step was a first-run SOFT-LOCK** (base nodes were removed
in Step 11 — `OreNow > OreBaseline` could never satisfy at base). New cascade: Welcome → Move → Build
(grubstake) → Fabricator → **ReadyUp** → Rooms (mine in-run) → Boon → Return → Defend → Done. Same 10 bits
(re-meant, never renamed); veteran masks stay dormant. How-to-Play "The Loop"/Threats/Controls/Economy tabs
rewritten for the run loop.
## 3. Polish additions (this session)
Clickable READY panel (toggle + party pips + 3-2-1 countdown + abort) · run-depth dots (room i/N through the
whole run) · boss presence bar ("ALPHA HUSK" + red HP; client heuristic: a Boss room spawns exactly ONE enemy)
+ arrival roar · boon rarity borders from draw `Weight` (100/60/30/10 → quiet/green/violet/gold) · meta-shop
tier pips + auto-height rows · outcome flash + camera kick on the banner edge · DOWNED respawn countdown
(client-local: death-edge latch + baked `DelayTicks`) · core crystal dims/blood-shifts with `CoreIntegrity` +
white-hot hit flash (`CoreVisualFeedbackSystem` — the crystals are CLASSIC GameObjects in Game.unity;
MaterialPropertyBlock, property-guarded) + core-hit alarm sting · countdown tick beeps ·
**`Tuning.RoomEntryGraceTicks` = 100** (landing grace before the first enemy slot — an idle player died in
~2 s of teleport-in) · **slash-arc reach fix**: the arc now folds the player's `StatModifier`s
(`StatMath.Apply(MeleeRange)`) exactly like the server — class seed/boons/meta were missing from the visual
(local AND remote arcs) · **Warrior cone ability VFX** (Slice-2 deferred): predicted `AbilityCooldown` edge +
blob archetype gate → slash-arc sweep with the ability's effective geometry · Fabricator **can-die parity**
(DR-032: bakes Health 120/DamageEvent/Destructible; it was invulnerable-by-exclusion from the AI snapshot) ·
Staging top-line copy → intent framing (the READY panel owns the action) · EditorBuildSettings trimmed to
MainMenu+Game · `com.unity.ai.assistant` package removed (console noise).
## 4. Validation
456/456 EditMode (from 452 at session start; +4 new pins, 2 traversal/2 room-director tests updated to the new
contracts). Live Play: launch conscription arc sampled (Staging→Launching→InRoom, x=1000, participant=True),
wipe-abort self-validated twice, L3 screenshots (READY panel · route map with traced path · rarity boon modal ·
depth dots · meta shop). Console clean (the only remaining editor noise was the removed AI package).
## 5. Open / operator-side
Standalone build + 2-instance LAN smoke (operator runs it; `-mhost`/`-mjoin`, watchdog log lines = evidence) ·
onboarding fun-gate playthrough (Force-Each-Launch toggle) · tick-batching perf re-measure in the build ·
HudTheme structure icons vs new silhouettes + projectile/pickup placeholder meshes (art curation — needs
operator eyes; explicitly deferred) · GoalProgress.Target demo pacing (currently 4 runs).
@@ -395,3 +395,17 @@ DR-043's first-run onboarding shipped + Play-validated green (20/20 EditMode; li
- **Forcing a specific step in a live Play smoke without input:** the step machine's `_step`/`_mask`/`_stepInit` are private — set them via reflection on `world.GetExistingSystemManaged(typeof(OnboardingSystem))`; remember an MCP `screenshot` can leave the editor **paused** (`EditorApplication.isPaused`), so the field write won't surface until you unpause. Pause + reflection-restyle the `_pointer` Label is also how to get a clean glyph capture (OnUpdate would otherwise overwrite the style next frame). - **Forcing a specific step in a live Play smoke without input:** the step machine's `_step`/`_mask`/`_stepInit` are private — set them via reflection on `world.GetExistingSystemManaged(typeof(OnboardingSystem))`; remember an MCP `screenshot` can leave the editor **paused** (`EditorApplication.isPaused`), so the field write won't surface until you unpause. Pause + reflection-restyle the `_pointer` Label is also how to get a clean glyph capture (OnUpdate would otherwise overwrite the style next frame).
Net-zero: archive-only add (no CLAUDE.md bytes changed), so no inline condensation needed. Net-zero: archive-only add (no CLAUDE.md bytes changed), so no inline condensation needed.
## 2026-07-04 — Demo polish pass: superseded CLAUDE.md core-loop bullet (archived verbatim)
Replaced in CLAUDE.md by the DR-044 ready-check-runs bullet (the expedition redesign retired
`BaseFieldSpawnSystem`/`ExpeditionGateSystem`/`ExpeditionFieldSystem`):
> - **Core loop is base-local ★ (DR-031 · DR-042):** `BaseFieldSpawnSystem` (server) tops up `RegionTag{Base}` Ore nodes around `BaseGridMath.PlotCenter` (DISTINCT `BaseFieldSpawner`; `SetComponent`-override Region+Ore — Add throws; Ore-only). **Win = expedition CLEARS (DR-042):** `ExpeditionGateSystem` is sole writer of `GoalProgress.Charge` (+1/clear on RETURN); base sieges = **retaliation only**, blind `ScheduleEnabled` baked OFF (★ a serialized prefab bool ignores the C# initializer — flip `CycleDirector.prefab`, not the authoring default). `ExpeditionFieldSystem` teardown Expedition-filtered (else wipes base field); dormant expedition at `base+(1000,0,0)` hidden via `GhostRelevancy`. See [[DR-031_Base_Mining_Loop_Cohesion]] · [[DR-013_M6_Aether_Cycle_Region_Split]] · [[DR-042_Loop_Reshape_Expedition_Driven]].
Also archived from the same pass: the Stack "History" long sentence (6.4.7→6.5.0 validation detail — DR-002 +
the 2026-06-17 session log hold it), and the harvest bullet's dead BASE→ledger half (base nodes no longer
exist; in-run nodes → personal `InventorySlot`, spill→ledger, `G`=deposit is unchanged). One-off corruption
note: `Assets/_Project/Scripts/Simulation/Meta.meta` was committed whitespace-only in `Run Re-Do` and stayed
latent until a package re-resolve forced a full reimport ("The GUID … cannot be extracted") — deleted so Unity
regenerated it; folder-meta GUIDs are unreferenced, so regeneration is safe.
-1
View File
@@ -1,7 +1,6 @@
{ {
"dependencies": { "dependencies": {
"com.coplaydev.unity-mcp": "https://github.com/CoplayDev/unity-mcp.git?path=/MCPForUnity#main", "com.coplaydev.unity-mcp": "https://github.com/CoplayDev/unity-mcp.git?path=/MCPForUnity#main",
"com.unity.ai.assistant": "2.12.0-pre.2",
"com.unity.ai.inference": "2.6.1", "com.unity.ai.inference": "2.6.1",
"com.unity.ai.navigation": "2.0.13", "com.unity.ai.navigation": "2.0.13",
"com.unity.charactercontroller": "1.4.2", "com.unity.charactercontroller": "1.4.2",
-20
View File
@@ -39,26 +39,6 @@
"source": "embedded", "source": "embedded",
"dependencies": {} "dependencies": {}
}, },
"com.unity.2d.sprite": {
"version": "1.0.0",
"depth": 1,
"source": "builtin",
"dependencies": {}
},
"com.unity.ai.assistant": {
"version": "2.12.0-pre.2",
"depth": 0,
"source": "registry",
"dependencies": {
"com.unity.2d.sprite": "1.0.0",
"com.unity.mathematics": "1.3.2",
"com.unity.nuget.mono-cecil": "1.11.5",
"com.unity.modules.uielements": "1.0.0",
"com.unity.nuget.newtonsoft-json": "3.2.1",
"com.unity.modules.unitywebrequest": "1.0.0"
},
"url": "https://packages.unity.com"
},
"com.unity.ai.inference": { "com.unity.ai.inference": {
"version": "2.6.1", "version": "2.6.1",
"depth": 0, "depth": 0,
@@ -11,12 +11,6 @@ EditorBuildSettings:
- enabled: 1 - enabled: 1
path: Assets/Scenes/Game.unity path: Assets/Scenes/Game.unity
guid: db8738f16e802c8488f9c22aa56e1e60 guid: db8738f16e802c8488f9c22aa56e1e60
- enabled: 1
path: Assets/Scenes/DevSandbox.unity
guid: 1d4b5e92ac7b6ea4880d30545df94704
- enabled: 1
path: Assets/Scenes/SampleScene.unity
guid: 99c9720ab356a0642a771bea13969a05
m_configObjects: m_configObjects:
com.unity.dt.app-ui: {fileID: 11400000, guid: 1b1c20d82303e4b5781c3ef50ac1449f, type: 2} com.unity.dt.app-ui: {fileID: 11400000, guid: 1b1c20d82303e4b5781c3ef50ac1449f, type: 2}
com.unity.input.settings.actions: {fileID: -944628639613478452, guid: 5aa62f6ed584c43b791e76f2fd31820f, type: 3} com.unity.input.settings.actions: {fileID: -944628639613478452, guid: 5aa62f6ed584c43b791e76f2fd31820f, type: 3}
+11
View File
@@ -36,6 +36,15 @@ GraphicsSettings:
- {fileID: 10783, guid: 0000000000000000f000000000000000, type: 0} - {fileID: 10783, guid: 0000000000000000f000000000000000, type: 0}
m_PreloadedShaders: [] m_PreloadedShaders: []
m_PreloadShadersBatchTimeLimit: -1 m_PreloadShadersBatchTimeLimit: -1
m_GraphicsStateCollection: {fileID: 0}
m_CollectionStartupAction: 0
m_TraceSavePath: TracedCollection
m_TraceSendToEditor: 1
m_AdditionalWarmupCollections: []
m_WarmupAsync: 1
m_EnableCacheMissTracing: 0
m_WarmupProgressivelyLimit: -1
m_CacheMissCollectionPath: CacheMissCollection
m_SpritesDefaultMaterial: {fileID: 10754, guid: 0000000000000000f000000000000000, type: 0} m_SpritesDefaultMaterial: {fileID: 10754, guid: 0000000000000000f000000000000000, type: 0}
m_CustomRenderPipeline: {fileID: 11400000, guid: 4b83569d67af61e458304325a23e5dfd, type: 2} m_CustomRenderPipeline: {fileID: 11400000, guid: 4b83569d67af61e458304325a23e5dfd, type: 2}
m_TransparencySortMode: 0 m_TransparencySortMode: 0
@@ -61,6 +70,8 @@ GraphicsSettings:
UnityEngine.Rendering.Universal.UniversalRenderPipeline: {fileID: 11400000, guid: 18dc0cd2c080841dea60987a38ce93fa, type: 2} UnityEngine.Rendering.Universal.UniversalRenderPipeline: {fileID: 11400000, guid: 18dc0cd2c080841dea60987a38ce93fa, type: 2}
m_ShaderBuildSettings: m_ShaderBuildSettings:
keywordDeclarationOverrides: [] keywordDeclarationOverrides: []
numInternalDefines: 0
defines: []
m_LightsUseLinearIntensity: 1 m_LightsUseLinearIntensity: 1
m_LightsUseColorTemperature: 1 m_LightsUseColorTemperature: 1
m_LogWhenShaderIsCompiled: 0 m_LogWhenShaderIsCompiled: 0
+1 -2
View File
@@ -149,8 +149,7 @@ PlayerSettings:
visionOSBundleVersion: 1.0 visionOSBundleVersion: 1.0
tvOSBundleVersion: 1.0 tvOSBundleVersion: 1.0
bundleVersion: 0.1.0 bundleVersion: 0.1.0
preloadedAssets: preloadedAssets: []
- {fileID: 11400000, guid: 44cd3062bcdc07e43aaec59b78f224a6, type: 2}
metroInputSource: 0 metroInputSource: 0
wsaTransparentSwapchain: 0 wsaTransparentSwapchain: 0
xboxOneDisableKinectGpuReservation: 1 xboxOneDisableKinectGpuReservation: 1