diff --git a/Semesterprojekt/docs/enhancement-ideas.md b/Semesterprojekt/docs/enhancement-ideas.md index 0990c2f..c404a7e 100644 --- a/Semesterprojekt/docs/enhancement-ideas.md +++ b/Semesterprojekt/docs/enhancement-ideas.md @@ -86,8 +86,13 @@ Farb-Rollen: Items / NPCs / Exits / Heading / Gefahr. > ✅ #3.2 umgesetzt (Branch `feature/quests`): condition-driven Quests > (Quest/QuestStage), QuestEngine mit Auto-Advance + Ansagen, START_QUEST-Effekt, > Konsolen-Befehl `quests` und GUI-Quest-Box unter der Karte. Demo-Quest -> `restore_power`. Offen: #3.3 Win-Condition/Enden sowie Licht/Dunkelheit & -> Item-Kombination. +> `restore_power`. +> +> ✅ #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.** Offen: Licht/Dunkelheit & +> Item-Kombination sowie #4 Content-Ausbau. - Aktuelles NPC-System: Begrüßung + Geschenk-Reaktion (Item → Item). diff --git a/Semesterprojekt/docs/superpowers/plans/2026-05-31-endings.md b/Semesterprojekt/docs/superpowers/plans/2026-05-31-endings.md new file mode 100644 index 0000000..d723459 --- /dev/null +++ b/Semesterprojekt/docs/superpowers/plans/2026-05-31-endings.md @@ -0,0 +1,458 @@ +# 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. diff --git a/Semesterprojekt/docs/superpowers/specs/2026-05-31-endings-design.md b/Semesterprojekt/docs/superpowers/specs/2026-05-31-endings-design.md new file mode 100644 index 0000000..e26a1e9 --- /dev/null +++ b/Semesterprojekt/docs/superpowers/specs/2026-05-31-endings-design.md @@ -0,0 +1,116 @@ +# Spec: Win-Condition & Enden (Teilprojekt 3.3) + +Stand: 2026-05-31. Capstone des Quest-Systems. Baut auf Fundament (3.1) + Quests +(3.2) auf: Conditions/Effects/Flags + QuestLog sind vorhanden. + +## 1. Kontext & Ziel + +Das Spiel endet bisher nur per `quit`. Ziel: **mehrere, priorisierte Enden**, +condition-driven (Ansatz wie Quests), plus ein **End-Screen mit Zusammenfassung** +(Züge, abgeschlossene Quests, Rang). Bestätigt: **A mit Summary**. + +## 2. Scope + +**In Scope:** +- `Ending`-Datenmodell + optionale `endings.yaml` (Reihenfolge = Priorität). +- `EndingEngine` (erstes passendes Ende ermitteln + End-Screen rendern). +- `Game`-Anbindung: pro Zug nach dem Quest-Tick prüfen; bei Treffer Ende + + Summary ausgeben und `stop()`. +- End-Summary: Züge, Quests X/Y, Rang. +- Loader: `EndingDto`, `EndingFactory`, `World.endings`. +- Demo: zwei spielbar erreichbare Enden ohne neue Mechanik. + +**Out of Scope:** Scoreboard/Persistenz; zugbasierte Conditions; Spielstand-Reset. + +## 3. Datenmodell (model) + +```java +public record Ending(String id, String title, boolean victory, + List when, String text) { /* when: List.copyOf */ } +``` +`endings.yaml` (geordnete Liste, erstes passendes gewinnt): +```yaml +- id: victory + title: "The Manor Reclaimed" + victory: true + when: [{ flag: manor_secured }] + text: | + The lights hold steady, the whispers fade. You've made the manor yours. +- id: fled + title: "Into the Night" + victory: false + when: [{ flag: fled }] + text: | + You bolt through the front door. Safe — but the manor keeps its secrets. +``` + +## 4. Engine (game) + +- `EndingEngine.triggered(GameContext ctx)` → erstes `Ending`, dessen `when` via + `Conditions.all` gilt, sonst `null`. Rein, testbar (Priorität/erster Treffer). +- `EndingEngine.render(Ending e, GameContext ctx, int turns)` → `StyledText`: + Titel-Banner + `text` + Summary. +- **Summary**: `Turns: N`, `Quests completed: X / Y` + (`X = questLog.completed().size()`, `Y = world.getQuests().size()`), plus + **Rang**: + - victory & alle Quests → „Master of the Manor" + - victory → „Manor Reclaimed" + - sonst → „Escaped — the manor keeps its secrets" + +## 5. Game-Anbindung + +In `Game.run` nach `publishHud()` (Initial **und** pro Schleifendurchlauf) ein +`maybeEnd()`: +```java +private void maybeEnd() { + Ending e = EndingEngine.triggered(ctx); + if (e != null) { + ctx.getIo().print(EndingEngine.render(e, ctx, turn)); + stop(); + } +} +``` +`stop()` setzt `running=false`; die Schleife endet sauber, kein zusätzlicher Prompt. +Konsole: Programm endet. GUI: Worker-Thread endet, Fenster bleibt mit End-Screen. + +## 6. Loading + +- `EndingDto(id, title, Boolean victory, List when, String text)`. +- `EndingFactory.fromDto` baut `Ending` (`when` via `ConditionDto.toModelList`). +- `WorldLoader` liest `endings.yaml` **optional** (`readListOptional`), baut eine + **geordnete** `List` (Reihenfolge der YAML-Liste), `requireUniqueIds("ending", …)`. +- `World` bekommt Feld `endings` (List) + **rückwärtskompatible 5- und + 6-Arg-Konstruktoren** (leere Quests/Endings), damit bestehende `new World(...)`- + Aufrufe (Tests, bisheriger Loader-Pfad) unverändert kompilieren. Der Loader nutzt + den vollen 7-Arg-Konstruktor. + +## 7. Fehlerbehandlung + +| Fall | Verhalten | +|---|---| +| `endings.yaml` fehlt | leere Liste → Spiel endet nur per `quit` (wie bisher) | +| mehrere Conditions treffen | erstes Ende in Listenreihenfolge gewinnt | +| Ending ohne `when` | trifft sofort (leere Condition-Liste = true) – Autoren sollten Reihenfolge beachten | +| keine Quests definiert | Summary zeigt `0 / 0`, Rang nach victory-Flag | + +## 8. Demonstration (zwei erreichbare Enden, keine neue Mechanik) + +- **victory**: `manor_secured` wird gesetzt, wenn die `restore_power`-Quest endet + (bereits vorhanden) → Sieg-Ende. +- **fled**: neuer schaltbarer Gegenstand **Front Door** (`effects: setFlag fled`) + in der Küche → Flucht-Ende. `victory` steht vor `fled`, also schlägt das + Sichern der Villa die Flucht. + +## 9. Testing + +- `EndingEngine.triggered`: Priorität/erster Treffer; kein Treffer → `null`. +- `EndingEngine.render`: enthält Titel, Text, Züge, Quests-Zähler, Rang. +- `EndingFactory.fromDto`: Mapping inkl. victory/when/text. +- End-to-End (Konsole): Villa sichern → Sieg-Ende + Summary, Spiel endet; + alternativ Front Door benutzen → Flucht-Ende. +- `Game`-Integration: nicht separat unit-getestet (Loop/IO); über Konsolen-Smoke. + +## 10. Offene Detailfragen (in Implementierung) + +- Genaue Banner-Optik des End-Screens (HEADING-Rahmen). +- Rang-Schwellen (zunächst nur victory + alle-Quests-Abfrage). diff --git a/Semesterprojekt/src/main/java/thb/jeanluc/adventure/game/EndingEngine.java b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/game/EndingEngine.java new file mode 100644 index 0000000..e70a46d --- /dev/null +++ b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/game/EndingEngine.java @@ -0,0 +1,46 @@ +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"; + } +} diff --git a/Semesterprojekt/src/main/java/thb/jeanluc/adventure/game/Game.java b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/game/Game.java index 0d5a5cb..20d0f02 100644 --- a/Semesterprojekt/src/main/java/thb/jeanluc/adventure/game/Game.java +++ b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/game/Game.java @@ -8,6 +8,7 @@ import thb.jeanluc.adventure.command.ParsedCommand; import thb.jeanluc.adventure.io.text.Hud; import thb.jeanluc.adventure.io.text.MapView; import thb.jeanluc.adventure.map.MapLayout; +import thb.jeanluc.adventure.model.Ending; import java.util.Optional; @@ -39,6 +40,7 @@ public class Game { */ public void run() { publishHud(); + maybeEnd(); while (running) { String input = ctx.getIo().readLine(); if (input == null) { @@ -56,6 +58,16 @@ public class Game { turn++; } publishHud(); + maybeEnd(); + } + } + + /** Ends the game if any ending's conditions are met, printing its screen. */ + private void maybeEnd() { + Ending e = EndingEngine.triggered(ctx); + if (e != null) { + ctx.getIo().print(EndingEngine.render(e, ctx, turn)); + stop(); } } diff --git a/Semesterprojekt/src/main/java/thb/jeanluc/adventure/loader/EndingFactory.java b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/loader/EndingFactory.java new file mode 100644 index 0000000..9ac368d --- /dev/null +++ b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/loader/EndingFactory.java @@ -0,0 +1,21 @@ +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()); + } +} diff --git a/Semesterprojekt/src/main/java/thb/jeanluc/adventure/loader/WorldLoader.java b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/loader/WorldLoader.java index b783a63..b5c0eb1 100644 --- a/Semesterprojekt/src/main/java/thb/jeanluc/adventure/loader/WorldLoader.java +++ b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/loader/WorldLoader.java @@ -5,11 +5,13 @@ import com.fasterxml.jackson.databind.type.CollectionType; import com.fasterxml.jackson.dataformat.yaml.YAMLFactory; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import thb.jeanluc.adventure.loader.dto.EndingDto; import thb.jeanluc.adventure.loader.dto.GameDto; import thb.jeanluc.adventure.loader.dto.ItemDto; import thb.jeanluc.adventure.loader.dto.NpcDto; import thb.jeanluc.adventure.loader.dto.QuestDto; import thb.jeanluc.adventure.loader.dto.RoomDto; +import thb.jeanluc.adventure.model.Ending; import thb.jeanluc.adventure.model.Npc; import thb.jeanluc.adventure.model.Player; import thb.jeanluc.adventure.model.Quest; @@ -19,6 +21,7 @@ import thb.jeanluc.adventure.model.item.Item; import java.io.IOException; import java.io.InputStream; +import java.util.ArrayList; import java.util.HashMap; import java.util.List; import java.util.Map; @@ -71,12 +74,14 @@ public class WorldLoader { List npcDtos = readList(basePath + "/npcs.yaml", NpcDto.class); List roomDtos = readList(basePath + "/rooms.yaml", RoomDto.class); List questDtos = readListOptional(basePath + "/quests.yaml", QuestDto.class); + List endingDtos = readListOptional(basePath + "/endings.yaml", EndingDto.class); GameDto gameDto = readSingle(basePath + "/game.yaml", GameDto.class); requireUniqueIds("item", itemDtos.stream().map(ItemDto::id).toList()); requireUniqueIds("npc", npcDtos.stream().map(NpcDto::id).toList()); requireUniqueIds("room", roomDtos.stream().map(RoomDto::id).toList()); requireUniqueIds("quest", questDtos.stream().map(QuestDto::id).toList()); + requireUniqueIds("ending", endingDtos.stream().map(EndingDto::id).toList()); Map items = new HashMap<>(); for (ItemDto dto : itemDtos) { @@ -94,6 +99,10 @@ public class WorldLoader { for (QuestDto dto : questDtos) { quests.put(dto.id(), QuestFactory.fromDto(dto)); } + List endings = new ArrayList<>(); + for (EndingDto dto : endingDtos) { + endings.add(EndingFactory.fromDto(dto)); + } ReferenceResolver resolver = new ReferenceResolver(items, npcs, rooms); resolver.resolveRooms(roomDtos); @@ -106,7 +115,7 @@ public class WorldLoader { Player player = new Player(start, gold); World world = new World(rooms, items, npcs, - gameDto.title(), gameDto.welcomeMessage(), quests); + gameDto.title(), gameDto.welcomeMessage(), quests, endings); log.info("World '{}' loaded: {} rooms, {} items, {} npcs", gameDto.title(), rooms.size(), items.size(), npcs.size()); return new LoadResult(world, player); diff --git a/Semesterprojekt/src/main/java/thb/jeanluc/adventure/loader/dto/EndingDto.java b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/loader/dto/EndingDto.java new file mode 100644 index 0000000..0434956 --- /dev/null +++ b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/loader/dto/EndingDto.java @@ -0,0 +1,7 @@ +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) { +} diff --git a/Semesterprojekt/src/main/java/thb/jeanluc/adventure/model/Ending.java b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/model/Ending.java new file mode 100644 index 0000000..83458d9 --- /dev/null +++ b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/model/Ending.java @@ -0,0 +1,10 @@ +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); + } +} diff --git a/Semesterprojekt/src/main/java/thb/jeanluc/adventure/model/World.java b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/model/World.java index b478fa8..56dffb1 100644 --- a/Semesterprojekt/src/main/java/thb/jeanluc/adventure/model/World.java +++ b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/model/World.java @@ -4,6 +4,7 @@ import lombok.Getter; import lombok.RequiredArgsConstructor; import thb.jeanluc.adventure.model.item.Item; +import java.util.List; import java.util.Map; /** @@ -33,9 +34,18 @@ public class World { /** Global lookup of quests by id. */ private final Map quests; - /** Backward-compatible constructor for worlds without quests. */ + /** Ordered list of endings (first matching one wins). */ + private final List endings; + + /** 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()); + 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()); } } diff --git a/Semesterprojekt/src/main/resources/world/endings.yaml b/Semesterprojekt/src/main/resources/world/endings.yaml new file mode 100644 index 0000000..cab9b91 --- /dev/null +++ b/Semesterprojekt/src/main/resources/world/endings.yaml @@ -0,0 +1,14 @@ +- 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. diff --git a/Semesterprojekt/src/main/resources/world/items.yaml b/Semesterprojekt/src/main/resources/world/items.yaml index 70a52d5..8db6c63 100644 --- a/Semesterprojekt/src/main/resources/world/items.yaml +++ b/Semesterprojekt/src/main/resources/world/items.yaml @@ -34,3 +34,15 @@ You pull the lever back; the manor falls dark again. effects: - { setFlag: power_on } + +- type: switchable + id: front_door + name: Front Door + description: The heavy front door. It would let you leave the manor for good. + initialState: 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 } diff --git a/Semesterprojekt/src/main/resources/world/rooms.yaml b/Semesterprojekt/src/main/resources/world/rooms.yaml index 2ce13cb..0fb43ef 100644 --- a/Semesterprojekt/src/main/resources/world/rooms.yaml +++ b/Semesterprojekt/src/main/resources/world/rooms.yaml @@ -7,7 +7,7 @@ north: hallway east: cellar south: dungeon - items: [letter, lamp] + items: [letter, lamp, front_door] npcs: [old_man] exitLocks: - direction: east diff --git a/Semesterprojekt/src/test/java/thb/jeanluc/adventure/game/EndingEngineTest.java b/Semesterprojekt/src/test/java/thb/jeanluc/adventure/game/EndingEngineTest.java new file mode 100644 index 0000000..f40859c --- /dev/null +++ b/Semesterprojekt/src/test/java/thb/jeanluc/adventure/game/EndingEngineTest.java @@ -0,0 +1,50 @@ +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"); + } +} diff --git a/Semesterprojekt/src/test/java/thb/jeanluc/adventure/loader/EndingLoadingTest.java b/Semesterprojekt/src/test/java/thb/jeanluc/adventure/loader/EndingLoadingTest.java new file mode 100644 index 0000000..257d519 --- /dev/null +++ b/Semesterprojekt/src/test/java/thb/jeanluc/adventure/loader/EndingLoadingTest.java @@ -0,0 +1,22 @@ +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."); + } +}