Files
square-defense/docs/superpowers/plans/2026-04-17-phase3-k1-test-harness.md
2026-06-16 10:57:01 +03:00

56 KiB
Raw Permalink Blame History

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.2K.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 28). Tasks 914 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:

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):

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
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:

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<String, Object>. flush() is a no-op.

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<String, Object> 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<String, ?> 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<String, ?> 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
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:

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 3775 in the current file) with:

    /**
     * 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
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:

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<String> list = abilities.getPurchasedAbilities();

        assertThat(list).containsExactly("freeze", "explosion");
    }

    @Test
    void getPurchasedAbilities_afterShieldPurchase_thenIncludesShield() {
        abilities.purchaseAbility("shield");

        Array<String> 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<String> 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 3377 in the current file) with:

    /**
     * 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
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:

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<String> first = new Array<>();
        first.add("freeze");
        state.setSelectedAbilities(first);

        Array<String> second = new Array<>();
        second.add("dash");
        second.add("pull");
        state.setSelectedAbilities(second);

        Array<String> stored = state.getSelectedAbilities();
        assertThat(stored.size).isEqualTo(2);
        assertThat(stored).contains("dash", "pull");
    }

    @Test
    void resetGameState_whenInvoked_thenClearsReturnFlagAndAbilities() {
        Array<String> 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<String> 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:

    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
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:

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<TalentData> 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 1418) and getInstance() (lines 2025):
    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;
    }
  1. Replace line 133 (return PlayerStats.getInstance().getTalentNodeLevel(talentId);) with:
        return stats.getTalentNodeLevel(talentId);
  1. Replace line 138 (PlayerStats.getInstance().incrementTalentNode(talentId);) with:
        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
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:

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
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:

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:

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
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:

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:
    private final GameContext ctx;
  1. Change the ctor signature at line 87 from public MainScreen(Game game) { to:
    public MainScreen(Game game, GameContext ctx) {
        this.ctx = ctx;

Leave the rest of the body identical (only the first assignment is added).

  1. 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):
    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.

  1. Change the ctor signature at line 496 from public FirstScreen(Game game) { to:
    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.

  1. Replace the 13 getInstance() call-sites. Line numbers are from the pre-refactor state; after Steps 12 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");
  1. 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:
        gameState = ctx.gameState;
        talentTree = ctx.talentTree;
  1. 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

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 <Class> instance; field.
  2. Delete the public static <Class> 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
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

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.