diff --git a/Semesterprojekt/src/main/java/thb/jeanluc/adventure/command/impl/LookCommand.java b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/command/impl/LookCommand.java
index e5ca6e1..a22d948 100644
--- a/Semesterprojekt/src/main/java/thb/jeanluc/adventure/command/impl/LookCommand.java
+++ b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/command/impl/LookCommand.java
@@ -2,6 +2,7 @@ package thb.jeanluc.adventure.command.impl;
import thb.jeanluc.adventure.command.Command;
import thb.jeanluc.adventure.game.Conditions;
+import thb.jeanluc.adventure.game.EscortEngine;
import thb.jeanluc.adventure.game.GameContext;
import thb.jeanluc.adventure.game.Light;
import thb.jeanluc.adventure.io.text.RoomView;
@@ -36,6 +37,10 @@ public class LookCommand implements Command {
break;
}
}
+ String followers = EscortEngine.followerNote(ctx);
+ if (followers != null) {
+ description = description.stripTrailing() + "\n\n" + followers;
+ }
ctx.getIo().showRoom(new RoomView(room.getName(), description, items, npcs, exits));
}
diff --git a/Semesterprojekt/src/main/java/thb/jeanluc/adventure/game/EscortEngine.java b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/game/EscortEngine.java
new file mode 100644
index 0000000..4cd8848
--- /dev/null
+++ b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/game/EscortEngine.java
@@ -0,0 +1,125 @@
+package thb.jeanluc.adventure.game;
+
+import thb.jeanluc.adventure.io.text.StyledText;
+import thb.jeanluc.adventure.model.Npc;
+import thb.jeanluc.adventure.model.Room;
+import thb.jeanluc.adventure.model.item.Item;
+
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * Drives the optional "Lost Twins" escort side-quest. Once a twin is recruited
+ * (a {@code give} reaction sets its {@code *_following} flag) the twin tags
+ * along with the player: this engine removes them from their home room and
+ * renders them as a follower. When the player reaches the foyer with both
+ * twins in tow, the brothers are reunited — granting the reward and firing the
+ * {@code twins_reunited} flag that unlocks the bonus ending.
+ *
+ *
State lives entirely in flags, so a save/load round-trip needs no extra
+ * persistence: {@link #tick} reconciles follower placement from the restored
+ * flags on the first turn after loading.
+ */
+public final class EscortEngine {
+
+ /** Room the twins must be led to in order to reunite and escape. */
+ static final String REUNION_ROOM_ID = "foyer";
+
+ /** Flag set (and bonus-ending gate) once the brothers are reunited. */
+ static final String REUNITED_FLAG = "twins_reunited";
+
+ /** Item handed to the player as thanks; pure flavour, no mechanical effect. */
+ static final String REWARD_ITEM_ID = "brass_compass";
+
+ /** The two escortable twins, each pinned to a home room and a follow flag. */
+ private static final List TWINS = List.of(
+ new Twin("marlon", "marlon_following", "conservatory"),
+ new Twin("malte", "malte_following", "boiler_room"));
+
+ private EscortEngine() {
+ }
+
+ /** A recruitable twin and the state that tracks their escort. */
+ private record Twin(String npcId, String followingFlag, String homeRoomId) {
+ }
+
+ /**
+ * Per-turn escort progression: pull recruited twins out of their home rooms
+ * and, when both stand with the player in the foyer, reunite them.
+ *
+ * @param ctx the active game context
+ */
+ public static void tick(GameContext ctx) {
+ for (Twin twin : TWINS) {
+ if (isFollowing(ctx, twin)) {
+ Room home = ctx.getWorld().getRooms().get(twin.homeRoomId());
+ if (home != null) {
+ home.removeNpc(twin.npcId());
+ }
+ }
+ }
+ maybeReunite(ctx);
+ }
+
+ /**
+ * A short line naming the twins currently at the player's heels, or
+ * {@code null} when nobody is following. Appended to room descriptions.
+ *
+ * @param ctx the active game context
+ * @return the follower note, or {@code null}
+ */
+ public static String followerNote(GameContext ctx) {
+ if (ctx.getState().isSet(REUNITED_FLAG)) {
+ return null;
+ }
+ List names = new ArrayList<>();
+ for (Twin twin : TWINS) {
+ if (isFollowing(ctx, twin)) {
+ names.add(displayName(ctx, twin));
+ }
+ }
+ if (names.isEmpty()) {
+ return null;
+ }
+ if (names.size() == 1) {
+ return names.getFirst() + " keeps close at your shoulder.";
+ }
+ return String.join(" and ", names) + " keep close at your shoulders.";
+ }
+
+ private static void maybeReunite(GameContext ctx) {
+ if (ctx.getState().isSet(REUNITED_FLAG)) {
+ return;
+ }
+ boolean inFoyer = REUNION_ROOM_ID.equals(ctx.getPlayer().getCurrentRoom().getId());
+ boolean bothFollowing = TWINS.stream().allMatch(t -> isFollowing(ctx, t));
+ if (!inFoyer || !bothFollowing) {
+ return;
+ }
+
+ ctx.getState().set(REUNITED_FLAG);
+ grantReward(ctx);
+ ctx.getIo().print(StyledText.builder()
+ .heading("Marlon and Malte are reunited! ")
+ .plain("The brothers crash together in the lamplight, clinging on as if "
+ + "the dark might split them again. Then they're gone — out the "
+ + "front door and into the night, hand in hand, free.")
+ .build());
+ }
+
+ private static void grantReward(GameContext ctx) {
+ Item reward = ctx.getWorld().getItems().get(REWARD_ITEM_ID);
+ if (reward != null && !ctx.getPlayer().hasItem(REWARD_ITEM_ID)) {
+ ctx.getPlayer().addItem(reward);
+ }
+ }
+
+ private static boolean isFollowing(GameContext ctx, Twin twin) {
+ return ctx.getState().isSet(twin.followingFlag());
+ }
+
+ private static String displayName(GameContext ctx, Twin twin) {
+ Npc npc = ctx.getWorld().getNpcs().get(twin.npcId());
+ return npc != null ? npc.getName() : twin.npcId();
+ }
+}
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 0205c91..3ef935b 100644
--- a/Semesterprojekt/src/main/java/thb/jeanluc/adventure/game/Game.java
+++ b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/game/Game.java
@@ -98,6 +98,7 @@ public class Game {
private void publishHud() {
QuestEngine.tick(ctx);
+ EscortEngine.tick(ctx);
ctx.getIo().setHud(new Hud(
ctx.getPlayer().getCurrentRoom().getName(),
ctx.getPlayer().getGold(),
diff --git a/Semesterprojekt/src/main/java/thb/jeanluc/adventure/model/Room.java b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/model/Room.java
index 4cc61fa..fcb7054 100644
--- a/Semesterprojekt/src/main/java/thb/jeanluc/adventure/model/Room.java
+++ b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/model/Room.java
@@ -147,4 +147,14 @@ public class Room {
public Optional findNpc(String npcId) {
return Optional.ofNullable(npcs.get(npcId));
}
+
+ /**
+ * Removes an NPC from this room by id.
+ *
+ * @param npcId id of the NPC to remove
+ * @return the removed NPC, or empty if no such NPC was present
+ */
+ public Optional removeNpc(String npcId) {
+ return Optional.ofNullable(npcs.remove(npcId));
+ }
}
diff --git a/Semesterprojekt/src/main/resources/world/endings.yaml b/Semesterprojekt/src/main/resources/world/endings.yaml
index 4d313e6..e501fc2 100644
--- a/Semesterprojekt/src/main/resources/world/endings.yaml
+++ b/Semesterprojekt/src/main/resources/world/endings.yaml
@@ -2,6 +2,17 @@
# specific (best) ending is listed first. ghost_banished implies power_on
# implies the upper floor was reached, so #1 needs only left_manor + ghost_banished.
+# Best possible end: spirit laid to rest AND the lost twins led out (optional).
+- id: no_one_left_behind
+ title: "No One Left Behind"
+ victory: true
+ when: [{ flag: left_manor }, { flag: ghost_banished }, { flag: twins_reunited }]
+ text: |
+ You step into the night, and you are not alone. Marlon and Malte wait at
+ the gate, together, breathing the cold free air. Behind you the manor is
+ utterly still. You laid a spirit to rest and brought two brothers home —
+ there is no better way out of that house than this one.
+
- id: spirit_at_rest
title: "Spirit at Rest"
victory: true
diff --git a/Semesterprojekt/src/main/resources/world/items.yaml b/Semesterprojekt/src/main/resources/world/items.yaml
index 7ceb5a3..64586e1 100644
--- a/Semesterprojekt/src/main/resources/world/items.yaml
+++ b/Semesterprojekt/src/main/resources/world/items.yaml
@@ -122,3 +122,27 @@
id: shovel
name: Shovel
description: A rusty shovel. Sturdy enough to dig, though nothing here needs digging.
+
+# ── The lost twins (optional escort side-quest) ──────────────────────────────
+
+- type: plain
+ id: marlon_keepsake
+ name: Blue Marble
+ description: |
+ A child's blue glass marble, worn smooth from years in a pocket. Marlon's.
+ Somewhere its red twin is being clutched just as tightly.
+
+- type: plain
+ id: malte_keepsake
+ name: Red Marble
+ description: |
+ A child's red glass marble, the mate to a blue one. Malte's — the only
+ thing he kept hold of when the dark took him.
+
+- type: readable
+ id: brass_compass
+ name: Brass Compass
+ description: A small brass compass, pressed on you by two very grateful brothers.
+ readText: |
+ Engraved on the back, in a child's scratchy hand: "For the one who led us
+ out. — M & M." The needle, you notice, always settles toward the door.
diff --git a/Semesterprojekt/src/main/resources/world/npcs.yaml b/Semesterprojekt/src/main/resources/world/npcs.yaml
index 0560b2e..2f6fa3d 100644
--- a/Semesterprojekt/src/main/resources/world/npcs.yaml
+++ b/Semesterprojekt/src/main/resources/world/npcs.yaml
@@ -27,3 +27,54 @@
consumes: letter
effects:
- { setFlag: heard_tale }
+
+# ── The lost twins (optional escort side-quest) ──────────────────────────────
+# Marlon (conservatory) and Malte (boiler room) were separated in the manor.
+# Give each the other's marble as proof his brother lives, and he follows you;
+# lead both to the foyer and they escape together. See EscortEngine.
+
+- id: marlon
+ name: Marlon
+ description: |
+ A frightened young man pressed into the corner, knuckles white around a
+ blue glass marble. He has his brother's face — minus the brother.
+ greeting: |
+ "Please — have you seen Malte? My twin. We were exploring and the dark
+ just... took him. I can't leave this house without him. I can't."
+ dialogue:
+ - when: [{ hasItem: malte_keepsake }]
+ text: |
+ "That red marble — that's MALTE'S! He's alive? Here, quick: give it to
+ me so I know it's real. Then I'll follow you to him."
+ reactions:
+ - onReceive: malte_keepsake
+ response: |
+ "His marble. His actual marble. Then he's alive — and you've seen him.
+ Lead the way. I'm right behind you, I won't lose him again."
+ consumes: malte_keepsake
+ effects:
+ - { setFlag: marlon_following }
+ - { startQuest: reunite_twins }
+
+- id: malte
+ name: Malte
+ description: |
+ A shivering young man clutching a red glass marble in the furnace-dark.
+ The image of his brother, alone.
+ greeting: |
+ "Who's there? Marlon? No... Please, have you seen him — my twin, Marlon?
+ We got split up and I can't find my way back in this dark."
+ dialogue:
+ - when: [{ hasItem: marlon_keepsake }]
+ text: |
+ "A blue marble — Marlon's! You've found him! Give it here so I know
+ it's real, then take me to him, please, before I lose my nerve."
+ reactions:
+ - onReceive: marlon_keepsake
+ response: |
+ "Marlon's marble. He's really here. Don't let go of me — wherever
+ you're going, I'm following. Take me to my brother."
+ consumes: marlon_keepsake
+ effects:
+ - { setFlag: malte_following }
+ - { startQuest: reunite_twins }
diff --git a/Semesterprojekt/src/main/resources/world/quests.yaml b/Semesterprojekt/src/main/resources/world/quests.yaml
index dc2e7f4..4d2bdc9 100644
--- a/Semesterprojekt/src/main/resources/world/quests.yaml
+++ b/Semesterprojekt/src/main/resources/world/quests.yaml
@@ -16,3 +16,13 @@
stages:
- objective: "Lay the restless spirit to rest in the chapel."
completion: [{ flag: ghost_banished }]
+
+# Optional side-quest: started when you first recruit a twin (see npcs.yaml).
+- id: reunite_twins
+ title: "The Lost Twins"
+ autoStart: false
+ stages:
+ - objective: "Reunite Marlon and Malte — lead them both to the entrance."
+ completion: [{ flag: twins_reunited }]
+ onComplete:
+ - { say: "The brothers are together again, and gone into the night. You didn't have to help them. You're glad you did." }
diff --git a/Semesterprojekt/src/main/resources/world/rooms.yaml b/Semesterprojekt/src/main/resources/world/rooms.yaml
index aa6092d..760b538 100644
--- a/Semesterprojekt/src/main/resources/world/rooms.yaml
+++ b/Semesterprojekt/src/main/resources/world/rooms.yaml
@@ -74,11 +74,18 @@
description: |
A glass-roofed room choked with dead ferns. Rusted plumbing lines the
walls, and a heavy iron valve wheel has been left on a potting bench.
+ A frightened young man cowers among the planters, a blue marble in his fist.
music: manor-theme.ogg
exits:
south: dining_room
- items: [valve_wheel]
- npcs: []
+ items: [valve_wheel, marlon_keepsake]
+ npcs: [marlon]
+ descriptionStates:
+ - when: [{ flag: marlon_following }]
+ text: |
+ A glass-roofed room choked with dead ferns. Rusted plumbing lines the
+ walls, and a heavy iron valve wheel has been left on a potting bench.
+ The corner where the frightened young man crouched is empty now.
- id: grand_staircase
name: Grand Staircase
@@ -135,13 +142,20 @@
name: Boiler Room
description: |
A cramped stone room behind the furnace. A rusty generator squats in the
- corner, an empty fuse socket gaping in its housing.
+ corner, an empty fuse socket gaping in its housing. A shivering young man
+ huddles against the cold iron, a red marble clenched in his hand.
music: cellar-drone.ogg
dark: true
exits:
east: cellar_stairs
- items: [generator]
- npcs: []
+ items: [generator, malte_keepsake]
+ npcs: [malte]
+ descriptionStates:
+ - when: [{ flag: malte_following }]
+ text: |
+ A cramped stone room behind the furnace. A rusty generator squats in
+ the corner, an empty fuse socket gaping in its housing. The cold spot
+ by the iron where the shivering man huddled is empty now.
# ── Upper floor (reachable only once the power is on) ───────────────────────
diff --git a/Semesterprojekt/src/test/java/thb/jeanluc/adventure/game/EscortEngineTest.java b/Semesterprojekt/src/test/java/thb/jeanluc/adventure/game/EscortEngineTest.java
new file mode 100644
index 0000000..5c4b3dc
--- /dev/null
+++ b/Semesterprojekt/src/test/java/thb/jeanluc/adventure/game/EscortEngineTest.java
@@ -0,0 +1,123 @@
+package thb.jeanluc.adventure.game;
+
+import org.junit.jupiter.api.Test;
+import thb.jeanluc.adventure.io.TestIO;
+import thb.jeanluc.adventure.model.Npc;
+import thb.jeanluc.adventure.model.Player;
+import thb.jeanluc.adventure.model.Room;
+import thb.jeanluc.adventure.model.World;
+import thb.jeanluc.adventure.model.item.Item;
+import thb.jeanluc.adventure.model.item.PlainItem;
+
+import java.util.Map;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+class EscortEngineTest {
+
+ private final Room foyer = new Room("foyer", "Foyer", "An empty foyer.");
+ private final Room conservatory = new Room("conservatory", "Conservatory", "Ferns.");
+ private final Room boilerRoom = new Room("boiler_room", "Boiler Room", "Furnace-dark.");
+ private final Npc marlon = Npc.shell("marlon", "Marlon", "A twin.", "...");
+ private final Npc malte = Npc.shell("malte", "Malte", "A twin.", "...");
+
+ private GameContext ctx(Room start) {
+ conservatory.addNpc(marlon);
+ boilerRoom.addNpc(malte);
+ Item compass = PlainItem.builder()
+ .id("brass_compass").name("Brass Compass").description("d").build();
+ World w = new World(
+ Map.of("foyer", foyer, "conservatory", conservatory, "boiler_room", boilerRoom),
+ Map.of("brass_compass", compass),
+ Map.of("marlon", marlon, "malte", malte),
+ "t", "w");
+ return new GameContext(w, new Player(start, 0), new TestIO());
+ }
+
+ @Test
+ void recruitedTwinIsPulledFromHomeRoomAndAppearsInFollowerNote() {
+ GameContext ctx = ctx(conservatory);
+ ctx.getState().set("marlon_following");
+
+ EscortEngine.tick(ctx);
+
+ assertThat(conservatory.findNpc("marlon")).isEmpty();
+ assertThat(EscortEngine.followerNote(ctx)).contains("Marlon").contains("shoulder");
+ }
+
+ @Test
+ void followerNoteListsBothTwinsWhenBothFollow() {
+ GameContext ctx = ctx(foyer);
+ ctx.getState().set("marlon_following");
+ ctx.getState().set("malte_following");
+
+ assertThat(EscortEngine.followerNote(ctx))
+ .contains("Marlon").contains("Malte").contains("shoulders");
+ }
+
+ @Test
+ void followerNoteIsNullWhenNobodyFollows() {
+ GameContext ctx = ctx(foyer);
+ assertThat(EscortEngine.followerNote(ctx)).isNull();
+ }
+
+ @Test
+ void reunionInFoyerWithBothFollowingSetsFlagGrantsRewardAndPrintsBeat() {
+ GameContext ctx = ctx(foyer);
+ ctx.getState().set("marlon_following");
+ ctx.getState().set("malte_following");
+
+ EscortEngine.tick(ctx);
+
+ assertThat(ctx.getState().isSet("twins_reunited")).isTrue();
+ assertThat(ctx.getPlayer().hasItem("brass_compass")).isTrue();
+ assertThat(((TestIO) ctx.getIo()).allOutput()).contains("reunited");
+ }
+
+ @Test
+ void followerNoteIsNullOnceReunitedSoTheTwinsAppearGone() {
+ GameContext ctx = ctx(foyer);
+ ctx.getState().set("marlon_following");
+ ctx.getState().set("malte_following");
+ ctx.getState().set("twins_reunited");
+
+ assertThat(EscortEngine.followerNote(ctx)).isNull();
+ }
+
+ @Test
+ void reunionFiresOnlyOnce() {
+ GameContext ctx = ctx(foyer);
+ ctx.getState().set("marlon_following");
+ ctx.getState().set("malte_following");
+
+ EscortEngine.tick(ctx);
+ EscortEngine.tick(ctx);
+
+ long beats = ((TestIO) ctx.getIo()).outputs().stream()
+ .filter(s -> s.contains("reunited")).count();
+ assertThat(beats).isEqualTo(1);
+ }
+
+ @Test
+ void noReunionWhenOnlyOneTwinFollows() {
+ GameContext ctx = ctx(foyer);
+ ctx.getState().set("marlon_following");
+
+ EscortEngine.tick(ctx);
+
+ assertThat(ctx.getState().isSet("twins_reunited")).isFalse();
+ assertThat(ctx.getPlayer().hasItem("brass_compass")).isFalse();
+ }
+
+ @Test
+ void noReunionWhenBothFollowButNotInFoyer() {
+ GameContext ctx = ctx(conservatory);
+ ctx.getState().set("marlon_following");
+ ctx.getState().set("malte_following");
+
+ EscortEngine.tick(ctx);
+
+ assertThat(ctx.getState().isSet("twins_reunited")).isFalse();
+ assertThat(ctx.getPlayer().hasItem("brass_compass")).isFalse();
+ }
+}