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:
@@ -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());
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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()));
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
}
|
||||||
@@ -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) {
|
||||||
|
}
|
||||||
@@ -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) {
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user