56 KiB
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:
GameContexthaspublic finalfields (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
// Arrangecomments. - No code coverage gate (no JaCoCo), no CI wiring.
- Headless libGDX is explicitly not used.
Project realities
- No git repository.
Is a git repository: falsein 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, rungit initonce 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 testbecomes 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 forPlayerStats
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-memoryPreferencesimplementation.core/src/test/java/ru/project/tower/FakePreferencesTest.javacore/src/test/java/ru/project/tower/PlayerStatsTest.javacore/src/test/java/ru/project/tower/PlayerAbilitiesTest.javacore/src/test/java/ru/project/tower/GameStateTest.javacore/src/test/java/ru/project/tower/TalentTreeTest.javacore/src/test/java/ru/project/tower/TalentDataTest.javacore/src/test/java/ru/project/tower/GameContextTest.java
Modified files:
core/build.gradle— addtestImplementationdeps anduseJUnitPlatform().core/src/main/java/ru/project/tower/PlayerStats.java— add public ctor; removegetInstance()(Task 10).core/src/main/java/ru/project/tower/PlayerAbilities.java— add public ctor; removegetInstance().core/src/main/java/ru/project/tower/GameState.java— make ctor public; removegetInstance().core/src/main/java/ru/project/tower/TalentTree.java— addTalentTree(PlayerStats)ctor; removegetInstance(); replace 2 internalPlayerStats.getInstance()calls with field.core/src/main/java/ru/project/tower/Main.java— composition root.core/src/main/java/ru/project/tower/MainScreen.java— takeGameContextin ctor; pass to downstream screens.core/src/main/java/ru/project/tower/FirstScreen.java— takeGameContext; cache services in fields; replace 13 call-sites.core/src/main/java/ru/project/tower/AbilityShopScreen.java— takeGameContext; replace 2 call-sites.core/src/main/java/ru/project/tower/AbilitySelectionScreen.java— takeGameContext; replace 2 call-sites; update internalnew FirstScreen(...)andnew MainScreen(...)construction.core/src/main/java/ru/project/tower/TalentTreeScreen.java— takeGameContext; replace 2 call-sites; update internalnew 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 37–75 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 33–77 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:
- Add a
private final PlayerStats stats;field. - Replace the single constructor (lines 14–18) and
getInstance()(lines 20–25):
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;
}
- Replace line 133 (
return PlayerStats.getInstance().getTalentNodeLevel(talentId);) with:
return stats.getTalentNodeLevel(talentId);
- 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— buildGameContextand pass it toMainScreen
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— acceptGameContext, pass to downstream screens
In core/src/main/java/ru/project/tower/MainScreen.java:
- Add a field near the existing private fields:
private final GameContext ctx;
- 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).
- 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— acceptGameContext, cache four services, replace 13 call-sites
In core/src/main/java/ru/project/tower/FirstScreen.java:
- Add fields in the field-declarations block (roughly near line 490 where
PlayerStats playerStatsandPlayerAbilities playerAbilitiesare 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.
- 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.
- 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"); |
- Immediately after line 645 (where
playerAbilities = ctx.playerAbilities;now lives), initialize the new cached fields so later code that references the localgameState/talentTreecan fall back to them when not shadowed:
gameState = ctx.gameState;
talentTree = ctx.talentTree;
- 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— acceptGameContext, replace 2 call-sites
In core/src/main/java/ru/project/tower/AbilityShopScreen.java:
- Add field:
private final GameContext ctx;. - Change ctor signature at line 65 to
public AbilityShopScreen(Game game, GameContext ctx) {and addthis.ctx = ctx;as the first line of the body. - 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— acceptGameContext, replace 2 call-sites + 2 screen-construction sites
In core/src/main/java/ru/project/tower/AbilitySelectionScreen.java:
- Add field:
private final GameContext ctx;. - Change ctor signature at line 94 to
public AbilitySelectionScreen(Game game, GameContext ctx) {and addthis.ctx = ctx;as the first line of the body. - 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— acceptGameContext, replace 2 call-sites
In core/src/main/java/ru/project/tower/TalentTreeScreen.java:
- Add field:
private final GameContext ctx;. - Change ctor signature at line 65 to
public TalentTreeScreen(Game game, GameContext ctx) {and addthis.ctx = ctx;as the first line of the body. - 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— ifreturnedFromShopflag 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()andprivate static instancefrom each singleton
In each of PlayerStats.java, PlayerAbilities.java, GameState.java, TalentTree.java:
- Delete the
private static <Class> instance;field. - Delete the
public static <Class> getInstance()method.
In PlayerStats.java, also delete:
- The
PREFS_NAMEconstant (the literal moved toMain.javain Task 9). - The
import com.badlogic.gdx.Gdx;line — no longer used after removinggetInstance().
In PlayerAbilities.java, also delete:
-
The
PREFS_NAMEconstant. -
Keep the
import com.badlogic.gdx.Gdx;line — the ctor still logs errors viaGdx.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.