feat: condition-locked exits (+ room/dialogue state plumbing)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-31 21:52:06 +02:00
parent 228efaffcb
commit df4a46ae5b
11 changed files with 159 additions and 1 deletions

View File

@@ -1,8 +1,10 @@
package thb.jeanluc.adventure.command.impl; 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.GameContext; import thb.jeanluc.adventure.game.GameContext;
import thb.jeanluc.adventure.model.Direction; import thb.jeanluc.adventure.model.Direction;
import thb.jeanluc.adventure.model.ExitLock;
import thb.jeanluc.adventure.model.Room; import thb.jeanluc.adventure.model.Room;
import java.util.List; import java.util.List;
@@ -32,6 +34,11 @@ public class GoCommand implements Command {
ctx.getIo().write("You can't go " + dir.getLabel() + " from here."); ctx.getIo().write("You can't go " + dir.getLabel() + " from here.");
return; return;
} }
ExitLock lock = ctx.getPlayer().getCurrentRoom().getExitLocks().get(dir);
if (lock != null && !Conditions.all(lock.requires(), ctx)) {
ctx.getIo().write(lock.blocked());
return;
}
ctx.getPlayer().setCurrentRoom(next.get()); ctx.getPlayer().setCurrentRoom(next.get());
new LookCommand().execute(ctx, List.of()); new LookCommand().execute(ctx, List.of());
} }

View File

@@ -1,10 +1,18 @@
package thb.jeanluc.adventure.loader; package thb.jeanluc.adventure.loader;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import thb.jeanluc.adventure.loader.dto.ConditionDto;
import thb.jeanluc.adventure.loader.dto.DescriptionStateDto;
import thb.jeanluc.adventure.loader.dto.DialogueLineDto;
import thb.jeanluc.adventure.loader.dto.EffectDto;
import thb.jeanluc.adventure.loader.dto.ExitLockDto;
import thb.jeanluc.adventure.loader.dto.NpcDto; import thb.jeanluc.adventure.loader.dto.NpcDto;
import thb.jeanluc.adventure.loader.dto.ReactionDto; import thb.jeanluc.adventure.loader.dto.ReactionDto;
import thb.jeanluc.adventure.loader.dto.RoomDto; import thb.jeanluc.adventure.loader.dto.RoomDto;
import thb.jeanluc.adventure.model.DescriptionState;
import thb.jeanluc.adventure.model.DialogueLine;
import thb.jeanluc.adventure.model.Direction; import thb.jeanluc.adventure.model.Direction;
import thb.jeanluc.adventure.model.ExitLock;
import thb.jeanluc.adventure.model.Npc; import thb.jeanluc.adventure.model.Npc;
import thb.jeanluc.adventure.model.NpcReaction; import thb.jeanluc.adventure.model.NpcReaction;
import thb.jeanluc.adventure.model.Room; import thb.jeanluc.adventure.model.Room;
@@ -75,6 +83,25 @@ public class ReferenceResolver {
room.addNpc(npc); room.addNpc(npc);
} }
} }
if (dto.exitLocks() != null) {
for (ExitLockDto lock : dto.exitLocks()) {
Direction direction;
try {
direction = Direction.fromString(lock.direction());
} catch (IllegalArgumentException ex) {
throw new WorldLoadException(
"Room '" + dto.id() + "' has exit lock with unknown direction '" + lock.direction() + "'");
}
room.addExitLock(direction, new ExitLock(
ConditionDto.toModelList(lock.requires()), lock.blocked()));
}
}
if (dto.descriptionStates() != null) {
for (DescriptionStateDto ds : dto.descriptionStates()) {
room.addDescriptionState(new DescriptionState(
ConditionDto.toModelList(ds.when()), ds.text()));
}
}
} }
} }

View File

@@ -0,0 +1,7 @@
package thb.jeanluc.adventure.loader.dto;
import java.util.List;
/** YAML representation of a conditional room description. */
public record DescriptionStateDto(List<ConditionDto> when, String text) {
}

View File

@@ -0,0 +1,7 @@
package thb.jeanluc.adventure.loader.dto;
import java.util.List;
/** YAML representation of a conditional NPC line. */
public record DialogueLineDto(List<ConditionDto> when, String text) {
}

View File

@@ -0,0 +1,7 @@
package thb.jeanluc.adventure.loader.dto;
import java.util.List;
/** YAML representation of a single condition-gated exit. */
public record ExitLockDto(String direction, List<ConditionDto> requires, String blocked) {
}

View File

@@ -12,6 +12,8 @@ import java.util.Map;
* @param exits direction-name to target-room-id map * @param exits direction-name to target-room-id map
* @param items ids of items initially in this room * @param items ids of items initially in this room
* @param npcs ids of NPCs initially in this room * @param npcs ids of NPCs initially in this room
* @param exitLocks optional condition-gates per exit direction
* @param descriptionStates optional condition-gated description variants
*/ */
public record RoomDto( public record RoomDto(
String id, String id,
@@ -19,6 +21,13 @@ public record RoomDto(
String description, String description,
Map<String, String> exits, Map<String, String> exits,
List<String> items, List<String> items,
List<String> npcs List<String> npcs,
List<ExitLockDto> exitLocks,
List<DescriptionStateDto> descriptionStates
) { ) {
/** Backward-compatible constructor without the optional state fields. */
public RoomDto(String id, String name, String description,
Map<String, String> exits, List<String> items, List<String> npcs) {
this(id, name, description, exits, items, npcs, null, null);
}
} }

View File

@@ -0,0 +1,10 @@
package thb.jeanluc.adventure.model;
import java.util.List;
/** An alternate room description shown while its conditions hold. */
public record DescriptionState(List<Condition> when, String text) {
public DescriptionState {
when = when == null ? List.of() : List.copyOf(when);
}
}

View File

@@ -0,0 +1,10 @@
package thb.jeanluc.adventure.model;
import java.util.List;
/** A conditional line an NPC says on {@code talk}, first match wins. */
public record DialogueLine(List<Condition> when, String text) {
public DialogueLine {
when = when == null ? List.of() : List.copyOf(when);
}
}

View File

@@ -0,0 +1,10 @@
package thb.jeanluc.adventure.model;
import java.util.List;
/** A condition-gated exit with a message shown when it is blocked. */
public record ExitLock(List<Condition> requires, String blocked) {
public ExitLock {
requires = requires == null ? List.of() : List.copyOf(requires);
}
}

View File

@@ -4,8 +4,10 @@ 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.ArrayList;
import java.util.EnumMap; import java.util.EnumMap;
import java.util.LinkedHashMap; import java.util.LinkedHashMap;
import java.util.List;
import java.util.Optional; import java.util.Optional;
/** /**
@@ -42,6 +44,12 @@ public class Room {
/** NPCs currently in this room, keyed by npc id. Insertion-ordered for stable display. */ /** NPCs currently in this room, keyed by npc id. Insertion-ordered for stable display. */
private final LinkedHashMap<String, Npc> npcs = new LinkedHashMap<>(); private final LinkedHashMap<String, Npc> npcs = new LinkedHashMap<>();
/** Optional condition-gates per exit direction. */
private final EnumMap<Direction, ExitLock> exitLocks = new EnumMap<>(Direction.class);
/** Optional condition-gated description variants, first match wins. */
private final List<DescriptionState> descriptionStates = new ArrayList<>();
/** /**
* Connects this room to another in the given direction. Does not * Connects this room to another in the given direction. Does not
* create the reverse connection — callers must set that up * create the reverse connection — callers must set that up
@@ -54,6 +62,25 @@ public class Room {
exits.put(direction, neighbour); exits.put(direction, neighbour);
} }
/**
* Adds a condition-gate to the exit in the given direction.
*
* @param direction the gated direction
* @param lock the lock to apply
*/
public void addExitLock(Direction direction, ExitLock lock) {
exitLocks.put(direction, lock);
}
/**
* Adds a condition-gated description variant (first match wins).
*
* @param state the description variant
*/
public void addDescriptionState(DescriptionState state) {
descriptionStates.add(state);
}
/** /**
* Looks up the room reachable in the given direction. * Looks up the room reachable in the given direction.
* *

View File

@@ -0,0 +1,37 @@
package thb.jeanluc.adventure.command.impl;
import org.junit.jupiter.api.Test;
import thb.jeanluc.adventure.game.GameContext;
import thb.jeanluc.adventure.io.TestIO;
import thb.jeanluc.adventure.model.Condition;
import thb.jeanluc.adventure.model.Direction;
import thb.jeanluc.adventure.model.ExitLock;
import thb.jeanluc.adventure.model.Player;
import thb.jeanluc.adventure.model.Room;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
class LockedExitTest {
@Test
void lockedExitBlocksUntilFlagSet() {
Room kitchen = new Room("kitchen", "Kitchen", "d");
Room cellar = new Room("cellar", "Cellar", "d");
kitchen.addExit(Direction.EAST, cellar);
kitchen.addExitLock(Direction.EAST, new ExitLock(
List.of(new Condition(Condition.Type.FLAG, "power_on")),
"The cellar door is sealed."));
Player player = new Player(kitchen, 0);
TestIO io = new TestIO();
GameContext ctx = new GameContext(null, player, io);
new GoCommand().execute(ctx, List.of("east"));
assertThat(player.getCurrentRoom()).isEqualTo(kitchen);
assertThat(io.allOutput()).contains("sealed");
ctx.getState().set("power_on");
new GoCommand().execute(ctx, List.of("east"));
assertThat(player.getCurrentRoom()).isEqualTo(cellar);
}
}