# Phase 3.K.1 — Test harness foundation 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:** Introduce JUnit 5 + AssertJ test infrastructure, refactor four persistent-state singletons (`PlayerStats`, `PlayerAbilities`, `GameState`, `TalentTree`) to constructor injection via a new `GameContext` holder, and author ~30 unit tests that will serve as the safety net for subsequent K.2–K.7 logic-extraction specs. **Architecture:** Each singleton gains a public constructor that accepts its dependencies; the legacy `getInstance()` methods are kept in a transitional state until every call-site is switched, then deleted in one sweep. `Main.create()` becomes the composition root, building a `GameContext` and passing it to every screen constructor. Tests use an in-memory `FakePreferences` that implements `com.badlogic.gdx.Preferences`, so no headless libGDX runtime is required. **Tech Stack:** Java 8, libGDX 1.13.1, Gradle, JUnit Jupiter 5.10.2, AssertJ 3.26.3. --- ## Design reference The authoritative design is `docs/superpowers/specs/2026-04-17-phase3-k1-test-harness-design.md`. Read it once before beginning. Key constraints: - `GameContext` has `public final` fields (Google Java Style §5.3 idiom for immutable data carrier). - Tests are named `methodName_whenCondition_thenExpected`. - Bodies use Arrange / Act / Assert separated by blank lines, no `// Arrange` comments. - No code coverage gate (no JaCoCo), no CI wiring. - Headless libGDX is explicitly **not** used. ## Project realities - **No git repository.** `Is a git repository: false` in the environment. Every "Commit" step below should be executed as a checkpoint: confirm the change set is clean, then run the verification. If you initialize git before starting, run `git init` once and treat commits as real; otherwise use each commit step as a mental save-point and keep moving. - **No existing test infrastructure.** Task 1 is the first point at which `./gradlew test` becomes a meaningful command. - **TDD applies to test-bearing tasks (Tasks 2–8).** Tasks 9–14 are mechanical refactors with no new behaviour — their verification is *compile passes* + *existing tests still pass* + *desktop launcher still runs correctly*. The plan calls this out per-task. - **Call-site counts (verified via grep, 2026-04-17):** 21 total `getInstance()` calls across 6 files: - `FirstScreen.java`: 13 (lines 179, 502, 579, 617, 644, 645, 1366, 1474, 1497, 1794, 2634, 5737, 6756) - `AbilityShopScreen.java`: 2 (lines 69, 70) - `AbilitySelectionScreen.java`: 2 (lines 171, 340) - `TalentTreeScreen.java`: 2 (lines 98, 99) - `TalentTree.java`: 2 (lines 133, 138) — self-references for `PlayerStats` Line numbers may drift as prior tasks add imports; use the tagged symbol names rather than hardcoded line numbers when editing. ## File structure **New files (production):** - `core/src/main/java/ru/project/tower/GameContext.java` — DI holder. **New files (test):** - `core/src/test/java/ru/project/tower/FakePreferences.java` — in-memory `Preferences` implementation. - `core/src/test/java/ru/project/tower/FakePreferencesTest.java` - `core/src/test/java/ru/project/tower/PlayerStatsTest.java` - `core/src/test/java/ru/project/tower/PlayerAbilitiesTest.java` - `core/src/test/java/ru/project/tower/GameStateTest.java` - `core/src/test/java/ru/project/tower/TalentTreeTest.java` - `core/src/test/java/ru/project/tower/TalentDataTest.java` - `core/src/test/java/ru/project/tower/GameContextTest.java` **Modified files:** - `core/build.gradle` — add `testImplementation` deps and `useJUnitPlatform()`. - `core/src/main/java/ru/project/tower/PlayerStats.java` — add public ctor; remove `getInstance()` (Task 10). - `core/src/main/java/ru/project/tower/PlayerAbilities.java` — add public ctor; remove `getInstance()`. - `core/src/main/java/ru/project/tower/GameState.java` — make ctor public; remove `getInstance()`. - `core/src/main/java/ru/project/tower/TalentTree.java` — add `TalentTree(PlayerStats)` ctor; remove `getInstance()`; replace 2 internal `PlayerStats.getInstance()` calls with field. - `core/src/main/java/ru/project/tower/Main.java` — composition root. - `core/src/main/java/ru/project/tower/MainScreen.java` — take `GameContext` in ctor; pass to downstream screens. - `core/src/main/java/ru/project/tower/FirstScreen.java` — take `GameContext`; cache services in fields; replace 13 call-sites. - `core/src/main/java/ru/project/tower/AbilityShopScreen.java` — take `GameContext`; replace 2 call-sites. - `core/src/main/java/ru/project/tower/AbilitySelectionScreen.java` — take `GameContext`; replace 2 call-sites; update internal `new FirstScreen(...)` and `new MainScreen(...)` construction. - `core/src/main/java/ru/project/tower/TalentTreeScreen.java` — take `GameContext`; replace 2 call-sites; update internal `new MainScreen(...)`. --- ## Task 1: Build config for test infrastructure **Files:** - Modify: `core/build.gradle` - Create: `core/src/test/java/ru/project/tower/HarnessSanityTest.java` - [ ] **Step 1: Add JUnit 5 + AssertJ dependencies to `core/build.gradle`** Replace the current `dependencies` block in `core/build.gradle`: ```gradle dependencies { api "com.badlogicgames.gdx:gdx-freetype:$gdxVersion" api "com.badlogicgames.gdx:gdx:$gdxVersion" testImplementation 'org.junit.jupiter:junit-jupiter:5.10.2' testImplementation 'org.assertj:assertj-core:3.26.3' if(enableGraalNative == 'true') { implementation "io.github.berstanio:gdx-svmhelper-annotations:$graalHelperVersion" } } test { useJUnitPlatform() } ``` - [ ] **Step 2: Create the test source tree and a sanity test** Create `core/src/test/java/ru/project/tower/HarnessSanityTest.java` (verifies JUnit + AssertJ actually wire up): ```java package ru.project.tower; import org.junit.jupiter.api.Test; import static org.assertj.core.api.Assertions.assertThat; class HarnessSanityTest { @Test void assertj_whenUsed_thenWorks() { assertThat(1 + 1).isEqualTo(2); } } ``` - [ ] **Step 3: Run the test** Run: `./gradlew core:test --info` Expected: `BUILD SUCCESSFUL`, `HarnessSanityTest > assertj_whenUsed_thenWorks() PASSED`. If you get `Could not resolve org.junit.jupiter:junit-jupiter:5.10.2` — check the subprojects repositories in the root `build.gradle` include `mavenCentral()` (they already do as of 2026-04-17). - [ ] **Step 4: Commit** ```bash git add core/build.gradle core/src/test/java/ru/project/tower/HarnessSanityTest.java git commit -m "feat(test): add JUnit 5 + AssertJ test harness to core module" ``` --- ## Task 2: `FakePreferences` helper **Files:** - Create: `core/src/test/java/ru/project/tower/FakePreferences.java` - Create: `core/src/test/java/ru/project/tower/FakePreferencesTest.java` - [ ] **Step 1: Write the failing test** Create `core/src/test/java/ru/project/tower/FakePreferencesTest.java`: ```java package ru.project.tower; import org.junit.jupiter.api.Test; import static org.assertj.core.api.Assertions.assertThat; class FakePreferencesTest { @Test void putInteger_whenRead_thenReturnsSameValue() { FakePreferences prefs = new FakePreferences(); prefs.putInteger("k", 42); assertThat(prefs.getInteger("k", -1)).isEqualTo(42); } @Test void getInteger_whenMissing_thenReturnsDefault() { FakePreferences prefs = new FakePreferences(); assertThat(prefs.getInteger("missing", 7)).isEqualTo(7); } @Test void putBoolean_whenRead_thenReturnsSameValue() { FakePreferences prefs = new FakePreferences(); prefs.putBoolean("flag", true); assertThat(prefs.getBoolean("flag", false)).isTrue(); } @Test void putString_whenRead_thenReturnsSameValue() { FakePreferences prefs = new FakePreferences(); prefs.putString("name", "neo"); assertThat(prefs.getString("name", "")).isEqualTo("neo"); } @Test void contains_whenKeyPresent_thenTrue() { FakePreferences prefs = new FakePreferences(); prefs.putInteger("k", 1); assertThat(prefs.contains("k")).isTrue(); assertThat(prefs.contains("other")).isFalse(); } @Test void clear_whenInvoked_thenEmptiesStore() { FakePreferences prefs = new FakePreferences(); prefs.putInteger("k", 1); prefs.clear(); assertThat(prefs.contains("k")).isFalse(); } @Test void remove_whenKeyExists_thenRemovesOnlyThatKey() { FakePreferences prefs = new FakePreferences(); prefs.putInteger("a", 1); prefs.putInteger("b", 2); prefs.remove("a"); assertThat(prefs.contains("a")).isFalse(); assertThat(prefs.getInteger("b", -1)).isEqualTo(2); } } ``` - [ ] **Step 2: Run test to verify it fails** Run: `./gradlew core:test --tests FakePreferencesTest` Expected: `FAIL` with "cannot find symbol: class FakePreferences". - [ ] **Step 3: Implement `FakePreferences`** Create `core/src/test/java/ru/project/tower/FakePreferences.java`. This is a full implementation of `com.badlogic.gdx.Preferences`. Every method delegates to a `HashMap`. `flush()` is a no-op. ```java package ru.project.tower; import com.badlogic.gdx.Preferences; import java.util.HashMap; import java.util.Map; /** * In-memory test double for {@link Preferences}. No disk I/O, no libGDX * runtime required. {@link #flush()} is a no-op. */ public class FakePreferences implements Preferences { private final Map store = new HashMap<>(); @Override public Preferences putBoolean(String key, boolean val) { store.put(key, val); return this; } @Override public Preferences putInteger(String key, int val) { store.put(key, val); return this; } @Override public Preferences putLong(String key, long val) { store.put(key, val); return this; } @Override public Preferences putFloat(String key, float val) { store.put(key, val); return this; } @Override public Preferences putString(String key, String val) { store.put(key, val); return this; } @Override public Preferences put(Map vals) { store.putAll(vals); return this; } @Override public boolean getBoolean(String key) { return getBoolean(key, false); } @Override public int getInteger(String key) { return getInteger(key, 0); } @Override public long getLong(String key) { return getLong(key, 0L); } @Override public float getFloat(String key) { return getFloat(key, 0f); } @Override public String getString(String key) { return getString(key, ""); } @Override public boolean getBoolean(String key, boolean defValue) { Object v = store.get(key); return v instanceof Boolean ? (Boolean) v : defValue; } @Override public int getInteger(String key, int defValue) { Object v = store.get(key); return v instanceof Integer ? (Integer) v : defValue; } @Override public long getLong(String key, long defValue) { Object v = store.get(key); return v instanceof Long ? (Long) v : defValue; } @Override public float getFloat(String key, float defValue) { Object v = store.get(key); return v instanceof Float ? (Float) v : defValue; } @Override public String getString(String key, String defValue) { Object v = store.get(key); return v instanceof String ? (String) v : defValue; } @Override public Map get() { return new HashMap<>(store); } @Override public boolean contains(String key) { return store.containsKey(key); } @Override public void clear() { store.clear(); } @Override public void remove(String key) { store.remove(key); } @Override public void flush() { /* no-op */ } } ``` - [ ] **Step 4: Run tests to verify they pass** Run: `./gradlew core:test --tests FakePreferencesTest` Expected: all 7 tests pass. - [ ] **Step 5: Commit** ```bash git add core/src/test/java/ru/project/tower/FakePreferences.java \ core/src/test/java/ru/project/tower/FakePreferencesTest.java git commit -m "test: add FakePreferences in-memory double" ``` --- ## Task 3: `PlayerStats` — DI refactor + tests **Files:** - Modify: `core/src/main/java/ru/project/tower/PlayerStats.java` - Create: `core/src/test/java/ru/project/tower/PlayerStatsTest.java` - [ ] **Step 1: Write the failing tests** Create `core/src/test/java/ru/project/tower/PlayerStatsTest.java`: ```java package ru.project.tower; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import static org.assertj.core.api.Assertions.assertThat; class PlayerStatsTest { private FakePreferences prefs; private PlayerStats stats; @BeforeEach void setUp() { prefs = new FakePreferences(); stats = new PlayerStats(prefs); } @Test void getCurrency_whenNotYetSet_thenReturnsZero() { assertThat(stats.getCurrency()).isZero(); } @Test void addCurrency_whenPositiveAmount_thenIncrementsAndPersists() { stats.addCurrency(50); assertThat(stats.getCurrency()).isEqualTo(50); assertThat(prefs.getInteger("player_currency", -1)).isEqualTo(50); } @Test void addCurrency_whenNonPositive_thenNoChange() { stats.addCurrency(0); stats.addCurrency(-10); assertThat(stats.getCurrency()).isZero(); } @Test void spendCurrency_whenInsufficient_thenReturnsFalseAndLeavesBalance() { stats.addCurrency(10); boolean result = stats.spendCurrency(25); assertThat(result).isFalse(); assertThat(stats.getCurrency()).isEqualTo(10); } @Test void spendCurrency_whenSufficient_thenReturnsTrueAndDeducts() { stats.addCurrency(100); boolean result = stats.spendCurrency(30); assertThat(result).isTrue(); assertThat(stats.getCurrency()).isEqualTo(70); assertThat(prefs.getInteger("player_currency", -1)).isEqualTo(70); } @Test void spendCurrency_whenZeroOrNegative_thenReturnsTrueWithoutDeducting() { stats.addCurrency(10); assertThat(stats.spendCurrency(0)).isTrue(); assertThat(stats.spendCurrency(-5)).isTrue(); assertThat(stats.getCurrency()).isEqualTo(10); } @Test void updateMaxWave_whenHigher_thenUpdatesAndPersists() { stats.updateMaxWave(5); stats.updateMaxWave(12); assertThat(stats.getMaxWaveReached()).isEqualTo(12); assertThat(prefs.getInteger("max_wave_reached", -1)).isEqualTo(12); } @Test void updateMaxWave_whenLowerOrEqual_thenIgnored() { stats.updateMaxWave(10); stats.updateMaxWave(10); stats.updateMaxWave(3); assertThat(stats.getMaxWaveReached()).isEqualTo(10); } @Test void getTalentNodeLevel_whenUnseen_thenZero() { assertThat(stats.getTalentNodeLevel("damage_1")).isZero(); } @Test void incrementTalentNode_whenCalled_thenLevelOneAndPersisted() { stats.incrementTalentNode("damage_1"); assertThat(stats.getTalentNodeLevel("damage_1")).isEqualTo(1); assertThat(prefs.getInteger("talent_node_damage_1", -1)).isEqualTo(1); } @Test void incrementTalentNode_whenCalledTwice_thenLevelTwo() { stats.incrementTalentNode("damage_1"); stats.incrementTalentNode("damage_1"); assertThat(stats.getTalentNodeLevel("damage_1")).isEqualTo(2); } @Test void legacyAggregateGetter_whenMultipleTiers_thenReturnsSumAcrossNodes() { stats.incrementTalentNode("damage_1"); stats.incrementTalentNode("damage_1"); stats.incrementTalentNode("damage_2"); assertThat(stats.getDamageTalentLevel()).isEqualTo(3); } @Test void construct_whenPreferencesPrePopulated_thenRoundTripsValues() { FakePreferences seeded = new FakePreferences(); seeded.putInteger("player_currency", 250); seeded.putInteger("max_wave_reached", 17); seeded.putInteger("talent_node_keystone_pierce", 1); PlayerStats fresh = new PlayerStats(seeded); assertThat(fresh.getCurrency()).isEqualTo(250); assertThat(fresh.getMaxWaveReached()).isEqualTo(17); assertThat(fresh.getTalentNodeLevel("keystone_pierce")).isEqualTo(1); } @Test void construct_whenLegacyDamageKeyPresent_thenSeedsDamage1() { FakePreferences seeded = new FakePreferences(); seeded.putInteger("talent_damage_level", 3); PlayerStats fresh = new PlayerStats(seeded); assertThat(fresh.getTalentNodeLevel("damage_1")).isEqualTo(3); } } ``` - [ ] **Step 2: Run tests to verify they fail** Run: `./gradlew core:test --tests PlayerStatsTest` Expected: `FAIL` with "constructor PlayerStats(FakePreferences) not found" (ctor is currently private & argumentless). - [ ] **Step 3: Refactor `PlayerStats`** Modify `core/src/main/java/ru/project/tower/PlayerStats.java`. The change has three parts: add a public ctor that accepts `Preferences`, remove the old private ctor (its body moves into the public one), and rewrite `getInstance()` to delegate via the new ctor. `getInstance()` stays alive until Task 10 deletes it after all call-sites are switched. Replace the existing ctor block and `getInstance()` (lines 37–75 in the current file) with: ```java /** * DI-friendly constructor. Used by {@link GameContext} and tests. */ public PlayerStats(Preferences preferences) { this.preferences = preferences; currency = preferences.getInteger(CURRENCY_KEY, 0); maxWaveReached = preferences.getInteger(MAX_WAVE_KEY, 0); for (String id : TALENT_NODE_IDS) { talentLevels.put(id, preferences.getInteger(TALENT_NODE_KEY_PREFIX + id, 0)); } seedLegacyLevel("damage_1", DAMAGE_TALENT_KEY); seedLegacyLevel("health_1", HEALTH_TALENT_KEY); seedLegacyLevel("speed_1", SPEED_TALENT_KEY); seedLegacyLevel("cooldown_1", COOLDOWN_TALENT_KEY); seedLegacyLevel("regen_1", REGEN_TALENT_KEY); } /** * Legacy singleton accessor. Delete in Task 10 once call-sites use GameContext. */ public static PlayerStats getInstance() { if (instance == null) { instance = new PlayerStats(Gdx.app.getPreferences(PREFS_NAME)); } return instance; } ``` The old `private PlayerStats()` constructor is gone — its body moved verbatim into the public one, minus the first line which was `preferences = Gdx.app.getPreferences(PREFS_NAME);`. The `preferences` field stays, `PREFS_NAME` stays (still used by `getInstance()`). Leave `instance`, `loadData`-adjacent behaviour, `saveData`, `resetData`, and all public accessors unchanged. - [ ] **Step 4: Run tests to verify they pass** Run: `./gradlew core:test --tests PlayerStatsTest` Expected: all 13 tests pass. Also run the full suite: `./gradlew core:test` Expected: `FakePreferencesTest` + `HarnessSanityTest` + `PlayerStatsTest` all pass. - [ ] **Step 5: Verify production code still compiles** Run: `./gradlew core:compileJava` Expected: `BUILD SUCCESSFUL` (existing `PlayerStats.getInstance()` callers in `AbilityShopScreen`, `FirstScreen`, `TalentTree`, `TalentTreeScreen` are untouched and still compile). - [ ] **Step 6: Commit** ```bash git add core/src/main/java/ru/project/tower/PlayerStats.java \ core/src/test/java/ru/project/tower/PlayerStatsTest.java git commit -m "refactor(PlayerStats): expose public ctor + add unit tests" ``` --- ## Task 4: `PlayerAbilities` — DI refactor + tests **Files:** - Modify: `core/src/main/java/ru/project/tower/PlayerAbilities.java` - Create: `core/src/test/java/ru/project/tower/PlayerAbilitiesTest.java` - [ ] **Step 1: Write the failing tests** Create `core/src/test/java/ru/project/tower/PlayerAbilitiesTest.java`: ```java package ru.project.tower; import com.badlogic.gdx.utils.Array; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import static org.assertj.core.api.Assertions.assertThat; class PlayerAbilitiesTest { private FakePreferences prefs; private PlayerAbilities abilities; @BeforeEach void setUp() { prefs = new FakePreferences(); abilities = new PlayerAbilities(prefs); } @Test void defaults_whenFreshPreferences_thenFreezeAndExplosionUnlocked() { // Defaults in current code: freeze=true, explosion=true, others=false. assertThat(abilities.hasAbility("freeze")).isTrue(); assertThat(abilities.hasAbility("explosion")).isTrue(); assertThat(abilities.hasAbility("shield")).isFalse(); assertThat(abilities.hasAbility("impulse")).isFalse(); assertThat(abilities.hasAbility("dash")).isFalse(); assertThat(abilities.hasAbility("pull")).isFalse(); assertThat(abilities.hasAbility("turret")).isFalse(); } @Test void purchaseAbility_whenShield_thenUnlocksAndPersists() { abilities.purchaseAbility("shield"); assertThat(abilities.hasAbility("shield")).isTrue(); assertThat(prefs.getBoolean("has_shield_ability", false)).isTrue(); } @Test void purchaseAbility_whenUnknownType_thenNoChange() { abilities.purchaseAbility("rocket_launcher"); // No crash, no state change to known abilities. assertThat(abilities.hasAbility("shield")).isFalse(); } @Test void hasAbility_whenUnknownType_thenFalse() { assertThat(abilities.hasAbility("rocket_launcher")).isFalse(); } @Test void getPurchasedAbilities_whenDefaults_thenContainsFreezeAndExplosion() { Array list = abilities.getPurchasedAbilities(); assertThat(list).containsExactly("freeze", "explosion"); } @Test void getPurchasedAbilities_afterShieldPurchase_thenIncludesShield() { abilities.purchaseAbility("shield"); Array list = abilities.getPurchasedAbilities(); assertThat(list).contains("shield"); } @Test void construct_whenPreferencesPrePopulated_thenHonoursStored() { FakePreferences seeded = new FakePreferences(); seeded.putBoolean("has_freeze_ability", false); seeded.putBoolean("has_shield_ability", true); PlayerAbilities fresh = new PlayerAbilities(seeded); assertThat(fresh.hasAbility("freeze")).isFalse(); assertThat(fresh.hasAbility("shield")).isTrue(); } @Test void resetData_whenInvoked_thenAllFalseAndPersisted() { abilities.purchaseAbility("shield"); abilities.resetData(); assertThat(abilities.hasAbility("freeze")).isFalse(); assertThat(abilities.hasAbility("explosion")).isFalse(); assertThat(abilities.hasAbility("shield")).isFalse(); assertThat(prefs.getBoolean("has_shield_ability", true)).isFalse(); } } ``` Note on `containsExactly`: AssertJ's support for libGDX `Array` works because AssertJ accepts any `Iterable`; `com.badlogic.gdx.utils.Array` implements `Iterable` since libGDX 1.x. - [ ] **Step 2: Run tests to verify they fail** Run: `./gradlew core:test --tests PlayerAbilitiesTest` Expected: `FAIL` with "constructor PlayerAbilities(FakePreferences) not found". - [ ] **Step 3: Refactor `PlayerAbilities`** Modify `core/src/main/java/ru/project/tower/PlayerAbilities.java`. Same shape as `PlayerStats`: add public ctor, delegate the legacy private ctor. Replace the existing ctor block and `getInstance()` (lines 33–77 in the current file) with: ```java /** * DI-friendly constructor. Used by {@link GameContext} and tests. */ public PlayerAbilities(Preferences preferences) { try { DebugLog.log("PlayerAbilities", "Initializing PlayerAbilities"); this.preferences = preferences; DebugLog.log("PlayerAbilities", "Got preferences"); hasFreezeAbility = preferences.getBoolean(FREEZE_ABILITY_KEY, true); hasExplosionAbility = preferences.getBoolean(EXPLOSION_ABILITY_KEY, true); hasShieldAbility = preferences.getBoolean(SHIELD_ABILITY_KEY, false); hasImpulseAbility = preferences.getBoolean(IMPULSE_ABILITY_KEY, false); hasDashAbility = preferences.getBoolean(DASH_ABILITY_KEY, false); hasPullAbility = preferences.getBoolean(PULL_ABILITY_KEY, false); hasTurretAbility = preferences.getBoolean(TURRET_ABILITY_KEY, false); DebugLog.log("PlayerAbilities", String.format( "Loaded abilities - Freeze: %b, Explosion: %b, Shield: %b, Impulse: %b, Dash: %b, Pull: %b, Turret: %b", hasFreezeAbility, hasExplosionAbility, hasShieldAbility, hasImpulseAbility, hasDashAbility, hasPullAbility, hasTurretAbility )); } catch (Exception e) { Gdx.app.error("PlayerAbilities", "Error in constructor: ", e); throw e; } } /** * Legacy singleton accessor. Delete in Task 10 once call-sites use GameContext. */ public static PlayerAbilities getInstance() { try { if (instance == null) { DebugLog.log("PlayerAbilities", "Creating new instance"); instance = new PlayerAbilities(Gdx.app.getPreferences(PREFS_NAME)); DebugLog.log("PlayerAbilities", "Instance created"); } return instance; } catch (Exception e) { Gdx.app.error("PlayerAbilities", "Error in getInstance: ", e); throw e; } } ``` Constants `PREFS_NAME`, `FREEZE_ABILITY_KEY`, etc. remain as-is. The `private PlayerAbilities()` ctor is replaced. - [ ] **Step 4: Run tests to verify they pass** Run: `./gradlew core:test --tests PlayerAbilitiesTest` Expected: all 8 tests pass. - [ ] **Step 5: Compile check** Run: `./gradlew core:compileJava` Expected: `BUILD SUCCESSFUL`. - [ ] **Step 6: Commit** ```bash git add core/src/main/java/ru/project/tower/PlayerAbilities.java \ core/src/test/java/ru/project/tower/PlayerAbilitiesTest.java git commit -m "refactor(PlayerAbilities): expose public ctor + add unit tests" ``` --- ## Task 5: `GameState` — DI refactor + tests **Files:** - Modify: `core/src/main/java/ru/project/tower/GameState.java` - Create: `core/src/test/java/ru/project/tower/GameStateTest.java` `GameState` has no `Preferences`; the refactor simply makes the private ctor public. Tests avoid methods that take a `FirstScreen` argument (those are coupled to libGDX; they stay covered by the manual smoke test in Task 11). - [ ] **Step 1: Write the failing tests** Create `core/src/test/java/ru/project/tower/GameStateTest.java`: ```java package ru.project.tower; import com.badlogic.gdx.utils.Array; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import static org.assertj.core.api.Assertions.assertThat; class GameStateTest { private GameState state; @BeforeEach void setUp() { state = new GameState(); } @Test void freshState_whenCreated_thenNotReturnedFromShop() { assertThat(state.isReturnedFromShop()).isFalse(); } @Test void freshState_whenCreated_thenSelectedAbilitiesEmpty() { assertThat(state.getSelectedAbilities().size).isZero(); } @Test void freshState_whenCreated_thenSpawnedBossesEmpty() { assertThat(state.getSpawnedBosses().size).isZero(); } @Test void setSelectedAbilities_whenCalled_thenReplacesPreviousList() { Array first = new Array<>(); first.add("freeze"); state.setSelectedAbilities(first); Array second = new Array<>(); second.add("dash"); second.add("pull"); state.setSelectedAbilities(second); Array stored = state.getSelectedAbilities(); assertThat(stored.size).isEqualTo(2); assertThat(stored).contains("dash", "pull"); } @Test void resetGameState_whenInvoked_thenClearsReturnFlagAndAbilities() { Array initial = new Array<>(); initial.add("freeze"); state.setSelectedAbilities(initial); // No public setter for returnedFromShop; the flag flips to true in // saveGameState(FirstScreen), which we cannot call without the screen. // We therefore verify the reset clears the abilities list. state.resetGameState(); assertThat(state.getSelectedAbilities().size).isZero(); assertThat(state.isReturnedFromShop()).isFalse(); } @Test void setSpawnedBosses_whenCalled_thenGetSpawnedBossesReturnsSame() { Array bosses = new Array<>(); bosses.add("juggernaut"); state.setSpawnedBosses(bosses); assertThat(state.getSpawnedBosses()).isSameAs(bosses); } } ``` - [ ] **Step 2: Run tests to verify they fail** Run: `./gradlew core:test --tests GameStateTest` Expected: `FAIL` with "GameState() has private access". - [ ] **Step 3: Refactor `GameState`** In `core/src/main/java/ru/project/tower/GameState.java`, change line 45 (`private GameState() { }`) to: ```java public GameState() { } ``` That is literally the entire production change for this task. Leave `instance`, `getInstance()`, and every other method untouched. - [ ] **Step 4: Run tests to verify they pass** Run: `./gradlew core:test --tests GameStateTest` Expected: all 6 tests pass. - [ ] **Step 5: Compile check** Run: `./gradlew core:compileJava` Expected: `BUILD SUCCESSFUL`. - [ ] **Step 6: Commit** ```bash git add core/src/main/java/ru/project/tower/GameState.java \ core/src/test/java/ru/project/tower/GameStateTest.java git commit -m "refactor(GameState): expose public ctor + add unit tests" ``` --- ## Task 6: `TalentTree` — DI refactor + tests **Files:** - Modify: `core/src/main/java/ru/project/tower/TalentTree.java` - Create: `core/src/test/java/ru/project/tower/TalentTreeTest.java` `TalentTree` must hold a `PlayerStats` reference. After this task, the class has both its legacy zero-arg ctor (delegating via `PlayerStats.getInstance()`) and the new DI ctor. - [ ] **Step 1: Write the failing tests** Create `core/src/test/java/ru/project/tower/TalentTreeTest.java`: ```java package ru.project.tower; import com.badlogic.gdx.utils.Array; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import static org.assertj.core.api.Assertions.assertThat; class TalentTreeTest { private FakePreferences prefs; private PlayerStats stats; private TalentTree tree; @BeforeEach void setUp() { prefs = new FakePreferences(); stats = new PlayerStats(prefs); tree = new TalentTree(stats); } @Test void getTalent_whenHealth1_thenReturnsRootNode() { TalentData root = tree.getTalent("health_1"); assertThat(root).isNotNull(); assertThat(root.getType()).isEqualTo(TalentData.TalentType.HEALTH); assertThat(root.getDependencyIds().size).isZero(); } @Test void getAllTalents_thenContainsAllExpectedIds() { Array all = tree.getAllTalents(); assertThat(all.size).isEqualTo(12); } @Test void isUnlocked_whenRoot_thenTrue() { assertThat(tree.isUnlocked("health_1")).isTrue(); } @Test void isUnlocked_whenDependencyNotMet_thenFalse() { assertThat(tree.isUnlocked("damage_1")).isFalse(); } @Test void isUnlocked_whenDependencyHasLevel_thenTrue() { stats.incrementTalentNode("health_1"); assertThat(tree.isUnlocked("damage_1")).isTrue(); } @Test void getCurrentLevel_whenDelegatedToStats_thenMatches() { stats.incrementTalentNode("damage_1"); stats.incrementTalentNode("damage_1"); assertThat(tree.getCurrentLevel("damage_1")).isEqualTo(2); } @Test void getCurrentLevel_whenUnknownTalent_thenZero() { assertThat(tree.getCurrentLevel("nonexistent")).isZero(); } @Test void upgradeTalent_whenCalled_thenStatsLevelIncrements() { tree.upgradeTalent("damage_1"); assertThat(stats.getTalentNodeLevel("damage_1")).isEqualTo(1); } @Test void upgradeTalent_whenUnknownId_thenNoCrash() { tree.upgradeTalent("nonexistent"); // No assertion needed; the test passes if no exception was thrown. } @Test void keystoneNodes_thenAllRegistered() { assertThat(tree.getTalent("keystone_pierce")).isNotNull(); assertThat(tree.getTalent("keystone_crit")).isNotNull(); assertThat(tree.getTalent("keystone_aoe")).isNotNull(); } @Test void getStatBonus_whenDamage1HasLevel_thenBonusOnePerLevel() { stats.incrementTalentNode("damage_1"); stats.incrementTalentNode("damage_1"); float bonus = tree.getStatBonus(TalentData.TalentType.DAMAGE); assertThat(bonus).isEqualTo(2f); } @Test void getStatBonus_whenNoLevels_thenZero() { assertThat(tree.getStatBonus(TalentData.TalentType.DAMAGE)).isZero(); } } ``` - [ ] **Step 2: Run tests to verify they fail** Run: `./gradlew core:test --tests TalentTreeTest` Expected: `FAIL` with "constructor TalentTree(PlayerStats) not found". - [ ] **Step 3: Refactor `TalentTree`** In `core/src/main/java/ru/project/tower/TalentTree.java`: 1. Add a `private final PlayerStats stats;` field. 2. Replace the single constructor (lines 14–18) and `getInstance()` (lines 20–25): ```java private final PlayerStats stats; /** * DI-friendly constructor. Used by {@link GameContext} and tests. */ public TalentTree(PlayerStats stats) { this.stats = stats; this.talents = new ObjectMap<>(); this.roots = new Array<>(); initializeTree(); } /** * Legacy singleton accessor. Delete in Task 10 once call-sites use GameContext. */ public static TalentTree getInstance() { if (instance == null) { instance = new TalentTree(PlayerStats.getInstance()); } return instance; } ``` 3. Replace line 133 (`return PlayerStats.getInstance().getTalentNodeLevel(talentId);`) with: ```java return stats.getTalentNodeLevel(talentId); ``` 4. Replace line 138 (`PlayerStats.getInstance().incrementTalentNode(talentId);`) with: ```java stats.incrementTalentNode(talentId); ``` Leave the rest of the file (tree initialization, `getStatBonus`, etc.) unchanged. - [ ] **Step 4: Run tests to verify they pass** Run: `./gradlew core:test --tests TalentTreeTest` Expected: all 12 tests pass. - [ ] **Step 5: Compile check** Run: `./gradlew core:compileJava` Expected: `BUILD SUCCESSFUL`. - [ ] **Step 6: Commit** ```bash git add core/src/main/java/ru/project/tower/TalentTree.java \ core/src/test/java/ru/project/tower/TalentTreeTest.java git commit -m "refactor(TalentTree): inject PlayerStats via ctor + add unit tests" ``` --- ## Task 7: `TalentData` — tests only **Files:** - Create: `core/src/test/java/ru/project/tower/TalentDataTest.java` No refactor needed; `TalentData` is a plain data class. - [ ] **Step 1: Write the tests** Create `core/src/test/java/ru/project/tower/TalentDataTest.java`: ```java package ru.project.tower; import org.junit.jupiter.api.Test; import static org.assertj.core.api.Assertions.assertThat; class TalentDataTest { @Test void getters_whenConstructed_thenReturnProvidedValues() { TalentData data = new TalentData( "id_x", "Name", "Desc", TalentData.TalentType.DAMAGE, 10f, 20f, 5, 100, 1.5f); assertThat(data.getId()).isEqualTo("id_x"); assertThat(data.getName()).isEqualTo("Name"); assertThat(data.getDescription()).isEqualTo("Desc"); assertThat(data.getType()).isEqualTo(TalentData.TalentType.DAMAGE); assertThat(data.getPosition().x).isEqualTo(10f); assertThat(data.getPosition().y).isEqualTo(20f); assertThat(data.getMaxLevel()).isEqualTo(5); } @Test void addDependency_whenCalled_thenPresentInDependencyIds() { TalentData data = new TalentData( "child", "", "", TalentData.TalentType.HEALTH, 0, 0, 1, 10, 1f); data.addDependency("parent"); assertThat(data.getDependencyIds()).containsExactly("parent"); } @Test void getCost_whenLevelZero_thenBaseCost() { TalentData data = new TalentData( "id_x", "", "", TalentData.TalentType.DAMAGE, 0, 0, 5, 100, 1.5f); assertThat(data.getCost(0)).isEqualTo(100); } @Test void getCost_whenLevelOne_thenBaseCostTimesMultiplier() { TalentData data = new TalentData( "id_x", "", "", TalentData.TalentType.DAMAGE, 0, 0, 5, 100, 1.5f); // 100 * 1.5^1 = 150 (int cast) assertThat(data.getCost(1)).isEqualTo(150); } @Test void getCost_whenLevelTwo_thenAppliesExponent() { TalentData data = new TalentData( "id_x", "", "", TalentData.TalentType.DAMAGE, 0, 0, 5, 100, 1.5f); // 100 * 1.5^2 = 225 (int cast) assertThat(data.getCost(2)).isEqualTo(225); } @Test void enum_whenKeystone_thenEnumMatches() { assertThat(TalentData.TalentType.KEYSTONE).isNotEqualTo(TalentData.TalentType.DAMAGE); } } ``` - [ ] **Step 2: Run tests to verify they pass** Run: `./gradlew core:test --tests TalentDataTest` Expected: all 6 tests pass (no production change needed). - [ ] **Step 3: Commit** ```bash git add core/src/test/java/ru/project/tower/TalentDataTest.java git commit -m "test(TalentData): add unit tests for data accessors and cost formula" ``` --- ## Task 8: `GameContext` + its test **Files:** - Create: `core/src/main/java/ru/project/tower/GameContext.java` - Create: `core/src/test/java/ru/project/tower/GameContextTest.java` - [ ] **Step 1: Write the failing test** Create `core/src/test/java/ru/project/tower/GameContextTest.java`: ```java package ru.project.tower; import org.junit.jupiter.api.Test; import static org.assertj.core.api.Assertions.assertThat; class GameContextTest { @Test void construct_whenGivenTwoPreferences_thenAllServicesNonNull() { FakePreferences playerData = new FakePreferences(); FakePreferences abilities = new FakePreferences(); GameContext ctx = new GameContext(playerData, abilities); assertThat(ctx.playerStats).isNotNull(); assertThat(ctx.playerAbilities).isNotNull(); assertThat(ctx.gameState).isNotNull(); assertThat(ctx.talentTree).isNotNull(); assertThat(ctx.playerDataPrefs).isSameAs(playerData); assertThat(ctx.abilitiesPrefs).isSameAs(abilities); } @Test void construct_whenTalentTreeQueriesPlayerStats_thenReadsInjectedInstance() { FakePreferences playerData = new FakePreferences(); FakePreferences abilities = new FakePreferences(); GameContext ctx = new GameContext(playerData, abilities); ctx.playerStats.incrementTalentNode("damage_1"); assertThat(ctx.talentTree.getCurrentLevel("damage_1")).isEqualTo(1); } @Test void construct_whenPlayerDataPrefsSeeded_thenPlayerStatsReflects() { FakePreferences playerData = new FakePreferences(); playerData.putInteger("player_currency", 500); FakePreferences abilities = new FakePreferences(); GameContext ctx = new GameContext(playerData, abilities); assertThat(ctx.playerStats.getCurrency()).isEqualTo(500); } @Test void construct_whenAbilitiesPrefsSeeded_thenPlayerAbilitiesReflects() { FakePreferences playerData = new FakePreferences(); FakePreferences abilities = new FakePreferences(); abilities.putBoolean("has_shield_ability", true); GameContext ctx = new GameContext(playerData, abilities); assertThat(ctx.playerAbilities.hasAbility("shield")).isTrue(); } } ``` - [ ] **Step 2: Run test to verify it fails** Run: `./gradlew core:test --tests GameContextTest` Expected: `FAIL` with "cannot find symbol: class GameContext". - [ ] **Step 3: Create `GameContext`** Create `core/src/main/java/ru/project/tower/GameContext.java`: ```java package ru.project.tower; import com.badlogic.gdx.Preferences; /** * Composition-root holder for shared persistent-state services. * Construction order is load-bearing: {@link TalentTree} depends on {@link PlayerStats}. */ public class GameContext { public final Preferences playerDataPrefs; public final Preferences abilitiesPrefs; public final PlayerStats playerStats; public final PlayerAbilities playerAbilities; public final GameState gameState; public final TalentTree talentTree; public GameContext(Preferences playerDataPrefs, Preferences abilitiesPrefs) { this.playerDataPrefs = playerDataPrefs; this.abilitiesPrefs = abilitiesPrefs; this.playerStats = new PlayerStats(playerDataPrefs); this.playerAbilities = new PlayerAbilities(abilitiesPrefs); this.gameState = new GameState(); this.talentTree = new TalentTree(this.playerStats); } } ``` - [ ] **Step 4: Run tests to verify they pass** Run: `./gradlew core:test --tests GameContextTest` Expected: all 4 tests pass. Run the full suite: `./gradlew core:test` Expected: all accumulated tests still pass. - [ ] **Step 5: Commit** ```bash git add core/src/main/java/ru/project/tower/GameContext.java \ core/src/test/java/ru/project/tower/GameContextTest.java git commit -m "feat: add GameContext composition-root holder" ``` --- ## Task 9: Thread `GameContext` through `Main` and all five screens (atomic refactor) **Files:** - Modify: `core/src/main/java/ru/project/tower/Main.java` - Modify: `core/src/main/java/ru/project/tower/MainScreen.java` - Modify: `core/src/main/java/ru/project/tower/FirstScreen.java` - Modify: `core/src/main/java/ru/project/tower/AbilityShopScreen.java` - Modify: `core/src/main/java/ru/project/tower/AbilitySelectionScreen.java` - Modify: `core/src/main/java/ru/project/tower/TalentTreeScreen.java` This is a single atomic refactor: every screen constructor changes signature in one sweep so there is **no intermediate transition state** where some screens use `ctx` and others use `getInstance()`. Without atomicity, `GameState` (which is in-memory only, not `Preferences`-backed) would drift across two live instances during the transition and break the shop save/restore flow. The `getInstance()` methods themselves are not removed here — they stay alive but unused until Task 10. That keeps the diff focused on the wiring and lets Task 10 be a pure deletion. Mid-task the build will be red because each file edit leaves downstream call-sites broken until the next file is updated. That is expected — the build goes green only after all sub-steps are applied. - [ ] **Step 1: `Main.java` — build `GameContext` and pass it to `MainScreen`** Replace the entire file with: ```java package ru.project.tower; import com.badlogic.gdx.Game; import com.badlogic.gdx.Gdx; public class Main extends Game { private static final String PLAYER_DATA_PREFS_NAME = "tower_attack_player_data"; private static final String ABILITIES_PREFS_NAME = "tower_attack_player_abilities"; public GameContext ctx; @Override public void create() { ctx = new GameContext( Gdx.app.getPreferences(PLAYER_DATA_PREFS_NAME), Gdx.app.getPreferences(ABILITIES_PREFS_NAME) ); setScreen(new MainScreen(this, ctx)); } } ``` - [ ] **Step 2: `MainScreen.java` — accept `GameContext`, pass to downstream screens** In `core/src/main/java/ru/project/tower/MainScreen.java`: 1. Add a field near the existing private fields: ```java private final GameContext ctx; ``` 2. Change the ctor signature at line 87 from `public MainScreen(Game game) {` to: ```java public MainScreen(Game game, GameContext ctx) { this.ctx = ctx; ``` Leave the rest of the body identical (only the first assignment is added). 3. Update downstream screen-construction call-sites: | Line | Before | After | |---|---|---| | 222 | `game.setScreen(new AbilitySelectionScreen(game));` | `game.setScreen(new AbilitySelectionScreen(game, ctx));` | | 231 | `game.setScreen(new TalentTreeScreen(game));` | `game.setScreen(new TalentTreeScreen(game, ctx));` | | 251 | `game.setScreen(new AbilityShopScreen(game));` | `game.setScreen(new AbilityShopScreen(game, ctx));` | - [ ] **Step 3: `FirstScreen.java` — accept `GameContext`, cache four services, replace 13 call-sites** In `core/src/main/java/ru/project/tower/FirstScreen.java`: 1. Add fields in the field-declarations block (roughly near line 490 where `PlayerStats playerStats` and `PlayerAbilities playerAbilities` are already declared): ```java private final GameContext ctx; private GameState gameState; private TalentTree talentTree; ``` If `playerStats` and `playerAbilities` are already declared with `private` but without `final`, leave them as-is. The existing code later assigns them via `= PlayerStats.getInstance()`; we redirect those assignments below. 2. Change the ctor signature at line 496 from `public FirstScreen(Game game) {` to: ```java public FirstScreen(Game game, GameContext ctx) { this.ctx = ctx; ``` Leave the rest of the ctor body identical except for the field-wiring update in Step 3.4 below. 3. Replace the 13 `getInstance()` call-sites. Line numbers are from the pre-refactor state; after Steps 1–2 lines drift, so search by symbol. | Current line | Before | After | |---|---|---| | 179 | `return (long) TalentTree.getInstance().getStatBonus(TalentData.TalentType.COOLDOWN);` | `return (long) talentTree.getStatBonus(TalentData.TalentType.COOLDOWN);` | | 502 | `GameState gameState = GameState.getInstance();` | `GameState gameState = ctx.gameState;` | | 579 | `GameState gameState = GameState.getInstance();` | `GameState gameState = ctx.gameState;` | | 617 | `TalentTree talentTree = TalentTree.getInstance();` | `TalentTree talentTree = ctx.talentTree;` | | 644 | `playerStats = PlayerStats.getInstance();` | `playerStats = ctx.playerStats;` | | 645 | `playerAbilities = PlayerAbilities.getInstance();` | `playerAbilities = ctx.playerAbilities;` | | 1366 | `GameState gameState = GameState.getInstance();` | `GameState gameState = ctx.gameState;` | | 1474 | `TalentTree talentTree = TalentTree.getInstance();` | `TalentTree talentTree = ctx.talentTree;` | | 1497 | `GameState.getInstance().resetGameState();` | `ctx.gameState.resetGameState();` | | 1794 | `TalentTree tt = TalentTree.getInstance();` | `TalentTree tt = ctx.talentTree;` | | 2634 | `GameState.getInstance().setSpawnedBosses(spawnedBosses);` | `ctx.gameState.setSpawnedBosses(spawnedBosses);` | | 5737 | `GameState gameState = GameState.getInstance();` | `GameState gameState = ctx.gameState;` | | 6756 | `int critKeystone = TalentTree.getInstance().getCurrentLevel("keystone_crit");` | `int critKeystone = ctx.talentTree.getCurrentLevel("keystone_crit");` | 4. **Immediately after line 645 (where `playerAbilities = ctx.playerAbilities;` now lives)**, initialize the new cached fields so later code that references the local `gameState` / `talentTree` can fall back to them when not shadowed: ```java gameState = ctx.gameState; talentTree = ctx.talentTree; ``` 5. Update the internal screen-construction call in `FirstScreen`: | Line | Before | After | |---|---|---| | 6066 | `game.setScreen(new MainScreen(game));` | `game.setScreen(new MainScreen(game, ctx));` | - [ ] **Step 4: `AbilityShopScreen.java` — accept `GameContext`, replace 2 call-sites** In `core/src/main/java/ru/project/tower/AbilityShopScreen.java`: 1. Add field: `private final GameContext ctx;`. 2. Change ctor signature at line 65 to `public AbilityShopScreen(Game game, GameContext ctx) {` and add `this.ctx = ctx;` as the first line of the body. 3. Replace call-sites: | Line | Before | After | |---|---|---| | 69 | `this.playerStats = PlayerStats.getInstance();` | `this.playerStats = ctx.playerStats;` | | 70 | `this.playerAbilities = PlayerAbilities.getInstance();` | `this.playerAbilities = ctx.playerAbilities;` | | 209 | `game.setScreen(new MainScreen(game));` | `game.setScreen(new MainScreen(game, ctx));` | - [ ] **Step 5: `AbilitySelectionScreen.java` — accept `GameContext`, replace 2 call-sites + 2 screen-construction sites** In `core/src/main/java/ru/project/tower/AbilitySelectionScreen.java`: 1. Add field: `private final GameContext ctx;`. 2. Change ctor signature at line 94 to `public AbilitySelectionScreen(Game game, GameContext ctx) {` and add `this.ctx = ctx;` as the first line of the body. 3. Replace call-sites: | Line | Before | After | |---|---|---| | 171 | `PlayerAbilities playerAbilities = PlayerAbilities.getInstance();` | `PlayerAbilities playerAbilities = ctx.playerAbilities;` | | 219 | `gameInstance.setScreen(new MainScreen(gameInstance));` | `gameInstance.setScreen(new MainScreen(gameInstance, ctx));` | | 340 | `GameState gameState = GameState.getInstance();` | `GameState gameState = ctx.gameState;` | | 371 | `FirstScreen firstScreen = new FirstScreen(gameInstance);` | `FirstScreen firstScreen = new FirstScreen(gameInstance, ctx);` | - [ ] **Step 6: `TalentTreeScreen.java` — accept `GameContext`, replace 2 call-sites** In `core/src/main/java/ru/project/tower/TalentTreeScreen.java`: 1. Add field: `private final GameContext ctx;`. 2. Change ctor signature at line 65 to `public TalentTreeScreen(Game game, GameContext ctx) {` and add `this.ctx = ctx;` as the first line of the body. 3. Replace call-sites: | Line | Before | After | |---|---|---| | 98 | `this.talentTree = TalentTree.getInstance();` | `this.talentTree = ctx.talentTree;` | | 99 | `this.playerStats = PlayerStats.getInstance();` | `this.playerStats = ctx.playerStats;` | | 248 | `game.setScreen(new MainScreen(game));` | `game.setScreen(new MainScreen(game, ctx));` | - [ ] **Step 7: Compile check** Run: `./gradlew core:compileJava` Expected: `BUILD SUCCESSFUL`. All screen ctors take two args, all call-sites pass two args. If compile fails, search for missed `new XxxScreen(` patterns: `rg 'new (Main|First|AbilityShop|AbilitySelection|TalentTree)Screen\(' core/src/main | rg -v 'ctx'` should return zero hits. - [ ] **Step 8: Run test suite (no regression)** Run: `./gradlew core:test` Expected: all tests pass. Tests do not depend on screens, so the only way this fails is if a prior task's test was accidentally broken while touching singleton files. Unlikely, but good to verify. - [ ] **Step 9: Manual smoke test** Launch `lwjgl3/src/main/java/ru/project/tower/lwjgl3/Lwjgl3Launcher`. Verify: - Main menu renders with persisted currency / max wave. - Navigate: main menu → Ability Shop → back. - Navigate: main menu → Talent Tree → buy one level → back. Currency decremented. - Navigate: main menu → Ability Selection → pick freeze + explosion → Start Game. Wave 1 plays. - During wave: fire bullets, kill enemies. Enter shop mid-run if the game allows it; buy an upgrade; return to game. Confirm the upgrade applied (shop save/restore uses in-memory `GameState` — if `returnedFromShop` flag is lost, stats will reset on return). - Die. Return to menu. Currency reflects collected loot. - [ ] **Step 10: Commit** ```bash git add core/src/main/java/ru/project/tower/Main.java \ core/src/main/java/ru/project/tower/MainScreen.java \ core/src/main/java/ru/project/tower/FirstScreen.java \ core/src/main/java/ru/project/tower/AbilityShopScreen.java \ core/src/main/java/ru/project/tower/AbilitySelectionScreen.java \ core/src/main/java/ru/project/tower/TalentTreeScreen.java git commit -m "refactor: thread GameContext through Main and all screens" ``` --- ## Task 10: Remove legacy `getInstance()` methods and `instance` fields Every non-test call-site now uses `ctx.x` after Task 9. Time to delete the legacy paths. **Files:** - Modify: `core/src/main/java/ru/project/tower/PlayerStats.java` - Modify: `core/src/main/java/ru/project/tower/PlayerAbilities.java` - Modify: `core/src/main/java/ru/project/tower/GameState.java` - Modify: `core/src/main/java/ru/project/tower/TalentTree.java` - [ ] **Step 1: Verify no call-sites remain in production code** Run: `rg 'getInstance\(' core/src/main` Expected: zero matches. If any match appears, it's a missed call-site in Task 9 — fix it and re-run before continuing. - [ ] **Step 2: Delete `getInstance()` and `private static instance` from each singleton** In each of `PlayerStats.java`, `PlayerAbilities.java`, `GameState.java`, `TalentTree.java`: 1. Delete the `private static instance;` field. 2. Delete the `public static getInstance()` method. In `PlayerStats.java`, also delete: - The `PREFS_NAME` constant (the literal moved to `Main.java` in Task 9). - The `import com.badlogic.gdx.Gdx;` line — no longer used after removing `getInstance()`. In `PlayerAbilities.java`, also delete: - The `PREFS_NAME` constant. - Keep the `import com.badlogic.gdx.Gdx;` line — the ctor still logs errors via `Gdx.app.error(...)`. - [ ] **Step 3: Compile check** Run: `./gradlew core:compileJava` Expected: `BUILD SUCCESSFUL`. No call-sites, no references to the deleted symbols. - [ ] **Step 4: Run test suite** Run: `./gradlew core:test` Expected: all tests pass. The tests themselves do not call `getInstance()` (they construct services directly) — if any test is red here, it's a regression in how a service is built. Most likely culprit: accidentally deleted `preferences` field instead of just `instance`. - [ ] **Step 5: Grep-gate to confirm removal** Run: `rg 'getInstance\(' core/src/main` Expected: zero matches. Run: `rg 'private static \w+ instance;' core/src/main` Expected: zero matches. - [ ] **Step 6: Commit** ```bash git add core/src/main/java/ru/project/tower/PlayerStats.java \ core/src/main/java/ru/project/tower/PlayerAbilities.java \ core/src/main/java/ru/project/tower/GameState.java \ core/src/main/java/ru/project/tower/TalentTree.java git commit -m "refactor: remove legacy singleton getInstance methods" ``` --- ## Task 11: Final verification No code change — confirmation that the refactor is complete and the game still runs. - [ ] **Step 1: Full test run** Run: `./gradlew core:test` Expected: all tests pass. Count should be roughly: `HarnessSanityTest (1)` + `FakePreferencesTest (7)` + `PlayerStatsTest (13)` + `PlayerAbilitiesTest (8)` + `GameStateTest (6)` + `TalentTreeTest (12)` + `TalentDataTest (6)` + `GameContextTest (4)` = **57 tests**, all green. - [ ] **Step 2: Full build** Run: `./gradlew build` Expected: `BUILD SUCCESSFUL`. `lwjgl3` and `android` modules compile. - [ ] **Step 3: Desktop launcher smoke test** Launch `lwjgl3/src/main/java/ru/project/tower/lwjgl3/Lwjgl3Launcher`. Verify: - Main menu renders. Currency and max-wave values show up (non-zero if a previous session existed; otherwise zero — try the game once to seed them before running this check if you started from a clean `Preferences`). - Click "Talent Tree" → tree displays. Purchase one talent level. Return to menu — currency decremented. - Click "Ability Shop" → shop displays owned abilities with correct enabled state. - Click "Start Game" → ability-selection → first wave plays. Fire bullets, kill an enemy, collect currency, die. Return to menu — currency increased. - Close and reopen the launcher. Previous currency is persisted. - [ ] **Step 4: Final commit checkpoint** ```bash git add -A git status # should show clean working tree — no stray modifications git commit --allow-empty -m "chore(K.1): Phase 3.K.1 complete — test harness + DI refactor" ``` (If the project is not a git repository, skip the commit and treat this as a mental save-point — confirm that `git status` equivalent in your workflow shows the expected file set.) --- ## Spec coverage Mapping from the spec's requirements to the tasks that satisfy them: | Spec requirement | Task(s) | |---|---| | DI refactor: `PlayerStats`, `PlayerAbilities`, `GameState`, `TalentTree` | 3, 4, 5, 6 | | `GameContext` holder (public final fields, construction order) | 8 | | `Main` as composition root | 9 | | Screen ctors accept `GameContext` | 9 | | `FakePreferences` test helper | 2 | | JUnit 5 + AssertJ build config | 1 | | Test class layout under `core/src/test/java/ru/project/tower/` | all tasks 2+ | | `PlayerStatsTest` per spec test list | 3 | | `PlayerAbilitiesTest` per spec test list | 4 | | `GameStateTest` per spec test list | 5 | | `TalentTreeTest` per spec test list | 6 | | `TalentDataTest` per spec test list | 7 | | `GameContextTest` per spec test list | 8 | | Deletion of `getInstance()` methods | 10 | | Manual desktop-launcher smoke test | 9, 11 | | Headless libGDX *not* used | (entire plan — proved by use of `FakePreferences` only) | All spec requirements have at least one task. No placeholders. No references to methods introduced in later tasks without prior definition.