Merge quest foundation (world-state + conditions/effects) into develop
One typed condition/effect vocabulary (GameState + Conditions + Effects) wired into five integration points: condition-locked exits, state-dependent room descriptions, conditional NPC dialogue, reactions with requires/effects, and switch effects. All new YAML fields optional (backward compatible). Demo: a generator switch sets power_on, which unlocks and lights the cellar and changes the old man's dialogue. 107 tests green. Light/dark, item-combination, quest objects and endings deferred. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -78,6 +78,14 @@ Farb-Rollen: Items / NPCs / Exits / Heading / Gefahr.
|
|||||||
|
|
||||||
### 5. Quest-System (NPC-Ausbau)
|
### 5. Quest-System (NPC-Ausbau)
|
||||||
|
|
||||||
|
> ✅ Fundament umgesetzt (Branch `feature/quest-foundation`): World-Flags +
|
||||||
|
> Condition/Effect-Engine, verschlossene Exits, zustandsabhängige Beschreibungen,
|
||||||
|
> bedingte Dialoge, Reaktionen mit requires/effects, Schalter-Effekte. Demo:
|
||||||
|
> Generator schaltet Strom → Keller-Tür öffnet + Raum wird beleuchtet. Quest-
|
||||||
|
> Objekte/Log (#3.2) und Enden (#3.3) sowie Licht/Dunkelheit & Item-Kombination
|
||||||
|
> stehen noch aus.
|
||||||
|
|
||||||
|
|
||||||
- Aktuelles NPC-System: Begrüßung + Geschenk-Reaktion (Item → Item).
|
- Aktuelles NPC-System: Begrüßung + Geschenk-Reaktion (Item → Item).
|
||||||
- Ausbau Richtung: mehrstufige Quests, Bedingungen, NPC-"Memory" (Zustand),
|
- Ausbau Richtung: mehrstufige Quests, Bedingungen, NPC-"Memory" (Zustand),
|
||||||
Quest-Log, evtl. Win-Condition daran gekoppelt.
|
Quest-Log, evtl. Win-Condition daran gekoppelt.
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
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.Effects;
|
||||||
import thb.jeanluc.adventure.game.GameContext;
|
import thb.jeanluc.adventure.game.GameContext;
|
||||||
import thb.jeanluc.adventure.model.Npc;
|
import thb.jeanluc.adventure.model.Npc;
|
||||||
import thb.jeanluc.adventure.model.NpcReaction;
|
import thb.jeanluc.adventure.model.NpcReaction;
|
||||||
@@ -44,12 +46,17 @@ public class GiveCommand implements Command {
|
|||||||
}
|
}
|
||||||
|
|
||||||
NpcReaction reaction = reactionOpt.get();
|
NpcReaction reaction = reactionOpt.get();
|
||||||
|
if (!Conditions.all(reaction.getRequires(), ctx)) {
|
||||||
|
ctx.getIo().write(npc.getName() + " isn't ready for that yet.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (reaction.getConsumes() != null) {
|
if (reaction.getConsumes() != null) {
|
||||||
ctx.getPlayer().removeItem(reaction.getConsumes().getId());
|
ctx.getPlayer().removeItem(reaction.getConsumes().getId());
|
||||||
}
|
}
|
||||||
if (reaction.getGives() != null) {
|
if (reaction.getGives() != null) {
|
||||||
ctx.getPlayer().addItem(reaction.getGives());
|
ctx.getPlayer().addItem(reaction.getGives());
|
||||||
}
|
}
|
||||||
|
Effects.applyAll(reaction.getEffects(), ctx);
|
||||||
ctx.getIo().write(npc.getName() + ": " + reaction.getResponse());
|
ctx.getIo().write(npc.getName() + ": " + reaction.getResponse());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,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.io.text.RoomView;
|
import thb.jeanluc.adventure.io.text.RoomView;
|
||||||
|
import thb.jeanluc.adventure.model.DescriptionState;
|
||||||
import thb.jeanluc.adventure.model.Direction;
|
import thb.jeanluc.adventure.model.Direction;
|
||||||
import thb.jeanluc.adventure.model.Npc;
|
import thb.jeanluc.adventure.model.Npc;
|
||||||
import thb.jeanluc.adventure.model.Room;
|
import thb.jeanluc.adventure.model.Room;
|
||||||
@@ -22,7 +24,14 @@ public class LookCommand implements Command {
|
|||||||
List<String> items = room.getItems().values().stream().map(Item::getName).toList();
|
List<String> items = room.getItems().values().stream().map(Item::getName).toList();
|
||||||
List<String> npcs = room.getNpcs().values().stream().map(Npc::getName).toList();
|
List<String> npcs = room.getNpcs().values().stream().map(Npc::getName).toList();
|
||||||
List<String> exits = room.getExits().keySet().stream().map(Direction::getLabel).toList();
|
List<String> exits = room.getExits().keySet().stream().map(Direction::getLabel).toList();
|
||||||
ctx.getIo().showRoom(new RoomView(room.getName(), room.getDescription(), items, npcs, exits));
|
String description = room.getDescription();
|
||||||
|
for (DescriptionState ds : room.getDescriptionStates()) {
|
||||||
|
if (Conditions.all(ds.when(), ctx)) {
|
||||||
|
description = ds.text();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ctx.getIo().showRoom(new RoomView(room.getName(), description, items, npcs, exits));
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
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.DialogueLine;
|
||||||
import thb.jeanluc.adventure.model.Npc;
|
import thb.jeanluc.adventure.model.Npc;
|
||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
@@ -24,7 +26,15 @@ public class TalkCommand implements Command {
|
|||||||
ctx.getIo().write("There is no '" + npcId + "' here to talk to.");
|
ctx.getIo().write("There is no '" + npcId + "' here to talk to.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
ctx.getIo().write(npc.get().getName() + " says: " + npc.get().getGreeting());
|
Npc found = npc.get();
|
||||||
|
String line = found.getGreeting();
|
||||||
|
for (DialogueLine dl : found.getDialogue()) {
|
||||||
|
if (Conditions.all(dl.when(), ctx)) {
|
||||||
|
line = dl.text();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ctx.getIo().write(found.getName() + " says: " + line);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
package thb.jeanluc.adventure.game;
|
||||||
|
|
||||||
|
import thb.jeanluc.adventure.model.Condition;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/** Evaluates {@link Condition}s against a {@link GameContext}. */
|
||||||
|
public final class Conditions {
|
||||||
|
|
||||||
|
private Conditions() {
|
||||||
|
}
|
||||||
|
|
||||||
|
public static boolean holds(Condition c, GameContext ctx) {
|
||||||
|
return switch (c.type()) {
|
||||||
|
case FLAG -> ctx.getState().isSet(c.arg());
|
||||||
|
case NOT_FLAG -> !ctx.getState().isSet(c.arg());
|
||||||
|
case HAS_ITEM -> ctx.getPlayer().hasItem(c.arg());
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** True iff every condition holds. A null or empty list is vacuously true. */
|
||||||
|
public static boolean all(List<Condition> conditions, GameContext ctx) {
|
||||||
|
if (conditions == null) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
for (Condition c : conditions) {
|
||||||
|
if (!holds(c, ctx)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
package thb.jeanluc.adventure.game;
|
||||||
|
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import thb.jeanluc.adventure.model.Effect;
|
||||||
|
import thb.jeanluc.adventure.model.item.Item;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/** Applies {@link Effect}s to a {@link GameContext}. */
|
||||||
|
@Slf4j
|
||||||
|
public final class Effects {
|
||||||
|
|
||||||
|
private Effects() {
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void apply(Effect e, GameContext ctx) {
|
||||||
|
switch (e.type()) {
|
||||||
|
case SET_FLAG -> ctx.getState().set(e.arg());
|
||||||
|
case CLEAR_FLAG -> ctx.getState().clear(e.arg());
|
||||||
|
case GIVE_ITEM -> {
|
||||||
|
Item it = ctx.getWorld().getItems().get(e.arg());
|
||||||
|
if (it == null) {
|
||||||
|
log.warn("Effect GIVE_ITEM references unknown item '{}'", e.arg());
|
||||||
|
} else {
|
||||||
|
ctx.getPlayer().addItem(it);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case REMOVE_ITEM -> ctx.getPlayer().removeItem(e.arg());
|
||||||
|
case SAY -> ctx.getIo().write(e.arg());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Applies each effect in order. A null list is a no-op. */
|
||||||
|
public static void applyAll(List<Effect> effects, GameContext ctx) {
|
||||||
|
if (effects == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for (Effect e : effects) {
|
||||||
|
apply(e, ctx);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -23,4 +23,7 @@ public class GameContext {
|
|||||||
|
|
||||||
/** IO channel for reading input and writing output. */
|
/** IO channel for reading input and writing output. */
|
||||||
private final GameIO io;
|
private final GameIO io;
|
||||||
|
|
||||||
|
/** Mutable world flags. Created fresh per context; not a constructor arg. */
|
||||||
|
private final GameState state = new GameState();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
package thb.jeanluc.adventure.game;
|
||||||
|
|
||||||
|
import java.util.Collections;
|
||||||
|
import java.util.LinkedHashSet;
|
||||||
|
import java.util.Set;
|
||||||
|
|
||||||
|
/** Named world flags. A flag is "set" (true) when present in the set. */
|
||||||
|
public class GameState {
|
||||||
|
|
||||||
|
private final Set<String> flags = new LinkedHashSet<>();
|
||||||
|
|
||||||
|
public boolean isSet(String flag) {
|
||||||
|
return flags.contains(flag);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void set(String flag) {
|
||||||
|
flags.add(flag);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void clear(String flag) {
|
||||||
|
flags.remove(flag);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Set<String> all() {
|
||||||
|
return Collections.unmodifiableSet(flags);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -44,6 +44,7 @@ public final class ItemFactory {
|
|||||||
.state(Boolean.TRUE.equals(dto.initialState()))
|
.state(Boolean.TRUE.equals(dto.initialState()))
|
||||||
.onText(dto.onText())
|
.onText(dto.onText())
|
||||||
.offText(dto.offText())
|
.offText(dto.offText())
|
||||||
|
.effects(thb.jeanluc.adventure.loader.dto.EffectDto.toModelList(dto.effects()))
|
||||||
.build();
|
.build();
|
||||||
default -> throw new WorldLoadException(
|
default -> throw new WorldLoadException(
|
||||||
"Unknown item type '" + dto.type() + "' on item '" + dto.id() + "'");
|
"Unknown item type '" + dto.type() + "' on item '" + dto.id() + "'");
|
||||||
|
|||||||
@@ -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()));
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -86,10 +113,15 @@ public class ReferenceResolver {
|
|||||||
*/
|
*/
|
||||||
public void resolveNpcs(List<NpcDto> npcDtos) {
|
public void resolveNpcs(List<NpcDto> npcDtos) {
|
||||||
for (NpcDto dto : npcDtos) {
|
for (NpcDto dto : npcDtos) {
|
||||||
|
Npc npc = npcs.get(dto.id());
|
||||||
|
if (dto.dialogue() != null) {
|
||||||
|
for (DialogueLineDto dl : dto.dialogue()) {
|
||||||
|
npc.addDialogue(new DialogueLine(ConditionDto.toModelList(dl.when()), dl.text()));
|
||||||
|
}
|
||||||
|
}
|
||||||
if (dto.reactions() == null) {
|
if (dto.reactions() == null) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
Npc npc = npcs.get(dto.id());
|
|
||||||
for (ReactionDto r : dto.reactions()) {
|
for (ReactionDto r : dto.reactions()) {
|
||||||
if (r.onReceive() == null) {
|
if (r.onReceive() == null) {
|
||||||
throw new WorldLoadException(
|
throw new WorldLoadException(
|
||||||
@@ -123,6 +155,8 @@ public class ReferenceResolver {
|
|||||||
.consumes(consumes)
|
.consumes(consumes)
|
||||||
.gives(gives)
|
.gives(gives)
|
||||||
.response(r.response())
|
.response(r.response())
|
||||||
|
.requires(ConditionDto.toModelList(r.requires()))
|
||||||
|
.effects(EffectDto.toModelList(r.effects()))
|
||||||
.build();
|
.build();
|
||||||
npc.putReaction(r.onReceive(), reaction);
|
npc.putReaction(r.onReceive(), reaction);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
package thb.jeanluc.adventure.loader.dto;
|
||||||
|
|
||||||
|
import thb.jeanluc.adventure.loader.WorldLoadException;
|
||||||
|
import thb.jeanluc.adventure.model.Condition;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/** YAML condition: exactly one of the fields is set. */
|
||||||
|
public record ConditionDto(String flag, String notFlag, String hasItem) {
|
||||||
|
|
||||||
|
public Condition toModel() {
|
||||||
|
if (flag != null) {
|
||||||
|
return new Condition(Condition.Type.FLAG, flag);
|
||||||
|
}
|
||||||
|
if (notFlag != null) {
|
||||||
|
return new Condition(Condition.Type.NOT_FLAG, notFlag);
|
||||||
|
}
|
||||||
|
if (hasItem != null) {
|
||||||
|
return new Condition(Condition.Type.HAS_ITEM, hasItem);
|
||||||
|
}
|
||||||
|
throw new WorldLoadException("Condition must set one of flag/notFlag/hasItem");
|
||||||
|
}
|
||||||
|
|
||||||
|
public static List<Condition> toModelList(List<ConditionDto> dtos) {
|
||||||
|
List<Condition> out = new ArrayList<>();
|
||||||
|
if (dtos != null) {
|
||||||
|
for (ConditionDto d : dtos) {
|
||||||
|
out.add(d.toModel());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,40 @@
|
|||||||
|
package thb.jeanluc.adventure.loader.dto;
|
||||||
|
|
||||||
|
import thb.jeanluc.adventure.loader.WorldLoadException;
|
||||||
|
import thb.jeanluc.adventure.model.Effect;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/** YAML effect: exactly one of the fields is set. */
|
||||||
|
public record EffectDto(String setFlag, String clearFlag, String giveItem, String removeItem, String say) {
|
||||||
|
|
||||||
|
public Effect toModel() {
|
||||||
|
if (setFlag != null) {
|
||||||
|
return new Effect(Effect.Type.SET_FLAG, setFlag);
|
||||||
|
}
|
||||||
|
if (clearFlag != null) {
|
||||||
|
return new Effect(Effect.Type.CLEAR_FLAG, clearFlag);
|
||||||
|
}
|
||||||
|
if (giveItem != null) {
|
||||||
|
return new Effect(Effect.Type.GIVE_ITEM, giveItem);
|
||||||
|
}
|
||||||
|
if (removeItem != null) {
|
||||||
|
return new Effect(Effect.Type.REMOVE_ITEM, removeItem);
|
||||||
|
}
|
||||||
|
if (say != null) {
|
||||||
|
return new Effect(Effect.Type.SAY, say);
|
||||||
|
}
|
||||||
|
throw new WorldLoadException("Effect must set one of setFlag/clearFlag/giveItem/removeItem/say");
|
||||||
|
}
|
||||||
|
|
||||||
|
public static List<Effect> toModelList(List<EffectDto> dtos) {
|
||||||
|
List<Effect> out = new ArrayList<>();
|
||||||
|
if (dtos != null) {
|
||||||
|
for (EffectDto d : dtos) {
|
||||||
|
out.add(d.toModel());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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) {
|
||||||
|
}
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
package thb.jeanluc.adventure.loader.dto;
|
package thb.jeanluc.adventure.loader.dto;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* YAML representation of a single item. Fields outside of the type's
|
* YAML representation of a single item. Fields outside of the type's
|
||||||
* scope (e.g. {@code readText} on a switchable) are simply ignored.
|
* scope (e.g. {@code readText} on a switchable) are simply ignored.
|
||||||
@@ -12,6 +14,7 @@ package thb.jeanluc.adventure.loader.dto;
|
|||||||
* @param initialState initial on/off state of a switchable item
|
* @param initialState initial on/off state of a switchable item
|
||||||
* @param onText message printed when a switchable transitions to on
|
* @param onText message printed when a switchable transitions to on
|
||||||
* @param offText message printed when a switchable transitions to off
|
* @param offText message printed when a switchable transitions to off
|
||||||
|
* @param effects effects applied when a switchable transitions to on; nullable
|
||||||
*/
|
*/
|
||||||
public record ItemDto(
|
public record ItemDto(
|
||||||
String type,
|
String type,
|
||||||
@@ -21,6 +24,12 @@ public record ItemDto(
|
|||||||
String readText,
|
String readText,
|
||||||
Boolean initialState,
|
Boolean initialState,
|
||||||
String onText,
|
String onText,
|
||||||
String offText
|
String offText,
|
||||||
|
List<EffectDto> effects
|
||||||
) {
|
) {
|
||||||
|
/** Backward-compatible constructor without effects. */
|
||||||
|
public ItemDto(String type, String id, String name, String description,
|
||||||
|
String readText, Boolean initialState, String onText, String offText) {
|
||||||
|
this(type, id, name, description, readText, initialState, onText, offText, null);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,12 +10,18 @@ import java.util.List;
|
|||||||
* @param description examine description
|
* @param description examine description
|
||||||
* @param greeting text the NPC says on {@code talk}
|
* @param greeting text the NPC says on {@code talk}
|
||||||
* @param reactions list of trigger/response definitions; may be null
|
* @param reactions list of trigger/response definitions; may be null
|
||||||
|
* @param dialogue optional condition-gated dialogue lines; may be null
|
||||||
*/
|
*/
|
||||||
public record NpcDto(
|
public record NpcDto(
|
||||||
String id,
|
String id,
|
||||||
String name,
|
String name,
|
||||||
String description,
|
String description,
|
||||||
String greeting,
|
String greeting,
|
||||||
List<ReactionDto> reactions
|
List<ReactionDto> reactions,
|
||||||
|
List<DialogueLineDto> dialogue
|
||||||
) {
|
) {
|
||||||
|
/** Backward-compatible constructor without dialogue. */
|
||||||
|
public NpcDto(String id, String name, String description, String greeting, List<ReactionDto> reactions) {
|
||||||
|
this(id, name, description, greeting, reactions, null);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
package thb.jeanluc.adventure.loader.dto;
|
package thb.jeanluc.adventure.loader.dto;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* YAML representation of a single NPC reaction.
|
* YAML representation of a single NPC reaction.
|
||||||
*
|
*
|
||||||
@@ -7,11 +9,19 @@ package thb.jeanluc.adventure.loader.dto;
|
|||||||
* @param response text the NPC says after the exchange
|
* @param response text the NPC says after the exchange
|
||||||
* @param gives id of the item handed back; nullable
|
* @param gives id of the item handed back; nullable
|
||||||
* @param consumes id of the item taken from the player; nullable
|
* @param consumes id of the item taken from the player; nullable
|
||||||
|
* @param requires conditions that must hold; nullable
|
||||||
|
* @param effects effects applied after a successful exchange; nullable
|
||||||
*/
|
*/
|
||||||
public record ReactionDto(
|
public record ReactionDto(
|
||||||
String onReceive,
|
String onReceive,
|
||||||
String response,
|
String response,
|
||||||
String gives,
|
String gives,
|
||||||
String consumes
|
String consumes,
|
||||||
|
List<ConditionDto> requires,
|
||||||
|
List<EffectDto> effects
|
||||||
) {
|
) {
|
||||||
|
/** Backward-compatible constructor without requires/effects. */
|
||||||
|
public ReactionDto(String onReceive, String response, String gives, String consumes) {
|
||||||
|
this(onReceive, response, gives, consumes, null, null);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,6 @@
|
|||||||
|
package thb.jeanluc.adventure.model;
|
||||||
|
|
||||||
|
/** A single boolean test evaluated against world state and the player. */
|
||||||
|
public record Condition(Type type, String arg) {
|
||||||
|
public enum Type { FLAG, NOT_FLAG, HAS_ITEM }
|
||||||
|
}
|
||||||
@@ -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,6 @@
|
|||||||
|
package thb.jeanluc.adventure.model;
|
||||||
|
|
||||||
|
/** A single state mutation or message. */
|
||||||
|
public record Effect(Type type, String arg) {
|
||||||
|
public enum Type { SET_FLAG, CLEAR_FLAG, GIVE_ITEM, REMOVE_ITEM, SAY }
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,7 +3,9 @@ package thb.jeanluc.adventure.model;
|
|||||||
import lombok.Builder;
|
import lombok.Builder;
|
||||||
import lombok.Getter;
|
import lombok.Getter;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
import java.util.HashMap;
|
import java.util.HashMap;
|
||||||
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
|
|
||||||
@@ -34,6 +36,19 @@ public class Npc {
|
|||||||
*/
|
*/
|
||||||
private final Map<String, NpcReaction> reactions;
|
private final Map<String, NpcReaction> reactions;
|
||||||
|
|
||||||
|
/** Optional condition-gated dialogue lines, first match wins; else {@link #greeting}. */
|
||||||
|
@Builder.Default
|
||||||
|
private final List<DialogueLine> dialogue = new ArrayList<>();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Adds a conditional dialogue line.
|
||||||
|
*
|
||||||
|
* @param line the dialogue line
|
||||||
|
*/
|
||||||
|
public void addDialogue(DialogueLine line) {
|
||||||
|
dialogue.add(line);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Looks up a reaction triggered by an item.
|
* Looks up a reaction triggered by an item.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import lombok.Builder;
|
|||||||
import lombok.Getter;
|
import lombok.Getter;
|
||||||
import thb.jeanluc.adventure.model.item.Item;
|
import thb.jeanluc.adventure.model.item.Item;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A single trigger/response pair on an NPC. When the player gives the
|
* A single trigger/response pair on an NPC. When the player gives the
|
||||||
* NPC an item matching {@link #consumes}, the NPC writes {@link #response}
|
* NPC an item matching {@link #consumes}, the NPC writes {@link #response}
|
||||||
@@ -21,4 +23,10 @@ public class NpcReaction {
|
|||||||
|
|
||||||
/** Text the NPC says after a successful exchange. */
|
/** Text the NPC says after a successful exchange. */
|
||||||
private final String response;
|
private final String response;
|
||||||
|
|
||||||
|
/** Conditions that must hold for the exchange; null/empty = always. */
|
||||||
|
private final List<Condition> requires;
|
||||||
|
|
||||||
|
/** Effects applied after a successful exchange; null = none. */
|
||||||
|
private final List<Effect> effects;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -2,7 +2,11 @@ package thb.jeanluc.adventure.model.item;
|
|||||||
|
|
||||||
import lombok.Getter;
|
import lombok.Getter;
|
||||||
import lombok.experimental.SuperBuilder;
|
import lombok.experimental.SuperBuilder;
|
||||||
|
import thb.jeanluc.adventure.game.Effects;
|
||||||
import thb.jeanluc.adventure.game.GameContext;
|
import thb.jeanluc.adventure.game.GameContext;
|
||||||
|
import thb.jeanluc.adventure.model.Effect;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Item with two boolean states (on / off). Each {@code use} toggles the
|
* Item with two boolean states (on / off). Each {@code use} toggles the
|
||||||
@@ -24,6 +28,9 @@ public class SwitchableItem extends Item {
|
|||||||
*/
|
*/
|
||||||
private boolean state;
|
private boolean state;
|
||||||
|
|
||||||
|
/** Effects applied when this item transitions to on; null = none. */
|
||||||
|
private final List<Effect> effects;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Toggles the state and writes the corresponding transition text.
|
* Toggles the state and writes the corresponding transition text.
|
||||||
*
|
*
|
||||||
@@ -33,6 +40,9 @@ public class SwitchableItem extends Item {
|
|||||||
public void use(GameContext ctx) {
|
public void use(GameContext ctx) {
|
||||||
state = !state;
|
state = !state;
|
||||||
ctx.getIo().write(state ? onText : offText);
|
ctx.getIo().write(state ? onText : offText);
|
||||||
|
if (state) {
|
||||||
|
Effects.applyAll(effects, ctx);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -22,3 +22,15 @@
|
|||||||
id: key
|
id: key
|
||||||
name: Brass Key
|
name: Brass Key
|
||||||
description: A small brass key, polished from use.
|
description: A small brass key, polished from use.
|
||||||
|
|
||||||
|
- type: switchable
|
||||||
|
id: generator
|
||||||
|
name: Generator
|
||||||
|
description: A rusty generator with a heavy lever.
|
||||||
|
initialState: false
|
||||||
|
onText: |
|
||||||
|
You heave the lever. Somewhere deep in the manor, the lights flicker on.
|
||||||
|
offText: |
|
||||||
|
You pull the lever back; the manor falls dark again.
|
||||||
|
effects:
|
||||||
|
- { setFlag: power_on }
|
||||||
|
|||||||
@@ -10,3 +10,8 @@
|
|||||||
"Thank you! Here, take this key."
|
"Thank you! Here, take this key."
|
||||||
gives: key
|
gives: key
|
||||||
consumes: lamp
|
consumes: lamp
|
||||||
|
dialogue:
|
||||||
|
- when: [{ flag: power_on }]
|
||||||
|
text: |
|
||||||
|
"You got the power running! The whole house feels less...
|
||||||
|
watchful now."
|
||||||
|
|||||||
@@ -9,6 +9,11 @@
|
|||||||
south: dungeon
|
south: dungeon
|
||||||
items: [letter, lamp]
|
items: [letter, lamp]
|
||||||
npcs: [old_man]
|
npcs: [old_man]
|
||||||
|
exitLocks:
|
||||||
|
- direction: east
|
||||||
|
requires: [{ flag: power_on }]
|
||||||
|
blocked: |
|
||||||
|
The cellar door is held shut by a magnetic lock — dead without power.
|
||||||
|
|
||||||
- id: hallway
|
- id: hallway
|
||||||
name: Dark Hallway
|
name: Dark Hallway
|
||||||
@@ -40,12 +45,17 @@
|
|||||||
west: kitchen
|
west: kitchen
|
||||||
items: []
|
items: []
|
||||||
npcs: []
|
npcs: []
|
||||||
|
descriptionStates:
|
||||||
|
- when: [{ flag: power_on }]
|
||||||
|
text: |
|
||||||
|
With the power back, a bare bulb buzzes overhead, revealing
|
||||||
|
shelves of dusty jars along the far wall.
|
||||||
|
|
||||||
- id: dungeon
|
- id: dungeon
|
||||||
name: Dungeon
|
name: Dungeon
|
||||||
description: |
|
description: |
|
||||||
A dark, damp room.
|
A dark, damp room. A rusty generator squats in the corner.
|
||||||
exits:
|
exits:
|
||||||
north: kitchen
|
north: kitchen
|
||||||
items: []
|
items: [generator]
|
||||||
npcs: []
|
npcs: []
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
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.DescriptionState;
|
||||||
|
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 DescriptionStateTest {
|
||||||
|
@Test
|
||||||
|
void descriptionSwitchesWithFlag() {
|
||||||
|
Room cellar = new Room("cellar", "Cellar", "Pitch black down here.");
|
||||||
|
cellar.addDescriptionState(new DescriptionState(
|
||||||
|
List.of(new Condition(Condition.Type.FLAG, "power_on")),
|
||||||
|
"Lit now, a workbench stands against the wall."));
|
||||||
|
Player player = new Player(cellar, 0);
|
||||||
|
TestIO io = new TestIO();
|
||||||
|
GameContext ctx = new GameContext(null, player, io);
|
||||||
|
|
||||||
|
new LookCommand().execute(ctx, List.of());
|
||||||
|
assertThat(io.allOutput()).contains("Pitch black");
|
||||||
|
|
||||||
|
io.outputs().clear();
|
||||||
|
ctx.getState().set("power_on");
|
||||||
|
new LookCommand().execute(ctx, List.of());
|
||||||
|
assertThat(io.allOutput()).contains("workbench").doesNotContain("Pitch black");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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.DialogueLine;
|
||||||
|
import thb.jeanluc.adventure.model.Npc;
|
||||||
|
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 DialogueTest {
|
||||||
|
@Test
|
||||||
|
void dialogueSwitchesWithFlagElseGreeting() {
|
||||||
|
Room room = new Room("k", "K", "d");
|
||||||
|
Npc man = Npc.shell("man", "Old Man", "stooped", "Hello there.");
|
||||||
|
man.addDialogue(new DialogueLine(
|
||||||
|
List.of(new Condition(Condition.Type.FLAG, "power_on")),
|
||||||
|
"The lights are back, bless you!"));
|
||||||
|
room.addNpc(man);
|
||||||
|
Player player = new Player(room, 0);
|
||||||
|
TestIO io = new TestIO();
|
||||||
|
GameContext ctx = new GameContext(null, player, io);
|
||||||
|
|
||||||
|
new TalkCommand().execute(ctx, List.of("man"));
|
||||||
|
assertThat(io.allOutput()).contains("Hello there");
|
||||||
|
|
||||||
|
io.outputs().clear();
|
||||||
|
ctx.getState().set("power_on");
|
||||||
|
new TalkCommand().execute(ctx, List.of("man"));
|
||||||
|
assertThat(io.allOutput()).contains("lights are back").doesNotContain("Hello there");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
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.Effect;
|
||||||
|
import thb.jeanluc.adventure.model.Npc;
|
||||||
|
import thb.jeanluc.adventure.model.NpcReaction;
|
||||||
|
import thb.jeanluc.adventure.model.Player;
|
||||||
|
import thb.jeanluc.adventure.model.Room;
|
||||||
|
import thb.jeanluc.adventure.model.item.PlainItem;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
|
||||||
|
class ReactionEffectsTest {
|
||||||
|
@Test
|
||||||
|
void reactionRequiresFlagAndAppliesEffects() {
|
||||||
|
Room room = new Room("k", "K", "d");
|
||||||
|
Npc man = Npc.shell("man", "Old Man", "stooped", "hi");
|
||||||
|
man.putReaction("fuse", NpcReaction.builder()
|
||||||
|
.requires(List.of(new Condition(Condition.Type.FLAG, "cellar_drained")))
|
||||||
|
.effects(List.of(new Effect(Effect.Type.SET_FLAG, "fuse_installed")))
|
||||||
|
.response("Done.")
|
||||||
|
.build());
|
||||||
|
room.addNpc(man);
|
||||||
|
Player player = new Player(room, 0);
|
||||||
|
player.addItem(PlainItem.builder().id("fuse").name("Fuse").description("d").build());
|
||||||
|
TestIO io = new TestIO();
|
||||||
|
GameContext ctx = new GameContext(null, player, io);
|
||||||
|
|
||||||
|
new GiveCommand().execute(ctx, List.of("fuse", "man"));
|
||||||
|
assertThat(ctx.getState().isSet("fuse_installed")).isFalse(); // requires not met
|
||||||
|
|
||||||
|
ctx.getState().set("cellar_drained");
|
||||||
|
new GiveCommand().execute(ctx, List.of("fuse", "man"));
|
||||||
|
assertThat(ctx.getState().isSet("fuse_installed")).isTrue();
|
||||||
|
assertThat(io.allOutput()).contains("Done.");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
package thb.jeanluc.adventure.game;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import thb.jeanluc.adventure.io.TestIO;
|
||||||
|
import thb.jeanluc.adventure.model.Condition;
|
||||||
|
import thb.jeanluc.adventure.model.Player;
|
||||||
|
import thb.jeanluc.adventure.model.Room;
|
||||||
|
import thb.jeanluc.adventure.model.item.PlainItem;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
|
||||||
|
class ConditionsTest {
|
||||||
|
private GameContext ctx() {
|
||||||
|
Player p = new Player(new Room("k", "K", "d"), 0);
|
||||||
|
return new GameContext(null, p, new TestIO());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void flagAndNotFlag() {
|
||||||
|
GameContext ctx = ctx();
|
||||||
|
ctx.getState().set("power_on");
|
||||||
|
assertThat(Conditions.holds(new Condition(Condition.Type.FLAG, "power_on"), ctx)).isTrue();
|
||||||
|
assertThat(Conditions.holds(new Condition(Condition.Type.NOT_FLAG, "power_on"), ctx)).isFalse();
|
||||||
|
assertThat(Conditions.holds(new Condition(Condition.Type.NOT_FLAG, "x"), ctx)).isTrue();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void hasItem() {
|
||||||
|
GameContext ctx = ctx();
|
||||||
|
ctx.getPlayer().addItem(PlainItem.builder().id("lamp").name("Lamp").description("d").build());
|
||||||
|
assertThat(Conditions.holds(new Condition(Condition.Type.HAS_ITEM, "lamp"), ctx)).isTrue();
|
||||||
|
assertThat(Conditions.holds(new Condition(Condition.Type.HAS_ITEM, "key"), ctx)).isFalse();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void allRequiresEveryConditionAndEmptyIsTrue() {
|
||||||
|
GameContext ctx = ctx();
|
||||||
|
ctx.getState().set("a");
|
||||||
|
assertThat(Conditions.all(List.of(), ctx)).isTrue();
|
||||||
|
assertThat(Conditions.all(List.of(new Condition(Condition.Type.FLAG, "a")), ctx)).isTrue();
|
||||||
|
assertThat(Conditions.all(List.of(
|
||||||
|
new Condition(Condition.Type.FLAG, "a"),
|
||||||
|
new Condition(Condition.Type.FLAG, "b")), ctx)).isFalse();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
package thb.jeanluc.adventure.game;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import thb.jeanluc.adventure.io.TestIO;
|
||||||
|
import thb.jeanluc.adventure.model.Effect;
|
||||||
|
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.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
|
||||||
|
class EffectsTest {
|
||||||
|
private GameContext ctx(Map<String, Item> items) {
|
||||||
|
Player p = new Player(new Room("k", "K", "d"), 0);
|
||||||
|
World w = new World(Map.of(), items, Map.of(), "t", "w");
|
||||||
|
return new GameContext(w, p, new TestIO());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void setAndClearFlag() {
|
||||||
|
GameContext ctx = ctx(Map.of());
|
||||||
|
Effects.applyAll(List.of(new Effect(Effect.Type.SET_FLAG, "power_on")), ctx);
|
||||||
|
assertThat(ctx.getState().isSet("power_on")).isTrue();
|
||||||
|
Effects.apply(new Effect(Effect.Type.CLEAR_FLAG, "power_on"), ctx);
|
||||||
|
assertThat(ctx.getState().isSet("power_on")).isFalse();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void giveAndRemoveItem() {
|
||||||
|
Item key = PlainItem.builder().id("key").name("Key").description("d").build();
|
||||||
|
GameContext ctx = ctx(Map.of("key", key));
|
||||||
|
Effects.apply(new Effect(Effect.Type.GIVE_ITEM, "key"), ctx);
|
||||||
|
assertThat(ctx.getPlayer().hasItem("key")).isTrue();
|
||||||
|
Effects.apply(new Effect(Effect.Type.REMOVE_ITEM, "key"), ctx);
|
||||||
|
assertThat(ctx.getPlayer().hasItem("key")).isFalse();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void giveUnknownItemIsTolerated() {
|
||||||
|
GameContext ctx = ctx(Map.of());
|
||||||
|
Effects.apply(new Effect(Effect.Type.GIVE_ITEM, "ghost"), ctx);
|
||||||
|
assertThat(ctx.getPlayer().hasItem("ghost")).isFalse();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void sayWritesToIo() {
|
||||||
|
GameContext ctx = ctx(Map.of());
|
||||||
|
Effects.apply(new Effect(Effect.Type.SAY, "boo"), ctx);
|
||||||
|
assertThat(((TestIO) ctx.getIo()).allOutput()).contains("boo");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
package thb.jeanluc.adventure.game;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
|
||||||
|
class GameStateTest {
|
||||||
|
@Test
|
||||||
|
void setIsSetClear() {
|
||||||
|
GameState s = new GameState();
|
||||||
|
assertThat(s.isSet("power_on")).isFalse();
|
||||||
|
s.set("power_on");
|
||||||
|
assertThat(s.isSet("power_on")).isTrue();
|
||||||
|
s.clear("power_on");
|
||||||
|
assertThat(s.isSet("power_on")).isFalse();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
package thb.jeanluc.adventure.loader.dto;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import thb.jeanluc.adventure.loader.WorldLoadException;
|
||||||
|
import thb.jeanluc.adventure.model.Condition;
|
||||||
|
import thb.jeanluc.adventure.model.Effect;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||||
|
|
||||||
|
class ConditionEffectDtoTest {
|
||||||
|
@Test
|
||||||
|
void conditionToModel() {
|
||||||
|
assertThat(new ConditionDto("power_on", null, null).toModel())
|
||||||
|
.isEqualTo(new Condition(Condition.Type.FLAG, "power_on"));
|
||||||
|
assertThat(new ConditionDto(null, "x", null).toModel().type())
|
||||||
|
.isEqualTo(Condition.Type.NOT_FLAG);
|
||||||
|
assertThat(new ConditionDto(null, null, "lamp").toModel().type())
|
||||||
|
.isEqualTo(Condition.Type.HAS_ITEM);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void emptyConditionThrows() {
|
||||||
|
assertThatThrownBy(() -> new ConditionDto(null, null, null).toModel())
|
||||||
|
.isInstanceOf(WorldLoadException.class);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void effectToModel() {
|
||||||
|
assertThat(new EffectDto("p", null, null, null, null).toModel())
|
||||||
|
.isEqualTo(new Effect(Effect.Type.SET_FLAG, "p"));
|
||||||
|
assertThat(new EffectDto(null, null, "key", null, null).toModel().type())
|
||||||
|
.isEqualTo(Effect.Type.GIVE_ITEM);
|
||||||
|
assertThat(new EffectDto(null, null, null, null, "boo").toModel().type())
|
||||||
|
.isEqualTo(Effect.Type.SAY);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void emptyEffectThrows() {
|
||||||
|
assertThatThrownBy(() -> new EffectDto(null, null, null, null, null).toModel())
|
||||||
|
.isInstanceOf(WorldLoadException.class);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
package thb.jeanluc.adventure.model.item;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import thb.jeanluc.adventure.game.GameContext;
|
||||||
|
import thb.jeanluc.adventure.io.TestIO;
|
||||||
|
import thb.jeanluc.adventure.model.Effect;
|
||||||
|
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 SwitchEffectsTest {
|
||||||
|
@Test
|
||||||
|
void switchingOnAppliesEffects() {
|
||||||
|
SwitchableItem breaker = SwitchableItem.builder()
|
||||||
|
.id("breaker").name("Breaker").description("d")
|
||||||
|
.state(false).onText("hums to life").offText("off")
|
||||||
|
.effects(List.of(new Effect(Effect.Type.SET_FLAG, "power_on")))
|
||||||
|
.build();
|
||||||
|
Player player = new Player(new Room("k", "K", "d"), 0);
|
||||||
|
GameContext ctx = new GameContext(null, player, new TestIO());
|
||||||
|
|
||||||
|
breaker.use(ctx);
|
||||||
|
assertThat(ctx.getState().isSet("power_on")).isTrue();
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user