Initial commit

This commit is contained in:
2026-06-16 10:57:01 +03:00
commit 1cd5c8b03c
277 changed files with 42921 additions and 0 deletions
@@ -0,0 +1,604 @@
# TowerAn — Phase 3: Content, Meta & Retention
> **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.
>
> **Note on TDD:** This project has **no test infrastructure** (libGDX game, procedural rendering, no existing JUnit/Spock tests). The "write failing test first" ritual is not applicable. Verification is **visual / manual**: run the desktop launcher (`lwjgl3/src/main/java/ru/project/tower/lwjgl3/Lwjgl3Launcher`), reproduce the scenario, confirm the change. Each task lists explicit visual checks in place of test assertions. Introducing a test harness is itself a Phase 3 candidate (Task 0) but is not a prerequisite.
**Goal:** Close the content and retention gaps identified by the three game-design agents — new enemy archetypes, boss fight depth, environment variety, audio, onboarding, achievements, daily seeds, save/resume, and full-tree build diversity.
**Architecture:** All enemies extend `BaseCircleData` in the flat `ru.project.tower` package. New systems (achievements, daily seed, settings) piggyback on existing `Preferences`-based persistence alongside `PlayerStats`/`PlayerAbilities`. Onboarding is a `FirstScreen` overlay (same pattern as the mid-run upgrade pick added in Phase 2). Biomes are data-driven palette swaps applied each wave.
**Tech Stack:** Java 8, libGDX 1.13.1, `ShapeRenderer`/`SpriteBatch`, `Preferences` for persistence, `GlyphLayout` for tooltips.
**Prerequisites:** Phases 1 and 2 merged. Run the game at least once to confirm Phase 2 overlays render correctly (the Phase 2 session discovered that `renderUI()`/`renderGameObjects()` are not wired — render pipeline is direct-to-`render()`; new renderers must be called explicitly from `render()`).
---
## Task ordering
Tasks are grouped by area. Within an area they go easiest → hardest. Between areas:
1. **Enemy archetypes** (pure data classes, mechanical payoff, low risk)
2. **Boss depth** (enrage phase, reuses existing frameworks)
3. **Projectile variants** (ties into existing `BulletData` flags from Phase 1)
4. **Audio pack** (tiny code, requires asset decisions)
5. **Biomes** (procedural palette swap)
6. **Onboarding / tooltips** (touches many screens)
7. **Achievements & unlocks** (new persistent store)
8. **Daily seed & run modifiers** (roguelite hook)
9. **Save/resume mid-run** (serialization work)
10. **Ability mastery** (XP per ability, optional late-tier)
Each Task group ends with a commit. Skip any group that the user explicitly de-prioritizes.
---
# Area A — Enemy archetypes
Two complaints from the variety agent: **only 5 enemy archetypes** (Basic/Strong/Fast/Spawner/Splitter), **all travel in a straight line toward center** (no behaviour differences beyond HP/speed/colour). Add 4 behaviourally distinct enemies. Each is a single file + a tiny spawn-chance hook in `FirstScreen.spawnCircle`.
## Task A1: `HealerCircleData` — AoE healer
**Files:**
- Create: `core/src/main/java/ru/project/tower/HealerCircleData.java`
- Modify: `core/src/main/java/ru/project/tower/FirstScreen.java` (add spawn branch + constants)
- [ ] **Step 1: Create `HealerCircleData`**
```java
package ru.project.tower;
import com.badlogic.gdx.math.Circle;
import com.badlogic.gdx.math.Vector2;
import com.badlogic.gdx.utils.TimeUtils;
public class HealerCircleData extends BaseCircleData {
private static final long HEAL_INTERVAL_MS = 1500L;
private static final float HEAL_RADIUS = 120f;
private static final int HEAL_AMOUNT = 3;
private long lastHealTime = TimeUtils.millis();
public HealerCircleData(Circle circle, Vector2 velocity, int health, int damage) {
super(circle, velocity, health, damage);
}
public boolean shouldPulse() {
if (TimeUtils.millis() - lastHealTime > HEAL_INTERVAL_MS) {
lastHealTime = TimeUtils.millis();
return true;
}
return false;
}
public static float getHealRadius() { return HEAL_RADIUS; }
public static int getHealAmount() { return HEAL_AMOUNT; }
@Override
public float[] getColor() {
return new float[]{0.2f, 1.0f, 0.5f, 1f};
}
@Override
protected int getBaseReward() { return 4; }
}
```
- [ ] **Step 2: Add spawn constants in `FirstScreen.java`** near the other enemy constants (search for `SPLITTER_CIRCLE_CHANCE`):
```java
private static final float HEALER_CIRCLE_CHANCE = 0.08f;
private static final int HEALER_CIRCLE_HEALTH = 6;
private static final int HEALER_START_WAVE = 8;
```
- [ ] **Step 3: Add spawn branch in `spawnCircle()`** — after the `isSplitterCircle` branch, before `isStrongCircle`:
```java
boolean isHealerCircle = !isSpawnerCircle && !isFastCircle && !isSplitterCircle
&& currentWave >= HEALER_START_WAVE
&& random < (currentFastCircleChance + currentSplitterCircleChance + HEALER_CIRCLE_CHANCE);
```
Then add a construction branch mirroring the other archetypes and set `spawned = new HealerCircleData(...)`.
- [ ] **Step 4: Pulse loop in the per-frame circle iterator** (same block that spawns from `SpawnerCircleData`):
```java
if (circleData instanceof HealerCircleData) {
HealerCircleData healer = (HealerCircleData) circleData;
if (healer.shouldPulse()) {
for (BaseCircleData other : circles) {
if (other == healer) continue;
if (Vector2.dst(healer.getX(), healer.getY(), other.getX(), other.getY())
<= HealerCircleData.getHealRadius()) {
other.health += HealerCircleData.getHealAmount();
}
}
createExplosion(healer.getX(), healer.getY(),
new Color(0.2f, 1f, 0.5f, 1f));
}
}
```
- [ ] **Step 5: Visual check** — play until wave 8, spot a green enemy, watch nearby enemies gain HP (visible because `StrongCircleData.getColor` scales alpha with health — strong enemies near a healer should stay bright blue).
- [ ] **Step 6: Commit**
```bash
git add core/src/main/java/ru/project/tower/HealerCircleData.java \
core/src/main/java/ru/project/tower/FirstScreen.java
git commit -m "feat: add HealerCircleData AoE healer enemy"
```
---
## Task A2: `DasherCircleData` — telegraphed charge
Uses `JuggernautData`'s charge pattern at a smaller scale for a rank-and-file enemy.
**Files:**
- Create: `core/src/main/java/ru/project/tower/DasherCircleData.java`
- Modify: `FirstScreen.java` (spawn branch, per-frame state tick, render tint for the "windup" phase)
- [ ] **Step 1: Create class** — holds a `long dashReadyAt` and `boolean dashing`. At `update()` time: if idle and in range of player, enter windup for 800ms (velocity halved, visually pulses yellow), then lock a direction, multiply velocity ×3 for 400ms, return to normal.
```java
package ru.project.tower;
import com.badlogic.gdx.math.Circle;
import com.badlogic.gdx.math.Vector2;
import com.badlogic.gdx.utils.TimeUtils;
public class DasherCircleData extends BaseCircleData {
private static final long WINDUP_MS = 800L;
private static final long DASH_MS = 400L;
private static final float DASH_SPEED_MULT = 3f;
private enum State { IDLE, WINDUP, DASHING }
private State state = State.IDLE;
private long stateEnter = 0;
private Vector2 baseVelocity;
public DasherCircleData(Circle circle, Vector2 velocity, int health, int damage) {
super(circle, velocity, health, damage);
this.baseVelocity = new Vector2(velocity);
}
public boolean isWindup() { return state == State.WINDUP; }
public boolean isDashing() { return state == State.DASHING; }
public void tick(float squareX, float squareY) {
long now = TimeUtils.millis();
switch (state) {
case IDLE:
if (Vector2.dst(getX(), getY(), squareX, squareY) < 280f) {
state = State.WINDUP;
stateEnter = now;
velocity.scl(0.3f);
}
break;
case WINDUP:
if (now - stateEnter > WINDUP_MS) {
state = State.DASHING;
stateEnter = now;
float dx = squareX - getX(), dy = squareY - getY();
float len = (float)Math.sqrt(dx*dx + dy*dy);
if (len > 0f) {
velocity.set(dx/len, dy/len).scl(baseVelocity.len() * DASH_SPEED_MULT);
}
}
break;
case DASHING:
if (now - stateEnter > DASH_MS) {
state = State.IDLE;
velocity.set(baseVelocity);
}
break;
}
}
@Override
public float[] getColor() {
switch (state) {
case WINDUP: return new float[]{1f, 0.9f, 0.3f, 1f};
case DASHING: return new float[]{1f, 0.4f, 0.1f, 1f};
default: return new float[]{0.9f, 0.6f, 0.2f, 1f};
}
}
@Override
protected int getBaseReward() { return 3; }
}
```
- [ ] **Step 2: Spawn branch** in `spawnCircle` with `currentWave >= 6` and chance 0.10.
- [ ] **Step 3: Call `dasher.tick(squareCenterX, squareCenterY)`** inside the per-frame circles loop (same spot that handles spawner spawning).
- [ ] **Step 4: Visual check** — from wave 6 a dasher should pulse yellow for ~0.8s then streak toward the square at 3× speed. Good stress test for Dash/Shield abilities.
- [ ] **Step 5: Commit.**
---
## Task A3: `SniperCircleData` — ranged shooter
Stays at range, fires `BulletData(isEnemyBullet=true)` projectiles.
**Files:** as above.
- [ ] **Step 1: Class holds `long lastShotAt` and overrides movement** — moves toward player until distance ~= 400, then stops (velocity set to 0) and fires every 2s.
- [ ] **Step 2: Spawning & firing** mirror `ShadowWeaverData.shouldShoot()` usage on lines 59355940 of current FirstScreen.
- [ ] **Step 3: Balance** — damage = 2× basic damage, HP = 0.5× basic.
- [ ] **Step 4: Visual check** — sniper stops in a ring ~400px from square, red tracer shots visible. Shield/Dash should block them.
- [ ] **Step 5: Commit.**
---
## Task A4: `GnatCircleData` — 1-HP swarm
Fastest type, 1 HP, spawned in packs. Overrides `BaseCircleData`'s single-spawn assumption by inserting a small "swarm" around a spawn roll.
**Files:** as above.
- [ ] **Step 1: Class is trivial** — health always 1, speed 1.8× basic, radius 0.6× basic.
- [ ] **Step 2: Spawn as a pack** — when rolled, call `spawnCircle`-like helper 5 times with small offsets around the roll point. Add `isGnatSwarm` branch before the Basic branch.
- [ ] **Step 3: Visual check** — from wave 10+, occasional pack of tiny pink dots darts in. AoE abilities should shred them.
- [ ] **Step 4: Commit.**
---
# Area B — Boss depth
## Task B1: Enrage phase for all 6 bosses
Add a shared `BaseBossData` abstract class **or** a `BossEnrage` mixin-helper invoked from each boss. Pragmatic path: helper invoked from each boss's `update()`.
**Files:**
- Modify: `JuggernautData`, `SwarmQueenData`, `GlacialWardenData`, `IllusionMasterData`, `ShadowWeaverData`, `VolatileReactorData`
- [ ] **Step 1: Add per-boss `maxHealth` field** — captured at construction time. Boss HP is already scaled in `spawnRandomBoss`; snapshot right after construction.
- [ ] **Step 2: Add `boolean isEnraged()` per boss**`health / maxHealth < 0.33f`. Cache the flag so enrage triggers once.
- [ ] **Step 3: Multiply attack cadence by 1.5× when enraged** — find each `shouldShoot`/`shouldCharge`/`shouldCreateFireZone` cadence constant and divide by `isEnraged() ? 1.5f : 1f`.
- [ ] **Step 4: Visual/audio telegraph** — one-time screen shake + red flash + `"ENRAGE!"` floating text when transitioning. Trigger from `FirstScreen.updateBosses` by checking `boss.isEnraged() && !boss.enrageTriggered`.
- [ ] **Step 5: Visual check** — boss at low HP flashes red, floating text appears, projectile cadence noticeably faster.
- [ ] **Step 6: Commit.**
## Task B2: De-templatize the "projectile barrage + summon" pattern
Agent noted 4 of 6 bosses use the same `burst + minions` pattern. Low-effort differentiator:
- [ ] **Step 1: Give `ShadowWeaverData` a beam attack** (continuous line that deals 1 dmg/tick instead of its volley).
- [ ] **Step 2: Give `GlacialWardenData` a wall-spawn** (creates temporary immovable obstacles that bullets collide with).
- [ ] **Step 3: Commit.**
_(Lower priority — skip if schedule tight.)_
---
# Area C — Projectile variants exposed to the player
Phase 1 added `piercesLeft / homing / aoeRadius` to `BulletData` and Phase 2 wired pierce + AoE to keystones. `homing` is defined but unimplemented.
## Task C1: Implement homing
**Files:** `FirstScreen.java`, `BulletData.java`
- [ ] **Step 1:** In the bullet-update loop (where `bulletData.move(delta)` lives), if `bullet.homing && !bullet.isEnemyBullet()`: find nearest enemy within 400px, compute desired velocity direction, lerp `bullet.velocity` toward it by `0.06f` per frame (caps turn rate so homing feels responsive but not snap-on).
- [ ] **Step 2:** Add a new **Keystone talent** `keystone_homing` (tier 3) that sets `bullet.homing = true` on all bullets when level >= 1. Add registration in `TalentTree.initializeTree`, add `"keystone_homing"` to `PlayerStats.TALENT_NODE_IDS`.
- [ ] **Step 3: Visual check** — with the keystone bought, bullets curve toward enemies.
- [ ] **Step 4: Commit.**
## Task C2: Chain lightning on hit
New per-bullet flag `chainJumps` (int). On collision, if `chainJumps > 0`, find nearest enemy within 200px, spawn a short-lived line render + deal damage, decrement.
- [ ] **Step 1:** Add field to `BulletData`.
- [ ] **Step 2:** Add collision-site chain logic in `FirstScreen` bullet-vs-circle resolution block (near the existing `bulletData.aoeRadius > 0f` check).
- [ ] **Step 3:** Render the chain as a thin additive-blend line from hit-point to hit-target, alpha fading over 150ms.
- [ ] **Step 4:** New keystone `keystone_chain`. Each level = +1 jump.
- [ ] **Step 5: Commit.**
---
# Area D — Audio pack
The project ships with **2 sounds** (`pop.wav`, `shot.wav`) and 1 PNG. Need a base pack. **Asset dependency** — cannot be completed in a code-only session. The plan's task is to wire up the loader; the user supplies the files.
## Task D1: Sound asset loader
**Files:**
- Create: `core/src/main/java/ru/project/tower/SoundAssets.java`
- Modify: `FirstScreen.java`
- [ ] **Step 1: Create `SoundAssets` singleton** that lazy-loads and caches `Sound` handles:
```java
public class SoundAssets {
private static SoundAssets instance;
private final Map<String, Sound> cache = new HashMap<>();
public static SoundAssets get() {
if (instance == null) instance = new SoundAssets();
return instance;
}
public Sound load(String filename) {
Sound s = cache.get(filename);
if (s != null) return s;
FileHandle f = Gdx.files.internal(filename);
if (!f.exists() || f.length() < 200) return null;
try { s = Gdx.audio.newSound(f); cache.put(filename, s); return s; }
catch (Exception e) { return null; }
}
public void play(String filename, float volume, float pitch) {
Sound s = load(filename);
if (s != null) s.play(volume, pitch, 0f);
}
}
```
- [ ] **Step 2: Replace direct `Gdx.audio.newSound` in `FirstScreen.show`** with `SoundAssets.get().load(...)`.
- [ ] **Step 3: Add explicit call sites** for the 10 events the variety agent listed. For each event, add `SoundAssets.get().play("<name>.wav", vol, pitch)`:
- `player_hit.wav` — in the 5 damage-to-player sites added in Phase 1
- `boss_spawn.wav` — in `spawnRandomBoss`
- `ability_freeze.wav` / `_explosion.wav` / `_shield.wav` / `_impulse.wav` / `_dash.wav` / `_pull.wav` / `_turret.wav` — start of each `activateX` method
- `wave_start.wav` — first line of `startNextWave`
- `wave_clear.wav` — in `endWave` after bonus calculation
- `game_over.wav` — first line of `gameOver`
- `ui_click.wav` — in `handleUpgradeButtons` / `handleUpgradePickTouch` after a successful button hit
- [ ] **Step 4:** Missing files degrade silently (loader returns null → play is no-op). No crash if user hasn't supplied assets yet.
- [ ] **Step 5: Commit.**
## Task D2: Music loop slots
- [ ] **Step 1:** Add `Music menuTrack, combatTrack, bossTrack` fields; load via `Gdx.audio.newMusic(Gdx.files.internal("music/...ogg"))` guarded by `exists()`.
- [ ] **Step 2:** Start combat track in `show()`; switch to boss track when a boss is active (`isAnyBossOnScreen`); restore combat track when boss dies.
- [ ] **Step 3: Commit.**
---
# Area E — Biomes / palette variety
One `BACKGROUND_COLOR`, one particle palette. Agent: rotate every ~5 waves.
## Task E1: `BiomePalette` data class + per-wave selection
**Files:**
- Create: `core/src/main/java/ru/project/tower/BiomePalette.java`
- Modify: `FirstScreen.java`
- [ ] **Step 1: Create class**
```java
public class BiomePalette {
public final Color background;
public final Color particle1;
public final Color particle2;
public final String label;
public BiomePalette(Color bg, Color p1, Color p2, String label) {
this.background = bg; this.particle1 = p1; this.particle2 = p2; this.label = label;
}
public static final BiomePalette[] ALL = {
new BiomePalette(new Color(0.05f,0.05f,0.10f,1f), new Color(0.2f,0.6f,1f,1f), new Color(1f,0.2f,0.6f,1f), "Neon City"),
new BiomePalette(new Color(0.02f,0.08f,0.05f,1f), new Color(0.2f,1f,0.5f,1f), new Color(1f,1f,0.3f,1f), "Bio Jungle"),
new BiomePalette(new Color(0.08f,0.02f,0.02f,1f), new Color(1f,0.3f,0.1f,1f), new Color(1f,0.8f,0.2f,1f), "Magma Forge"),
new BiomePalette(new Color(0.03f,0.03f,0.12f,1f), new Color(0.5f,0.2f,1f,1f), new Color(0.2f,0.9f,1f,1f), "Void Shift"),
};
}
```
- [ ] **Step 2: Select in `startNextWave`**`currentBiome = BiomePalette.ALL[(currentWave / 5) % BiomePalette.ALL.length]`. Replace the static `BACKGROUND_COLOR` references in `render()` with `currentBiome.background`.
- [ ] **Step 3: Background particle colors** — in the background particle spawn logic, pick randomly between `currentBiome.particle1` and `particle2`.
- [ ] **Step 4: Biome transition floating text** — when biome changes, spawn a 3-second floating text with `currentBiome.label`.
- [ ] **Step 5: Visual check** — at waves 5, 10, 15, 20 the background and particle colors rotate.
- [ ] **Step 6: Commit.**
## Task E2: Random wave modifiers
Once per run, at wave-start, roll a 25% chance of a modifier from a small list: "double gravity" (pulls enemies toward center faster), "fog" (shooting range -30%), "rush" (+25% enemy speed, +25% reward). Display as floating text at wave start. Stores as fields on `FirstScreen`; cleared in `endWave`.
- [ ] **Step 1:** Add `enum WaveModifier { NONE, GRAVITY, FOG, RUSH }` and `WaveModifier currentWaveModifier`.
- [ ] **Step 2:** Roll in `startNextWave`.
- [ ] **Step 3:** Apply effects: GRAVITY → multiply `currentEnemySpeedMultiplier` by 1.3 and pull velocity toward center each tick; FOG → shrink `SHOOTING_RANGE` effective value by 0.7; RUSH → multiply speed × 1.25, reward × 1.25.
- [ ] **Step 4:** Floating text at wave start with the label.
- [ ] **Step 5: Commit.**
---
# Area F — Onboarding
## Task F1: Tutorial overlay for first run
**Files:**
- Modify: `FirstScreen.java`, `PlayerStats.java` (add `tutorialSeen` preference)
- [ ] **Step 1:** Add boolean `tutorialSeen` to `PlayerStats` persisted in Preferences, default false.
- [ ] **Step 2:** In `FirstScreen.show`, if `!playerStats.tutorialSeen`, set `showTutorialStep = 0` and skip auto-starting wave 1.
- [ ] **Step 3:** Add `renderTutorialOverlay()` method — dims the screen and shows one tip at a time with an arrow pointing at the relevant UI region.
- Step 0: "Автоматическая турель в центре стреляет по ближайшему врагу"
- Step 1: "Кнопки способностей снизу — жмите когда заряжены"
- Step 2: "Каждые 3 волны выбирайте улучшение"
- Step 3: "Магазин и дерево талантов в главном меню"
- [ ] **Step 4:** Tap advances. Last step → `tutorialSeen = true; saveData();` and wave 1 starts.
- [ ] **Step 5: Call `renderTutorialOverlay()` at the end of `render()`** (alongside `renderUpgradePickOverlay`).
- [ ] **Step 6: Commit.**
## Task F2: Tooltips on upgrade/talent/ability buttons
- [ ] **Step 1:** Add `tooltipText` field on each button and track hover state (already tracked in `renderNeonButton` via `isHovered`).
- [ ] **Step 2:** When hovered, render a small panel above the button with text. One pass, one method — `drawTooltip(Rectangle btn, String text)`.
- [ ] **Step 3:** Wire per button — damage/speed/cooldown upgrades get stat deltas, ability buttons get radius/damage/duration values.
- [ ] **Step 4: Commit.**
---
# Area G — Achievements & unlocks
## Task G1: Achievements store
**Files:**
- Create: `core/src/main/java/ru/project/tower/Achievements.java`
- Modify: `FirstScreen.java` (hook increment calls)
- [ ] **Step 1: Define enum** of 15 achievements:
- `FIRST_BLOOD` (1 kill), `KILL_100`, `KILL_1000`
- `WAVE_10`, `WAVE_25`, `WAVE_50`
- `BOSS_ALL_6` (defeat each boss once)
- `NO_HIT_WAVE` (complete a wave with no damage taken)
- `FREEZE_100_FROZEN`, `SHATTER_50`
- `ELITE_KILL_100`
- `TALENT_TREE_COMPLETE`, `FULL_ABILITY_ROSTER`
- `DAILY_SEED_FIRST`, `RUN_WITH_ALL_3_KEYSTONES`
- [ ] **Step 2: `Achievements` singleton with `Preferences`-backed unlocked set and counters**. `increment(key)`, `unlock(id)`, `isUnlocked(id)`. Each unlock awards a currency bounty (e.g., 25 for easy, 100 for hard).
- [ ] **Step 3: Hook call sites** — kill counter in the bullet-kill block, wave counter in `endWave`, boss-defeated in the circle.health<=0 block when `isBossCircle`, etc.
- [ ] **Step 4: Unlock toast** — on first unlock of an achievement, floating text "ДОСТИЖЕНИЕ: <name> (+N монет)" + shake.
- [ ] **Step 5:** Add an "Achievements" button on `MainScreen` → new `AchievementsScreen` with a scrollable list (use same stage/skin style as `AbilityShopScreen`).
- [ ] **Step 6: Commit.**
## Task G2: Cosmetic unlocks
Simple: square tint unlocks (5 colours unlocked by achievement thresholds). Stored in `PlayerStats.selectedTint`. Applied in `renderNeonSquare`.
- [ ] **Step 1:** Add `String selectedTint` / list of unlocked tints to `PlayerStats`.
- [ ] **Step 2:** Cosmetics screen launched from `MainScreen`.
- [ ] **Step 3: Commit.**
---
# Area H — Daily seed & run modifiers
## Task H1: Daily seed
**Files:**
- Create: `core/src/main/java/ru/project/tower/DailySeed.java`
- Modify: `MainScreen.java`, `FirstScreen.java`
- [ ] **Step 1:** `DailySeed.todaySeed()` returns `LocalDate.now().toString().hashCode()`.
- [ ] **Step 2:** New "Daily Challenge" button on main menu that starts a run with `MathUtils.random.setSeed(seed)` injected into `FirstScreen` before first `spawnCircle`.
- [ ] **Step 3:** Record best-wave-today in Preferences keyed by date. Display on main menu "Daily: best W14".
- [ ] **Step 4:** At wave end in daily mode, also save to a rolling 7-day log for a "last 7 days" leaderboard view.
- [ ] **Step 5: Commit.**
## Task H2: Pre-run modifiers
Before starting a run, offer an optional modifier card: "+30% reward if all 3 abilities selected", "Bosses +50% HP / reward ×2", etc. Applied as `GameState.runModifier` flag, checked in relevant methods.
- [ ] Deferred — pair with Area E2 work.
---
# Area I — Save mid-run
Agent noted `GameState` is in-memory only. Serialize run to JSON on pause/quit so players can resume.
**Files:**
- Modify: `GameState.java`, `FirstScreen.java` (serialize on pause, deserialize on show)
- [ ] **Step 1:** Add `toJson()`/`fromJson(String)` on `GameState` using libGDX `Json`. Include: `currentWave`, `squareHealth`, `maxSquareHealth`, `money`, `runDamageBonus`, `runPierceBonus`, etc. (the Phase 2 `run*` fields).
- [ ] **Step 2:** On pause (`shouldPause` branch), write JSON string into `Preferences`.
- [ ] **Step 3:** On `FirstScreen.show`, if a saved run exists and user chose "Resume" from main menu, deserialize and jump to `startNextWave`-equivalent state.
- [ ] **Step 4:** Main menu gets a "Resume" button that appears only when save exists.
- [ ] **Step 5: Commit.**
---
# Area J — Ability mastery
Roguelite depth — each ability gains XP as it's used; XP unlocks passive modifiers.
**Files:**
- Create: `core/src/main/java/ru/project/tower/AbilityMastery.java`
- Modify: `FirstScreen.java` (increment on each cast), `AbilitySelectionScreen.java` (show levels)
- [ ] **Step 1:** `AbilityMastery` singleton tracks `int masteryXp[ability]` persisted in Preferences. Level = `floor(sqrt(xp/10))`.
- [ ] **Step 2:** Increment 10 XP per cast; 50 XP per "meaningful kill" (freeze of 5+, explosion of 10+ etc.)
- [ ] **Step 3:** Unlocks per level:
- Freeze L2: +30% radius; L4: +25% duration; L6: enemies under Freeze take +25% bullet damage.
- Explosion L2: +30% radius; L4: applies brief slow; L6: double damage vs frozen (stacks with Phase 1 synergy).
- Shield L2: +1s duration; L4: reflects projectiles; L6: explosion on expire.
- Impulse L2: +30% force; L4: +50% damage to pushed enemies; L6: also applies freeze 500ms.
- Dash L2: +150ms i-frames; L4: leaves fire trail; L6: double-dash (2 charges).
- Pull L2: +30% radius; L4: pulled enemies take +20% damage for 2s; L6: pulls bosses briefly.
- Turret L2: +3s lifetime; L4: +25% fire rate; L6: turret shots pierce +1.
- [ ] **Step 4: Visualise** — in `AbilitySelectionScreen`, show level number on each selected ability card.
- [ ] **Step 5: Commit.**
---
# Area K — Test harness (optional, enabler for future work)
Not required for any Phase 3 task but strongly suggested before Phase 4.
- [ ] **Step 1:** Add JUnit 5 to `core/build.gradle`: `testImplementation 'org.junit.jupiter:junit-jupiter:5.9.0'`.
- [ ] **Step 2:** Headless libGDX test scaffolding via `Headless Application` — enough for `PlayerStats`, `Achievements`, `TalentTree`, `DailySeed` logic tests.
- [ ] **Step 3:** One sample test per class to prove the pattern.
- [ ] **Step 4:** Document in README the `gradle test` command.
- [ ] **Step 5: Commit.**
---
## Priority summary
| Priority | Area | Rationale |
| --- | --- | --- |
| P1 | A1 Healer, A3 Sniper, B1 Enrage, E1 Biomes | Cheap, high-impact variety; no new assets |
| P1 | F1 Tutorial, F2 Tooltips | Fixes onboarding (agent's #3 critical gap) |
| P1 | G1 Achievements (core only) | Fixes retention goals (agent's #2 critical gap) |
| P2 | A2 Dasher, A4 Gnat, C1 Homing, C2 Chain | More variety + keystone payoffs |
| P2 | H1 Daily seed | Flywheel for returning players |
| P2 | I Save mid-run | Mobile QoL |
| P3 | D1 Audio pack | Requires assets |
| P3 | B2 Boss de-templating | Large rework, lower leverage |
| P3 | J Ability mastery | Late-tier depth |
| P3 | E2 Wave modifiers, H2 Pre-run modifiers | Nice-to-have |
| P4 | K Test harness | Enabler for Phase 4 |
Implement top-to-bottom. P1 block (A1+A3+B1+E1+F1+F2+G1) is the minimum that addresses all three agent reports.
---
## Self-review notes
- **Spec coverage:** Every top-level gap from the three agent reports maps to at least one task. Elite modifier and 3 new abilities (Dash/Pull/Turret) from Phase 2 aren't re-planned here; homing is the only `BulletData` flag that was dormant.
- **Placeholder scan:** Task B2 is the only explicitly-deferred item; flagged P3. All other code steps show the code.
- **Type consistency:** `HealerCircleData.getHealRadius()` used from `FirstScreen` matches class definition. `BiomePalette.ALL` array is the public API used in `startNextWave`. `Achievements.increment(String key)` signature referenced in Task G1.step3 matches Task G1.step2.
- **Out-of-scope:** Multiplayer, shader-based bloom, and new fonts are not in this plan. Boss entry cinematics, cosmetic particle trails, and lore text are intentionally skipped — re-open after data shows engagement.
---
## Execution handoff
This plan is saved as `docs/superpowers/plans/2026-04-17-phase3-content-meta-retention.md`. When you're ready to execute:
1. **Subagent-Driven (recommended):** Fresh subagent per task group (A, B, ... J), review between groups. Use `superpowers:subagent-driven-development`.
2. **Inline Execution:** Step through in-session with `superpowers:executing-plans`, checkpoint after each area.
Tell Claude which approach and which priority tier (P1 only? P1+P2? full sweep?) to run.
@@ -0,0 +1,127 @@
# Phase 3.K.1 — Completion Report
**Date completed:** 2026-04-17
**Source plan:** `2026-04-17-phase3-k1-test-harness.md`
**Source spec:** `../specs/2026-04-17-phase3-k1-test-harness-design.md`
**Execution mode:** subagent-driven-development (fresh subagent per task, two-stage review).
## Status
- **Code:** ✅ Shipped. All 11 tasks complete.
- **Automated verification:** ✅ `./gradlew core:test` → 58/58 green. `./gradlew :core:build :lwjgl3:build` → BUILD SUCCESSFUL.
- **Manual smoke test:** ⏳ Pending user — see checklist below.
- **Git:** project is not a git repository; no commits were made. All work lives in the working tree.
## What was delivered
### New files (10)
Production (1):
- `core/src/main/java/ru/project/tower/GameContext.java` — composition-root holder for shared persistent-state services. `public final` fields for `PlayerStats`, `PlayerAbilities`, `GameState`, `TalentTree`, and two `Preferences` refs.
Tests (9):
- `core/src/test/java/ru/project/tower/HarnessSanityTest.java`
- `core/src/test/java/ru/project/tower/FakePreferences.java` — in-memory `Preferences` impl, reused by every downstream test.
- `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 (10)
Production classes (singletons → DI):
- `PlayerStats.java` — public `PlayerStats(Preferences)` ctor, `getInstance()` deleted, `instance` field deleted, `PREFS_NAME` deleted, `Gdx` import deleted, `preferences` field made `final`.
- `PlayerAbilities.java` — public `PlayerAbilities(Preferences)` ctor, `getInstance()` deleted, `instance` field deleted, `PREFS_NAME` deleted, `preferences` field made `final`. `Gdx` import kept (used by `Gdx.app.error(...)`).
- `GameState.java` — ctor made public, `getInstance()` deleted, `instance` field deleted.
- `TalentTree.java` — public `TalentTree(PlayerStats)` ctor, `getInstance()` deleted, `instance` field deleted, two internal `PlayerStats.getInstance()` calls replaced with injected `stats` field, `talents`/`roots` made `final`.
Composition root + screens:
- `Main.java` — rewritten; builds `GameContext` from two `Preferences`, passes to `MainScreen`.
- `MainScreen.java` — 2-arg ctor, 3 downstream `new X(game)``new X(game, ctx)`.
- `FirstScreen.java` — 2-arg ctor, 3 new cached fields (`ctx`, `gameState`, `talentTree`), 4 cached services assigned at ctor top (closed an NPE window), 13 `getInstance()` call-sites replaced, internal `new MainScreen(game)``new MainScreen(game, ctx)`, 2 redundant local shadows removed.
- `AbilityShopScreen.java` — 2-arg ctor, 2 `getInstance()` replacements, internal `new MainScreen(game, ctx)`.
- `AbilitySelectionScreen.java` — 2-arg ctor, 2 `getInstance()` replacements, internal `new MainScreen(gameInstance, ctx)` + `new FirstScreen(gameInstance, ctx)`.
- `TalentTreeScreen.java` — 2-arg ctor, 2 `getInstance()` replacements, internal `new MainScreen(game, ctx)`.
Build:
- `core/build.gradle``testImplementation 'org.junit.jupiter:junit-jupiter:5.10.2'`, `testImplementation 'org.assertj:assertj-core:3.26.3'`, `test { useJUnitPlatform() }`.
### Test counts (by class)
| Test class | Tests |
|---|---|
| HarnessSanityTest | 1 |
| FakePreferencesTest | 7 |
| PlayerStatsTest | 14 |
| PlayerAbilitiesTest | 8 |
| GameStateTest | 6 |
| TalentTreeTest | 12 |
| TalentDataTest | 6 |
| GameContextTest | 4 |
| **Total** | **58** |
### Invariants verified at completion
- `grep "getInstance(" core/src/main/java/ru/project/tower -r` → 0 matches.
- `grep "private static \w+ instance;" core/src/main/java/ru/project/tower -r` → 0 matches.
- `./gradlew core:test` → 58 passed, 0 failed.
- `./gradlew :core:build :lwjgl3:build` → BUILD SUCCESSFUL.
## Manual smoke test — ⏳ pending
Run `lwjgl3/src/main/java/ru/project/tower/lwjgl3/Lwjgl3Launcher` and verify:
- [ ] Main menu renders with persisted currency + max-wave.
- [ ] Talent Tree: purchased levels display, new purchase deducts currency, back returns to menu.
- [ ] Ability Shop: owned abilities display correctly, new purchase (e.g., Shield) persists after a restart.
- [ ] Ability Selection → Start Game → wave 1 plays, firing kills enemies, death returns to menu, currency reflects loot.
- [ ] Mid-run shop flow: save/restore works (upgrade survives shop round-trip).
- [ ] Close and relaunch: all persisted state (currency, max wave, unlocked abilities, talent levels) survives.
If any check fails, report back — most likely a missed call-site or a `ctx` threading bug; fix scope is minutes, not hours.
## Carry-over items (non-blocking)
These were flagged by reviewers during K.1 execution but are either pre-existing (not regressions introduced by K.1) or are tiny cleanups better folded into the next spec. They do not block K.2.
### Pre-existing design issues surfaced by the new test harness
- **`PlayerStats.getRegenTalentLevel()` sums only `regen_1`.** Other aggregate getters sum two nodes. If a `regen_2` node is added later, this getter silently reports the wrong value. Add a `regen_2` node or a comment explaining the omission. (Flagged in Task 3 review.)
- **`PlayerStats.incrementTalentNode(String)` accepts any string, including typos.** Phantom nodes are created in the in-memory `talentLevels` map but not persisted (saveData iterates `TALENT_NODE_IDS`). Consider a validation guard. (Flagged in Task 3 review.)
- **`PlayerStats.resetData()` Javadoc says "для тестирования"** but the test suite doesn't call it — callers live elsewhere. Clarify the comment or remove the method. (Task 3 review.)
- **`PlayerAbilities.resetData()` clears everything, including `freeze` and `explosion` which default to `true` on construction.** Intentional per the existing code, but the name implies "factory defaults" which it isn't. Rename or add a comment. (Task 4 review.)
- **`GameState.resetGameState()` doesn't reset `spawnedBosses` or the gameplay stat fields** (money, squareHealth, bulletSpeed, etc.). If this method is the "start new run" boundary, that's a latent bug. (Task 5 review.)
- **`FirstScreen.java` field declarations near lines 429435 are non-final** while the `ctx` field is `final`. Consistency drift inside one file. (Final review.)
- **`Main.ctx` is `public` non-final.** Safe given libGDX `Game.create()` semantics, but `public final` would document intent. (Final review.)
### Test-coverage gaps (not bugs)
- `TalentTreeTest.getStatBonus_*` exercises only the `damage_1` multiplier. A second multiplier (cooldown_1, health_1, etc.) would lock more of the formula. (Task 6 review.)
- `TalentDataTest.enum_whenKeystone_thenEnumMatches` is a tautology (`KEYSTONE != DAMAGE`). Safe to delete. (Final review.)
- `PlayerAbilitiesTest.purchaseAbility_whenUnknownType_thenNoChange` doesn't assert that the unknown key wasn't written to Preferences. (Task 4 review.)
- `PlayerAbilitiesTest.construct_whenPreferencesPrePopulated_thenHonoursStored` seeds only `freeze` and `shield` — doesn't verify that absent `explosion` key falls through to the `true` default. (Task 4 review.)
### FakePreferences polish
- `FakePreferences.put(Map<String,?>)` bulk-put isn't guarded — values inserted through this path could be types no getter recognises (e.g., `Double`, `Short`). No test exercises this. (Task 2 review.)
- `FakePreferences.getFloat`/`getLong` and the no-default single-arg getters have no tests. (Task 2 review.)
## What's next
K.1 is the foundation. The remaining specs in the K-series each handle one subsystem extraction out of `FirstScreen`'s god-class. Each follows the same brainstorm → spec → plan → subagent-driven-implementation cycle.
| Spec | Scope | Dependency |
|---|---|---|
| **K.2** | `BulletSystem` extraction — bullet tick, bullet-vs-circle collision, homing/pierce/aoe/chain resolution | K.1 |
| **K.3** | `SpawnController` extraction — `spawnCircle()` cascade | K.1 |
| **K.4** | `WaveController` extraction — `startNextWave` / `endWave` / scaling | K.1 |
| **K.5** | `EnemyBehaviour` tests — `tick()` for `HealerCircleData`, `DasherCircleData`, `SniperCircleData` (and others) | K.1 + Phase 3 A1/A2/A3 |
| **K.6** | `AbilitySystem` extraction — cooldowns, activations, effects | K.1 |
| **K.7** | `UpgradeSystem` extraction — mid-run upgrade pick, money, apply-effect | K.1 |
Recommended next step: brainstorm **K.2** (`BulletSystem`) — highest-value extraction because bullet logic is central to the game, has the most flags (`piercesLeft`, `homing`, `aoeRadius`, `chainJumps`), and is the first place Phase 3's `homing` keystone + C2 chain work will land.
Phase 3 gameplay tasks (AJ from the parent Phase 3 plan) remain untouched and can proceed in parallel with K.2+ when desired.
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,312 @@
# Phase 3.K.1 — Test harness foundation (DI refactor + pure-logic tests)
**Status:** Design, awaiting review
**Date:** 2026-04-17
**Parent:** Phase 3, Area K (Test harness). Originally a single P4 task in `2026-04-17-phase3-content-meta-retention.md`; expanded into the K.x series after brainstorming.
---
## Goal
Establish a test harness in the TowerAn project that serves as a **safety net for future refactoring**. Introduce unit tests for pure-logic singletons (`PlayerStats`, `PlayerAbilities`, `GameState`, `TalentTree`, `TalentData`) so that subsequent extraction work in the `FirstScreen` god-class (planned as spec series K.2K.7) can proceed without fear of silent regressions.
The spec also delivers the architectural prerequisite for testability: **removing the `getInstance()` singleton pattern** from the persistent-state classes, replacing it with constructor injection via a lightweight `GameContext` holder.
## Non-goals
- Any extraction of logic out of `FirstScreen`. Those are separate specs (K.2 `BulletSystem`, K.3 `SpawnController`, K.4 `WaveController`, K.5 `EnemyBehaviour`, K.6 `AbilitySystem`, K.7 `UpgradeSystem`). K.1 only prepares the ground.
- Tests for `Achievements` and `DailySeed`. These classes do not exist yet (Phase 3 Tasks G1 / H1); their tests are authored alongside the classes, not in K.1.
- Tests for enemy behaviour (`BaseCircleData` and subclasses). Deferred to K.5 because the new Phase 3 archetypes (Healer, Dasher, Sniper, Gnat) are not yet implemented.
- Code coverage gating (JaCoCo). Introducing coverage thresholds without baseline history is premature.
- CI integration (GitHub Actions or similar). Scope is local `./gradlew test`. CI wiring can be a tiny follow-up once K.1 is merged.
- Headless libGDX (`HeadlessApplication`). Not needed because the DI refactor lets tests supply an in-memory `Preferences` directly.
## Background
TowerAn is a libGDX 1.13.1 game on Java 8, gdx-liftoff template, flat `ru.project.tower` package. There are currently **no tests of any kind** — no JUnit, no Spock, no manual harness. Verification has been purely visual: launch `Lwjgl3Launcher`, play, watch.
Five classes hold persistent or shared state through a static-singleton pattern:
- `PlayerStats` — currency, max wave, talent node levels. Backed by `Preferences` (`tower_attack_player_data`). `getInstance()` eagerly calls `Gdx.app.getPreferences(...)`.
- `PlayerAbilities` — unlocked abilities, levels. Backed by `Preferences` (`tower_attack_player_abilities`). Same pattern.
- `GameState` — in-memory run state (current wave, square health, money, run bonuses). No `Preferences`.
- `TalentTree` — tree structure + level queries that call `PlayerStats.getInstance()` internally (lines 133, 138).
- `TalentData` — plain data class used by `TalentTree`. No singleton; already testable but currently untested.
`getInstance()` usage is concentrated in five files (15 calls): `AbilitySelectionScreen` (2), `AbilityShopScreen` (2), `FirstScreen` (8), `TalentTreeScreen` (1), `TalentTree` (2).
Because singletons eagerly touch `Gdx.app` and `Preferences` on first `getInstance()`, any test that instantiates them fails outside a running libGDX context. The options are (a) reflection-nuking the `instance` field between tests, (b) `@VisibleForTesting resetForTests()` helpers, or (c) breaking the pattern. We chose (c) — it's a one-time architectural change that makes tests clean and unlocks downstream `FirstScreen` extraction work in K.2+.
## Architecture overview
Three layers, implemented in this order (later layers depend on earlier ones):
1. **DI refactor.** Delete `private static instance` and `public static getInstance()` from `PlayerStats`, `PlayerAbilities`, `GameState`, `TalentTree`. Make constructors public and accept their dependencies explicitly.
2. **Composition root.** Introduce `GameContext` as a holder for the refactored singletons. `Main.create()` constructs two `Preferences` instances, wraps them in a `GameContext`, and passes the `GameContext` to each screen via constructor injection.
3. **Test harness.** Add JUnit 5 + AssertJ to `core/build.gradle`. Create an in-memory `FakePreferences` helper in `core/src/test/java`. Write unit tests for the five target classes.
Nothing in the rendering layer, the screen hierarchy, the enemy hierarchy, or the gameplay loop changes beyond mechanical `X.getInstance()``ctx.x` substitutions.
## Components
### `GameContext` (new, `core/src/main/java/ru/project/tower/GameContext.java`)
A tiny holder with `public final` fields, idiomatic Java for an immutable data carrier (Google Java Style §5.3 allows `public final` for this role).
```java
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);
}
}
```
Construction order matters: `TalentTree` depends on `PlayerStats`, so `playerStats` is created first. `Preferences` references are retained on the holder so future services (e.g., `Achievements`, `DailySeed` in Phase 3 G/H) can reuse them without re-plumbing `Main`.
### Singleton-to-DI conversions
For each target, the change is the same mechanical shape. Example for `PlayerStats`:
```java
// Before
private static PlayerStats instance;
private Preferences preferences;
private PlayerStats() {
preferences = Gdx.app.getPreferences(PREFS_NAME);
loadData();
}
public static PlayerStats getInstance() {
if (instance == null) instance = new PlayerStats();
return instance;
}
// After
private final Preferences preferences;
public PlayerStats(Preferences preferences) {
this.preferences = preferences;
loadData();
}
```
The private `PREFS_NAME = "tower_attack_player_data"` constant is deleted; the same literal moves to `Main` as `private static final String PLAYER_DATA_PREFS_NAME`. `PlayerAbilities` gets analogous treatment with `ABILITIES_PREFS_NAME`.
`GameState` has no `Preferences`, so its constructor is simply made public and argumentless. `TalentTree(PlayerStats stats)` stores the reference and replaces the two `PlayerStats.getInstance()` calls at lines 133 / 138 with `this.stats.getTalentNodeLevel(...)` and `this.stats.incrementTalentNode(...)`.
### `Main` as composition root
```java
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));
}
}
```
`ctx` is a public field on `Main` so screens that already hold a `Main` reference can read it if needed, but the preferred path is receiving `GameContext` in the constructor.
### Screen constructor changes
Every screen class currently instantiated with `new Xxx(game)` becomes `new Xxx(game, ctx)`:
- `MainScreen(Main game, GameContext ctx)`
- `FirstScreen(Main game, GameContext ctx)`
- `AbilityShopScreen(Main game, GameContext ctx)`
- `AbilitySelectionScreen(Main game, GameContext ctx)`
- `TalentTreeScreen(Main game, GameContext ctx)`
Every `X.getInstance()` call becomes `ctx.x`. In `FirstScreen`, where there are 8 calls, cache into fields (`private final PlayerStats playerStats;` etc.) during the constructor to avoid `ctx.playerStats` repetition.
### `FakePreferences` (test helper, `core/src/test/java/ru/project/tower/FakePreferences.java`)
An in-memory implementation of `com.badlogic.gdx.Preferences` backed by `HashMap<String, Object>`. Approximately 30 trivial methods — every `putX(k, v)` stores in the map, every `getX(k)` / `getX(k, default)` reads. `flush()` is a no-op. `clear()` clears the map. `contains(k)` delegates to `map.containsKey`.
One shared helper serves every test class. Chosen over Mockito because Mockito for a single deterministic interface adds a dependency and weaker error messages for a trivial case.
## Data flow
**Production:**
```
Main.create()
└─ creates two Preferences via Gdx.app.getPreferences(...)
└─ new GameContext(playerDataPrefs, abilitiesPrefs)
└─ setScreen(new MainScreen(this, ctx))
└─ ctx propagates to every downstream screen constructor
└─ screens access ctx.playerStats, ctx.talentTree, etc.
```
**Tests:**
```
@BeforeEach
└─ new FakePreferences()
└─ new PlayerStats(fakePrefs) // or the class under test, injected with fakes
@Test
└─ AAA: arrange state, invoke method, assert outcome
(both on the object's API and on the fake Preferences where persistence matters)
```
## Test conventions
- **Framework:** JUnit 5 (`org.junit.jupiter:junit-jupiter:5.10.2`).
- **Assertions:** AssertJ (`org.assertj:assertj-core:3.26.3`). All assertions use `assertThat(...)` — no `assertEquals`. Rationale: fluent API, clearer messages, industry standard in modern Java.
- **Test-method naming:** `methodName_whenCondition_thenExpected`. Sorts naturally by method in IDE, reads like a sentence.
- `incrementTalentNode_whenNewNode_thenStoredInPreferences`
- `getCurrency_whenNotYetSet_thenReturnsZero`
- **Test-class naming:** `<ClassUnderTest>Test` (e.g., `PlayerStatsTest.java`).
- **Body structure:** Arrange / Act / Assert separated by blank lines. No `// Arrange` comments.
- **Isolation:** JUnit 5's default new-instance-per-test + `@BeforeEach` for setup. No static state between tests.
- **One behaviour per test.** If a test has three `assertThat(...)` lines, they should all be aspects of the same behaviour (e.g., state returned _and_ persisted). Don't bundle unrelated scenarios.
## Test coverage targets
Approximately 3035 test methods across 6 test classes, as sketched below. This is a floor, not a ceiling — prefer covering each public-API method with at least one happy-path test and one edge case.
### `PlayerStatsTest`
- `getCurrency_whenNotYetSet_thenReturnsZero`
- `addCurrency_whenCalled_thenIncrementsAndPersists`
- `spendCurrency_whenInsufficient_thenReturnsFalseAndDoesNotDeduct`
- `spendCurrency_whenSufficient_thenReturnsTrueAndDeducts`
- `getMaxWaveReached_thenReadsFromPreferences`
- `updateMaxWaveReached_whenNewHigher_thenUpdates`
- `updateMaxWaveReached_whenNewLower_thenIgnored`
- `getTalentNodeLevel_whenUnseen_thenZero`
- `incrementTalentNode_whenCalled_thenLevelOneAndPersisted`
- `incrementTalentNode_whenCalledTwice_thenLevelTwo`
- Legacy aggregate getter(s): one test covering the wrapper still reports the right total.
- A test that constructs `PlayerStats` from pre-populated `FakePreferences` and reads back expected state (persistence round-trip).
### `PlayerAbilitiesTest`
- Every ability-unlock flag, get/set round-trip through `FakePreferences`.
- Unlock toggles don't affect unrelated flags.
- Default-state test (fresh `FakePreferences` gives the expected initial roster).
### `GameStateTest`
- Initial values (wave, square health, money).
- Wave increment.
- Phase 2 `run*` bonus fields (damage, pierce, etc.) accumulate correctly.
- Reset-for-new-run behaviour. The implementation plan must first confirm whether `GameState` has a reset API; if not, the plan adds a small one as part of this spec (this is a test-enabling concession, not feature work).
### `TalentTreeTest`
- `initializeTree` creates expected roots and node IDs.
- `getTalentLevel(id)` delegates to injected `PlayerStats`.
- `incrementTalent(id)` delegates to injected `PlayerStats`.
- Keystone node IDs (`keystone_pierce`, `keystone_crit`, `keystone_aoe`) are all registered.
### `TalentDataTest`
- Construction: id, name, description, type, position, max level, base cost, cost multiplier all round-trip via getters.
- Cost-at-level formula round-trip. The exact formula is read from the current `TalentData` source during implementation; the test locks in whatever that formula is, not a newly-invited one.
### `GameContextTest`
- Construction with two `FakePreferences` yields non-null services.
- `ctx.talentTree.getTalentLevel(someId)` reads from the same `PlayerStats` that `ctx.playerStats` exposes (i.e., wiring is correct).
- Construction order: `TalentTree` sees the injected `PlayerStats`, not a new one.
## Build configuration
`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()
}
```
Java 8 compatibility is preserved: JUnit 5.10 and AssertJ 3.26 both still ship Java-8-compatible bytecode.
## File layout
```
core/src/
main/java/ru/project/tower/
GameContext.java [NEW]
PlayerStats.java [MODIFIED — constructor + remove getInstance]
PlayerAbilities.java [MODIFIED — constructor + remove getInstance]
GameState.java [MODIFIED — constructor public + remove getInstance]
TalentTree.java [MODIFIED — constructor + remove getInstance]
Main.java [MODIFIED — composition root]
MainScreen.java [MODIFIED — constructor takes ctx]
FirstScreen.java [MODIFIED — constructor takes ctx, replace 8 getInstance]
AbilityShopScreen.java [MODIFIED — constructor takes ctx]
AbilitySelectionScreen.java [MODIFIED — constructor takes ctx]
TalentTreeScreen.java [MODIFIED — constructor takes ctx]
test/java/ru/project/tower/ [NEW TREE]
FakePreferences.java
PlayerStatsTest.java
PlayerAbilitiesTest.java
GameStateTest.java
TalentTreeTest.java
TalentDataTest.java
GameContextTest.java
```
## Error handling
There is one edge where the refactor can silently change behaviour: `getInstance()` lazily creates the singleton on first call. A class that was instantiated *before* `Gdx.app` was ready (unlikely in this codebase but worth naming) would now fail eagerly at `Main.create()` instead of at first use. Since `Main.create()` is the libGDX entry point and `Gdx.app` is guaranteed set by the backend before `create()` runs, this is safe.
All other error paths are unchanged — `Preferences.putX`/`getX` throw the same exceptions; `loadData()` runs the same logic.
## Verification
Because K.1 introduces the test harness itself, verification is both **automated** (the tests) and **manual** (the DI refactor must not break the running game):
- `./gradlew test` runs cleanly, all tests pass.
- Launch `Lwjgl3Launcher`. Confirm:
- Main menu renders; currency and max-wave values read from existing preferences.
- Entering the talent tree shows purchased levels unchanged.
- Starting a run, buying an upgrade, dying, and returning to menu preserves currency.
- Ability shop displays owned abilities correctly.
Any of these failing indicates the `getInstance()``ctx.x` substitution dropped a wire.
## Risks and mitigations
- **Missed call-site.** 15 `getInstance()` calls plus possibly some we didn't count (constants, static initializers). Mitigation: after the refactor, `grep -r "getInstance()" core/src/main` must return zero hits. Automated in implementation plan.
- **Screen-constructor signature changes break something external.** `lwjgl3/` and `android/` only touch `Main`, not individual screens. Mitigation: grep across all modules for `new MainScreen(`, `new FirstScreen(`, etc. after the refactor.
- **Hidden `Preferences`-name constants.** We found two (`tower_attack_player_data`, `tower_attack_player_abilities`). If Phase 3 work introduces a third, the spec still works — just add another `Preferences` parameter to `GameContext`. Not a risk for K.1 itself.
- **Test flakiness from order dependence.** Prevented by JUnit 5 per-test-instance default + fresh `FakePreferences` in `@BeforeEach`.
## Out of scope (reminder)
K.1 delivers the harness and proves the pattern on five pure-logic classes. Every subsequent K.x spec (K.2K.7) builds on this to extract and test real gameplay subsystems. K.1 is merged when:
1. All 15+ `getInstance()` calls are gone.
2. `GameContext` is the sole composition point for persistent services.
3. `./gradlew test` runs and passes all 30+ tests authored in this spec.
4. The game still boots and plays correctly on the desktop launcher.