From 03bfb16cf32ea9b3035c79d88b0919c938952bfa Mon Sep 17 00:00:00 2001 From: Gabriel Kaszewski Date: Thu, 14 May 2026 01:40:56 +0200 Subject: [PATCH] chore: remove stale preloaded asset ref, add refactor plan --- ProjectSettings/ProjectSettings.asset | 3 +- .../plans/2026-05-14-architecture-refactor.md | 1327 +++++++++++++++++ 2 files changed, 1328 insertions(+), 2 deletions(-) create mode 100644 docs/superpowers/plans/2026-05-14-architecture-refactor.md diff --git a/ProjectSettings/ProjectSettings.asset b/ProjectSettings/ProjectSettings.asset index bdcb530..16ee30e 100644 --- a/ProjectSettings/ProjectSettings.asset +++ b/ProjectSettings/ProjectSettings.asset @@ -144,8 +144,7 @@ PlayerSettings: visionOSBundleVersion: 1.0 tvOSBundleVersion: 1.0 bundleVersion: 1.0 - preloadedAssets: - - {fileID: -944628639613478452, guid: 052faaac586de48259a63d0c4782560b, type: 3} + preloadedAssets: [] metroInputSource: 0 wsaTransparentSwapchain: 0 m_HolographicPauseOnTrackingLoss: 1 diff --git a/docs/superpowers/plans/2026-05-14-architecture-refactor.md b/docs/superpowers/plans/2026-05-14-architecture-refactor.md new file mode 100644 index 0000000..7fb3b4a --- /dev/null +++ b/docs/superpowers/plans/2026-05-14-architecture-refactor.md @@ -0,0 +1,1327 @@ +# Decay Grid Architecture Refactor Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Fix 6 concrete architectural problems in order of dependency — effect stacking, interface coupling, parallel collections, hardcoded floors, split power-up logic, and a god object — each phase leaving the game in a playable, compiling state. + +**Architecture:** Ports & Adapters is already the structure; we're deepening it. Domain stays pure C#, Infrastructure stays Unity-aware. No new patterns introduced — just existing boundaries enforced properly. + +**Tech Stack:** C# 10, Unity 6, Unity Test Runner (EditMode for domain logic), LeanTween, New Input System + +--- + +## Phase 1 — Test Infrastructure + +**Files:** +- Create: `Assets/Tests/EditMode/DecayGrid.Tests.EditMode.asmdef` +- Create: `Assets/Tests/EditMode/StatusManagerTests.cs` + +- [ ] **Step 1: Create test directory** + +```bash +mkdir -p "/mnt/drive/dev/Decay Grid/Assets/Tests/EditMode" +``` + +- [ ] **Step 2: Create assembly definition** + +Create `Assets/Tests/EditMode/DecayGrid.Tests.EditMode.asmdef`: + +```json +{ + "name": "DecayGrid.Tests.EditMode", + "rootNamespace": "DecayGrid.Tests", + "references": [ + "UnityEngine.TestRunner", + "UnityEditor.TestRunner" + ], + "includePlatforms": [ + "Editor" + ], + "excludePlatforms": [], + "allowUnsafeCode": false, + "overrideReferences": true, + "precompiledReferences": [ + "nunit.framework.dll" + ], + "autoReferenced": false, + "defineConstraints": [], + "versionDefines": [], + "noEngineReferences": false +} +``` + +- [ ] **Step 3: Write a smoke test** + +Create `Assets/Tests/EditMode/StatusManagerTests.cs`: + +```csharp +using NUnit.Framework; +using Core.Domain.Status; + +namespace DecayGrid.Tests +{ + public class StatusManagerTests + { + [Test] + public void DefaultCapabilities_HaveExpectedValues() + { + var sm = new StatusManager(); + var caps = sm.CurrentCapabilities; + Assert.IsTrue(caps.CanTriggerDecay); + Assert.AreEqual(1f, caps.SpeedMultiplier, 0.001f); + Assert.IsFalse(caps.CanHover); + } + } +} +``` + +- [ ] **Step 4: Run tests in Unity Editor** + +Open Unity → Window → General → Test Runner → EditMode → Run All + +Expected: 1 test passes. + +- [ ] **Step 5: Commit** + +```bash +cd "/mnt/drive/dev/Decay Grid" +git add Assets/Tests/ +git commit -m "test: add EditMode test assembly" +``` + +--- + +## Phase 2 — Fix Status Effect Stacking (#6) + +**Problem:** `LightFootedEffect.ModifyCapabilities` sets `caps.SpeedMultiplier = 1.2f` and `SpeedBoostEffect.ModifyCapabilities` sets `caps.SpeedMultiplier = _multiplier`. The second applied overwrites the first — last effect wins instead of stacking. + +**Files:** +- Modify: `Assets/Scripts/Core/Domain/Status/Effects/LightFootedEffect.cs:21` +- Modify: `Assets/Scripts/Core/Domain/Status/Effects/SpeedBoostEffect.cs:15` +- Modify: `Assets/Tests/EditMode/StatusManagerTests.cs` + +- [ ] **Step 1: Write failing stacking tests** + +Add to `Assets/Tests/EditMode/StatusManagerTests.cs`: + +```csharp +using Core.Domain.Status.Effects; + +// (add inside StatusManagerTests class) + +[Test] +public void SpeedBoostAlone_AppliesMultiplier() +{ + var sm = new StatusManager(); + sm.AddEffect(new SpeedBoostEffect(10f, 1.5f)); + Assert.AreEqual(1.5f, sm.CurrentCapabilities.SpeedMultiplier, 0.001f); +} + +[Test] +public void LightFootedAlone_AppliesMultiplier() +{ + var sm = new StatusManager(); + sm.AddEffect(new LightFootedEffect(10f)); + Assert.AreEqual(1.2f, sm.CurrentCapabilities.SpeedMultiplier, 0.001f); +} + +[Test] +public void TwoSpeedEffectsStack_Multiplicatively() +{ + var sm = new StatusManager(); + sm.AddEffect(new LightFootedEffect(10f)); // *1.2 + sm.AddEffect(new SpeedBoostEffect(10f, 1.5f)); // *1.5 + Assert.AreEqual(1.8f, sm.CurrentCapabilities.SpeedMultiplier, 0.001f); // 1.0 * 1.2 * 1.5 +} +``` + +- [ ] **Step 2: Run tests — confirm TwoSpeedEffectsStack fails** + +Expected: `TwoSpeedEffectsStack_Multiplicatively` FAILS (actual: 1.5f, expected: 1.8f). + +- [ ] **Step 3: Fix LightFootedEffect** + +In `Assets/Scripts/Core/Domain/Status/Effects/LightFootedEffect.cs`, line 21: + +```csharp +// Before: +caps.SpeedMultiplier = 1.2f; + +// After: +caps.SpeedMultiplier *= 1.2f; +``` + +- [ ] **Step 4: Fix SpeedBoostEffect** + +In `Assets/Scripts/Core/Domain/Status/Effects/SpeedBoostEffect.cs`, line 15: + +```csharp +// Before: +caps.SpeedMultiplier = _multiplier; + +// After: +caps.SpeedMultiplier *= _multiplier; +``` + +- [ ] **Step 5: Run tests — all pass** + +Expected: 4 tests pass. + +- [ ] **Step 6: Commit** + +```bash +git add Assets/Scripts/Core/Domain/Status/Effects/LightFootedEffect.cs \ + Assets/Scripts/Core/Domain/Status/Effects/SpeedBoostEffect.cs \ + Assets/Tests/EditMode/StatusManagerTests.cs +git commit -m "fix: speed effects stack multiplicatively" +``` + +--- + +## Phase 3 — Fix Backwards Coupling: Controllers Must Not Know TileViewAdapter (#3) + +**Problem:** `PlayerController.InteractWithGround()`, `NpcController.FixedUpdate()`, and `HunterNpcController.FixedUpdate()` all do `TryGetComponent()` and call `tile.OnPlayerStep()`. The UI adapter should not be in the call chain from physics controllers. The `Core.Ports.ITileView` exists exactly for this — it just needs `StepOn()` added to it. + +**Files:** +- Modify: `Assets/Scripts/Core/Ports/ITileView.cs` +- Modify: `Assets/Scripts/Infrastructure/Unity/TileViewAdapter.cs:109` +- Modify: `Assets/Scripts/Infrastructure/Unity/PlayerController.cs:243-252` +- Modify: `Assets/Scripts/Infrastructure/Unity/NpcController.cs:50-56` +- Modify: `Assets/Scripts/Infrastructure/Unity/HunterNpcController.cs:38-44` + +- [ ] **Step 1: Add StepOn() to ITileView** + +Full replacement of `Assets/Scripts/Core/Ports/ITileView.cs`: + +```csharp +using Core.Domain; + +namespace Core.Ports +{ + public interface ITileView + { + string TileId { get; } + void Initialize(Tile tile); + void SetVisualState(TileState state); + void DropPhysics(); + void Dispose(); + void StepOn(); + } +} +``` + +- [ ] **Step 2: Confirm compile error** + +Unity will show: `TileViewAdapter does not implement interface member 'ITileView.StepOn()'` + +- [ ] **Step 3: Rename OnPlayerStep to StepOn in TileViewAdapter** + +In `Assets/Scripts/Infrastructure/Unity/TileViewAdapter.cs`, line 109: + +```csharp +// Before: +public void OnPlayerStep() +{ + _linkedTile?.StepOn(); +} + +// After: +public void StepOn() +{ + _linkedTile?.StepOn(); +} +``` + +- [ ] **Step 4: Update PlayerController** + +In `Assets/Scripts/Infrastructure/Unity/PlayerController.cs`: + +Add `using Core.Ports;` with the other usings at the top. + +Replace `InteractWithGround()` (lines 243–252): + +```csharp +private void InteractWithGround() +{ + if (Physics.SphereCast(transform.position, 0.3f, Vector3.down, out var hit, groundCheckDistance, tileLayer)) + { + if (hit.collider.TryGetComponent(out var tileView)) + { + tileView.StepOn(); + } + } +} +``` + +- [ ] **Step 5: Update NpcController** + +In `Assets/Scripts/Infrastructure/Unity/NpcController.cs`: + +Add `using Core.Ports;` at the top. + +In `FixedUpdate()`, replace the raycast block (lines 50–56): + +```csharp +if (Physics.Raycast(transform.position, Vector3.down, out var hit, 2.0f, tileLayer)) +{ + if (hit.collider.TryGetComponent(out var tileView)) + { + tileView.StepOn(); + } +} +``` + +- [ ] **Step 6: Update HunterNpcController** + +In `Assets/Scripts/Infrastructure/Unity/HunterNpcController.cs`: + +Add `using Core.Ports;` at the top. + +In `FixedUpdate()`, replace the raycast block (lines 38–44): + +```csharp +if (Physics.Raycast(transform.position, Vector3.down, out var hit, 2f, tileLayer)) +{ + if (hit.collider.TryGetComponent(out var tileView)) + { + tileView.StepOn(); + } +} +``` + +- [ ] **Step 7: Play test — tiles decay when walked on** + +Play in Unity Editor. Walk the player over tiles and verify they go Warning → Falling → Destroyed. NPCs should also cause decay. + +- [ ] **Step 8: Commit** + +```bash +git add Assets/Scripts/Core/Ports/ITileView.cs \ + Assets/Scripts/Infrastructure/Unity/TileViewAdapter.cs \ + Assets/Scripts/Infrastructure/Unity/PlayerController.cs \ + Assets/Scripts/Infrastructure/Unity/NpcController.cs \ + Assets/Scripts/Infrastructure/Unity/HunterNpcController.cs +git commit -m "refactor: controllers use ITileView port, not TileViewAdapter directly" +``` + +--- + +## Phase 4 — Introduce TileRegistry: Replace Parallel Collections (#5) + +**Problem:** `GameBootstrap` maintains `_allTiles: List` and `_tileViews: Dictionary` in lockstep. Both are passed into `LevelGenerator.GenerateAsync()` as separate parameters. `FloorVisibilityManager` also receives both. Any lookup requires consulting both manually. + +**Files:** +- Create: `Assets/Scripts/Infrastructure/Unity/TileRegistry.cs` +- Create: `Assets/Tests/EditMode/TileRegistryTests.cs` +- Modify: `Assets/Scripts/Core/Domain/GameSession.cs` +- Modify: `Assets/Scripts/Infrastructure/Unity/LevelGenerator.cs` +- Modify: `Assets/Scripts/Infrastructure/Unity/GameBootstrap.cs` +- Modify: `Assets/Scripts/Infrastructure/Unity/FloorVisibilityManager.cs` + +- [ ] **Step 1: Write failing TileRegistry tests** + +Create `Assets/Tests/EditMode/TileRegistryTests.cs`: + +```csharp +using NUnit.Framework; +using Core.Domain; +using Infrastructure.Unity; + +namespace DecayGrid.Tests +{ + public class TileRegistryTests + { + private TileRegistry _registry; + + [SetUp] + public void SetUp() => _registry = new TileRegistry(); + + [Test] + public void Register_AddsToAllTiles() + { + var tile = new Tile("0_0_0", 0, 0.5f, 2f); + _registry.Register(tile); + Assert.AreEqual(1, _registry.AllTiles.Count); + } + + [Test] + public void FindTiles_ReturnsMatchingTiles() + { + var tile = new Tile("0_0_0", 0, 0.5f, 2f); + _registry.Register(tile); + var result = _registry.FindTiles(t => t.CurrentState == TileState.Stable); + Assert.AreEqual(1, result.Count); + } + + [Test] + public void FindTiles_ExcludesNonMatchingTiles() + { + var tile = new Tile("0_0_0", 0, 0.5f, 2f); + tile.StepOn(); // Now Warning + _registry.Register(tile); + var result = _registry.FindTiles(t => t.CurrentState == TileState.Stable); + Assert.AreEqual(0, result.Count); + } + + [Test] + public void TryGetView_ReturnsFalse_WhenNoViewRegistered() + { + var tile = new Tile("0_0_0", 0, 0.5f, 2f); + _registry.Register(tile); // no view + Assert.IsFalse(_registry.TryGetView("0_0_0", out _)); + } + } +} +``` + +- [ ] **Step 2: Run tests — fail with compile error (TileRegistry missing)** + +- [ ] **Step 3: Create TileRegistry** + +Create `Assets/Scripts/Infrastructure/Unity/TileRegistry.cs`: + +```csharp +using System; +using System.Collections.Generic; +using Core.Domain; + +namespace Infrastructure.Unity +{ + public class TileRegistry + { + private readonly List _tiles = new(); + private readonly Dictionary _views = new(); + + public IReadOnlyList AllTiles => _tiles; + + public void Register(Tile tile, TileViewAdapter view = null) + { + _tiles.Add(tile); + if (view != null) _views[tile.Id] = view; + } + + public bool TryGetView(string tileId, out TileViewAdapter view) + => _views.TryGetValue(tileId, out view); + + public List FindTiles(Predicate predicate) + => _tiles.FindAll(predicate); + + public List> GroupViewsByFloor(int floorCount) + { + var floors = new List>(floorCount); + for (var i = 0; i < floorCount; i++) floors.Add(new List()); + + foreach (var tile in _tiles) + { + if (tile.Floor < floorCount && _views.TryGetValue(tile.Id, out var view)) + floors[tile.Floor].Add(view); + } + + return floors; + } + } +} +``` + +- [ ] **Step 4: Run tests — all 4 pass** + +- [ ] **Step 5: Update GameSession to accept IReadOnlyList\** + +In `Assets/Scripts/Core/Domain/GameSession.cs`: + +Add `using System.Linq;` at the top. + +Change the field and constructor: + +```csharp +// Before: +private readonly List _tiles; +public GameSession(List tiles, IPersistenceService persistenceService) +{ + _tiles = tiles; + +// After: +private readonly IReadOnlyList _tiles; +public GameSession(IReadOnlyList tiles, IPersistenceService persistenceService) +{ + _tiles = tiles; +``` + +Replace both `FindAll` calls with LINQ. In `SpawnNextOrb()`: + +```csharp +// Before: +var validTiles = _tiles.FindAll(t => + t.CurrentState == TileState.Stable && + t.Floor == _playerFloorIndex +); +if (validTiles.Count == 0) + validTiles = _tiles.FindAll(t => t.CurrentState == TileState.Stable); + +// After: +var validTiles = _tiles.Where(t => + t.CurrentState == TileState.Stable && t.Floor == _playerFloorIndex).ToList(); +if (validTiles.Count == 0) + validTiles = _tiles.Where(t => t.CurrentState == TileState.Stable).ToList(); +``` + +In `SpawnRandomPowerUp()`: + +```csharp +// Before: +var validTiles = _tiles.FindAll(t => + t.CurrentState == TileState.Stable && + t.Floor == _playerFloorIndex +); +if (validTiles.Count == 0) + validTiles = _tiles.FindAll(t => t.CurrentState == TileState.Stable); + +// After: +var validTiles = _tiles.Where(t => + t.CurrentState == TileState.Stable && t.Floor == _playerFloorIndex).ToList(); +if (validTiles.Count == 0) + validTiles = _tiles.Where(t => t.CurrentState == TileState.Stable).ToList(); +``` + +- [ ] **Step 6: Update LevelGenerator.GenerateAsync signature** + +In `Assets/Scripts/Infrastructure/Unity/LevelGenerator.cs`: + +Change `GenerateAsync` to accept `TileRegistry` instead of the two separate collections: + +```csharp +// Before: +public IEnumerator GenerateAsync(SoundManager soundManager, List allTiles, + Dictionary tileViews, CameraController camera, + RumbleManager rumble, Action onComplete) +{ + _tilePool = new TilePool(tilePrefab, transform); + var stopwatch = new Stopwatch(); + stopwatch.Start(); + + yield return GenerateFloorAsync(0, MapPatterns.GenerateSquare(gridSizeX, gridSizeY), + soundManager, allTiles, tileViews, camera, rumble, stopwatch); + yield return GenerateFloorAsync(1, MapPatterns.GenerateDonut(gridSizeX, Mathf.FloorToInt(gridSizeX / 3f)), + soundManager, allTiles, tileViews, camera, rumble, stopwatch); + yield return GenerateFloorAsync(2, MapPatterns.GenerateCircle(gridSizeX), + soundManager, allTiles, tileViews, camera, rumble, stopwatch); + + stopwatch?.Stop(); + onComplete?.Invoke(); +} + +// After: +public IEnumerator GenerateAsync(SoundManager soundManager, TileRegistry registry, + CameraController camera, RumbleManager rumble, Action onComplete) +{ + _tilePool = new TilePool(tilePrefab, transform); + var stopwatch = new Stopwatch(); + stopwatch.Start(); + + yield return GenerateFloorAsync(0, MapPatterns.GenerateSquare(gridSizeX, gridSizeY), + soundManager, registry, camera, rumble, stopwatch); + yield return GenerateFloorAsync(1, MapPatterns.GenerateDonut(gridSizeX, Mathf.FloorToInt(gridSizeX / 3f)), + soundManager, registry, camera, rumble, stopwatch); + yield return GenerateFloorAsync(2, MapPatterns.GenerateCircle(gridSizeX), + soundManager, registry, camera, rumble, stopwatch); + + stopwatch.Stop(); + onComplete?.Invoke(); +} +``` + +Update `GenerateFloorAsync` signature (propagate `registry` down): + +```csharp +// Before: +private IEnumerator GenerateFloorAsync(int floorIndex, List coordinates, + SoundManager soundManager, List allTiles, Dictionary tileViews, + CameraController camera, RumbleManager rumble, Stopwatch stopwatch) + +// After: +private IEnumerator GenerateFloorAsync(int floorIndex, List coordinates, + SoundManager soundManager, TileRegistry registry, + CameraController camera, RumbleManager rumble, Stopwatch stopwatch) +``` + +Update the `CreateTile` call inside `GenerateFloorAsync`: + +```csharp +// Before: +CreateTile(pos, $"{floorIndex}_{coord.x}_{coord.y}", floorIndex, + soundManager, allTiles, tileViews, camera, rumble); + +// After: +CreateTile(pos, $"{floorIndex}_{coord.x}_{coord.y}", floorIndex, + soundManager, registry, camera, rumble); +``` + +Update `CreateTile` signature and its final two lines: + +```csharp +// Before: +private void CreateTile(Vector3 position, string id, int floorIndex, SoundManager soundManager, + List allTiles, Dictionary tileViews, + CameraController camera, RumbleManager rumble) +{ + // ... + allTiles.Add(tileLogic); + tileViews.Add(id, go); +} + +// After: +private void CreateTile(Vector3 position, string id, int floorIndex, SoundManager soundManager, + TileRegistry registry, CameraController camera, RumbleManager rumble) +{ + // ... + registry.Register(tileLogic, go); +} +``` + +- [ ] **Step 7: Update GameBootstrap to use TileRegistry** + +In `Assets/Scripts/Infrastructure/Unity/GameBootstrap.cs`: + +Remove: +```csharp +private readonly List _allTiles = new(); +private readonly Dictionary _tileViews = new(); +``` + +Add: +```csharp +private readonly TileRegistry _tileRegistry = new(); +``` + +In `Start()`, update the GameSession constructor call and GenerateAsync call: + +```csharp +// Before: +_gameSession = new GameSession(_allTiles, _persistenceService); +// ... +StartCoroutine(levelGenerator.GenerateAsync(soundManager, _allTiles, _tileViews, + cameraController, rumbleManager, () => + { + floorVisibilityManager.Initialize(_gameSession, _allTiles, _tileViews, floorsCount); + // ... + })); + +// After: +_gameSession = new GameSession(_tileRegistry.AllTiles, _persistenceService); +// ... +StartCoroutine(levelGenerator.GenerateAsync(soundManager, _tileRegistry, + cameraController, rumbleManager, () => + { + floorVisibilityManager.Initialize(_tileRegistry, floorsCount); + // ... + })); +``` + +Update `SpawnVisualOrb(string tileId)`: + +```csharp +// Before: +if (!_tileViews.TryGetValue(tileId, out var tileView)) return; + +// After: +if (!_tileRegistry.TryGetView(tileId, out var tileView)) return; +``` + +Update `SpawnNpc()`: + +```csharp +// Before: +var validTiles = _allTiles.FindAll(t => t.Floor == 0 && t.CurrentState == TileState.Stable); +if (validTiles.Count == 0) + validTiles = _allTiles.FindAll(t => t.CurrentState == TileState.Stable); +// ... +if (!_tileViews.TryGetValue(randomTile.Id, out var tileView)) return; + +// After: +var validTiles = _tileRegistry.FindTiles(t => t.Floor == 0 && t.CurrentState == TileState.Stable); +if (validTiles.Count == 0) + validTiles = _tileRegistry.FindTiles(t => t.CurrentState == TileState.Stable); +// ... +if (!_tileRegistry.TryGetView(randomTile.Id, out var tileView)) return; +``` + +Update `SpawnPowerUp(PowerUpType type, string tileId)`: + +```csharp +// Before: +if (!_tileViews.TryGetValue(tileId, out var tileView)) return; + +// After: +if (!_tileRegistry.TryGetView(tileId, out var tileView)) return; +``` + +Update `OnBeatMeasure()`: + +```csharp +// Before: +var randIndex = Random.Range(0, _allTiles.Count); +var tile = _allTiles[randIndex]; +// ... +if (_tileViews.TryGetValue(tile.Id, out var tileView)) + +// After: +var allTiles = _tileRegistry.AllTiles; +if (allTiles.Count == 0) return; +var randIndex = Random.Range(0, allTiles.Count); +var tile = allTiles[randIndex]; +// ... +if (_tileRegistry.TryGetView(tile.Id, out var tileView)) +``` + +Remove `using System.Collections.Generic;` from GameBootstrap if it is no longer used (check remaining usages first — `IEnumerator` is in `System.Collections`). + +- [ ] **Step 8: Update FloorVisibilityManager.Initialize** + +In `Assets/Scripts/Infrastructure/Unity/FloorVisibilityManager.cs`: + +Replace `Initialize`: + +```csharp +// Before: +public void Initialize(GameSession gameSession, List allTiles, + Dictionary tileViews, int totalFloors) +{ + _gameSession = gameSession; + _floors = new List>(); + for (var i = 0; i < totalFloors; i++) _floors.Add(new List()); + foreach (var tile in allTiles) + { + if (tileViews.TryGetValue(tile.Id, out var view)) + if (tile.Floor < _floors.Count) + _floors[tile.Floor].Add(view); + } +} + +// After: +public void Initialize(TileRegistry registry, int totalFloors) +{ + _floors = registry.GroupViewsByFloor(totalFloors); +} +``` + +Remove the `_gameSession` field from `FloorVisibilityManager` (it's only referenced in the now-empty `Update()` method body). Remove the `Update()` method entirely if it has no logic. + +Remove `using Core.Domain;` from FloorVisibilityManager if it's no longer used. + +- [ ] **Step 9: Compile and play test** + +Unity should compile clean. Play a full session — level generates, tiles decay, NPCs spawn, orbs appear, beat pulses fire. + +- [ ] **Step 10: Commit** + +```bash +git add Assets/Scripts/Infrastructure/Unity/TileRegistry.cs \ + Assets/Scripts/Core/Domain/GameSession.cs \ + Assets/Scripts/Infrastructure/Unity/LevelGenerator.cs \ + Assets/Scripts/Infrastructure/Unity/GameBootstrap.cs \ + Assets/Scripts/Infrastructure/Unity/FloorVisibilityManager.cs \ + Assets/Tests/EditMode/TileRegistryTests.cs +git commit -m "refactor: TileRegistry replaces parallel tile/view collections" +``` + +--- + +## Phase 5 — Introduce LevelDefinition: End Hardcoded 3-Floor Structure (#2) + +**Problem:** `LevelGenerator.GenerateAsync()` hardcodes three `yield return GenerateFloorAsync(...)` calls. Floor count, height distance, and grid size are spread across `LevelGenerator`, `GameBootstrap.Update()`, `GameBootstrap.SpawnDeathPlane()`, and `FloorVisibilityManager`. Adding a 4th floor requires touching all of them. + +**Files:** +- Create: `Assets/Scripts/Infrastructure/Unity/LevelDefinition.cs` +- Modify: `Assets/Scripts/Infrastructure/Unity/LevelGenerator.cs` +- Modify: `Assets/Scripts/Infrastructure/Unity/GameBootstrap.cs` + +- [ ] **Step 1: Create LevelDefinition ScriptableObject** + +Create `Assets/Scripts/Infrastructure/Unity/LevelDefinition.cs`: + +```csharp +using System; +using System.Collections.Generic; +using UnityEngine; + +namespace Infrastructure.Unity +{ + [CreateAssetMenu(fileName = "LevelDefinition", menuName = "Decay Grid/Level Definition")] + public class LevelDefinition : ScriptableObject + { + [SerializeField] private int gridSizeX = 10; + [SerializeField] private int gridSizeY = 10; + [SerializeField] private float floorHeightDistance = 15f; + [SerializeField] private FloorConfig[] floors = + { + new() { pattern = FloorPatternType.Square }, + new() { pattern = FloorPatternType.Donut }, + new() { pattern = FloorPatternType.Circle } + }; + + public int GridSizeX => gridSizeX; + public int GridSizeY => gridSizeY; + public float FloorHeightDistance => floorHeightDistance; + public int FloorCount => floors.Length; + public IReadOnlyList Floors => floors; + } + + [Serializable] + public class FloorConfig + { + public FloorPatternType pattern; + } + + public enum FloorPatternType + { + Square, + Donut, + Circle + } +} +``` + +- [ ] **Step 2: Update LevelGenerator to read from LevelDefinition** + +In `Assets/Scripts/Infrastructure/Unity/LevelGenerator.cs`: + +Remove these serialized fields and their public properties: + +```csharp +// Remove: +[SerializeField] private int gridSizeX = 10; +[SerializeField] private int gridSizeY = 10; +[SerializeField] private int floorsCount = 3; +[SerializeField] private float floorHeightDistance = 15f; + +// Remove: +public float FloorHeightDistance => floorHeightDistance; +public int FloorsCount => floorsCount; +public int GridSizeX => gridSizeX; +public int GridSizeY => gridSizeY; +``` + +Add: + +```csharp +[Header("Level")] +[SerializeField] private LevelDefinition levelDefinition; + +public LevelDefinition Definition => levelDefinition; +``` + +Replace the hardcoded three `yield return` calls in `GenerateAsync` with a loop: + +```csharp +// Before: +yield return GenerateFloorAsync(0, MapPatterns.GenerateSquare(gridSizeX, gridSizeY), ...); +yield return GenerateFloorAsync(1, MapPatterns.GenerateDonut(gridSizeX, Mathf.FloorToInt(gridSizeX / 3f)), ...); +yield return GenerateFloorAsync(2, MapPatterns.GenerateCircle(gridSizeX), ...); + +// After: +for (var i = 0; i < levelDefinition.FloorCount; i++) +{ + var coords = GetCoordsForFloor(levelDefinition.Floors[i].pattern); + yield return GenerateFloorAsync(i, coords, soundManager, registry, camera, rumble, stopwatch); +} +``` + +Add the helper method after `GenerateAsync`: + +```csharp +private List GetCoordsForFloor(FloorPatternType pattern) +{ + var sizeX = levelDefinition.GridSizeX; + var sizeY = levelDefinition.GridSizeY; + return pattern switch + { + FloorPatternType.Square => MapPatterns.GenerateSquare(sizeX, sizeY), + FloorPatternType.Donut => MapPatterns.GenerateDonut(sizeX, Mathf.FloorToInt(sizeX / 3f)), + FloorPatternType.Circle => MapPatterns.GenerateCircle(sizeX), + _ => MapPatterns.GenerateSquare(sizeX, sizeY) + }; +} +``` + +In `GenerateFloorAsync`, replace the three removed fields with `levelDefinition.*`: + +```csharp +// Before: +var yOffset = -(floorIndex * floorHeightDistance); +var xOffset = gridSizeX / 2f; +var zOffset = gridSizeY / 2f; + +// After: +var yOffset = -(floorIndex * levelDefinition.FloorHeightDistance); +var xOffset = levelDefinition.GridSizeX / 2f; +var zOffset = levelDefinition.GridSizeY / 2f; +``` + +Do the same for jump pad and teleporter position calculations within `GenerateFloorAsync` — replace `xOffset` and `zOffset` uses that referenced the removed fields (they now use the local variables defined above, so they're already covered if calculated at the top of the method). + +- [ ] **Step 3: Update GameBootstrap to use levelGenerator.Definition** + +In `Assets/Scripts/Infrastructure/Unity/GameBootstrap.cs`: + +In `Start()`: + +```csharp +// Before: +var floorsCount = levelGenerator ? levelGenerator.FloorsCount : 1; + +// After: +var floorsCount = levelGenerator ? levelGenerator.Definition.FloorCount : 1; +``` + +In `Update()`: + +```csharp +// Before: +var heightDist = levelGenerator.FloorHeightDistance; +var maxFloors = levelGenerator.FloorsCount; + +// After: +var heightDist = levelGenerator.Definition.FloorHeightDistance; +var maxFloors = levelGenerator.Definition.FloorCount; +``` + +In `SpawnDeathPlane()`: + +```csharp +// Before: +var lowestY = -(levelGenerator.FloorsCount * levelGenerator.FloorHeightDistance) - 5f; +var pos = new Vector3(levelGenerator.GridSizeX / 2f, lowestY, levelGenerator.GridSizeY / 2f); +plane.transform.localScale = new Vector3(levelGenerator.GridSizeX * 200f, 1f, levelGenerator.GridSizeY * 200f); + +// After: +var def = levelGenerator.Definition; +var lowestY = -(def.FloorCount * def.FloorHeightDistance) - 5f; +var pos = new Vector3(def.GridSizeX / 2f, lowestY, def.GridSizeY / 2f); +plane.transform.localScale = new Vector3(def.GridSizeX * 200f, 1f, def.GridSizeY * 200f); +``` + +- [ ] **Step 4: Create the LevelDefinition asset in the Unity Editor** + +In Unity Editor: Assets → Create → Decay Grid → Level Definition + +Save as `Assets/Settings/DefaultLevelDefinition.asset` + +In the Inspector, verify it has 3 floors (Square, Donut, Circle) matching the original hardcoded values. + +Assign this asset to the `LevelGenerator` component's `levelDefinition` field in the scene. + +- [ ] **Step 5: Play test — identical to before** + +The level should generate identically. Test: in the Inspector, add a 4th floor to `DefaultLevelDefinition` with `FloorPatternType.Square` — verify a 4th floor appears without changing any code. + +Remove the 4th floor after verifying, to restore the 3-floor default. + +- [ ] **Step 6: Commit** + +```bash +git add Assets/Scripts/Infrastructure/Unity/LevelDefinition.cs \ + Assets/Scripts/Infrastructure/Unity/LevelGenerator.cs \ + Assets/Scripts/Infrastructure/Unity/GameBootstrap.cs +git commit -m "refactor: LevelDefinition ScriptableObject drives floor count and geometry" +``` + +--- + +## Phase 6 — Unify Power-Up Collection (#4) + +**Problem:** `PowerUpViewAdapter.ApplyEffect(PlayerController)` applies `LightFooted` and `SpeedBoost` directly to the player. `TimeSlow` is silently skipped there (`// Handled globally`) and applied via a callback in `GameBootstrap.SpawnPowerUp`. Effect logic is split between a UI adapter and the bootstrap. All effect application should live in `GameBootstrap` where both `_playerInstance` and `_gameSession` are available. + +**Files:** +- Modify: `Assets/Scripts/Infrastructure/Unity/PowerUpViewAdapter.cs` +- Modify: `Assets/Scripts/Infrastructure/Unity/GameBootstrap.cs` + +- [ ] **Step 1: Change OnCollected to include duration, remove ApplyEffect** + +In `Assets/Scripts/Infrastructure/Unity/PowerUpViewAdapter.cs`: + +Change the event signature: + +```csharp +// Before: +public event Action OnCollected; + +// After: +public event Action OnCollected; +``` + +Replace `OnTriggerEnter`: + +```csharp +// Before: +private void OnTriggerEnter(Collider other) +{ + if (other.TryGetComponent(out var player)) + { + ApplyEffect(player); + OnCollected?.Invoke(type); + // ... vfx ... + Destroy(gameObject); + } +} + +// After: +private void OnTriggerEnter(Collider other) +{ + if (other.TryGetComponent(out _)) + { + OnCollected?.Invoke(type, duration); + + if (pickupVfx) + { + var vfx = Instantiate(pickupVfx, transform.position, Quaternion.identity); + var main = vfx.main; + meshRenderer.GetPropertyBlock(_propBlock); + main.startColor = _propBlock.GetColor(ColorProperty); + Destroy(vfx.gameObject, 2f); + } + + Destroy(gameObject); + } +} +``` + +Delete the `ApplyEffect(PlayerController player)` method entirely. + +Remove `using Core.Domain.Status.Effects;` from PowerUpViewAdapter (no longer needed). + +- [ ] **Step 2: Update GameBootstrap.SpawnPowerUp to apply all effects** + +In `Assets/Scripts/Infrastructure/Unity/GameBootstrap.cs`: + +Add at the top: `using Core.Domain.Status.Effects;` + +Replace the `OnCollected` handler in `SpawnPowerUp`: + +```csharp +// Before: +instance.OnCollected += (t) => +{ + cameraController?.Shake(0.2f, 0.15f); + rumbleManager?.PulseMedium(); + + if (t == PowerUpType.TimeSlow) + { + _gameSession.ActivateTimeSlow(10f); + } +}; + +// After: +instance.OnCollected += (t, dur) => +{ + cameraController?.Shake(0.2f, 0.15f); + rumbleManager?.PulseMedium(); + + switch (t) + { + case PowerUpType.TimeSlow: + _gameSession.ActivateTimeSlow(dur); + break; + case PowerUpType.LightFooted: + _playerInstance?.Status.AddEffect(new LightFootedEffect(dur)); + break; + case PowerUpType.SpeedBoost: + _playerInstance?.Status.AddEffect(new SpeedBoostEffect(dur)); + break; + } +}; +``` + +- [ ] **Step 3: Play test all three power-up types** + +Collect each power-up type in the editor: +- LightFooted: player color shifts, tiles should NOT decay under the player +- SpeedBoost: player moves noticeably faster +- TimeSlow: NPCs and decay visibly slow + +- [ ] **Step 4: Commit** + +```bash +git add Assets/Scripts/Infrastructure/Unity/PowerUpViewAdapter.cs \ + Assets/Scripts/Infrastructure/Unity/GameBootstrap.cs +git commit -m "refactor: power-up effects applied centrally in GameBootstrap" +``` + +--- + +## Phase 7 — Split GameBootstrap: Extract GameUiCoordinator (#1) + +**Problem:** `GameBootstrap` owns UI references (scoreText, highScoreText, gameOverUi, pauseUi, startScreenUi), LeanTween score animation, and show/hide logic for all menus. It's the last large concentration of unrelated responsibilities. Extracting UI into `GameUiCoordinator` removes ~70 lines and all TMP/UI concerns from GameBootstrap. + +**Files:** +- Create: `Assets/Scripts/Infrastructure/Unity/GameUiCoordinator.cs` +- Modify: `Assets/Scripts/Infrastructure/Unity/GameBootstrap.cs` + +- [ ] **Step 1: Create GameUiCoordinator** + +Create `Assets/Scripts/Infrastructure/Unity/GameUiCoordinator.cs`: + +```csharp +using Core.Domain; +using TMPro; +using UnityEngine; + +namespace Infrastructure.Unity +{ + public class GameUiCoordinator : MonoBehaviour + { + [Header("References")] + [SerializeField] private TMP_Text scoreText; + [SerializeField] private TMP_Text highScoreText; + [SerializeField] private GameObject gameOverUi; + [SerializeField] private GameObject pauseUi; + [SerializeField] private GameObject startScreenUi; + + private GameSession _session; + private int _currentDisplayedScore; + + public void Subscribe(GameSession session) + { + _session = session; + session.OnScoreChanged += UpdateScore; + session.OnGameOver += ShowGameOverUi; + } + + public void ShowStartScreen() + { + if (gameOverUi) gameOverUi.SetActive(false); + if (startScreenUi) startScreenUi.SetActive(true); + } + + public void HideStartScreen() + { + if (startScreenUi) startScreenUi.SetActive(false); + } + + public void ShowGameOverUi() + { + if (gameOverUi) gameOverUi.SetActive(true); + } + + public void ShowPauseUi() + { + if (pauseUi) pauseUi.SetActive(true); + } + + public void HidePauseUi() + { + if (pauseUi) pauseUi.SetActive(false); + } + + public void UpdateHighScore(int highScore) + { + if (highScoreText) highScoreText.text = $"BEST: {highScore}"; + } + + private void UpdateScore(int newScore) + { + if (!scoreText) return; + + LeanTween.cancel(scoreText.gameObject); + scoreText.rectTransform.localScale = Vector3.one; + LeanTween.scale(scoreText.rectTransform, Vector3.one * 1.5f, 0.5f).setEasePunch(); + + LeanTween.value(scoreText.gameObject, (float val) => + { + var combo = _session?.ComboMultiplier ?? 1; + var comboText = combo > 1 ? $" x{combo}" : ""; + scoreText.text = $"{Mathf.RoundToInt(val)}{comboText}"; + }, _currentDisplayedScore, newScore, 0.5f) + .setEaseOutExpo(); + + _currentDisplayedScore = newScore; + + if (highScoreText && _session != null) + highScoreText.text = $"BEST: {_session.HighScore}"; + } + } +} +``` + +- [ ] **Step 2: Remove UI fields from GameBootstrap and add coordinator reference** + +In `Assets/Scripts/Infrastructure/Unity/GameBootstrap.cs`: + +Remove the entire `[Header("Ui")]` block: + +```csharp +// Remove: +[Header("Ui")] +[SerializeField] private TMP_Text scoreText; +[SerializeField] private TMP_Text highScoreText; +[SerializeField] private GameObject gameOverUi; +[SerializeField] private GameObject pauseUi; +[SerializeField] private GameObject startScreenUi; +``` + +Remove the `_currentDisplayedScore` field: + +```csharp +// Remove: +private int _currentDisplayedScore; +``` + +Add the coordinator serialized field (in the `[Header("Infrastructure")]` section): + +```csharp +[SerializeField] private GameUiCoordinator uiCoordinator; +``` + +- [ ] **Step 3: Update all GameBootstrap methods that referenced UI fields** + +In `Start()` — replace UI setup: + +```csharp +// Before: +if (gameOverUi) gameOverUi.SetActive(false); +if (startScreenUi) startScreenUi.SetActive(true); // before level gen callback +// ... +if (gameOverUi) gameOverUi.SetActive(false); // inside level gen callback +if (startScreenUi) startScreenUi.SetActive(true); // inside level gen callback +// ... +UpdateScoreUi(_gameSession.Score); + +// After (before GenerateAsync call): +uiCoordinator?.ShowStartScreen(); + +// After (inside the level gen onComplete callback): +uiCoordinator?.ShowStartScreen(); +uiCoordinator?.UpdateHighScore(_gameSession.HighScore); +``` + +In `WireEvents()`: + +```csharp +// Before: +_gameSession.OnScoreChanged += UpdateScoreUi; +_gameSession.OnGameOver += HandleGameOver; + +// After: +uiCoordinator?.Subscribe(_gameSession); +_gameSession.OnGameOver += HandleGameOver; +``` + +In `HandleGameOver()`: + +```csharp +// Before: +private void HandleGameOver() +{ + _isGameRunning = false; + if (beatPulseController) beatPulseController.StopTracking(); + if (gameOverUi) gameOverUi.SetActive(true); // remove this line + StartCoroutine(RestartRoutine()); +} + +// After: +private void HandleGameOver() +{ + _isGameRunning = false; + if (beatPulseController) beatPulseController.StopTracking(); + StartCoroutine(RestartRoutine()); + // GameUiCoordinator.ShowGameOverUi() fires via its own OnGameOver subscription +} +``` + +In `TogglePause()`: + +```csharp +// Before: +if (_isPaused) +{ + Time.timeScale = 0f; + if (soundManager) soundManager.SetPaused(true); + if (pauseUi) pauseUi.SetActive(true); + if (rumbleManager) rumbleManager.SetPaused(true); +} +else +{ + Time.timeScale = 1f; + if (soundManager) soundManager.SetPaused(false); + if (pauseUi) pauseUi.SetActive(false); + if (rumbleManager) rumbleManager.SetPaused(false); +} + +// After: +if (_isPaused) +{ + Time.timeScale = 0f; + if (soundManager) soundManager.SetPaused(true); + if (rumbleManager) rumbleManager.SetPaused(true); + uiCoordinator?.ShowPauseUi(); +} +else +{ + Time.timeScale = 1f; + if (soundManager) soundManager.SetPaused(false); + if (rumbleManager) rumbleManager.SetPaused(false); + uiCoordinator?.HidePauseUi(); +} +``` + +In `StartGameSequence()`: + +```csharp +// Before: +if (startScreenUi) startScreenUi.SetActive(false); +_currentDisplayedScore = 0; + +// After: +uiCoordinator?.HideStartScreen(); +``` + +Delete `UpdateScoreUi(int newScore)` from GameBootstrap entirely. + +Remove `using TMPro;` from GameBootstrap if it's no longer used. + +- [ ] **Step 4: Wire the GameUiCoordinator in the Unity scene** + +In Unity Editor: +1. Select the GameBootstrap GameObject in the Hierarchy +2. Add Component → `GameUiCoordinator` +3. Wire `scoreText`, `highScoreText`, `gameOverUi`, `pauseUi`, `startScreenUi` on the new component (same references that were on GameBootstrap) +4. Assign the `GameUiCoordinator` component reference to the `uiCoordinator` field on GameBootstrap + +- [ ] **Step 5: Play a full game session** + +Verify the entire flow: +- Start screen shows on launch +- Game begins on input press +- Score increments and animates with combo multiplier +- High score displays correctly +- Pause menu toggles with pause input +- Game over screen appears after death +- Scene reloads after `restartTime` seconds + +- [ ] **Step 6: Commit** + +```bash +git add Assets/Scripts/Infrastructure/Unity/GameUiCoordinator.cs \ + Assets/Scripts/Infrastructure/Unity/GameBootstrap.cs +git commit -m "refactor: extract GameUiCoordinator from GameBootstrap" +``` + +--- + +## Self-Review + +**Spec coverage:** +- ✅ #6 Effect stacking: Phase 2 — `*=` in `LightFootedEffect` and `SpeedBoostEffect` +- ✅ #3 Backwards coupling: Phase 3 — `ITileView.StepOn()`, all three controllers use port +- ✅ #5 Parallel collections: Phase 4 — `TileRegistry` owns both, replaces `_allTiles`/`_tileViews` +- ✅ #2 Hardcoded floors: Phase 5 — `LevelDefinition` ScriptableObject drives floor structure +- ✅ #4 Split power-up: Phase 6 — all effect application in GameBootstrap +- ✅ #1 God object: Phase 7 — `GameUiCoordinator` extracts all UI logic + +**Placeholder scan:** None. All steps have exact code blocks and expected outcomes. + +**Type consistency:** +- `TileRegistry` referenced identically across `LevelGenerator`, `GameBootstrap`, `FloorVisibilityManager`, `TileRegistryTests` +- `ITileView.StepOn()` matches `TileViewAdapter.StepOn()` and all three controller call sites +- `LevelDefinition.FloorCount` (not `FloorsCount`) used consistently in LevelGenerator and GameBootstrap +- `OnCollected(PowerUpType, float)` matches the `(t, dur) =>` lambda in GameBootstrap +- `IReadOnlyList` in `GameSession` uses `.Where().ToList()` (not `FindAll`) consistently