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