From df4a46ae5b750f12049f05570ba305fceffc4261 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Sun, 31 May 2026 21:52:06 +0200 Subject: [PATCH] feat: condition-locked exits (+ room/dialogue state plumbing) Co-Authored-By: Claude Opus 4.8 (1M context) --- .../adventure/command/impl/GoCommand.java | 7 ++++ .../adventure/loader/ReferenceResolver.java | 27 ++++++++++++++ .../loader/dto/DescriptionStateDto.java | 7 ++++ .../adventure/loader/dto/DialogueLineDto.java | 7 ++++ .../adventure/loader/dto/ExitLockDto.java | 7 ++++ .../jeanluc/adventure/loader/dto/RoomDto.java | 11 +++++- .../adventure/model/DescriptionState.java | 10 +++++ .../jeanluc/adventure/model/DialogueLine.java | 10 +++++ .../thb/jeanluc/adventure/model/ExitLock.java | 10 +++++ .../thb/jeanluc/adventure/model/Room.java | 27 ++++++++++++++ .../command/impl/LockedExitTest.java | 37 +++++++++++++++++++ 11 files changed, 159 insertions(+), 1 deletion(-) create mode 100644 Semesterprojekt/src/main/java/thb/jeanluc/adventure/loader/dto/DescriptionStateDto.java create mode 100644 Semesterprojekt/src/main/java/thb/jeanluc/adventure/loader/dto/DialogueLineDto.java create mode 100644 Semesterprojekt/src/main/java/thb/jeanluc/adventure/loader/dto/ExitLockDto.java create mode 100644 Semesterprojekt/src/main/java/thb/jeanluc/adventure/model/DescriptionState.java create mode 100644 Semesterprojekt/src/main/java/thb/jeanluc/adventure/model/DialogueLine.java create mode 100644 Semesterprojekt/src/main/java/thb/jeanluc/adventure/model/ExitLock.java create mode 100644 Semesterprojekt/src/test/java/thb/jeanluc/adventure/command/impl/LockedExitTest.java diff --git a/Semesterprojekt/src/main/java/thb/jeanluc/adventure/command/impl/GoCommand.java b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/command/impl/GoCommand.java index e790bd5..ac31efb 100644 --- a/Semesterprojekt/src/main/java/thb/jeanluc/adventure/command/impl/GoCommand.java +++ b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/command/impl/GoCommand.java @@ -1,8 +1,10 @@ package thb.jeanluc.adventure.command.impl; import thb.jeanluc.adventure.command.Command; +import thb.jeanluc.adventure.game.Conditions; import thb.jeanluc.adventure.game.GameContext; import thb.jeanluc.adventure.model.Direction; +import thb.jeanluc.adventure.model.ExitLock; import thb.jeanluc.adventure.model.Room; import java.util.List; @@ -32,6 +34,11 @@ public class GoCommand implements Command { ctx.getIo().write("You can't go " + dir.getLabel() + " from here."); 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()); new LookCommand().execute(ctx, List.of()); } diff --git a/Semesterprojekt/src/main/java/thb/jeanluc/adventure/loader/ReferenceResolver.java b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/loader/ReferenceResolver.java index de1849d..fc10a46 100644 --- a/Semesterprojekt/src/main/java/thb/jeanluc/adventure/loader/ReferenceResolver.java +++ b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/loader/ReferenceResolver.java @@ -1,10 +1,18 @@ package thb.jeanluc.adventure.loader; 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.ReactionDto; 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.ExitLock; import thb.jeanluc.adventure.model.Npc; import thb.jeanluc.adventure.model.NpcReaction; import thb.jeanluc.adventure.model.Room; @@ -75,6 +83,25 @@ public class ReferenceResolver { 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())); + } + } } } diff --git a/Semesterprojekt/src/main/java/thb/jeanluc/adventure/loader/dto/DescriptionStateDto.java b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/loader/dto/DescriptionStateDto.java new file mode 100644 index 0000000..e08d9cb --- /dev/null +++ b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/loader/dto/DescriptionStateDto.java @@ -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 when, String text) { +} diff --git a/Semesterprojekt/src/main/java/thb/jeanluc/adventure/loader/dto/DialogueLineDto.java b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/loader/dto/DialogueLineDto.java new file mode 100644 index 0000000..2fe3464 --- /dev/null +++ b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/loader/dto/DialogueLineDto.java @@ -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 when, String text) { +} diff --git a/Semesterprojekt/src/main/java/thb/jeanluc/adventure/loader/dto/ExitLockDto.java b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/loader/dto/ExitLockDto.java new file mode 100644 index 0000000..9bce7fa --- /dev/null +++ b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/loader/dto/ExitLockDto.java @@ -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 requires, String blocked) { +} diff --git a/Semesterprojekt/src/main/java/thb/jeanluc/adventure/loader/dto/RoomDto.java b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/loader/dto/RoomDto.java index 44237d5..9a42aca 100644 --- a/Semesterprojekt/src/main/java/thb/jeanluc/adventure/loader/dto/RoomDto.java +++ b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/loader/dto/RoomDto.java @@ -12,6 +12,8 @@ import java.util.Map; * @param exits direction-name to target-room-id map * @param items ids of items 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( String id, @@ -19,6 +21,13 @@ public record RoomDto( String description, Map exits, List items, - List npcs + List npcs, + List exitLocks, + List descriptionStates ) { + /** Backward-compatible constructor without the optional state fields. */ + public RoomDto(String id, String name, String description, + Map exits, List items, List npcs) { + this(id, name, description, exits, items, npcs, null, null); + } } diff --git a/Semesterprojekt/src/main/java/thb/jeanluc/adventure/model/DescriptionState.java b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/model/DescriptionState.java new file mode 100644 index 0000000..4b2362e --- /dev/null +++ b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/model/DescriptionState.java @@ -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 when, String text) { + public DescriptionState { + when = when == null ? List.of() : List.copyOf(when); + } +} diff --git a/Semesterprojekt/src/main/java/thb/jeanluc/adventure/model/DialogueLine.java b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/model/DialogueLine.java new file mode 100644 index 0000000..4b02a11 --- /dev/null +++ b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/model/DialogueLine.java @@ -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 when, String text) { + public DialogueLine { + when = when == null ? List.of() : List.copyOf(when); + } +} diff --git a/Semesterprojekt/src/main/java/thb/jeanluc/adventure/model/ExitLock.java b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/model/ExitLock.java new file mode 100644 index 0000000..9a28d7d --- /dev/null +++ b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/model/ExitLock.java @@ -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 requires, String blocked) { + public ExitLock { + requires = requires == null ? List.of() : List.copyOf(requires); + } +} 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 f104fed..eca90b2 100644 --- a/Semesterprojekt/src/main/java/thb/jeanluc/adventure/model/Room.java +++ b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/model/Room.java @@ -4,8 +4,10 @@ import lombok.Getter; import lombok.RequiredArgsConstructor; import thb.jeanluc.adventure.model.item.Item; +import java.util.ArrayList; import java.util.EnumMap; import java.util.LinkedHashMap; +import java.util.List; 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. */ private final LinkedHashMap npcs = new LinkedHashMap<>(); + /** Optional condition-gates per exit direction. */ + private final EnumMap exitLocks = new EnumMap<>(Direction.class); + + /** Optional condition-gated description variants, first match wins. */ + private final List descriptionStates = new ArrayList<>(); + /** * Connects this room to another in the given direction. Does not * create the reverse connection — callers must set that up @@ -54,6 +62,25 @@ public class Room { 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. * diff --git a/Semesterprojekt/src/test/java/thb/jeanluc/adventure/command/impl/LockedExitTest.java b/Semesterprojekt/src/test/java/thb/jeanluc/adventure/command/impl/LockedExitTest.java new file mode 100644 index 0000000..0147b09 --- /dev/null +++ b/Semesterprojekt/src/test/java/thb/jeanluc/adventure/command/impl/LockedExitTest.java @@ -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); + } +}