# Win-Condition & Endings 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:** Priority-ordered, condition-driven endings with an end-of-game summary; the game ends when an ending fires. **Architecture:** `Ending` is data (condition + text), loaded from optional `endings.yaml` into an ordered `World.endings`. `EndingEngine.triggered(ctx)` returns the first matching ending; `Game` checks it each turn after the quest tick, prints the ending + summary, and stops. Reuses the existing condition engine and quest log. **Tech Stack:** Java 25, Maven, JUnit 5 + AssertJ, Lombok, Jackson/SnakeYAML. Spec: [docs/superpowers/specs/2026-05-31-endings-design.md](../specs/2026-05-31-endings-design.md) --- ## Task 1: Ending model, DTO/factory, World.endings, loading **Files:** - Create: `model/Ending.java`, `loader/dto/EndingDto.java`, `loader/EndingFactory.java` - Modify: `model/World.java`, `loader/WorldLoader.java` - Test: `loader/EndingLoadingTest.java` - [ ] **Step 1: Create `model/Ending.java`** ```java package thb.jeanluc.adventure.model; import java.util.List; /** A game ending: shown (and ends the game) when its conditions hold. */ public record Ending(String id, String title, boolean victory, List when, String text) { public Ending { when = when == null ? List.of() : List.copyOf(when); } } ``` - [ ] **Step 2: Add `endings` to `World.java` with back-compat constructors** Add import: ```java import java.util.List; ``` Add the field after `quests`: ```java /** Ordered list of endings (first matching one wins). */ private final List endings; ``` Replace the existing back-compat constructor with two that both delegate to the full constructor: ```java /** Backward-compatible constructor for worlds without quests or endings. */ public World(Map rooms, Map items, Map npcs, String title, String welcomeMessage) { this(rooms, items, npcs, title, welcomeMessage, Map.of(), List.of()); } /** Backward-compatible constructor for worlds with quests but no endings. */ public World(Map rooms, Map items, Map npcs, String title, String welcomeMessage, Map quests) { this(rooms, items, npcs, title, welcomeMessage, quests, List.of()); } ``` (The 7-arg constructor is the Lombok-generated `@RequiredArgsConstructor` over all final fields in declaration order: rooms, items, npcs, title, welcomeMessage, quests, endings.) - [ ] **Step 3: Create `loader/dto/EndingDto.java`** ```java package thb.jeanluc.adventure.loader.dto; import java.util.List; /** YAML representation of a game ending. */ public record EndingDto(String id, String title, Boolean victory, List when, String text) { } ``` - [ ] **Step 4: Create `loader/EndingFactory.java`** ```java package thb.jeanluc.adventure.loader; import thb.jeanluc.adventure.loader.dto.ConditionDto; import thb.jeanluc.adventure.loader.dto.EndingDto; import thb.jeanluc.adventure.model.Ending; /** Builds {@link Ending} objects from {@link EndingDto}s. */ public final class EndingFactory { private EndingFactory() { } public static Ending fromDto(EndingDto dto) { return new Ending( dto.id(), dto.title(), Boolean.TRUE.equals(dto.victory()), ConditionDto.toModelList(dto.when()), dto.text()); } } ``` - [ ] **Step 5: Load `endings.yaml` in `WorldLoader.java`** Add imports: ```java import thb.jeanluc.adventure.loader.dto.EndingDto; import thb.jeanluc.adventure.model.Ending; ``` After the `questDtos` read, add: ```java List endingDtos = readListOptional(basePath + "/endings.yaml", EndingDto.class); ``` After the quest `requireUniqueIds`, add: ```java requireUniqueIds("ending", endingDtos.stream().map(EndingDto::id).toList()); ``` Build an **ordered** ending list (preserve YAML order) next to the quest map build: ```java List endings = new ArrayList<>(); for (EndingDto dto : endingDtos) { endings.add(EndingFactory.fromDto(dto)); } ``` Add the import for `ArrayList` if missing: ```java import java.util.ArrayList; ``` Change the `World` construction to the 7-arg form: ```java World world = new World(rooms, items, npcs, gameDto.title(), gameDto.welcomeMessage(), quests, endings); ``` - [ ] **Step 6: Write the loading test** `src/test/java/thb/jeanluc/adventure/loader/EndingLoadingTest.java`: ```java package thb.jeanluc.adventure.loader; import org.junit.jupiter.api.Test; import thb.jeanluc.adventure.loader.dto.ConditionDto; import thb.jeanluc.adventure.loader.dto.EndingDto; import thb.jeanluc.adventure.model.Ending; import java.util.List; import static org.assertj.core.api.Assertions.assertThat; class EndingLoadingTest { @Test void factoryMapsFields() { Ending e = EndingFactory.fromDto(new EndingDto("victory", "Won", true, List.of(new ConditionDto("manor_secured", null, null)), "You win.")); assertThat(e.id()).isEqualTo("victory"); assertThat(e.victory()).isTrue(); assertThat(e.when()).hasSize(1); assertThat(e.text()).isEqualTo("You win."); } } ``` - [ ] **Step 7: Run tests** Run: `mvn -q test -Dtest=EndingLoadingTest` Expected: PASS. Run: `mvn -q test` Expected: PASS — the real `endings.yaml` does not exist yet, so `readListOptional` returns empty; existing `new World(...)` callers compile via the back-compat constructors. - [ ] **Step 8: Commit** ```bash git add src/main/java/thb/jeanluc/adventure/model/Ending.java \ src/main/java/thb/jeanluc/adventure/loader/dto/EndingDto.java \ src/main/java/thb/jeanluc/adventure/loader/EndingFactory.java \ src/main/java/thb/jeanluc/adventure/model/World.java \ src/main/java/thb/jeanluc/adventure/loader/WorldLoader.java \ src/test/java/thb/jeanluc/adventure/loader/EndingLoadingTest.java git commit -m "feat: Ending model + endings.yaml loading (ordered, optional)" ``` --- ## Task 2: EndingEngine + Game wiring **Files:** - Create: `game/EndingEngine.java` - Modify: `game/Game.java` - Test: `game/EndingEngineTest.java` - [ ] **Step 1: Write failing test** `src/test/java/thb/jeanluc/adventure/game/EndingEngineTest.java`: ```java package thb.jeanluc.adventure.game; import org.junit.jupiter.api.Test; import thb.jeanluc.adventure.io.TestIO; import thb.jeanluc.adventure.model.Condition; import thb.jeanluc.adventure.model.Ending; import thb.jeanluc.adventure.model.Player; import thb.jeanluc.adventure.model.Room; import thb.jeanluc.adventure.model.World; import java.util.List; import java.util.Map; import static org.assertj.core.api.Assertions.assertThat; class EndingEngineTest { private GameContext ctx(List endings) { Player p = new Player(new Room("k", "K", "d"), 0); World w = new World(Map.of(), Map.of(), Map.of(), "t", "w", Map.of(), endings); return new GameContext(w, p, new TestIO()); } private Ending ending(String id, boolean victory, String flag) { return new Ending(id, id + "-title", victory, List.of(new Condition(Condition.Type.FLAG, flag)), id + " text"); } @Test void firstMatchingEndingWinsByOrder() { GameContext ctx = ctx(List.of(ending("victory", true, "won"), ending("fled", false, "fled"))); ctx.getState().set("won"); ctx.getState().set("fled"); assertThat(EndingEngine.triggered(ctx).id()).isEqualTo("victory"); } @Test void noEndingWhenNoConditionHolds() { GameContext ctx = ctx(List.of(ending("victory", true, "won"))); assertThat(EndingEngine.triggered(ctx)).isNull(); } @Test void renderIncludesTitleTextAndSummary() { GameContext ctx = ctx(List.of(ending("victory", true, "won"))); ctx.getState().set("won"); String out = EndingEngine.render(EndingEngine.triggered(ctx), ctx, 7).plainText(); assertThat(out).contains("victory-title").contains("victory text"); assertThat(out).contains("Turns: 7").contains("Quests completed: 0 / 0"); } } ``` - [ ] **Step 2: Run to verify failure** Run: `mvn -q test -Dtest=EndingEngineTest` Expected: FAIL — `EndingEngine` missing. - [ ] **Step 3: Create `game/EndingEngine.java`** ```java package thb.jeanluc.adventure.game; import thb.jeanluc.adventure.io.text.StyledText; import thb.jeanluc.adventure.model.Ending; /** Detects the first matching ending and renders the end-of-game screen. */ public final class EndingEngine { private EndingEngine() { } /** First ending whose conditions hold, in list order; null if none. */ public static Ending triggered(GameContext ctx) { for (Ending e : ctx.getWorld().getEndings()) { if (Conditions.all(e.when(), ctx)) { return e; } } return null; } /** Builds the ending screen: title banner, text, and a run summary. */ public static StyledText render(Ending e, GameContext ctx, int turns) { int total = ctx.getWorld().getQuests().size(); int done = ctx.getQuestLog().completed().size(); String bar = "═".repeat(Math.max(12, e.title().length() + 6)); StyledText.Builder b = StyledText.builder(); b.heading(bar + "\n").heading(" " + e.title() + "\n").heading(bar + "\n"); b.plain(e.text().stripTrailing()).plain("\n\n"); b.dim("Turns: " + turns + "\n"); b.dim("Quests completed: " + done + " / " + total + "\n"); b.heading("Rank: " + rank(e, total, done)); return b.build(); } private static String rank(Ending e, int total, int done) { if (e.victory() && total > 0 && done == total) { return "Master of the Manor"; } if (e.victory()) { return "Manor Reclaimed"; } return "Escaped — the manor keeps its secrets"; } } ``` - [ ] **Step 4: Wire endings into `Game.java`** In `Game.java` add a `maybeEnd()` method and call it after each `publishHud()`. Add the import: ```java import thb.jeanluc.adventure.model.Ending; ``` (`EndingEngine` is in the same `game` package — no import.) Update `run()` so it calls `maybeEnd()` after the initial publish and after the per-iteration publish: ```java public void run() { publishHud(); maybeEnd(); while (running) { String input = ctx.getIo().readLine(); if (input == null) { break; } ParsedCommand parsed = parser.parse(input); if (parsed.verb().isEmpty()) { continue; } Optional cmd = registry.find(parsed.verb()); if (cmd.isEmpty()) { ctx.getIo().write("I don't understand '" + parsed.verb() + "'. Type 'help'."); } else { cmd.get().execute(ctx, parsed.args()); turn++; } publishHud(); maybeEnd(); } } private void maybeEnd() { Ending e = EndingEngine.triggered(ctx); if (e != null) { ctx.getIo().print(EndingEngine.render(e, ctx, turn)); stop(); } } ``` - [ ] **Step 5: Run tests** Run: `mvn -q test -Dtest=EndingEngineTest` Expected: PASS. Run: `mvn -q test` Expected: PASS — `GameTest`'s worlds have no endings (empty list), so `maybeEnd()` is a no-op there. - [ ] **Step 6: Commit** ```bash git add src/main/java/thb/jeanluc/adventure/game/EndingEngine.java \ src/main/java/thb/jeanluc/adventure/game/Game.java \ src/test/java/thb/jeanluc/adventure/game/EndingEngineTest.java git commit -m "feat(game): EndingEngine + per-turn end check with summary" ``` --- ## Task 3: Demo endings + flee door + docs + verification **Files:** - Create: `src/main/resources/world/endings.yaml` - Modify: `src/main/resources/world/items.yaml`, `rooms.yaml`, `docs/enhancement-ideas.md` - [ ] **Step 1: Create `src/main/resources/world/endings.yaml`** ```yaml - id: victory title: "The Manor Reclaimed" victory: true when: [{ flag: manor_secured }] text: | The lights hold steady and the whispers fade to nothing. Whatever held this place has loosened its grip. The manor is yours now. - id: fled title: "Into the Night" victory: false when: [{ flag: fled }] text: | You wrench the front door open and bolt into the dark. Safe — but the manor keeps its secrets, and they will keep you awake for years. ``` - [ ] **Step 2: Add a "front door" switch to `items.yaml`** Append: ```yaml - id: front_door type: switchable name: Front Door description: The heavy front door. It would let you leave the manor for good. state: false onText: | You haul the front door open and step out into the cold night. offText: | You ease the door shut again. effects: - { setFlag: fled } ``` - [ ] **Step 3: Place the door in the kitchen (`rooms.yaml`)** Change the kitchen's items line from: ```yaml items: [letter, lamp] ``` to: ```yaml items: [letter, lamp, front_door] ``` - [ ] **Step 4: Mark 3.3 done in `docs/enhancement-ideas.md`** Under the `### 5. Quest-System (NPC-Ausbau)` status block, append: ```markdown > ✅ #3.3 umgesetzt (Branch `feature/endings`): priorisierte, condition-driven > Enden (`endings.yaml`) + End-Screen mit Summary (Züge, Quests X/Y, Rang). > Demo: Sieg-Ende (`manor_secured`) und Flucht-Ende (Front Door → `fled`). > Damit ist das Quest-System (#3) komplett. ``` - [ ] **Step 5: Full suite + end-to-end console** Run: `mvn -q test` Expected: PASS. Victory path: ```bash printf 'go south\nuse generator\ngo north\ntake lamp\ngive lamp old_man\n' | mvn -q -DskipTests exec:java@run ``` Expected: completing the `restore_power` quest sets `manor_secured`; on the next turn the game prints the **"The Manor Reclaimed"** ending banner, the summary (`Turns: …`, `Quests completed: 1 / 1`, `Rank: Master of the Manor`), and exits — no `quit` needed. Flee path: ```bash printf 'use front_door\n' | mvn -q -DskipTests exec:java@run ``` Expected: the **"Into the Night"** ending + summary (`Rank: Escaped …`), then exit. - [ ] **Step 6: Commit** ```bash git add src/main/resources/world/endings.yaml \ src/main/resources/world/items.yaml \ src/main/resources/world/rooms.yaml \ docs/enhancement-ideas.md git commit -m "feat(content): victory & flee endings; front-door flee switch" ``` --- ## Self-Review notes - **Spec coverage:** model+loader (T1), engine+Game wiring+summary (T2), demo+docs (T3). Scoreboards/turn-conditions deferred. - **Backward compatibility:** `World` gains 5- and 6-arg back-compat constructors delegating to the 7-arg; existing `new World(...)` callers (tests, prior loader pattern) compile unchanged. `endings.yaml` optional. `GameTest` worlds have empty endings → `maybeEnd()` no-op. - **Type consistency:** `EndingEngine.triggered(ctx)`/`render(Ending, ctx, int)`, `Ending.when/victory/title/text`, `World.getEndings`, `EndingFactory.fromDto`, `EndingDto` used consistently. - **Layering:** `Ending` (model) carries no `game` dependency; `EndingEngine` (game) depends on model + io.text.