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

15 KiB

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


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

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:

import java.util.List;

Add the field after quests:

    /** 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:

    /** 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
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
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:

import thb.jeanluc.adventure.loader.dto.EndingDto;
import thb.jeanluc.adventure.model.Ending;

After the questDtos read, add:

        List<EndingDto> endingDtos = readListOptional(basePath + "/endings.yaml", EndingDto.class);

After the quest requireUniqueIds, add:

        requireUniqueIds("ending", endingDtos.stream().map(EndingDto::id).toList());

Build an ordered ending list (preserve YAML order) next to the quest map build:

        List<Ending> endings = new ArrayList<>();
        for (EndingDto dto : endingDtos) {
            endings.add(EndingFactory.fromDto(dto));
        }

Add the import for ArrayList if missing:

import java.util.ArrayList;

Change the World construction to the 7-arg form:

        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:

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
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:

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
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:

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:

    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
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

- 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:

- 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:

  items: [letter, lamp]

to:

  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:

> ✅ #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:

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:

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
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.