feat(world): add the Lost Twins optional escort side-quest
Two separated brothers, Marlon (conservatory) and Malte (boiler room), can be reunited as optional content. Carry each twin's marble to the other as proof he's alive and that twin follows you; lead both to the foyer and they escape together, granting a Brass Compass keepsake and unlocking a new top-priority "No One Left Behind" ending. Purely optional — no main win condition depends on it. The new EscortEngine drives following and the foyer reunion from flags alone, so save/load needs no format change (it reconciles follower placement on the first tick after loading). Adds Room.removeNpc, one tick call in the game loop, and a follower note appended by LookCommand. descriptionStates swap the twins' home-room prose once they leave. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -2,6 +2,7 @@ package thb.jeanluc.adventure.command.impl;
|
|||||||
|
|
||||||
import thb.jeanluc.adventure.command.Command;
|
import thb.jeanluc.adventure.command.Command;
|
||||||
import thb.jeanluc.adventure.game.Conditions;
|
import thb.jeanluc.adventure.game.Conditions;
|
||||||
|
import thb.jeanluc.adventure.game.EscortEngine;
|
||||||
import thb.jeanluc.adventure.game.GameContext;
|
import thb.jeanluc.adventure.game.GameContext;
|
||||||
import thb.jeanluc.adventure.game.Light;
|
import thb.jeanluc.adventure.game.Light;
|
||||||
import thb.jeanluc.adventure.io.text.RoomView;
|
import thb.jeanluc.adventure.io.text.RoomView;
|
||||||
@@ -36,6 +37,10 @@ public class LookCommand implements Command {
|
|||||||
break;
|
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));
|
ctx.getIo().showRoom(new RoomView(room.getName(), description, items, npcs, exits));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
|
*
|
||||||
|
* <p>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<Twin> 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<String> 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -98,6 +98,7 @@ public class Game {
|
|||||||
|
|
||||||
private void publishHud() {
|
private void publishHud() {
|
||||||
QuestEngine.tick(ctx);
|
QuestEngine.tick(ctx);
|
||||||
|
EscortEngine.tick(ctx);
|
||||||
ctx.getIo().setHud(new Hud(
|
ctx.getIo().setHud(new Hud(
|
||||||
ctx.getPlayer().getCurrentRoom().getName(),
|
ctx.getPlayer().getCurrentRoom().getName(),
|
||||||
ctx.getPlayer().getGold(),
|
ctx.getPlayer().getGold(),
|
||||||
|
|||||||
@@ -147,4 +147,14 @@ public class Room {
|
|||||||
public Optional<Npc> findNpc(String npcId) {
|
public Optional<Npc> findNpc(String npcId) {
|
||||||
return Optional.ofNullable(npcs.get(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<Npc> removeNpc(String npcId) {
|
||||||
|
return Optional.ofNullable(npcs.remove(npcId));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,17 @@
|
|||||||
# specific (best) ending is listed first. ghost_banished implies power_on
|
# 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.
|
# 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
|
- id: spirit_at_rest
|
||||||
title: "Spirit at Rest"
|
title: "Spirit at Rest"
|
||||||
victory: true
|
victory: true
|
||||||
|
|||||||
@@ -122,3 +122,27 @@
|
|||||||
id: shovel
|
id: shovel
|
||||||
name: Shovel
|
name: Shovel
|
||||||
description: A rusty shovel. Sturdy enough to dig, though nothing here needs digging.
|
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.
|
||||||
|
|||||||
@@ -27,3 +27,54 @@
|
|||||||
consumes: letter
|
consumes: letter
|
||||||
effects:
|
effects:
|
||||||
- { setFlag: heard_tale }
|
- { 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 }
|
||||||
|
|||||||
@@ -16,3 +16,13 @@
|
|||||||
stages:
|
stages:
|
||||||
- objective: "Lay the restless spirit to rest in the chapel."
|
- objective: "Lay the restless spirit to rest in the chapel."
|
||||||
completion: [{ flag: ghost_banished }]
|
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." }
|
||||||
|
|||||||
@@ -74,11 +74,18 @@
|
|||||||
description: |
|
description: |
|
||||||
A glass-roofed room choked with dead ferns. Rusted plumbing lines the
|
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.
|
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
|
music: manor-theme.ogg
|
||||||
exits:
|
exits:
|
||||||
south: dining_room
|
south: dining_room
|
||||||
items: [valve_wheel]
|
items: [valve_wheel, marlon_keepsake]
|
||||||
npcs: []
|
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
|
- id: grand_staircase
|
||||||
name: Grand Staircase
|
name: Grand Staircase
|
||||||
@@ -135,13 +142,20 @@
|
|||||||
name: Boiler Room
|
name: Boiler Room
|
||||||
description: |
|
description: |
|
||||||
A cramped stone room behind the furnace. A rusty generator squats in the
|
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
|
music: cellar-drone.ogg
|
||||||
dark: true
|
dark: true
|
||||||
exits:
|
exits:
|
||||||
east: cellar_stairs
|
east: cellar_stairs
|
||||||
items: [generator]
|
items: [generator, malte_keepsake]
|
||||||
npcs: []
|
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) ───────────────────────
|
# ── Upper floor (reachable only once the power is on) ───────────────────────
|
||||||
|
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user