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