Merge win-condition & endings (sub-project 3.3) into develop
Priority-ordered, condition-driven endings (endings.yaml) checked each turn after the quest tick; the first match prints an ending banner + run summary (turns, quests X/Y, rank) and ends the game. EndingEngine mirrors the quest engine; World gains 5/6/7-arg back-compat constructors. Two reachable demo endings: victory (manor_secured) and flee (front door -> fled). Completes the quest system (#3). 120 tests green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -86,8 +86,13 @@ Farb-Rollen: Items / NPCs / Exits / Heading / Gefahr.
|
|||||||
> ✅ #3.2 umgesetzt (Branch `feature/quests`): condition-driven Quests
|
> ✅ #3.2 umgesetzt (Branch `feature/quests`): condition-driven Quests
|
||||||
> (Quest/QuestStage), QuestEngine mit Auto-Advance + Ansagen, START_QUEST-Effekt,
|
> (Quest/QuestStage), QuestEngine mit Auto-Advance + Ansagen, START_QUEST-Effekt,
|
||||||
> Konsolen-Befehl `quests` und GUI-Quest-Box unter der Karte. Demo-Quest
|
> Konsolen-Befehl `quests` und GUI-Quest-Box unter der Karte. Demo-Quest
|
||||||
> `restore_power`. Offen: #3.3 Win-Condition/Enden sowie Licht/Dunkelheit &
|
> `restore_power`.
|
||||||
> Item-Kombination.
|
>
|
||||||
|
> ✅ #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).
|
- Aktuelles NPC-System: Begrüßung + Geschenk-Reaktion (Item → Item).
|
||||||
|
|||||||
458
Semesterprojekt/docs/superpowers/plans/2026-05-31-endings.md
Normal file
458
Semesterprojekt/docs/superpowers/plans/2026-05-31-endings.md
Normal file
@@ -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<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.
|
||||||
@@ -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<Condition> 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<ConditionDto> when, String text)`.
|
||||||
|
- `EndingFactory.fromDto` baut `Ending` (`when` via `ConditionDto.toModelList`).
|
||||||
|
- `WorldLoader` liest `endings.yaml` **optional** (`readListOptional`), baut eine
|
||||||
|
**geordnete** `List<Ending>` (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).
|
||||||
@@ -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";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,6 +8,7 @@ import thb.jeanluc.adventure.command.ParsedCommand;
|
|||||||
import thb.jeanluc.adventure.io.text.Hud;
|
import thb.jeanluc.adventure.io.text.Hud;
|
||||||
import thb.jeanluc.adventure.io.text.MapView;
|
import thb.jeanluc.adventure.io.text.MapView;
|
||||||
import thb.jeanluc.adventure.map.MapLayout;
|
import thb.jeanluc.adventure.map.MapLayout;
|
||||||
|
import thb.jeanluc.adventure.model.Ending;
|
||||||
|
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
|
|
||||||
@@ -39,6 +40,7 @@ public class Game {
|
|||||||
*/
|
*/
|
||||||
public void run() {
|
public void run() {
|
||||||
publishHud();
|
publishHud();
|
||||||
|
maybeEnd();
|
||||||
while (running) {
|
while (running) {
|
||||||
String input = ctx.getIo().readLine();
|
String input = ctx.getIo().readLine();
|
||||||
if (input == null) {
|
if (input == null) {
|
||||||
@@ -56,6 +58,16 @@ public class Game {
|
|||||||
turn++;
|
turn++;
|
||||||
}
|
}
|
||||||
publishHud();
|
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();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,11 +5,13 @@ import com.fasterxml.jackson.databind.type.CollectionType;
|
|||||||
import com.fasterxml.jackson.dataformat.yaml.YAMLFactory;
|
import com.fasterxml.jackson.dataformat.yaml.YAMLFactory;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
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.GameDto;
|
||||||
import thb.jeanluc.adventure.loader.dto.ItemDto;
|
import thb.jeanluc.adventure.loader.dto.ItemDto;
|
||||||
import thb.jeanluc.adventure.loader.dto.NpcDto;
|
import thb.jeanluc.adventure.loader.dto.NpcDto;
|
||||||
import thb.jeanluc.adventure.loader.dto.QuestDto;
|
import thb.jeanluc.adventure.loader.dto.QuestDto;
|
||||||
import thb.jeanluc.adventure.loader.dto.RoomDto;
|
import thb.jeanluc.adventure.loader.dto.RoomDto;
|
||||||
|
import thb.jeanluc.adventure.model.Ending;
|
||||||
import thb.jeanluc.adventure.model.Npc;
|
import thb.jeanluc.adventure.model.Npc;
|
||||||
import thb.jeanluc.adventure.model.Player;
|
import thb.jeanluc.adventure.model.Player;
|
||||||
import thb.jeanluc.adventure.model.Quest;
|
import thb.jeanluc.adventure.model.Quest;
|
||||||
@@ -19,6 +21,7 @@ import thb.jeanluc.adventure.model.item.Item;
|
|||||||
|
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
import java.io.InputStream;
|
import java.io.InputStream;
|
||||||
|
import java.util.ArrayList;
|
||||||
import java.util.HashMap;
|
import java.util.HashMap;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
@@ -71,12 +74,14 @@ public class WorldLoader {
|
|||||||
List<NpcDto> npcDtos = readList(basePath + "/npcs.yaml", NpcDto.class);
|
List<NpcDto> npcDtos = readList(basePath + "/npcs.yaml", NpcDto.class);
|
||||||
List<RoomDto> roomDtos = readList(basePath + "/rooms.yaml", RoomDto.class);
|
List<RoomDto> roomDtos = readList(basePath + "/rooms.yaml", RoomDto.class);
|
||||||
List<QuestDto> questDtos = readListOptional(basePath + "/quests.yaml", QuestDto.class);
|
List<QuestDto> questDtos = readListOptional(basePath + "/quests.yaml", QuestDto.class);
|
||||||
|
List<EndingDto> endingDtos = readListOptional(basePath + "/endings.yaml", EndingDto.class);
|
||||||
GameDto gameDto = readSingle(basePath + "/game.yaml", GameDto.class);
|
GameDto gameDto = readSingle(basePath + "/game.yaml", GameDto.class);
|
||||||
|
|
||||||
requireUniqueIds("item", itemDtos.stream().map(ItemDto::id).toList());
|
requireUniqueIds("item", itemDtos.stream().map(ItemDto::id).toList());
|
||||||
requireUniqueIds("npc", npcDtos.stream().map(NpcDto::id).toList());
|
requireUniqueIds("npc", npcDtos.stream().map(NpcDto::id).toList());
|
||||||
requireUniqueIds("room", roomDtos.stream().map(RoomDto::id).toList());
|
requireUniqueIds("room", roomDtos.stream().map(RoomDto::id).toList());
|
||||||
requireUniqueIds("quest", questDtos.stream().map(QuestDto::id).toList());
|
requireUniqueIds("quest", questDtos.stream().map(QuestDto::id).toList());
|
||||||
|
requireUniqueIds("ending", endingDtos.stream().map(EndingDto::id).toList());
|
||||||
|
|
||||||
Map<String, Item> items = new HashMap<>();
|
Map<String, Item> items = new HashMap<>();
|
||||||
for (ItemDto dto : itemDtos) {
|
for (ItemDto dto : itemDtos) {
|
||||||
@@ -94,6 +99,10 @@ public class WorldLoader {
|
|||||||
for (QuestDto dto : questDtos) {
|
for (QuestDto dto : questDtos) {
|
||||||
quests.put(dto.id(), QuestFactory.fromDto(dto));
|
quests.put(dto.id(), QuestFactory.fromDto(dto));
|
||||||
}
|
}
|
||||||
|
List<Ending> endings = new ArrayList<>();
|
||||||
|
for (EndingDto dto : endingDtos) {
|
||||||
|
endings.add(EndingFactory.fromDto(dto));
|
||||||
|
}
|
||||||
|
|
||||||
ReferenceResolver resolver = new ReferenceResolver(items, npcs, rooms);
|
ReferenceResolver resolver = new ReferenceResolver(items, npcs, rooms);
|
||||||
resolver.resolveRooms(roomDtos);
|
resolver.resolveRooms(roomDtos);
|
||||||
@@ -106,7 +115,7 @@ public class WorldLoader {
|
|||||||
Player player = new Player(start, gold);
|
Player player = new Player(start, gold);
|
||||||
|
|
||||||
World world = new World(rooms, items, npcs,
|
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",
|
log.info("World '{}' loaded: {} rooms, {} items, {} npcs",
|
||||||
gameDto.title(), rooms.size(), items.size(), npcs.size());
|
gameDto.title(), rooms.size(), items.size(), npcs.size());
|
||||||
return new LoadResult(world, player);
|
return new LoadResult(world, player);
|
||||||
|
|||||||
@@ -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<ConditionDto> when, String text) {
|
||||||
|
}
|
||||||
@@ -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<Condition> when, String text) {
|
||||||
|
public Ending {
|
||||||
|
when = when == null ? List.of() : List.copyOf(when);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ import lombok.Getter;
|
|||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import thb.jeanluc.adventure.model.item.Item;
|
import thb.jeanluc.adventure.model.item.Item;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -33,9 +34,18 @@ public class World {
|
|||||||
/** Global lookup of quests by id. */
|
/** Global lookup of quests by id. */
|
||||||
private final Map<String, Quest> quests;
|
private final Map<String, Quest> quests;
|
||||||
|
|
||||||
/** Backward-compatible constructor for worlds without quests. */
|
/** Ordered list of endings (first matching one wins). */
|
||||||
|
private final List<Ending> endings;
|
||||||
|
|
||||||
|
/** Backward-compatible constructor for worlds without quests or endings. */
|
||||||
public World(Map<String, Room> rooms, Map<String, Item> items, Map<String, Npc> npcs,
|
public World(Map<String, Room> rooms, Map<String, Item> items, Map<String, Npc> npcs,
|
||||||
String title, String welcomeMessage) {
|
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<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());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
14
Semesterprojekt/src/main/resources/world/endings.yaml
Normal file
14
Semesterprojekt/src/main/resources/world/endings.yaml
Normal file
@@ -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.
|
||||||
@@ -34,3 +34,15 @@
|
|||||||
You pull the lever back; the manor falls dark again.
|
You pull the lever back; the manor falls dark again.
|
||||||
effects:
|
effects:
|
||||||
- { setFlag: power_on }
|
- { 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 }
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
north: hallway
|
north: hallway
|
||||||
east: cellar
|
east: cellar
|
||||||
south: dungeon
|
south: dungeon
|
||||||
items: [letter, lamp]
|
items: [letter, lamp, front_door]
|
||||||
npcs: [old_man]
|
npcs: [old_man]
|
||||||
exitLocks:
|
exitLocks:
|
||||||
- direction: east
|
- direction: east
|
||||||
|
|||||||
@@ -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<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");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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.");
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user