Files
square-defense/docs/superpowers/plans/2026-04-17-phase3-content-meta-retention.md
T
2026-06-16 10:57:01 +03:00

605 lines
28 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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.