diff --git a/.serena/project.yml b/.serena/project.yml index 2d83ffcdb..0b831cf43 100644 --- a/.serena/project.yml +++ b/.serena/project.yml @@ -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" - -# list of languages for which language servers are started; choose from: -# al angular ansible bash clojure -# cpp cpp_ccls crystal csharp csharp_omnisharp -# dart elixir elm erlang fortran -# fsharp go groovy haskell haxe -# hlsl html java json julia -# kotlin lean4 lua luau markdown +# list of languages for which language servers are started (LSP backend only); choose from: +# ada al angular ansible bash +# bsl clojure cpp cpp_ccls crystal +# csharp csharp_omnisharp cue dart elixir +# elm erlang fortran fsharp gdscript +# go groovy haskell haxe hlsl +# html java json julia kotlin +# latex lean4 lua luau markdown # matlab msl nix ocaml pascal -# perl php php_phpactor powershell python -# python_jedi python_ty r rego ruby -# ruby_solargraph rust scala scss solidity -# svelte swift systemverilog terraform toml -# typescript typescript_vts vue yaml zig -# (This list may be outdated. 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.) +# perl php php_phpactor php_phpantom powershell +# python python_jedi python_pyrefly python_ty r +# rego ruby ruby_solargraph rust scala +# scss solidity svelte swift systemverilog +# terraform toml typescript typescript_vts vue +# yaml zig +# (This list may be outdated; generated with scripts/print_language_list.py; +# 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: # - For C, use cpp # - 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 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. # Same syntax as gitignore, so you can use * and **. # 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). # must be a positive number. 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: +- . diff --git a/Assets/Settings/PC_RPAsset.asset b/Assets/Settings/PC_RPAsset.asset index 5b5c18dd5..ada02541f 100644 --- a/Assets/Settings/PC_RPAsset.asset +++ b/Assets/Settings/PC_RPAsset.asset @@ -74,7 +74,6 @@ MonoBehaviour: m_MixedLightingSupported: 1 m_SupportsLightCookies: 1 m_SupportsLightLayers: 1 - m_DebugLevel: 0 m_StoreActionsOptimization: 0 m_UseAdaptivePerformance: 1 m_ColorGradingMode: 1 diff --git a/Assets/Settings/UniversalRenderPipelineGlobalSettings.asset b/Assets/Settings/UniversalRenderPipelineGlobalSettings.asset index 1287b8615..fcd28f2bc 100644 --- a/Assets/Settings/UniversalRenderPipelineGlobalSettings.asset +++ b/Assets/Settings/UniversalRenderPipelineGlobalSettings.asset @@ -73,7 +73,22 @@ MonoBehaviour: - rid: 6670095651985424593 - rid: 6670095651985424594 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_ObsoleteDefaultVolumeProfile: {fileID: 0} m_RenderingLayerNames: diff --git a/Assets/_Project/Scripts/Authoring/Automation/FabricatorAuthoring.cs b/Assets/_Project/Scripts/Authoring/Automation/FabricatorAuthoring.cs index 8f393a743..114ba3809 100644 --- a/Assets/_Project/Scripts/Authoring/Automation/FabricatorAuthoring.cs +++ b/Assets/_Project/Scripts/Authoring/Automation/FabricatorAuthoring.cs @@ -23,6 +23,7 @@ namespace ProjectM.Authoring [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.")] public byte InputFromLedger = 1; + [Min(1f)] public float MaxHp = 120f; private class FabricatorBaker : Baker { @@ -46,6 +47,14 @@ namespace ProjectM.Authoring InputFromLedger = authoring.InputFromLedger, }); AddBuffer(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(entity); + AddComponent(entity); } } } diff --git a/Assets/_Project/Scripts/Client/Building/BuildSendSystem.cs b/Assets/_Project/Scripts/Client/Building/BuildSendSystem.cs index bcfad3d92..cfc4a88c1 100644 --- a/Assets/_Project/Scripts/Client/Building/BuildSendSystem.cs +++ b/Assets/_Project/Scripts/Client/Building/BuildSendSystem.cs @@ -45,7 +45,7 @@ namespace ProjectM.Client 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 + - // the base meta-shop replaced the Aether damage upgrade.)des++; + // the base meta-shop replaced the Aether damage upgrade.) #if UNITY_EDITOR 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); 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). if (valid && !justSelected && mouse.leftButton.wasPressedThisFrame) diff --git a/Assets/_Project/Scripts/Client/Connection/ClientConnectionControlSystem.cs b/Assets/_Project/Scripts/Client/Connection/ClientConnectionControlSystem.cs index 96423cc42..e1701cdc3 100644 --- a/Assets/_Project/Scripts/Client/Connection/ClientConnectionControlSystem.cs +++ b/Assets/_Project/Scripts/Client/Connection/ClientConnectionControlSystem.cs @@ -36,6 +36,8 @@ namespace ProjectM.Client if (!NetworkEndpoint.TryParse(address.ToString(), port, out var endpoint, NetworkFamily.Ipv4)) { 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; } diff --git a/Assets/_Project/Scripts/Client/Connection/ConnectionWatchdogSystem.cs b/Assets/_Project/Scripts/Client/Connection/ConnectionWatchdogSystem.cs new file mode 100644 index 000000000..5eff4e682 --- /dev/null +++ b/Assets/_Project/Scripts/Client/Connection/ConnectionWatchdogSystem.cs @@ -0,0 +1,91 @@ +using Unity.Entities; +using Unity.NetCode; +using UnityEngine; + +namespace ProjectM.Client +{ + /// + /// Why the last session ended, surfaced once on the main menu (client-local). Set by + /// (host vanished / join never connected) or by the connect path + /// (unparseable address); shows + clears it. Statics reset on play-enter + /// (fast-enter-playmode safe, the VFXConfig precedent). + /// + public static class SessionEnd + { + public static string Reason; + + /// Force an immediate teardown-to-menu (e.g. a bad join address) without waiting for the + /// watchdog's connect timeout. Set first. + public static bool Abort; + + [RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.SubsystemRegistration)] + static void Reset() { Reason = null; Abort = false; } + } + + /// + /// Hands-off co-op fail-safe: watches this client world's connection and returns the player to the main + /// menu (reason on ) 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 + /// -guarded); (2) a join was requested but never accepted within + /// (wrong IP / host not hosting — covers the silent + /// NetworkStreamRequestConnect failure modes); (3) was flagged. Managed + /// observe-only client system — never writes sim state; world disposal runs on the WorldLauncher / + /// SessionRunner frame-boundary path, never here. + /// + [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(); + 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(); + } + } + } +} diff --git a/Assets/_Project/Scripts/Client/Connection/ConnectionWatchdogSystem.cs.meta b/Assets/_Project/Scripts/Client/Connection/ConnectionWatchdogSystem.cs.meta new file mode 100644 index 000000000..9b45eb542 --- /dev/null +++ b/Assets/_Project/Scripts/Client/Connection/ConnectionWatchdogSystem.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: ac9a33fcc24a6e945927c185a16b9e30 \ No newline at end of file diff --git a/Assets/_Project/Scripts/Client/Onboarding/OnboardingStepMath.cs b/Assets/_Project/Scripts/Client/Onboarding/OnboardingStepMath.cs index 3623b2a22..0136884b7 100644 --- a/Assets/_Project/Scripts/Client/Onboarding/OnboardingStepMath.cs +++ b/Assets/_Project/Scripts/Client/Onboarding/OnboardingStepMath.cs @@ -9,6 +9,12 @@ namespace ProjectM.Client /// observable client state each step reads, the deterministic per-step completion test, prompt copy, the /// spatial-cue kind, and the persisted-mask helpers. /// + /// RE-AUTHORED for the expedition redesign (demo polish): base nodes are gone (mining happens IN the run), + /// so the old at-base Mine step is dead — the first lap is now Build (grubstake Ore) → Fabricator → + /// READY UP → fight rooms (mine in-run) → boon → boss/return → defend. Step BITS are re-meant, never + /// renamed-in-place semantics: a veteran's full mask stays dormant; a partial first-run mask at worst + /// replays one beat. + /// /// Pacing (operator-locked = soft-gated): a step shows until its action is performed (no per-step timeout), /// EXCEPT two info beats — and — which also auto-advance, plus /// the timed 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) ---- public const byte Welcome = 0; // tiny win-condition framing strip (timed) 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 = 3; // open palette + place a Turret - public const byte Fabricator = 4; // Ore -> Charge (soft info beat) - public const byte Gate = 5; // reach the Expedition Gate - public const byte Clear = 6; // clear the expedition wave (the first real enemies) - public const byte Return = 7; // walk back to base to bank +1 charge + public const byte Build = 2; // open palette + place a Turret (the 50-Ore grubstake covers it) + public const byte Fabricator = 3; // Ore -> Charge (soft info beat) + public const byte ReadyUp = 4; // press T / click READY — the party launches together + public const byte Rooms = 5; // fight the rooms; attack crystal nodes to haul resources + public const byte Boon = 6; // clear a room -> pick 1 of 3 boons + choose the path + public const byte Return = 7; // fell the boss — the haul + the Engine charge come home public const byte Defend = 8; // survive the retaliation siege (soft info beat) public const byte Done = 9; // closing beat public const byte StepCount = 10; @@ -40,18 +46,18 @@ namespace ProjectM.Client // ---- spatial-cue kinds the System resolves to a live world target ---- public const byte PointerNone = 0; public const byte PointerOreNode = 1; - public const byte PointerBaseGate = 2; // the base-region gate (go to the expedition) - public const byte PointerExpeditionGate = 3; // the expedition-region gate (return home) + public const byte PointerBaseGate = 2; // RETIRED target (walk-in gate died with the redesign); byte kept + public const byte PointerExpeditionGate = 3; // RETIRED target; byte kept for mask/step stability /// Observable client state for one evaluation. Built by the System from ECS + input each frame. public struct Snapshot { public float StepElapsed; // seconds the current step has been shown public float MoveDistance; // accumulated player movement since the Move step began - public int 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 FabricatorCount; // live Fabricator structures (absolute) + public bool LocalReady; // the LOCAL player's replicated PlayerReady flag + public byte Lifecycle; // replicated RunInfo.Lifecycle (RunLifecycle.*) public bool OnExpedition; // local player is in the expedition region public byte ObjectiveState; // ExpeditionObjective.State (Idle/Active/Cleared) public bool SawSiege; // a Siege phase was observed while the Defend step was showing @@ -65,12 +71,14 @@ namespace ProjectM.Client { case Welcome: return s.StepElapsed >= WelcomeSeconds; case Move: return s.MoveDistance >= MoveThreshold; - case Mine: return s.OreNow > s.OreBaseline; case Build: return s.TurretCount >= 1; case Fabricator: return s.FabricatorCount >= 1 || s.StepElapsed >= FabricatorSoftSeconds; - case Gate: return s.OnExpedition || s.ObjectiveState == ExpeditionObjectiveState.Active; - case Clear: return s.ObjectiveState == ExpeditionObjectiveState.Cleared; - case Return: return !s.OnExpedition; // entered while on expedition; satisfied on crossing home + case ReadyUp: return s.LocalReady || s.Lifecycle != RunLifecycle.Staging; + case Rooms: return s.OnExpedition || s.ObjectiveState == ExpeditionObjectiveState.Active; + 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 Done: return s.StepElapsed >= DoneSeconds; default: return true; @@ -82,10 +90,8 @@ namespace ProjectM.Client { switch (step) { - case Mine: return PointerOreNode; - case Gate: return PointerBaseGate; - case Return: return PointerExpeditionGate; - default: return PointerNone; + case Rooms: return PointerOreNode; // in-run crystal nodes (base nodes no longer exist) + default: return PointerNone; } } @@ -93,19 +99,18 @@ namespace ProjectM.Client public static string Prompt(byte step, bool gamepad) { string move = gamepad ? "Left Stick" : "WASD"; - string attack = gamepad ? "RT" : "LMB"; string build = gamepad ? "Y" : "Tab"; // matches the existing HUD build-discovery chip glyph switch (step) { 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 Mine: return attack + " — Attack the glowing Ore to mine it"; - case Build: return build + " — open Build, then place a Turret by your Core"; + case Build: return build + " — open Build, place a Turret by your Core (your 50 starting Ore covers it)"; 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 Clear: return "Clear each room — pick a boon, choose your path, reach the boss"; - case Return: return "Fell the boss — you return home and the Engine charges (+1)";; - case Defend: return "Defend the Core! — hold the line through the siege"; + case ReadyUp: return "Press T (or click READY UP) — when everyone is ready, the party launches"; + case Rooms: return "Fight through the rooms — attack the glowing crystals to haul resources home"; + case Boon: return "Clear the room — pick 1 of 3 BOONS, then choose your path on the map"; + 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."; default: return ""; } diff --git a/Assets/_Project/Scripts/Client/Onboarding/OnboardingSystem.cs b/Assets/_Project/Scripts/Client/Onboarding/OnboardingSystem.cs index a89e6a0f0..4bb82cb44 100644 --- a/Assets/_Project/Scripts/Client/Onboarding/OnboardingSystem.cs +++ b/Assets/_Project/Scripts/Client/Onboarding/OnboardingSystem.cs @@ -41,7 +41,6 @@ namespace ProjectM.Client float _stepElapsed; float _moveAccum; float3 _lastPos; - int _oreBaseline; bool _sawSiege; protected override void OnStartRunning() @@ -92,7 +91,11 @@ namespace ProjectM.Client } // ---- remaining observable state ---- - int ore = LedgerOre(); + byte lifecycle = RunLifecycle.Staging; + if (SystemAPI.TryGetSingleton(out var runInfo)) lifecycle = runInfo.Lifecycle; + bool localReady = false; + foreach (var pr in SystemAPI.Query>().WithAll()) + { localReady = pr.ValueRO.Value != 0; break; } CountStructures(out int turrets, out int fabs); byte phase = CyclePhase.Calm; if (SystemAPI.TryGetSingleton(out var cyc)) phase = cyc.Phase; @@ -104,7 +107,7 @@ namespace ProjectM.Client if (!_stepInit) { _stepElapsed = 0f; _moveAccum = 0f; _sawSiege = false; - _oreBaseline = ore; _lastPos = playerPos; + _lastPos = playerPos; _stepInit = true; } @@ -120,8 +123,8 @@ namespace ProjectM.Client { StepElapsed = _stepElapsed, MoveDistance = _moveAccum, - OreNow = ore, - OreBaseline = _oreBaseline, + LocalReady = localReady, + Lifecycle = lifecycle, TurretCount = turrets, FabricatorCount = fabs, OnExpedition = onExp, @@ -159,16 +162,7 @@ namespace ProjectM.Client // ---- state gathering helpers ---- - int LedgerOre() - { - if (SystemAPI.TryGetSingletonEntity(out var e)) - { - var buf = SystemAPI.GetBuffer(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) { diff --git a/Assets/_Project/Scripts/Client/Presentation/AmbientAudioSystem.cs b/Assets/_Project/Scripts/Client/Presentation/AmbientAudioSystem.cs index 42ed2ba12..f17f9022e 100644 --- a/Assets/_Project/Scripts/Client/Presentation/AmbientAudioSystem.cs +++ b/Assets/_Project/Scripts/Client/Presentation/AmbientAudioSystem.cs @@ -27,6 +27,12 @@ namespace ProjectM.Client byte _lastPhase; 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 @@ -36,6 +42,9 @@ namespace ProjectM.Client _stingExpedition = MakeSting(520f, 880f, 0.45f, 0.30f); // airy rising "deploy" _stingDefend = MakeSting(300f, 140f, 0.55f, 0.42f); // tense falling "wave incoming" _stingBuild = MakeSting(440f, 660f, 0.40f, 0.26f); // soft confirm + _stingCoreHit = MakeSting(240f, 70f, 0.30f, 0.55f); // harsh falling "Core hit" alarm + _stingBeep = MakeSting(880f, 880f, 0.09f, 0.30f); // countdown tick + _stingRoar = MakeSting(90f, 38f, 0.90f, 0.60f); // boss-arrival roar (low falling growl) } protected override void OnStartRunning() @@ -76,6 +85,50 @@ namespace ProjectM.Client // Ease the drone intensity toward the phase target (tenser during Defend). float target = phase == CyclePhase.Siege ? AmbientBaseVolume * 1.7f : AmbientBaseVolume; _ambient.volume = Mathf.MoveTowards(_ambient.volume, target * GameVolume.Music, SystemAPI.Time.DeltaTime * 0.25f); + + // Core-under-attack alarm: a falling sting on each CoreIntegrity drop (rate-limited) so a base + // breach is AUDIBLE even when the fight has the player's eyes elsewhere. + _coreStingCooldown -= SystemAPI.Time.DeltaTime; + if (SystemAPI.TryGetSingleton(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(out var runAudio)) + { + int sec = -1; + if (runAudio.Lifecycle == RunLifecycle.Launching && runAudio.LaunchTick != 0 + && SystemAPI.TryGetSingleton(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>().WithAll()) + { + _ambient.PlayOneShot(_stingRoar, 0.9f * GameVolume.Sfx); + _bossRoared = true; + break; + } + } + } } void PlaySting(byte phase) diff --git a/Assets/_Project/Scripts/Client/Presentation/CombatFeedbackSystem.cs b/Assets/_Project/Scripts/Client/Presentation/CombatFeedbackSystem.cs index d8d7bdef7..4894cb80a 100644 --- a/Assets/_Project/Scripts/Client/Presentation/CombatFeedbackSystem.cs +++ b/Assets/_Project/Scripts/Client/Presentation/CombatFeedbackSystem.cs @@ -62,6 +62,8 @@ namespace ProjectM.Client bool _slashActive; 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) + uint _lastConeFireTick; // own latch — the muzzle block owns _lastLocalFireTick and runs first + bool _coneTickInit; Material _dangerMat; readonly Dictionary _dangerZones = new(); readonly HashSet _dangerSeen = new(); @@ -367,6 +369,11 @@ namespace ProjectM.Client bool finisher = step >= comboLen; float slashRange = tcfg.MeleeRange > 0f ? tcfg.MeleeRange : 2.6f; 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(_localPlayer)) + slashRange = math.max(0f, StatMath.Apply(slashRange, StatTarget.MeleeRange, + EntityManager.GetBuffer(_localPlayer, true))); 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 // "you bit" read (the authoritative server damage spark/number arrives a few ticks later). @@ -397,6 +404,40 @@ namespace ProjectM.Client _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(_localPlayer) + && EntityManager.HasComponent(_localPlayer) + && EntityManager.HasComponent(_localPlayer)) + { + uint nextFire = EntityManager.GetComponentData(_localPlayer).NextFireTick; + if (_coneTickInit && nextFire != 0 && nextFire != _lastConeFireTick + && SystemAPI.TryGetSingleton(out var coneDb) && coneDb.Value.IsCreated) + { + ref var coneAdb = ref coneDb.Value.Value; + if (coneAdb.TryGetAbility(EntityManager.GetComponentData(_localPlayer).Id, out var coneDef) + && coneDef.Archetype == (byte)AbilityArchetype.Cone) + { + var ceff = EntityManager.GetComponentData(_localPlayer); + Vector3 cface = Vector3.forward; + if (EntityManager.HasComponent(_localPlayer)) + { + var cfd = EntityManager.GetComponentData(_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. if (_localPlayer != Entity.Null) { @@ -820,7 +861,11 @@ namespace ProjectM.Client { int step = math.max(1, (int)mc.ValueRO.Step); 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(entity)) + rRange = math.max(0f, StatMath.Apply(baseRange, StatTarget.MeleeRange, + EntityManager.GetBuffer(entity, true))); + rs.Range = finisher ? rRange * finisherMult : rRange; rs.Half = baseHalf; rs.SweepSign = (step % 2 == 0) ? -1 : 1; rs.Tint = FeelConfig.RemoteSlashColor * (finisher ? 1.5f : 1f); diff --git a/Assets/_Project/Scripts/Client/Presentation/CoreVisualFeedbackSystem.cs b/Assets/_Project/Scripts/Client/Presentation/CoreVisualFeedbackSystem.cs new file mode 100644 index 000000000..9329d82fe --- /dev/null +++ b/Assets/_Project/Scripts/Client/Presentation/CoreVisualFeedbackSystem.cs @@ -0,0 +1,91 @@ +using System.Collections.Generic; +using ProjectM.Simulation; +using Unity.Entities; +using UnityEngine; +using UnityEngine.SceneManagement; + +namespace ProjectM.Client +{ + /// + /// The Engine Core's CRYSTAL answers its replicated (07-01 backlog: the mesh sat + /// static while draining). Client-only observe-only presentation: the cosmetic CoreCrystals / + /// CoreMachine 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). + /// + [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 _renderers = new(); + readonly List _authoredColors = new(); + MaterialPropertyBlock _mpb; + bool _resolved; + int _lastCore = -1; + float _flashLeft; + + protected override void OnUpdate() + { + if (!SystemAPI.TryGetSingleton(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()) + { + var m = r.sharedMaterial; + if (m == null || !m.HasProperty(BaseColorId)) continue; + _renderers.Add(r); + _authoredColors.Add(m.GetColor(BaseColorId)); + } + } + } + } +} diff --git a/Assets/_Project/Scripts/Client/Presentation/CoreVisualFeedbackSystem.cs.meta b/Assets/_Project/Scripts/Client/Presentation/CoreVisualFeedbackSystem.cs.meta new file mode 100644 index 000000000..f21d22610 --- /dev/null +++ b/Assets/_Project/Scripts/Client/Presentation/CoreVisualFeedbackSystem.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 47b2a19145125ac4e98411cab7f9b569 \ No newline at end of file diff --git a/Assets/_Project/Scripts/Client/Presentation/HudSystem.cs b/Assets/_Project/Scripts/Client/Presentation/HudSystem.cs index 9e0b1564f..13946db34 100644 --- a/Assets/_Project/Scripts/Client/Presentation/HudSystem.cs +++ b/Assets/_Project/Scripts/Client/Presentation/HudSystem.cs @@ -72,9 +72,9 @@ namespace ProjectM.Client // observe-only readers of replicated state (BoonOffer via GhostOwnerIsLocal; RunInfo RouteOpt*); clicks // enqueue through the client send-systems' statics. Built lazily on first show. VisualElement _boonModal, _boonCardRow; - VisualElement _routePanel, _routeBtnRow; + VisualElement _routePanel; 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; // Step 14 (meta shop): Staging-only permanent-upgrade shop (replicated MetaTierState + ledger Aether; // row clicks enqueue MetaSpendSendSystem.RequestPurchase — the server re-validates everything). @@ -82,6 +82,28 @@ namespace ProjectM.Client Label _metaShopTitle; bool _metaShopBuilt; 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 _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 _pips = new(); @@ -97,6 +119,8 @@ namespace ProjectM.Client // overlays VisualElement _vignette, _downed; + Label _downedText; // "RESPAWNING IN N" countdown (client-local: baked DelayTicks + death-edge latch) + float _downedSince = -1f; float _prevHp, _flash; bool _haveHp; // personal inventory panel (read-only; toggled with I) @@ -194,25 +218,21 @@ namespace ProjectM.Client switch (runInfo.Lifecycle) { case RunLifecycle.Staging: - { - // Client counts the party's replicated ready flags (send-to-all) for the N/M readout. - int total = 0, readyCount = 0; - foreach (var pr in SystemAPI.Query>().WithAll()) - { - 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"; + // The READY panel (bottom-center) owns the action + N/M count; the top line frames intent. + _locationText.text = "AT THE BASE - build defenses, buy upgrades, READY UP to launch"; _locationText.style.color = new Color(0.55f, 0.85f, 1f); break; - } case RunLifecycle.Launching: { - uint nowTick = SystemAPI.TryGetSingleton(out var ntime) && ntime.ServerTick.IsValid - ? ntime.ServerTick.TickIndexForValidTick : 0u; - int secs = runInfo.LaunchTick != 0 && nowTick != 0 - ? Mathf.Max(0, (int)((runInfo.LaunchTick - nowTick) / 60u) + 1) : 0; + // Wrap-safe countdown (post-impl review): the client's PREDICTED tick passes LaunchTick + // near zero while Lifecycle is still Launching — signed TicksSince, never raw uint math. + int secs = 0; + if (runInfo.LaunchTick != 0 && SystemAPI.TryGetSingleton(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.style.color = new Color(1f, 0.9f, 0.4f); break; @@ -272,9 +292,72 @@ namespace ProjectM.Client BlobAssetReference boonPool = default; if (SystemAPI.TryGetSingleton(out var bcat)) 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); + // 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(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>().WithAll()) + { + rTotal++; + if (pr.ValueRO.Value != 0) rReady++; + } + foreach (var pr in SystemAPI.Query>().WithAll()) + localReady = pr.ValueRO.Value != 0; + if (runInfo.Lifecycle == RunLifecycle.Launching && runInfo.LaunchTick != 0 + && SystemAPI.TryGetSingleton(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>().WithAll()) + { + 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) ---- if (SystemAPI.TryGetSingleton(out var goal)) { @@ -388,6 +471,14 @@ namespace ProjectM.Client if (SystemAPI.TryGetSingleton(out var runOutcome) && runOutcome.Value != RunOutcomeId.InProgress) { bool win = runOutcome.Value == RunOutcomeId.Victory; + if (_outcomeFlashedFor != runOutcome.Value) + { + // One-shot landing flourish: full-screen color flash + camera kick — the beat gets a payoff. + _outcomeFlashedFor = runOutcome.Value; + _outcomeFlashLeft = win ? 0.9f : 0.7f; + PrototypeCameraRig.PunchFov(win ? 5f : 3f, win ? 420f : 260f); + PrototypeCameraRig.AddShake(win ? 0.25f : 0.5f); + } _runBanner.style.display = DisplayStyle.Flex; _runBannerText.text = win ? "THE ENGINE HOLDS" : "OVERRUN"; _runBannerText.style.color = win ? new Color(0.45f, 0.95f, 1f) : new Color(1f, 0.35f, 0.3f); @@ -397,6 +488,32 @@ namespace ProjectM.Client else { _runBanner.style.display = DisplayStyle.None; + _outcomeFlashedFor = 0; + } + + // Outcome flash decay (lazy element; the banner dim sits above it, the world below). + if (_outcomeFlashLeft > 0f) + { + if (_outcomeFlash == null && _doc != null && _doc.rootVisualElement != null) + { + _outcomeFlash = new VisualElement { pickingMode = PickingMode.Ignore }; + _outcomeFlash.style.position = Position.Absolute; + _outcomeFlash.style.left = 0; _outcomeFlash.style.right = 0; + _outcomeFlash.style.top = 0; _outcomeFlash.style.bottom = 0; + _doc.rootVisualElement.Add(_outcomeFlash); + } + _outcomeFlashLeft -= dt; + if (_outcomeFlash != null) + { + bool winFlash = _outcomeFlashedFor == RunOutcomeId.Victory; + var fc = winFlash ? new Color(1f, 0.85f, 0.35f) : new Color(1f, 0.20f, 0.15f); + _outcomeFlash.style.backgroundColor = new Color(fc.r, fc.g, fc.b, Mathf.Clamp01(_outcomeFlashLeft) * 0.35f); + _outcomeFlash.style.display = DisplayStyle.Flex; + } + } + else if (_outcomeFlash != null) + { + _outcomeFlash.style.display = DisplayStyle.None; } @@ -487,6 +604,18 @@ namespace ProjectM.Client HudUi.SetFill(_cooldownFill, cdFrac); // 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 (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>().WithAll()) + { 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; } else @@ -1022,7 +1151,12 @@ namespace ProjectM.Client { _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; root.Add(_downed); } @@ -1042,17 +1176,32 @@ namespace ProjectM.Client _runBannerSub.style.marginTop = 8; col.Add(_runBannerSub); // END-2 (SL-5): the terminal banner offers a clear action so the player isn't hunting for Esc. - // PLAY AGAIN restarts a fresh Single run via the proven menu lifecycle; QUIT TO MENU reuses the teardown. - // Both self-guard on WorldLauncher.Busy. The row picks (Position) even though the banner root Ignores. + // SINGLE: PLAY AGAIN Continues as a fresh campaign (base+meta kept — the terminal save rolls + // forward on stage). CO-OP (operator-locked): the honest exit is a clean teardown for everyone — + // the host ends the session (each joiner's ConnectionWatchdog returns them to the menu with a + // reason), a joiner just leaves. All self-guard on WorldLauncher.Busy. The row picks (Position) + // even though the banner root Ignores. var btnRow = new VisualElement(); btnRow.style.flexDirection = FlexDirection.Row; btnRow.style.justifyContent = Justify.Center; btnRow.style.marginTop = 28; btnRow.pickingMode = PickingMode.Position; - var again = MenuUi.Button("PLAY AGAIN", () => WorldLauncher.StartSession(SessionMode.Single, null, false)); - again.style.marginRight = 12; - btnRow.Add(again); - btnRow.Add(MenuUi.Button("QUIT TO MENU", WorldLauncher.TeardownToMenu)); + switch (WorldLauncher.LastMode) + { + case SessionMode.Host: + btnRow.Add(MenuUi.Button("END SESSION — ALL TO MENU", WorldLauncher.TeardownToMenu)); + break; + case SessionMode.Join: + btnRow.Add(MenuUi.Button("LEAVE TO MENU", WorldLauncher.TeardownToMenu)); + break; + default: + var again = MenuUi.Button("PLAY AGAIN", + () => WorldLauncher.StartSession(SessionMode.Single, null, SaveService.HasSave())); + again.style.marginRight = 12; + btnRow.Add(again); + btnRow.Add(MenuUi.Button("QUIT TO MENU", WorldLauncher.TeardownToMenu)); + break; + } col.Add(btnRow); _runBanner.Add(col); _runBanner.style.display = DisplayStyle.None; @@ -1230,7 +1379,6 @@ namespace ProjectM.Client // ==== 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]" : 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). - byte sig = (byte)(offer.Option0 ^ (offer.Option1 * 3) ^ (offer.Option2 * 7)); - if (sig == 0) sig = 1; + // Exact signature (post-impl review): the old lossy byte XOR could collide across consecutive + // 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) { _boonCardRow.Clear(); @@ -1265,10 +1414,18 @@ namespace ProjectM.Client int idx = BoonMath.FindDef(ref defs, id); string title = idx >= 0 ? defs.Defs[idx].Name.ToString() : ("BOON " + id); 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 var card = MenuUi.Button(title + "\n" + desc, () => BoonSendSystem.PickBoon(pick)); 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.marginRight = 8; card.style.whiteSpace = WhiteSpace.Normal; @@ -1312,6 +1469,25 @@ namespace ProjectM.Client 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) { // Keyed on the LIFECYCLE (never RouteOptionCount alone — the review's D-F6 criterion). @@ -1319,7 +1495,7 @@ namespace ProjectM.Client if (!show) { if (_routePanel != null) _routePanel.style.display = DisplayStyle.None; - _routeShownFor = 0; + _routeMapSig = 0; return; } var root = _doc != null ? _doc.rootVisualElement : null; @@ -1330,23 +1506,15 @@ namespace ProjectM.Client _routePanelBuilt = true; } - byte sig = (byte)((runInfo.CurrentRoom + 1) ^ (runInfo.RouteOpt0Type * 3) - ^ (runInfo.RouteOpt1Type * 5) ^ (runInfo.RouteOpt2Type * 7) ^ runInfo.RouteOptionCount); + int sig = (int)runInfo.RunSeed ^ (runInfo.CurrentRoom + 1) * 131 ^ runInfo.CurrentCol * 31 + ^ (runInfo.RouteOptionCount << 24) ^ (runInfo.RouteOpt0Col << 16) + ^ (runInfo.RouteOpt1Col << 18) ^ (runInfo.RouteOpt2Col << 20); if (sig == 0) sig = 1; - if (_routeShownFor != sig) + if (_routeMapSig != sig) { - _routeBtnRow.Clear(); - 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); - } + RebuildRouteMap(runInfo); _routeTitle.text = "CHOOSE YOUR PATH — room " + (runInfo.CurrentRoom + 2) + "/" + runInfo.RoomCount; - _routeShownFor = sig; + _routeMapSig = sig; } _routePanel.style.display = DisplayStyle.Flex; } @@ -1356,33 +1524,299 @@ namespace ProjectM.Client _routePanel = new VisualElement { pickingMode = PickingMode.Ignore }; _routePanel.style.position = Position.Absolute; _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.justifyContent = Justify.Center; _routePanel.style.display = DisplayStyle.None; - var box = new VisualElement(); - box.style.backgroundColor = new Color(0.07f, 0.09f, 0.12f, 0.94f); + 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.95f); box.style.borderTopLeftRadius = 10; box.style.borderTopRightRadius = 10; box.style.borderBottomLeftRadius = 10; box.style.borderBottomRightRadius = 10; - box.style.paddingLeft = 16; box.style.paddingRight = 16; - box.style.paddingTop = 10; box.style.paddingBottom = 12; + box.style.paddingLeft = 18; box.style.paddingRight = 18; + box.style.paddingTop = 12; box.style.paddingBottom = 12; box.style.alignItems = Align.Center; _routeTitle = new Label("CHOOSE YOUR PATH"); _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.marginBottom = 8; + _routeTitle.style.marginBottom = 10; box.Add(_routeTitle); - _routeBtnRow = new VisualElement(); - _routeBtnRow.style.flexDirection = FlexDirection.Row; - box.Add(_routeBtnRow); + _routeMapHost = new VisualElement { pickingMode = PickingMode.Ignore }; + _routeMapHost.style.position = Position.Relative; + 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); 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(_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(_ => RouteSendSystem.PickRoute(pick)); + n.RegisterCallback(_ => + n.style.backgroundColor = new Color(c.r * 0.55f, c.g * 0.55f, c.b * 0.55f, 1f)); + n.RegisterCallback(_ => 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, BlobAssetReference pool, DynamicBuffer record) { @@ -1417,18 +1851,35 @@ namespace ProjectM.Client if ((defs.Defs[d].ClassMask & classBit) == 0) continue; byte id = defs.Defs[d].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; 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") + "\n" + defs.Defs[d].Desc.ToString(); byte buyId = id; // closure copy, never the loop variable var row = MenuUi.Button(label, () => MetaSpendSendSystem.RequestPurchase(buyId)); 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.whiteSpace = WhiteSpace.Normal; row.style.unityTextAlign = TextAnchor.MiddleLeft; 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); } _metaShownFor = sig; diff --git a/Assets/_Project/Scripts/Client/Presentation/MusicSystem.cs b/Assets/_Project/Scripts/Client/Presentation/MusicSystem.cs new file mode 100644 index 000000000..69f7e2d9a --- /dev/null +++ b/Assets/_Project/Scripts/Client/Presentation/MusicSystem.cs @@ -0,0 +1,285 @@ +using ProjectM.Simulation; +using Unity.Entities; +using Unity.NetCode; +using UnityEngine; + +namespace ProjectM.Client +{ + /// + /// Client-only PROCEDURAL MUSIC — four synchronized loop layers (bass / chord pad / arpeggio / percussion + /// pulse), all generated over the SAME 4-bar buffer (100 BPM, Am–F–C–G) 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 AmbientAudioSystem.Snap trick generalized to enveloped segments). The MIX is the state + /// machine: layer volumes ease toward targets chosen from replicated state only — + /// lifecycle (staging / combat / boss / reward lull), siege, and the terminal + /// (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 + /// × ; the low AmbientAudioSystem drone + /// (vol 0.10) remains as texture beneath it. + /// + [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(); + 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(out var run); + SystemAPI.TryGetSingleton(out var cyc); + bool siege = cyc.Phase == CyclePhase.Siege; + bool finalSiege = siege && SystemAPI.TryGetSingleton(out var goal) + && goal.Target > 0 && goal.Charge >= goal.Target; + + byte outcome = SystemAPI.TryGetSingleton(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); + } + + /// Bar-local envelope that is ~0 at both bar edges (click-free chord changes + loop point). + 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; + } + } +} diff --git a/Assets/_Project/Scripts/Client/Presentation/MusicSystem.cs.meta b/Assets/_Project/Scripts/Client/Presentation/MusicSystem.cs.meta new file mode 100644 index 000000000..459cbd09c --- /dev/null +++ b/Assets/_Project/Scripts/Client/Presentation/MusicSystem.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 0674dbc6d7e676f4cb88b18a67cdca99 \ No newline at end of file diff --git a/Assets/_Project/Scripts/Client/UI/HowToPlayPanel.cs b/Assets/_Project/Scripts/Client/UI/HowToPlayPanel.cs index 23f0b2372..21eb4fd72 100644 --- a/Assets/_Project/Scripts/Client/UI/HowToPlayPanel.cs +++ b/Assets/_Project/Scripts/Client/UI/HowToPlayPanel.cs @@ -76,21 +76,24 @@ namespace ProjectM.Client Body(c, "Build menu — Tab / Y"); Body(c, "Place / Cancel (in build) — LMB / RMB (A / B on gamepad)"); 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, "Pause — Esc"); break; case 1: // The Loop - Head(c, "THE LOOP — expeditions are how you win"); - Body(c, "1. BASE (Calm) — mine Ore, build Turrets + a Fabricator to defend your Engine Core."); - Body(c, "2. EXPEDITION — walk to the Gate and fight the wave. CLEARING it is the progress beat."); - Body(c, "3. RETURN — come home to bank the clear: +1 on the Engine meter."); - Body(c, "4. SIEGE — returning provokes a retaliation attack. Defend the Core!"); - Body(c, "5. WIN — fill the Engine meter, then hold the final siege."); - Body(c, "The Engine meter (top of screen) is the goal — base sieges are a consequence, not the win."); + Head(c, "THE LOOP — expedition RUNS are how you win"); + Body(c, "1. BASE — build Turrets + a Fabricator around your Engine Core; spend Aether on PERMANENT class upgrades."); + Body(c, "2. READY UP [T] — when everyone is ready, the party launches into a multi-room run together."); + Body(c, "3. RUN — clear each room, pick 1 of 3 BOONS (this run only), choose your path on the map, mine the crystals."); + Body(c, "4. BOSS — fell it and the party returns with the haul: +1 on the Engine meter. A wipe banks nothing."); + Body(c, "5. SIEGE — a completed run provokes retaliation at base. Defend the Core!"); + Body(c, "6. WIN — fill the Engine meter, then hold the final siege."); break; case 2: // Build & Economy 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, "Fabricator (30 Ore) — converts Ore → Charge so turrets keep firing."); Body(c, "Wall (Biomass) — a cheap barrier that blocks enemies."); @@ -101,7 +104,7 @@ namespace ProjectM.Client Head(c, "THREATS"); Body(c, "Husks assault the base during a Siege — keep them off the Engine Core."); Body(c, "A Husk that reaches the Core drains it. Lose the Core in the final siege and the run ends."); - Body(c, "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."); break; default: // Win / Lose diff --git a/Assets/_Project/Scripts/Client/UI/MainMenuController.cs b/Assets/_Project/Scripts/Client/UI/MainMenuController.cs index 759459b34..d5b34ecb0 100644 --- a/Assets/_Project/Scripts/Client/UI/MainMenuController.cs +++ b/Assets/_Project/Scripts/Client/UI/MainMenuController.cs @@ -80,6 +80,17 @@ namespace ProjectM.Client _mainPanel = MenuUi.FullScreenRoot(true); var card = MenuUi.Card("PROJECT M"); 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). _classLabel = new Label(ClassName(WorldLauncher.SelectedClass)); _classLabel.style.unityTextAlign = TextAnchor.MiddleCenter; diff --git a/Assets/_Project/Scripts/Client/UI/WorldLauncher.cs b/Assets/_Project/Scripts/Client/UI/WorldLauncher.cs index 5f17104f5..31ace2fbc 100644 --- a/Assets/_Project/Scripts/Client/UI/WorldLauncher.cs +++ b/Assets/_Project/Scripts/Client/UI/WorldLauncher.cs @@ -31,10 +31,16 @@ namespace ProjectM.Client /// Slice 2: the class chosen in the menu (a CharacterId byte), seeded into the client world at session start. public static byte SelectedClass = (byte)CharacterId.Warrior; + /// 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). + public static SessionMode LastMode { get; private set; } = SessionMode.Single; + + public static void StartSession(SessionMode mode, string joinIp, bool loadSave) { if (Busy) return; Busy = true; + LastMode = mode; SessionRunner.Run(StartRoutine(mode, joinIp, loadSave)); } @@ -47,6 +53,12 @@ namespace ProjectM.Client 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 // subscene then streams into the netcode worlds, exactly as in the always-on bootstrap flow). World.DisposeAllWorlds(); @@ -117,6 +129,10 @@ namespace ProjectM.Client static void StagePendingSave(World server) { 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; var em = server.EntityManager; var e = em.CreateEntity(); diff --git a/Assets/_Project/Scripts/Server/Combat/EnemyAISystem.cs b/Assets/_Project/Scripts/Server/Combat/EnemyAISystem.cs index 4c1c29656..89b87e9c9 100644 --- a/Assets/_Project/Scripts/Server/Combat/EnemyAISystem.cs +++ b/Assets/_Project/Scripts/Server/Combat/EnemyAISystem.cs @@ -56,8 +56,8 @@ namespace ProjectM.Server playerRegions.Add(region.ValueRO.Region); } - // EB-1 fortress aggro: also snapshot live structures (Turret/Wall/Pylon carry Health; automation - // machines lack it so the query excludes them). Snapshot ABOVE the early-return so Husks keep razing + // EB-1 fortress aggro: also snapshot live structures (Turret/Wall/Pylon AND the Fabricator all carry + // 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). var structureEntities = new NativeList(Allocator.Temp); var structurePositions = new NativeList(Allocator.Temp); diff --git a/Assets/_Project/Scripts/Server/Combat/PlayerRespawnSystem.cs b/Assets/_Project/Scripts/Server/Combat/PlayerRespawnSystem.cs index dfd5bc3ea..637b35cb9 100644 --- a/Assets/_Project/Scripts/Server/Combat/PlayerRespawnSystem.cs +++ b/Assets/_Project/Scripts/Server/Combat/PlayerRespawnSystem.cs @@ -45,7 +45,6 @@ namespace ProjectM.Server float3 center = haveAnchor ? BaseGridMath.PlotCenter(baseAnchor) : spawner.SpawnPoint; float ringRadius = haveSpawner ? spawner.SpawnRingRadius : 2f; int ringSlots = haveSpawner ? spawner.RingSlots : 4; - center = BaseGridMath.PlotCenter(baseAnchor); foreach (var (health, respawn, invuln, xform, region, owner, eff) in SystemAPI.Query, RefRW, RefRW, RefRW, diff --git a/Assets/_Project/Scripts/Server/Combat/RoomEnemyDirectorSystem.cs b/Assets/_Project/Scripts/Server/Combat/RoomEnemyDirectorSystem.cs index 016bf1755..0fcb1310a 100644 --- a/Assets/_Project/Scripts/Server/Combat/RoomEnemyDirectorSystem.cs +++ b/Assets/_Project/Scripts/Server/Combat/RoomEnemyDirectorSystem.cs @@ -123,7 +123,7 @@ namespace ProjectM.Server zs.SeededEpoch = run.RoomEpoch; zs.SpawnCounter = 0; 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) diff --git a/Assets/_Project/Scripts/Server/World/RunDirectorSystem.cs b/Assets/_Project/Scripts/Server/World/RunDirectorSystem.cs index dfd98eea3..21a61ca4c 100644 --- a/Assets/_Project/Scripts/Server/World/RunDirectorSystem.cs +++ b/Assets/_Project/Scripts/Server/World/RunDirectorSystem.cs @@ -139,6 +139,16 @@ namespace ProjectM.Server bool due = info.LaunchTick == 0u || !new NetworkTick(info.LaunchTick).IsNewerThan(serverTick); 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>().WithAll().WithEntityAccess()) + conscript.AddComponent(playerE); + conscript.Playback(state.EntityManager); + conscript.Dispose(); + // Enter room 0 (the guaranteed Combat landing at column 0, sub-slot 0). var map = RunMapMath.Generate(run.RunSeed); 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) // 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; - foreach (var offer in SystemAPI.Query>().WithAll()) - if (offer.ValueRO.Pending != 0) { anyPending = true; break; } + foreach (var (offer, pregion) in + SystemAPI.Query, RefRO>().WithAll()) + if (pregion.ValueRO.Region == RegionId.Expedition && offer.ValueRO.Pending != 0) + { anyPending = true; break; } bool graceElapsed = run.RewardGraceTick == 0u || !new NetworkTick(run.RewardGraceTick).IsNewerThan(serverTick); if (anyPending && !graceElapsed) break; 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>().WithAll()) + offer.ValueRW = default; if (run.LastTerminalCleared != 0) { info.Lifecycle = RunLifecycle.Returning; // boss cleared — go home a winner @@ -279,18 +298,25 @@ namespace ProjectM.Server 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; - foreach (var (region, xform) in - SystemAPI.Query, RefRW>().WithAll()) + var homebound = new EntityCommandBuffer(Allocator.Temp); + foreach (var (region, xform, playerE) in + SystemAPI.Query, RefRW>() + .WithAll().WithEntityAccess()) { region.ValueRW.Region = RegionId.Base; var p = baseCenter; p.x += 1.5f * idx; p.y = xform.ValueRO.Position.y; xform.ValueRW.Position = p; + homebound.RemoveComponent(playerE); idx++; } + homebound.Playback(state.EntityManager); + homebound.Dispose(); // THE terminal bank — once per RunEpoch (equality latch, F7), CLEAR-GATED (D-F3). if (run.LastBankedRunEpoch != run.RunEpoch) @@ -393,7 +419,7 @@ namespace ProjectM.Server float3 roomOrigin = RegionMath.ExpeditionRoomOrigin(baseCenter, run.ActiveSubSlot); int idx = 0; foreach (var (region, xform) in - SystemAPI.Query, RefRW>().WithAll()) + SystemAPI.Query, RefRW>().WithAll()) { region.ValueRW.Region = RegionId.Expedition; var p = roomOrigin; diff --git a/Assets/_Project/Scripts/Server/World/ThreatDirectorSystem.cs b/Assets/_Project/Scripts/Server/World/ThreatDirectorSystem.cs index 27b9484e3..09bc01e66 100644 --- a/Assets/_Project/Scripts/Server/World/ThreatDirectorSystem.cs +++ b/Assets/_Project/Scripts/Server/World/ThreatDirectorSystem.cs @@ -13,7 +13,7 @@ namespace ProjectM.Server /// hand-off is (this system sets it; CyclePhaseSystem consumes it). /// This slice wires ONE source — POST-EXPEDITION retaliation: a completed RUN (banked on the boss-clear /// return by into — 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 /// Husks after a /// 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 (): an diff --git a/Assets/_Project/Scripts/Simulation/Meta.meta b/Assets/_Project/Scripts/Simulation/Meta.meta index aba38dcff..1e7ff777b 100644 Binary files a/Assets/_Project/Scripts/Simulation/Meta.meta and b/Assets/_Project/Scripts/Simulation/Meta.meta differ diff --git a/Assets/_Project/Scripts/Simulation/Persistence/SaveData.cs b/Assets/_Project/Scripts/Simulation/Persistence/SaveData.cs index 41fd9a7ea..12c8f28ef 100644 --- a/Assets/_Project/Scripts/Simulation/Persistence/SaveData.cs +++ b/Assets/_Project/Scripts/Simulation/Persistence/SaveData.cs @@ -63,7 +63,7 @@ namespace ProjectM.Simulation [Serializable] 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 /// Oldest save schema the loader accepts (additive); a v2 save loads with structures at full HP. public const int MinLoadableVersion = 2; diff --git a/Assets/_Project/Scripts/Simulation/Persistence/SaveService.cs b/Assets/_Project/Scripts/Simulation/Persistence/SaveService.cs index 14aa86cd7..8c6141050 100644 --- a/Assets/_Project/Scripts/Simulation/Persistence/SaveService.cs +++ b/Assets/_Project/Scripts/Simulation/Persistence/SaveService.cs @@ -27,7 +27,7 @@ namespace ProjectM.Simulation // 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; data.Ledger ??= Array.Empty(); - data.MetaUpgrades ??= Array.Empty(); // v6: null on any v<=5 file(); + data.MetaUpgrades ??= Array.Empty(); // v6: null on any v<=5 file return data; } catch (Exception e) @@ -37,6 +37,22 @@ namespace ProjectM.Simulation } } + /// + /// 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. + /// + 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) { if (data == null) return; diff --git a/Assets/_Project/Scripts/Simulation/Tuning.cs b/Assets/_Project/Scripts/Simulation/Tuning.cs index d9eb069c2..766a9e8f0 100644 --- a/Assets/_Project/Scripts/Simulation/Tuning.cs +++ b/Assets/_Project/Scripts/Simulation/Tuning.cs @@ -90,6 +90,11 @@ namespace ProjectM.Simulation /// at spawn on the baked value, legible at a glance). public const float BossScaleMultiplier = 1.6f; + /// 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. + public const uint RoomEntryGraceTicks = 100; + // ---- Inventory (per-player bag; InventoryMath / ResourceHarvestSystem / InventoryDepositSystem) ---- /// Max stacks a player can carry; InventoryMath rejects deposits past this and the harvest remainder spills to the global ledger. diff --git a/Assets/_Project/Scripts/Simulation/World/RunParticipant.cs b/Assets/_Project/Scripts/Simulation/World/RunParticipant.cs new file mode 100644 index 000000000..7953ac585 --- /dev/null +++ b/Assets/_Project/Scripts/Simulation/World/RunParticipant.cs @@ -0,0 +1,16 @@ +using Unity.Entities; + +namespace ProjectM.Simulation +{ + /// + /// 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). + /// + public struct RunParticipant : IComponentData + { + } +} diff --git a/Assets/_Project/Scripts/Simulation/World/RunParticipant.cs.meta b/Assets/_Project/Scripts/Simulation/World/RunParticipant.cs.meta new file mode 100644 index 000000000..69953e4dd --- /dev/null +++ b/Assets/_Project/Scripts/Simulation/World/RunParticipant.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 92795e88279d04e42af70f8ec1032cc7 \ No newline at end of file diff --git a/Assets/_Project/Tests/EditMode/OnboardingStepTests.cs b/Assets/_Project/Tests/EditMode/OnboardingStepTests.cs index 288a8e062..62ee7418e 100644 --- a/Assets/_Project/Tests/EditMode/OnboardingStepTests.cs +++ b/Assets/_Project/Tests/EditMode/OnboardingStepTests.cs @@ -23,8 +23,8 @@ namespace ProjectM.Tests [Test] public void FirstIncomplete_SkipsCompletedPrefix() { - int mask = (1 << OnboardingStepMath.Welcome) | (1 << OnboardingStepMath.Move) | (1 << OnboardingStepMath.Mine); - Assert.AreEqual(OnboardingStepMath.Build, OnboardingStepMath.FirstIncomplete(mask)); + int mask = (1 << OnboardingStepMath.Welcome) | (1 << OnboardingStepMath.Move) | (1 << OnboardingStepMath.Build); + Assert.AreEqual(OnboardingStepMath.Fabricator, OnboardingStepMath.FirstIncomplete(mask)); } [Test] @@ -57,12 +57,14 @@ namespace ProjectM.Tests } [Test] - public void Mine_AdvancesOnlyWhenLedgerOreRises() + public void ReadyUp_AdvancesOnLocalReadyOrLaunch() { - var s = Empty(); s.OreBaseline = 50; s.OreNow = 50; // starts at the seeded baseline - Assert.IsFalse(OnboardingStepMath.IsSatisfied(OnboardingStepMath.Mine, s)); - s.OreNow = 51; - Assert.IsTrue(OnboardingStepMath.IsSatisfied(OnboardingStepMath.Mine, s)); + var s = Empty(); s.Lifecycle = RunLifecycle.Staging; + Assert.IsFalse(OnboardingStepMath.IsSatisfied(OnboardingStepMath.ReadyUp, s)); + var ready = Empty(); ready.LocalReady = true; + Assert.IsTrue(OnboardingStepMath.IsSatisfied(OnboardingStepMath.ReadyUp, ready)); + var launched = Empty(); launched.Lifecycle = RunLifecycle.Launching; + Assert.IsTrue(OnboardingStepMath.IsSatisfied(OnboardingStepMath.ReadyUp, launched)); } [Test] @@ -86,23 +88,27 @@ namespace ProjectM.Tests } [Test] - public void Gate_AdvancesOnExpeditionEntryOrActiveObjective() + public void Rooms_AdvancesOnExpeditionEntryOrActiveObjective() { var s = Empty(); - Assert.IsFalse(OnboardingStepMath.IsSatisfied(OnboardingStepMath.Gate, s)); + Assert.IsFalse(OnboardingStepMath.IsSatisfied(OnboardingStepMath.Rooms, s)); 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; - Assert.IsTrue(OnboardingStepMath.IsSatisfied(OnboardingStepMath.Gate, active)); + Assert.IsTrue(OnboardingStepMath.IsSatisfied(OnboardingStepMath.Rooms, active)); } [Test] - public void Clear_AdvancesOnClearedObjective() + public void Boon_AdvancesOnClearedObjectiveOrRewardLifecycle() { 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; - 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] @@ -157,11 +163,11 @@ namespace ProjectM.Tests // ---- pointer kinds ---- [Test] - public void PointerKinds_MatchSpatialStepsOnly() + public void PointerKinds_OnlyTheRoomsStepPoints() { - Assert.AreEqual(OnboardingStepMath.PointerOreNode, OnboardingStepMath.PointerKind(OnboardingStepMath.Mine)); - Assert.AreEqual(OnboardingStepMath.PointerBaseGate, OnboardingStepMath.PointerKind(OnboardingStepMath.Gate)); - Assert.AreEqual(OnboardingStepMath.PointerExpeditionGate, OnboardingStepMath.PointerKind(OnboardingStepMath.Return)); + Assert.AreEqual(OnboardingStepMath.PointerOreNode, OnboardingStepMath.PointerKind(OnboardingStepMath.Rooms)); + Assert.AreEqual(OnboardingStepMath.PointerNone, OnboardingStepMath.PointerKind(OnboardingStepMath.ReadyUp)); + Assert.AreEqual(OnboardingStepMath.PointerNone, OnboardingStepMath.PointerKind(OnboardingStepMath.Return)); Assert.AreEqual(OnboardingStepMath.PointerNone, OnboardingStepMath.PointerKind(OnboardingStepMath.Move)); Assert.AreEqual(OnboardingStepMath.PointerNone, OnboardingStepMath.PointerKind(OnboardingStepMath.Defend)); } diff --git a/Assets/_Project/Tests/EditMode/RoomEnemyDirectorSystemTests.cs b/Assets/_Project/Tests/EditMode/RoomEnemyDirectorSystemTests.cs index b59a095d0..d40328fcd 100644 --- a/Assets/_Project/Tests/EditMode/RoomEnemyDirectorSystemTests.cs +++ b/Assets/_Project/Tests/EditMode/RoomEnemyDirectorSystemTests.cs @@ -82,6 +82,12 @@ namespace ProjectM.Tests 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] 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); 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(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(zoneDir); 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 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(zoneDir); Assert.AreEqual(0, zs.RemainingToSpawn, "a boss room is a single-slot wave, fully spawned"); diff --git a/Assets/_Project/Tests/EditMode/RunDirectorTraversalTests.cs b/Assets/_Project/Tests/EditMode/RunDirectorTraversalTests.cs index 6ba2f886c..dc34a8097 100644 --- a/Assets/_Project/Tests/EditMode/RunDirectorTraversalTests.cs +++ b/Assets/_Project/Tests/EditMode/RunDirectorTraversalTests.cs @@ -54,7 +54,9 @@ namespace ProjectM.Tests }); 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, LocalTransform.Identity); return (world, group, dir, player); diff --git a/Assets/_Project/Tests/EditMode/RunRosterRegressionTests.cs b/Assets/_Project/Tests/EditMode/RunRosterRegressionTests.cs new file mode 100644 index 000000000..753e96b11 --- /dev/null +++ b/Assets/_Project/Tests/EditMode/RunRosterRegressionTests.cs @@ -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 +{ + /// + /// Regression pins for the two post-impl-review fixes on : + /// (1) a dead-respawned (base-region) player's stale 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 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). + /// + 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(); + group.AddSystemToUpdateList(world.GetOrCreateSystem()); + 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(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(deadAtBase).Pending, + "the stale offer is stripped on the gate exit"); + Assert.AreEqual(0, em.GetComponentData(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(dir); + Assert.AreEqual(RunLifecycle.InRoom, info.Lifecycle, "the committed pick advances the run"); + Assert.GreaterOrEqual(em.GetComponentData(inRoom).Position.x, 1000f, + "the in-room participant rides the advance"); + Assert.GreaterOrEqual(em.GetComponentData(deadAtBase).Position.x, 1000f, + "a dead-respawned participant is re-conscripted onto the new room"); + Assert.AreEqual(0f, em.GetComponentData(lateJoiner).Position.x, + "a non-participant late joiner is never yanked into the fight"); + Assert.AreEqual(RegionId.Base, em.GetComponentData(lateJoiner).Region, + "the late joiner stays in the Base relevancy bucket"); + + world.Dispose(); + } + } +} diff --git a/Assets/_Project/Tests/EditMode/RunRosterRegressionTests.cs.meta b/Assets/_Project/Tests/EditMode/RunRosterRegressionTests.cs.meta new file mode 100644 index 000000000..c7b67d27e --- /dev/null +++ b/Assets/_Project/Tests/EditMode/RunRosterRegressionTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 31b988b0527c7c247ad1976aab05d470 \ No newline at end of file diff --git a/Assets/_Project/Tests/EditMode/TerminalCampaignRollTests.cs b/Assets/_Project/Tests/EditMode/TerminalCampaignRollTests.cs new file mode 100644 index 000000000..c6fab40c8 --- /dev/null +++ b/Assets/_Project/Tests/EditMode/TerminalCampaignRollTests.cs @@ -0,0 +1,50 @@ +using NUnit.Framework; +using ProjectM.Simulation; + +namespace ProjectM.Tests +{ + /// + /// Pins : 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. + /// + 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)); + } + } +} diff --git a/Assets/_Project/Tests/EditMode/TerminalCampaignRollTests.cs.meta b/Assets/_Project/Tests/EditMode/TerminalCampaignRollTests.cs.meta new file mode 100644 index 000000000..23bf06ce2 --- /dev/null +++ b/Assets/_Project/Tests/EditMode/TerminalCampaignRollTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 085790610f52ea347aa4ccd208849316 \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md index 461a40d92..022246b67 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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`. - **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. -- Condensation history: 06-04 → 06-17 (M1–END-2 long-form + 6.5 stack swap → archive). +- Condensation history: 06-04 → 06-17 (M1–END-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 @@ -26,7 +26,7 @@ Multiplayer game on **Unity DOTS (Entities) + Netcode for Entities** — server- | `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]]. | -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 @@ -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. - **`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`, 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. -- **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` (NEVER `GetSingleton`): 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-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`). - **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]]. ### 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. ### 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`). -- **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. +- **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. ### 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). @@ -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]]. - **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) @@ -179,4 +177,4 @@ Full protocol + per-layer detail: [[Documentation_Protocol]] (`Docs/Vault/_Meta/ ## 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 "/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 "/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`). diff --git a/Docs/Vault/07_Sessions/2026/2026-07-04_Demo_Polish_Pass.md b/Docs/Vault/07_Sessions/2026/2026-07-04_Demo_Polish_Pass.md new file mode 100644 index 000000000..c63bc38e8 --- /dev/null +++ b/Docs/Vault/07_Sessions/2026/2026-07-04_Demo_Polish_Pass.md @@ -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 1–11" 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. diff --git a/Docs/Vault/07_Sessions/_Decisions/DR-044_Expedition_Redesign_Shipped_Demo_Polish.md b/Docs/Vault/07_Sessions/_Decisions/DR-044_Expedition_Redesign_Shipped_Demo_Polish.md new file mode 100644 index 000000000..bc8f07098 --- /dev/null +++ b/Docs/Vault/07_Sessions/_Decisions/DR-044_Expedition_Redesign_Shipped_Demo_Polish.md @@ -0,0 +1,91 @@ +--- +id: DR-044 +title: Expedition Redesign SHIPPED (steps 1–14) + 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]] (§§1–6 + §7 addendum) are built and live in +commit `16e396841` "Run Re-Do" — the 2026-07-02 interim log stopped at "steps 1–11" 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; Am–F–C–G @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). diff --git a/Docs/Vault/_Meta/CLAUDE_Build_Gotchas_Archive.md b/Docs/Vault/_Meta/CLAUDE_Build_Gotchas_Archive.md index 6e186739e..f5c3021fb 100644 --- a/Docs/Vault/_Meta/CLAUDE_Build_Gotchas_Archive.md +++ b/Docs/Vault/_Meta/CLAUDE_Build_Gotchas_Archive.md @@ -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). 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. diff --git a/Packages/manifest.json b/Packages/manifest.json index 282c7a006..99d7e71e0 100644 --- a/Packages/manifest.json +++ b/Packages/manifest.json @@ -1,7 +1,6 @@ { "dependencies": { "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.navigation": "2.0.13", "com.unity.charactercontroller": "1.4.2", diff --git a/Packages/packages-lock.json b/Packages/packages-lock.json index 829d4da53..4afc3a885 100644 --- a/Packages/packages-lock.json +++ b/Packages/packages-lock.json @@ -39,26 +39,6 @@ "source": "embedded", "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": { "version": "2.6.1", "depth": 0, diff --git a/ProjectSettings/EditorBuildSettings.asset b/ProjectSettings/EditorBuildSettings.asset index 9557f219a..d71e17461 100644 --- a/ProjectSettings/EditorBuildSettings.asset +++ b/ProjectSettings/EditorBuildSettings.asset @@ -11,12 +11,6 @@ EditorBuildSettings: - enabled: 1 path: Assets/Scenes/Game.unity guid: db8738f16e802c8488f9c22aa56e1e60 - - enabled: 1 - path: Assets/Scenes/DevSandbox.unity - guid: 1d4b5e92ac7b6ea4880d30545df94704 - - enabled: 1 - path: Assets/Scenes/SampleScene.unity - guid: 99c9720ab356a0642a771bea13969a05 m_configObjects: com.unity.dt.app-ui: {fileID: 11400000, guid: 1b1c20d82303e4b5781c3ef50ac1449f, type: 2} com.unity.input.settings.actions: {fileID: -944628639613478452, guid: 5aa62f6ed584c43b791e76f2fd31820f, type: 3} diff --git a/ProjectSettings/GraphicsSettings.asset b/ProjectSettings/GraphicsSettings.asset index 84b2182fe..634b7e577 100644 --- a/ProjectSettings/GraphicsSettings.asset +++ b/ProjectSettings/GraphicsSettings.asset @@ -36,6 +36,15 @@ GraphicsSettings: - {fileID: 10783, guid: 0000000000000000f000000000000000, type: 0} m_PreloadedShaders: [] 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_CustomRenderPipeline: {fileID: 11400000, guid: 4b83569d67af61e458304325a23e5dfd, type: 2} m_TransparencySortMode: 0 @@ -61,6 +70,8 @@ GraphicsSettings: UnityEngine.Rendering.Universal.UniversalRenderPipeline: {fileID: 11400000, guid: 18dc0cd2c080841dea60987a38ce93fa, type: 2} m_ShaderBuildSettings: keywordDeclarationOverrides: [] + numInternalDefines: 0 + defines: [] m_LightsUseLinearIntensity: 1 m_LightsUseColorTemperature: 1 m_LogWhenShaderIsCompiled: 0 diff --git a/ProjectSettings/ProjectSettings.asset b/ProjectSettings/ProjectSettings.asset index bfda586a9..8cabfd754 100644 --- a/ProjectSettings/ProjectSettings.asset +++ b/ProjectSettings/ProjectSettings.asset @@ -149,8 +149,7 @@ PlayerSettings: visionOSBundleVersion: 1.0 tvOSBundleVersion: 1.0 bundleVersion: 0.1.0 - preloadedAssets: - - {fileID: 11400000, guid: 44cd3062bcdc07e43aaec59b78f224a6, type: 2} + preloadedAssets: [] metroInputSource: 0 wsaTransparentSwapchain: 0 xboxOneDisableKinectGpuReservation: 1