Files
Jander_Semester2/Semesterprojekt/docs/superpowers/plans/2026-05-31-endings.md
2026-05-31 22:45:12 +02:00

459 lines
15 KiB
Markdown

# 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<Condition> 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<Ending> 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<String, Room> rooms, Map<String, Item> items, Map<String, Npc> 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<String, Room> rooms, Map<String, Item> items, Map<String, Npc> npcs,
String title, String welcomeMessage, Map<String, Quest> 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<ConditionDto> 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<EndingDto> 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<Ending> 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<Ending> 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<Command> 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.