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:
2026-05-31 22:51:15 +02:00
13 changed files with 224 additions and 6 deletions

View File

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

View File

@@ -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";
}
}

View File

@@ -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();
} }
} }

View File

@@ -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());
}
}

View File

@@ -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);

View File

@@ -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) {
}

View File

@@ -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);
}
}

View File

@@ -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());
} }
} }

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

View File

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

View File

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

View File

@@ -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");
}
}

View File

@@ -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.");
}
}