semesterprojekt: implement full text adventure (phases 1-7)
Walking skeleton through Swing GUI: YAML-driven world (4 rooms, 4 items, 1 NPC), HashMap command dispatch with parser, three-tier item hierarchy (readable / switchable / plain), and end-to-end NPC give/receive flow. 67 tests green.
This commit is contained in:
86
Semesterprojekt/src/main/java/thb/jeanluc/adventure/App.java
Normal file
86
Semesterprojekt/src/main/java/thb/jeanluc/adventure/App.java
Normal file
@@ -0,0 +1,86 @@
|
||||
package thb.jeanluc.adventure;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import thb.jeanluc.adventure.command.CommandParser;
|
||||
import thb.jeanluc.adventure.command.CommandRegistry;
|
||||
import thb.jeanluc.adventure.command.impl.DropCommand;
|
||||
import thb.jeanluc.adventure.command.impl.ExamineCommand;
|
||||
import thb.jeanluc.adventure.command.impl.GiveCommand;
|
||||
import thb.jeanluc.adventure.command.impl.GoCommand;
|
||||
import thb.jeanluc.adventure.command.impl.HelpCommand;
|
||||
import thb.jeanluc.adventure.command.impl.InventoryCommand;
|
||||
import thb.jeanluc.adventure.command.impl.LookCommand;
|
||||
import thb.jeanluc.adventure.command.impl.QuitCommand;
|
||||
import thb.jeanluc.adventure.command.impl.ReadCommand;
|
||||
import thb.jeanluc.adventure.command.impl.TakeCommand;
|
||||
import thb.jeanluc.adventure.command.impl.TalkCommand;
|
||||
import thb.jeanluc.adventure.command.impl.UseCommand;
|
||||
import thb.jeanluc.adventure.game.Game;
|
||||
import thb.jeanluc.adventure.game.GameContext;
|
||||
import thb.jeanluc.adventure.io.ConsoleIO;
|
||||
import thb.jeanluc.adventure.io.GameIO;
|
||||
import thb.jeanluc.adventure.loader.WorldLoader;
|
||||
|
||||
/**
|
||||
* Entry point for the console version of the game. Loads the world from
|
||||
* YAML, wires up the command registry, and hands control to the
|
||||
* {@link Game} loop.
|
||||
*/
|
||||
@Slf4j
|
||||
public final class App {
|
||||
|
||||
private App() {
|
||||
}
|
||||
|
||||
/**
|
||||
* Standard JVM entry point.
|
||||
*
|
||||
* @param args ignored
|
||||
*/
|
||||
public static void main(String[] args) {
|
||||
GameIO io = new ConsoleIO();
|
||||
try {
|
||||
run(io);
|
||||
} catch (RuntimeException e) {
|
||||
log.error("Fatal error during game startup", e);
|
||||
io.write("Fatal error: " + e.getMessage());
|
||||
System.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Boots the game on the given IO channel. Reusable by both
|
||||
* {@link App} (console) and {@code AppGui} (Swing worker thread).
|
||||
*
|
||||
* @param io the IO channel to use
|
||||
*/
|
||||
public static void run(GameIO io) {
|
||||
WorldLoader.LoadResult loaded = new WorldLoader().load();
|
||||
GameContext ctx = new GameContext(loaded.world(), loaded.player(), io);
|
||||
|
||||
CommandRegistry registry = new CommandRegistry();
|
||||
registry.register(new GoCommand(), "go", "move", "walk");
|
||||
registry.register(new LookCommand(), "look", "l");
|
||||
registry.register(new InventoryCommand(), "inventory", "inv", "i");
|
||||
registry.register(new TakeCommand(), "take", "pick", "get");
|
||||
registry.register(new DropCommand(), "drop", "put");
|
||||
registry.register(new UseCommand(), "use");
|
||||
registry.register(new ReadCommand(), "read");
|
||||
registry.register(new ExamineCommand(), "examine", "x", "inspect");
|
||||
registry.register(new TalkCommand(), "talk", "speak");
|
||||
registry.register(new GiveCommand(), "give");
|
||||
registry.register(new HelpCommand(registry), "help", "?");
|
||||
|
||||
QuitCommand quit = new QuitCommand();
|
||||
registry.register(quit, "quit", "exit");
|
||||
|
||||
Game game = new Game(ctx, registry, new CommandParser());
|
||||
quit.bind(game);
|
||||
|
||||
io.write(loaded.world().getTitle());
|
||||
io.write(loaded.world().getWelcomeMessage());
|
||||
new LookCommand().execute(ctx, java.util.List.of());
|
||||
|
||||
game.run();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
package thb.jeanluc.adventure;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import thb.jeanluc.adventure.io.SwingIO;
|
||||
|
||||
import javax.swing.SwingUtilities;
|
||||
|
||||
/**
|
||||
* Entry point for the Swing version of the game. The Swing window is
|
||||
* built on the Event Dispatch Thread; the game loop itself runs in a
|
||||
* background worker so that EDT events (typing) and blocking
|
||||
* {@code readLine()} calls never deadlock each other.
|
||||
*/
|
||||
@Slf4j
|
||||
public final class AppGui {
|
||||
|
||||
private AppGui() {
|
||||
}
|
||||
|
||||
/**
|
||||
* Standard JVM entry point.
|
||||
*
|
||||
* @param args ignored
|
||||
*/
|
||||
public static void main(String[] args) {
|
||||
SwingUtilities.invokeLater(() -> {
|
||||
SwingIO io = new SwingIO("Haunted Manor");
|
||||
Thread worker = new Thread(() -> {
|
||||
try {
|
||||
App.run(io);
|
||||
} catch (RuntimeException e) {
|
||||
log.error("Fatal error during game startup", e);
|
||||
io.write("Fatal error: " + e.getMessage());
|
||||
}
|
||||
}, "game-loop");
|
||||
worker.setDaemon(true);
|
||||
worker.start();
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package thb.jeanluc.adventure.command;
|
||||
|
||||
import thb.jeanluc.adventure.game.GameContext;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* A single in-game verb. Implementations are stateless and registered
|
||||
* (one or many aliases) in {@link CommandRegistry}.
|
||||
*/
|
||||
public interface Command {
|
||||
|
||||
/**
|
||||
* Executes the command.
|
||||
*
|
||||
* @param ctx active game context
|
||||
* @param args parser-tokenised argument list (filler words removed)
|
||||
*/
|
||||
void execute(GameContext ctx, List<String> args);
|
||||
|
||||
/**
|
||||
* Short single-line help text shown by the {@code help} command.
|
||||
*
|
||||
* @return human-readable usage hint
|
||||
*/
|
||||
String help();
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package thb.jeanluc.adventure.command;
|
||||
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* Tokenising parser for player input. Splits on whitespace, lowercases,
|
||||
* then drops a small set of grammatical filler words so that
|
||||
* {@code go to the north} parses the same as {@code go north}.
|
||||
*/
|
||||
public class CommandParser {
|
||||
|
||||
/**
|
||||
* Filler tokens removed from the argument list. Kept deliberately
|
||||
* small — when adding more, watch out for words that might be valid
|
||||
* item ids ({@code key}, {@code lamp}, etc.).
|
||||
*/
|
||||
private static final Set<String> FILLERS = Set.of("to", "with", "at", "the", "a", "an", "on");
|
||||
|
||||
/**
|
||||
* Parses an input line into a verb and its arguments.
|
||||
*
|
||||
* @param input raw player input; may be null or blank
|
||||
* @return parsed command; verb is the empty string if input is blank
|
||||
*/
|
||||
public ParsedCommand parse(String input) {
|
||||
if (input == null || input.isBlank()) {
|
||||
return new ParsedCommand("", List.of());
|
||||
}
|
||||
String[] tokens = input.trim().toLowerCase().split("\\s+");
|
||||
String verb = tokens[0];
|
||||
List<String> args = Arrays.stream(tokens, 1, tokens.length)
|
||||
.filter(t -> !FILLERS.contains(t))
|
||||
.toList();
|
||||
return new ParsedCommand(verb, args);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
package thb.jeanluc.adventure.command;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* Verb-to-{@link Command} dispatch table. Backed by a {@link HashMap}
|
||||
* (O(1) lookup), with each command optionally registered under multiple
|
||||
* aliases.
|
||||
*/
|
||||
public class CommandRegistry {
|
||||
|
||||
/** Lookup table; multiple aliases may map to the same command instance. */
|
||||
private final Map<String, Command> commands = new HashMap<>();
|
||||
|
||||
/**
|
||||
* Registers a command under one or more verbs (aliases). All names
|
||||
* are stored lowercase to keep dispatch case-insensitive.
|
||||
*
|
||||
* @param cmd the command to register
|
||||
* @param names one or more verb aliases
|
||||
*/
|
||||
public void register(Command cmd, String... names) {
|
||||
for (String n : names) {
|
||||
commands.put(n.toLowerCase(), cmd);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Looks up a command by verb.
|
||||
*
|
||||
* @param verb verb token, case-insensitive
|
||||
* @return the matching command, or empty if no command is registered
|
||||
*/
|
||||
public Optional<Command> find(String verb) {
|
||||
if (verb == null) {
|
||||
return Optional.empty();
|
||||
}
|
||||
return Optional.ofNullable(commands.get(verb.toLowerCase()));
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the set of distinct command instances (deduplicated across
|
||||
* aliases). Used by {@code HelpCommand} to list help texts.
|
||||
*
|
||||
* @return distinct commands in insertion order
|
||||
*/
|
||||
public Set<Command> distinctCommands() {
|
||||
return new LinkedHashSet<>(commands.values());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
package thb.jeanluc.adventure.command;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Result of tokenising a player input line. The first non-empty token is
|
||||
* the verb, all remaining tokens (filler words filtered) become the
|
||||
* argument list.
|
||||
*
|
||||
* @param verb verb token, lowercase; empty string for blank input
|
||||
* @param args remaining argument tokens, lowercase, filler words removed
|
||||
*/
|
||||
public record ParsedCommand(String verb, List<String> args) {
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
package thb.jeanluc.adventure.command.impl;
|
||||
|
||||
import thb.jeanluc.adventure.command.Command;
|
||||
import thb.jeanluc.adventure.game.GameContext;
|
||||
import thb.jeanluc.adventure.model.item.Item;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
* Drops an item from the inventory back into the current room.
|
||||
* Usage: {@code drop <item-id>}.
|
||||
*/
|
||||
public class DropCommand implements Command {
|
||||
|
||||
@Override
|
||||
public void execute(GameContext ctx, List<String> args) {
|
||||
if (args.isEmpty()) {
|
||||
ctx.getIo().write("Drop what?");
|
||||
return;
|
||||
}
|
||||
String itemId = args.getFirst();
|
||||
Optional<Item> dropped = ctx.getPlayer().removeItem(itemId);
|
||||
if (dropped.isEmpty()) {
|
||||
ctx.getIo().write("You are not carrying '" + itemId + "'.");
|
||||
return;
|
||||
}
|
||||
ctx.getPlayer().getCurrentRoom().addItem(dropped.get());
|
||||
ctx.getIo().write("You drop the " + dropped.get().getName() + ".");
|
||||
}
|
||||
|
||||
@Override
|
||||
public String help() {
|
||||
return "drop <item> - put an item from your inventory into the room";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
package thb.jeanluc.adventure.command.impl;
|
||||
|
||||
import thb.jeanluc.adventure.command.Command;
|
||||
import thb.jeanluc.adventure.game.GameContext;
|
||||
import thb.jeanluc.adventure.model.Npc;
|
||||
import thb.jeanluc.adventure.model.item.Item;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
* Shows the description of an item or NPC. The target may be in the
|
||||
* player's inventory or in the current room. Usage: {@code examine <id>}.
|
||||
*/
|
||||
public class ExamineCommand implements Command {
|
||||
|
||||
@Override
|
||||
public void execute(GameContext ctx, List<String> args) {
|
||||
if (args.isEmpty()) {
|
||||
ctx.getIo().write("Examine what?");
|
||||
return;
|
||||
}
|
||||
String id = args.getFirst();
|
||||
Optional<Item> item = ctx.getPlayer().findItem(id);
|
||||
if (item.isEmpty()) {
|
||||
item = ctx.getPlayer().getCurrentRoom().findItem(id);
|
||||
}
|
||||
if (item.isPresent()) {
|
||||
ctx.getIo().write(item.get().getDescription());
|
||||
return;
|
||||
}
|
||||
Optional<Npc> npc = ctx.getPlayer().getCurrentRoom().findNpc(id);
|
||||
if (npc.isPresent()) {
|
||||
ctx.getIo().write(npc.get().getDescription());
|
||||
return;
|
||||
}
|
||||
ctx.getIo().write("There is no '" + id + "' here.");
|
||||
}
|
||||
|
||||
@Override
|
||||
public String help() {
|
||||
return "examine <target> - inspect an item or NPC (alias: x)";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
package thb.jeanluc.adventure.command.impl;
|
||||
|
||||
import thb.jeanluc.adventure.command.Command;
|
||||
import thb.jeanluc.adventure.game.GameContext;
|
||||
import thb.jeanluc.adventure.model.Npc;
|
||||
import thb.jeanluc.adventure.model.NpcReaction;
|
||||
import thb.jeanluc.adventure.model.item.Item;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
* Offers an item from the inventory to an NPC. If the NPC has a matching
|
||||
* reaction, the item is consumed (when {@code consumes} is set) and the
|
||||
* NPC may give back another item. Usage: {@code give <item> <npc>}.
|
||||
*/
|
||||
public class GiveCommand implements Command {
|
||||
|
||||
@Override
|
||||
public void execute(GameContext ctx, List<String> args) {
|
||||
if (args.size() < 2) {
|
||||
ctx.getIo().write("Usage: give <item> <npc>.");
|
||||
return;
|
||||
}
|
||||
String itemId = args.get(0);
|
||||
String npcId = args.get(1);
|
||||
|
||||
Optional<Item> held = ctx.getPlayer().findItem(itemId);
|
||||
if (held.isEmpty()) {
|
||||
ctx.getIo().write("You are not carrying '" + itemId + "'.");
|
||||
return;
|
||||
}
|
||||
Optional<Npc> npcOpt = ctx.getPlayer().getCurrentRoom().findNpc(npcId);
|
||||
if (npcOpt.isEmpty()) {
|
||||
ctx.getIo().write("There is no '" + npcId + "' here.");
|
||||
return;
|
||||
}
|
||||
|
||||
Npc npc = npcOpt.get();
|
||||
Optional<NpcReaction> reactionOpt = npc.reactionFor(itemId);
|
||||
if (reactionOpt.isEmpty()) {
|
||||
ctx.getIo().write(npc.getName() + " does not react to the " + held.get().getName() + ".");
|
||||
return;
|
||||
}
|
||||
|
||||
NpcReaction reaction = reactionOpt.get();
|
||||
if (reaction.getConsumes() != null) {
|
||||
ctx.getPlayer().removeItem(reaction.getConsumes().getId());
|
||||
}
|
||||
if (reaction.getGives() != null) {
|
||||
ctx.getPlayer().addItem(reaction.getGives());
|
||||
}
|
||||
ctx.getIo().write(npc.getName() + ": " + reaction.getResponse());
|
||||
}
|
||||
|
||||
@Override
|
||||
public String help() {
|
||||
return "give <item> <npc> - hand an item to an NPC";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
package thb.jeanluc.adventure.command.impl;
|
||||
|
||||
import thb.jeanluc.adventure.command.Command;
|
||||
import thb.jeanluc.adventure.game.GameContext;
|
||||
import thb.jeanluc.adventure.model.Direction;
|
||||
import thb.jeanluc.adventure.model.Room;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
* Moves the player into the room reachable in a given direction.
|
||||
* Usage: {@code go <direction>}.
|
||||
*/
|
||||
public class GoCommand implements Command {
|
||||
|
||||
@Override
|
||||
public void execute(GameContext ctx, List<String> args) {
|
||||
if (args.isEmpty()) {
|
||||
ctx.getIo().write("Go where? Try 'go north'.");
|
||||
return;
|
||||
}
|
||||
Direction dir;
|
||||
try {
|
||||
dir = Direction.fromString(args.getFirst());
|
||||
} catch (IllegalArgumentException e) {
|
||||
ctx.getIo().write("I don't know which way '" + args.getFirst() + "' is.");
|
||||
return;
|
||||
}
|
||||
Optional<Room> next = ctx.getPlayer().getCurrentRoom().getExit(dir);
|
||||
if (next.isEmpty()) {
|
||||
ctx.getIo().write("You can't go " + dir.getLabel() + " from here.");
|
||||
return;
|
||||
}
|
||||
ctx.getPlayer().setCurrentRoom(next.get());
|
||||
new LookCommand().execute(ctx, List.of());
|
||||
}
|
||||
|
||||
@Override
|
||||
public String help() {
|
||||
return "go <direction> - move to the connected room (north/south/east/west)";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
package thb.jeanluc.adventure.command.impl;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import thb.jeanluc.adventure.command.Command;
|
||||
import thb.jeanluc.adventure.command.CommandRegistry;
|
||||
import thb.jeanluc.adventure.game.GameContext;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* Lists all available commands and their short help text. Pulls the
|
||||
* command set from the {@link CommandRegistry} so it stays in sync
|
||||
* automatically. Usage: {@code help}.
|
||||
*/
|
||||
@RequiredArgsConstructor
|
||||
public class HelpCommand implements Command {
|
||||
|
||||
/** Registry that owns the commands to be listed. */
|
||||
private final CommandRegistry registry;
|
||||
|
||||
@Override
|
||||
public void execute(GameContext ctx, List<String> args) {
|
||||
String body = registry.distinctCommands().stream()
|
||||
.map(Command::help)
|
||||
.collect(Collectors.joining("\n "));
|
||||
ctx.getIo().write("Available commands:\n " + body);
|
||||
}
|
||||
|
||||
@Override
|
||||
public String help() {
|
||||
return "help - show this list (alias: ?)";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package thb.jeanluc.adventure.command.impl;
|
||||
|
||||
import thb.jeanluc.adventure.command.Command;
|
||||
import thb.jeanluc.adventure.game.GameContext;
|
||||
import thb.jeanluc.adventure.model.item.Item;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* Lists the items the player is currently carrying.
|
||||
* Usage: {@code inventory}.
|
||||
*/
|
||||
public class InventoryCommand implements Command {
|
||||
|
||||
@Override
|
||||
public void execute(GameContext ctx, List<String> args) {
|
||||
var inventory = ctx.getPlayer().getInventory();
|
||||
if (inventory.isEmpty()) {
|
||||
ctx.getIo().write("You are not carrying anything.");
|
||||
return;
|
||||
}
|
||||
String list = inventory.values().stream()
|
||||
.map(Item::getName)
|
||||
.collect(Collectors.joining(", "));
|
||||
ctx.getIo().write("You are carrying: " + list + ".");
|
||||
}
|
||||
|
||||
@Override
|
||||
public String help() {
|
||||
return "inventory - list what you are carrying (alias: inv, i)";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
package thb.jeanluc.adventure.command.impl;
|
||||
|
||||
import thb.jeanluc.adventure.command.Command;
|
||||
import thb.jeanluc.adventure.game.GameContext;
|
||||
import thb.jeanluc.adventure.model.Direction;
|
||||
import thb.jeanluc.adventure.model.Npc;
|
||||
import thb.jeanluc.adventure.model.Room;
|
||||
import thb.jeanluc.adventure.model.item.Item;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* Prints a full description of the room the player is currently in:
|
||||
* name, description, visible items, visible NPCs, and available exits.
|
||||
* Usage: {@code look}.
|
||||
*/
|
||||
public class LookCommand implements Command {
|
||||
|
||||
@Override
|
||||
public void execute(GameContext ctx, List<String> args) {
|
||||
Room room = ctx.getPlayer().getCurrentRoom();
|
||||
StringBuilder sb = new StringBuilder();
|
||||
sb.append("== ").append(room.getName()).append(" ==\n");
|
||||
sb.append(room.getDescription().stripTrailing());
|
||||
|
||||
if (!room.getItems().isEmpty()) {
|
||||
String items = room.getItems().values().stream()
|
||||
.map(Item::getName)
|
||||
.collect(Collectors.joining(", "));
|
||||
sb.append("\nYou see: ").append(items).append('.');
|
||||
}
|
||||
if (!room.getNpcs().isEmpty()) {
|
||||
String npcs = room.getNpcs().values().stream()
|
||||
.map(Npc::getName)
|
||||
.collect(Collectors.joining(", "));
|
||||
sb.append("\nHere is: ").append(npcs).append('.');
|
||||
}
|
||||
if (room.getExits().isEmpty()) {
|
||||
sb.append("\nThere are no obvious exits.");
|
||||
} else {
|
||||
String exits = room.getExits().keySet().stream()
|
||||
.map(Direction::getLabel)
|
||||
.collect(Collectors.joining(", "));
|
||||
sb.append("\nExits: ").append(exits).append('.');
|
||||
}
|
||||
ctx.getIo().write(sb.toString());
|
||||
}
|
||||
|
||||
@Override
|
||||
public String help() {
|
||||
return "look - describe the current room";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
package thb.jeanluc.adventure.command.impl;
|
||||
|
||||
import thb.jeanluc.adventure.command.Command;
|
||||
import thb.jeanluc.adventure.game.Game;
|
||||
import thb.jeanluc.adventure.game.GameContext;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Ends the game loop. Holds a reference to the {@link Game} so it can
|
||||
* flip the loop flag. Usage: {@code quit}.
|
||||
*/
|
||||
public class QuitCommand implements Command {
|
||||
|
||||
/** The game whose loop is to be stopped. Set via {@link #bind(Game)}. */
|
||||
private Game game;
|
||||
|
||||
/**
|
||||
* Binds the game instance after construction. Two-phase wiring is
|
||||
* necessary because the registry is typically built before the game
|
||||
* starts running.
|
||||
*
|
||||
* @param game the running game; must not be null
|
||||
*/
|
||||
public void bind(Game game) {
|
||||
this.game = game;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void execute(GameContext ctx, List<String> args) {
|
||||
ctx.getIo().write("Goodbye.");
|
||||
if (game != null) {
|
||||
game.stop();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public String help() {
|
||||
return "quit - exit the game (alias: exit)";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
package thb.jeanluc.adventure.command.impl;
|
||||
|
||||
import thb.jeanluc.adventure.command.Command;
|
||||
import thb.jeanluc.adventure.game.GameContext;
|
||||
import thb.jeanluc.adventure.model.item.Item;
|
||||
import thb.jeanluc.adventure.model.item.ReadableItem;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
* Shorthand for using a {@link ReadableItem}. Behaves like
|
||||
* {@link UseCommand} but writes a more helpful message when applied to a
|
||||
* non-readable item. Usage: {@code read <item-id>}.
|
||||
*/
|
||||
public class ReadCommand implements Command {
|
||||
|
||||
@Override
|
||||
public void execute(GameContext ctx, List<String> args) {
|
||||
if (args.isEmpty()) {
|
||||
ctx.getIo().write("Read what?");
|
||||
return;
|
||||
}
|
||||
String itemId = args.getFirst();
|
||||
Optional<Item> item = ctx.getPlayer().findItem(itemId);
|
||||
if (item.isEmpty()) {
|
||||
item = ctx.getPlayer().getCurrentRoom().findItem(itemId);
|
||||
}
|
||||
if (item.isEmpty()) {
|
||||
ctx.getIo().write("There is no '" + itemId + "' here or in your inventory.");
|
||||
return;
|
||||
}
|
||||
if (item.get() instanceof ReadableItem readable) {
|
||||
readable.use(ctx);
|
||||
} else {
|
||||
ctx.getIo().write("There is nothing to read on the " + item.get().getName() + ".");
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public String help() {
|
||||
return "read <item> - read the text of a readable item";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package thb.jeanluc.adventure.command.impl;
|
||||
|
||||
import thb.jeanluc.adventure.command.Command;
|
||||
import thb.jeanluc.adventure.game.GameContext;
|
||||
import thb.jeanluc.adventure.model.Room;
|
||||
import thb.jeanluc.adventure.model.item.Item;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
* Picks an item up from the current room and places it in the inventory.
|
||||
* Usage: {@code take <item-id>}.
|
||||
*/
|
||||
public class TakeCommand implements Command {
|
||||
|
||||
@Override
|
||||
public void execute(GameContext ctx, List<String> args) {
|
||||
if (args.isEmpty()) {
|
||||
ctx.getIo().write("Take what?");
|
||||
return;
|
||||
}
|
||||
String itemId = args.getFirst();
|
||||
Room room = ctx.getPlayer().getCurrentRoom();
|
||||
Optional<Item> taken = room.removeItem(itemId);
|
||||
if (taken.isEmpty()) {
|
||||
ctx.getIo().write("There is no '" + itemId + "' here.");
|
||||
return;
|
||||
}
|
||||
ctx.getPlayer().addItem(taken.get());
|
||||
ctx.getIo().write("You take the " + taken.get().getName() + ".");
|
||||
}
|
||||
|
||||
@Override
|
||||
public String help() {
|
||||
return "take <item> - pick up an item from the room (alias: pick, get)";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
package thb.jeanluc.adventure.command.impl;
|
||||
|
||||
import thb.jeanluc.adventure.command.Command;
|
||||
import thb.jeanluc.adventure.game.GameContext;
|
||||
import thb.jeanluc.adventure.model.Npc;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
* Prints an NPC's greeting text. Usage: {@code talk <npc-id>}.
|
||||
*/
|
||||
public class TalkCommand implements Command {
|
||||
|
||||
@Override
|
||||
public void execute(GameContext ctx, List<String> args) {
|
||||
if (args.isEmpty()) {
|
||||
ctx.getIo().write("Talk to whom?");
|
||||
return;
|
||||
}
|
||||
String npcId = args.getFirst();
|
||||
Optional<Npc> npc = ctx.getPlayer().getCurrentRoom().findNpc(npcId);
|
||||
if (npc.isEmpty()) {
|
||||
ctx.getIo().write("There is no '" + npcId + "' here to talk to.");
|
||||
return;
|
||||
}
|
||||
ctx.getIo().write(npc.get().getName() + " says: " + npc.get().getGreeting());
|
||||
}
|
||||
|
||||
@Override
|
||||
public String help() {
|
||||
return "talk <npc> - start a conversation with an NPC (alias: speak)";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
package thb.jeanluc.adventure.command.impl;
|
||||
|
||||
import thb.jeanluc.adventure.command.Command;
|
||||
import thb.jeanluc.adventure.game.GameContext;
|
||||
import thb.jeanluc.adventure.model.item.Item;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
* Invokes the item-specific {@link Item#use(GameContext)} action. The
|
||||
* item must either be in the player's inventory or in the current room.
|
||||
* Usage: {@code use <item-id>}.
|
||||
*/
|
||||
public class UseCommand implements Command {
|
||||
|
||||
@Override
|
||||
public void execute(GameContext ctx, List<String> args) {
|
||||
if (args.isEmpty()) {
|
||||
ctx.getIo().write("Use what?");
|
||||
return;
|
||||
}
|
||||
String itemId = args.getFirst();
|
||||
Optional<Item> item = ctx.getPlayer().findItem(itemId);
|
||||
if (item.isEmpty()) {
|
||||
item = ctx.getPlayer().getCurrentRoom().findItem(itemId);
|
||||
}
|
||||
if (item.isEmpty()) {
|
||||
ctx.getIo().write("There is no '" + itemId + "' here or in your inventory.");
|
||||
return;
|
||||
}
|
||||
item.get().use(ctx);
|
||||
}
|
||||
|
||||
@Override
|
||||
public String help() {
|
||||
return "use <item> - use an item (read it, switch it on/off, ...)";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
package thb.jeanluc.adventure.game;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import thb.jeanluc.adventure.command.Command;
|
||||
import thb.jeanluc.adventure.command.CommandParser;
|
||||
import thb.jeanluc.adventure.command.CommandRegistry;
|
||||
import thb.jeanluc.adventure.command.ParsedCommand;
|
||||
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
* The main game loop. Reads a line, parses it, dispatches the matching
|
||||
* command, and writes the result — until {@link #stop()} flips the
|
||||
* {@link #running} flag (typically by the quit command or EOF on input).
|
||||
*/
|
||||
@RequiredArgsConstructor
|
||||
public class Game {
|
||||
|
||||
/** Game context shared with every command. */
|
||||
private final GameContext ctx;
|
||||
|
||||
/** Command lookup table. */
|
||||
private final CommandRegistry registry;
|
||||
|
||||
/** Tokenising parser for player input. */
|
||||
private final CommandParser parser;
|
||||
|
||||
/** Loop flag. Flipped to false by {@link #stop()}. */
|
||||
private boolean running = true;
|
||||
|
||||
/**
|
||||
* Runs the loop until {@link #stop()} is called or input is exhausted.
|
||||
*/
|
||||
public void run() {
|
||||
while (running) {
|
||||
String input = ctx.getIo().readLine();
|
||||
if (input == null) {
|
||||
break;
|
||||
}
|
||||
ParsedCommand parsed = parser.parse(input);
|
||||
if (parsed.verb().isEmpty()) {
|
||||
continue;
|
||||
}
|
||||
Optional<Command> cmd = registry.find(parsed.verb());
|
||||
if (cmd.isEmpty()) {
|
||||
ctx.getIo().write("I don't understand '" + parsed.verb() + "'. Type 'help'.");
|
||||
} else {
|
||||
cmd.get().execute(ctx, parsed.args());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Signals the loop to terminate after the current iteration.
|
||||
*/
|
||||
public void stop() {
|
||||
running = false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
package thb.jeanluc.adventure.game;
|
||||
|
||||
import lombok.Getter;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import thb.jeanluc.adventure.io.GameIO;
|
||||
import thb.jeanluc.adventure.model.Player;
|
||||
import thb.jeanluc.adventure.model.World;
|
||||
|
||||
/**
|
||||
* Bundle of everything a command needs to do its work: the world, the
|
||||
* player, and the IO channel. Passing this single object keeps command
|
||||
* signatures small and consistent.
|
||||
*/
|
||||
@Getter
|
||||
@RequiredArgsConstructor
|
||||
public class GameContext {
|
||||
|
||||
/** Loaded world (rooms, items, npcs). */
|
||||
private final World world;
|
||||
|
||||
/** The player avatar. */
|
||||
private final Player player;
|
||||
|
||||
/** IO channel for reading input and writing output. */
|
||||
private final GameIO io;
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
package thb.jeanluc.adventure.io;
|
||||
|
||||
import java.io.BufferedReader;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStreamReader;
|
||||
import java.io.PrintStream;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
|
||||
/**
|
||||
* {@link GameIO} backed by {@code System.in} and {@code System.out}.
|
||||
* Each {@code write} appends a newline so output blocks are visually
|
||||
* separated. {@code readLine} prints a {@code > } prompt before
|
||||
* blocking.
|
||||
*/
|
||||
public class ConsoleIO implements GameIO {
|
||||
|
||||
/** Input source. */
|
||||
private final BufferedReader in;
|
||||
|
||||
/** Output sink. */
|
||||
private final PrintStream out;
|
||||
|
||||
/**
|
||||
* Creates a console IO using standard input and output.
|
||||
*/
|
||||
public ConsoleIO() {
|
||||
this(new BufferedReader(new InputStreamReader(System.in, StandardCharsets.UTF_8)), System.out);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test-friendly constructor allowing custom streams.
|
||||
*
|
||||
* @param in reader to consume player input
|
||||
* @param out stream to write player output
|
||||
*/
|
||||
public ConsoleIO(BufferedReader in, PrintStream out) {
|
||||
this.in = in;
|
||||
this.out = out;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String readLine() {
|
||||
out.print("> ");
|
||||
out.flush();
|
||||
try {
|
||||
String line = in.readLine();
|
||||
return line == null ? "" : line;
|
||||
} catch (IOException e) {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void write(String s) {
|
||||
out.println(s);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package thb.jeanluc.adventure.io;
|
||||
|
||||
/**
|
||||
* Bidirectional text channel between the engine and the player. Implemented
|
||||
* by both {@code ConsoleIO} and {@code SwingIO} so the game loop is agnostic
|
||||
* to the actual interface.
|
||||
*/
|
||||
public interface GameIO {
|
||||
|
||||
/**
|
||||
* Blocks until a line of input is available.
|
||||
*
|
||||
* @return the next user input line, or an empty string at end of input
|
||||
*/
|
||||
String readLine();
|
||||
|
||||
/**
|
||||
* Writes a message to the player. Each call corresponds to one logical
|
||||
* output block; the implementation handles line breaks at the boundary.
|
||||
*
|
||||
* @param s message text; must not be null
|
||||
*/
|
||||
void write(String s);
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
package thb.jeanluc.adventure.io;
|
||||
|
||||
import javax.swing.BorderFactory;
|
||||
import javax.swing.JFrame;
|
||||
import javax.swing.JScrollPane;
|
||||
import javax.swing.JTextArea;
|
||||
import javax.swing.JTextField;
|
||||
import javax.swing.SwingUtilities;
|
||||
import java.awt.BorderLayout;
|
||||
import java.awt.Color;
|
||||
import java.awt.Font;
|
||||
import java.util.concurrent.LinkedBlockingQueue;
|
||||
|
||||
/**
|
||||
* {@link GameIO} backed by a Swing window. A read-only output area shows
|
||||
* game text; a bottom text field collects player input. The input from
|
||||
* the EDT is handed to the (worker-thread) game loop through a
|
||||
* {@link LinkedBlockingQueue}.
|
||||
*/
|
||||
public class SwingIO implements GameIO {
|
||||
|
||||
/** Bridge between the EDT and the game loop. One element = one line. */
|
||||
private final LinkedBlockingQueue<String> inputs = new LinkedBlockingQueue<>();
|
||||
|
||||
/** Main window. */
|
||||
private final JFrame frame;
|
||||
|
||||
/** Output text area; appended to from any thread via {@link SwingUtilities#invokeLater}. */
|
||||
private final JTextArea output;
|
||||
|
||||
/** Input text field; ENTER pushes the line into {@link #inputs}. */
|
||||
private final JTextField input;
|
||||
|
||||
/**
|
||||
* Creates and shows the window with the given title. Must be called on
|
||||
* the EDT (typical pattern: from {@code SwingUtilities.invokeLater}).
|
||||
*
|
||||
* @param title window title
|
||||
*/
|
||||
public SwingIO(String title) {
|
||||
frame = new JFrame(title);
|
||||
output = new JTextArea();
|
||||
input = new JTextField();
|
||||
|
||||
output.setEditable(false);
|
||||
output.setLineWrap(true);
|
||||
output.setWrapStyleWord(true);
|
||||
output.setBackground(Color.BLACK);
|
||||
output.setForeground(Color.LIGHT_GRAY);
|
||||
output.setFont(new Font(Font.MONOSPACED, Font.PLAIN, 14));
|
||||
output.setBorder(BorderFactory.createEmptyBorder(8, 8, 8, 8));
|
||||
|
||||
input.setFont(new Font(Font.MONOSPACED, Font.PLAIN, 14));
|
||||
input.addActionListener(e -> {
|
||||
String line = input.getText();
|
||||
input.setText("");
|
||||
output.append("> " + line + "\n");
|
||||
inputs.offer(line);
|
||||
});
|
||||
|
||||
frame.setLayout(new BorderLayout());
|
||||
frame.add(new JScrollPane(output), BorderLayout.CENTER);
|
||||
frame.add(input, BorderLayout.SOUTH);
|
||||
frame.setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE);
|
||||
frame.setSize(800, 600);
|
||||
frame.setLocationRelativeTo(null);
|
||||
frame.setVisible(true);
|
||||
input.requestFocusInWindow();
|
||||
}
|
||||
|
||||
@Override
|
||||
public String readLine() {
|
||||
try {
|
||||
return inputs.take();
|
||||
} catch (InterruptedException e) {
|
||||
Thread.currentThread().interrupt();
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void write(String s) {
|
||||
SwingUtilities.invokeLater(() -> output.append(s + "\n"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
package thb.jeanluc.adventure.loader;
|
||||
|
||||
import thb.jeanluc.adventure.loader.dto.ItemDto;
|
||||
import thb.jeanluc.adventure.model.item.Item;
|
||||
import thb.jeanluc.adventure.model.item.PlainItem;
|
||||
import thb.jeanluc.adventure.model.item.ReadableItem;
|
||||
import thb.jeanluc.adventure.model.item.SwitchableItem;
|
||||
|
||||
/**
|
||||
* Converts {@link ItemDto} instances coming from YAML into concrete
|
||||
* {@link Item} subclasses. The {@code type} discriminator picks the
|
||||
* subclass; unknown types fail fast with {@link WorldLoadException}.
|
||||
*/
|
||||
public final class ItemFactory {
|
||||
|
||||
private ItemFactory() {
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the appropriate {@link Item} subclass for the given DTO.
|
||||
*
|
||||
* @param dto data read from {@code items.yaml}; must not be null
|
||||
* @return the freshly built item
|
||||
* @throws WorldLoadException if {@code dto.type} is missing or unknown
|
||||
*/
|
||||
public static Item fromDto(ItemDto dto) {
|
||||
String type = dto.type() == null ? "plain" : dto.type().toLowerCase();
|
||||
return switch (type) {
|
||||
case "plain" -> PlainItem.builder()
|
||||
.id(dto.id())
|
||||
.name(dto.name())
|
||||
.description(dto.description())
|
||||
.build();
|
||||
case "readable" -> ReadableItem.builder()
|
||||
.id(dto.id())
|
||||
.name(dto.name())
|
||||
.description(dto.description())
|
||||
.readText(dto.readText())
|
||||
.build();
|
||||
case "switchable" -> SwitchableItem.builder()
|
||||
.id(dto.id())
|
||||
.name(dto.name())
|
||||
.description(dto.description())
|
||||
.state(Boolean.TRUE.equals(dto.initialState()))
|
||||
.onText(dto.onText())
|
||||
.offText(dto.offText())
|
||||
.build();
|
||||
default -> throw new WorldLoadException(
|
||||
"Unknown item type '" + dto.type() + "' on item '" + dto.id() + "'");
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package thb.jeanluc.adventure.loader;
|
||||
|
||||
import thb.jeanluc.adventure.loader.dto.NpcDto;
|
||||
import thb.jeanluc.adventure.model.Npc;
|
||||
|
||||
/**
|
||||
* Builds an NPC shell (without resolved reactions) from a DTO. Reactions
|
||||
* are wired in a later phase by {@link ReferenceResolver}.
|
||||
*/
|
||||
public final class NpcFactory {
|
||||
|
||||
private NpcFactory() {
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds an NPC carrying an empty reactions map.
|
||||
*
|
||||
* @param dto data read from {@code npcs.yaml}; must not be null
|
||||
* @return the freshly built NPC shell
|
||||
*/
|
||||
public static Npc shellFromDto(NpcDto dto) {
|
||||
return Npc.shell(dto.id(), dto.name(), dto.description(), dto.greeting());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
package thb.jeanluc.adventure.loader;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
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.Direction;
|
||||
import thb.jeanluc.adventure.model.Npc;
|
||||
import thb.jeanluc.adventure.model.NpcReaction;
|
||||
import thb.jeanluc.adventure.model.Room;
|
||||
import thb.jeanluc.adventure.model.item.Item;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* Phase 3 of the loading pipeline. Walks DTOs once more and resolves all
|
||||
* string ids to actual domain references on the prebuilt registries.
|
||||
*/
|
||||
@RequiredArgsConstructor
|
||||
public class ReferenceResolver {
|
||||
|
||||
/** Global item registry, prebuilt by phase 2. */
|
||||
private final Map<String, Item> items;
|
||||
|
||||
/** Global NPC registry, prebuilt by phase 2. */
|
||||
private final Map<String, Npc> npcs;
|
||||
|
||||
/** Global room registry, prebuilt by phase 2. */
|
||||
private final Map<String, Room> rooms;
|
||||
|
||||
/**
|
||||
* Wires exits, items, and NPCs into every room based on its DTO.
|
||||
*
|
||||
* @param roomDtos all room DTOs as read from YAML
|
||||
* @throws WorldLoadException on any unresolved id or unparsable direction
|
||||
*/
|
||||
public void resolveRooms(List<RoomDto> roomDtos) {
|
||||
for (RoomDto dto : roomDtos) {
|
||||
Room room = rooms.get(dto.id());
|
||||
if (dto.exits() != null) {
|
||||
for (Map.Entry<String, String> e : dto.exits().entrySet()) {
|
||||
Direction direction;
|
||||
try {
|
||||
direction = Direction.fromString(e.getKey());
|
||||
} catch (IllegalArgumentException ex) {
|
||||
throw new WorldLoadException(
|
||||
"Room '" + dto.id() + "' has unknown exit direction '" + e.getKey() + "'");
|
||||
}
|
||||
Room target = rooms.get(e.getValue());
|
||||
if (target == null) {
|
||||
throw new WorldLoadException(
|
||||
"Room '" + dto.id() + "' exits to unknown room '" + e.getValue() + "'");
|
||||
}
|
||||
room.addExit(direction, target);
|
||||
}
|
||||
}
|
||||
if (dto.items() != null) {
|
||||
for (String itemId : dto.items()) {
|
||||
Item item = items.get(itemId);
|
||||
if (item == null) {
|
||||
throw new WorldLoadException(
|
||||
"Room '" + dto.id() + "' references unknown item '" + itemId + "'");
|
||||
}
|
||||
room.addItem(item);
|
||||
}
|
||||
}
|
||||
if (dto.npcs() != null) {
|
||||
for (String npcId : dto.npcs()) {
|
||||
Npc npc = npcs.get(npcId);
|
||||
if (npc == null) {
|
||||
throw new WorldLoadException(
|
||||
"Room '" + dto.id() + "' references unknown npc '" + npcId + "'");
|
||||
}
|
||||
room.addNpc(npc);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Wires every NPC's reactions to actual item references.
|
||||
*
|
||||
* @param npcDtos all NPC DTOs as read from YAML
|
||||
* @throws WorldLoadException on any unresolved id or duplicate trigger
|
||||
*/
|
||||
public void resolveNpcs(List<NpcDto> npcDtos) {
|
||||
for (NpcDto dto : npcDtos) {
|
||||
if (dto.reactions() == null) {
|
||||
continue;
|
||||
}
|
||||
Npc npc = npcs.get(dto.id());
|
||||
for (ReactionDto r : dto.reactions()) {
|
||||
if (r.onReceive() == null) {
|
||||
throw new WorldLoadException(
|
||||
"NPC '" + dto.id() + "' has a reaction without onReceive");
|
||||
}
|
||||
if (!items.containsKey(r.onReceive())) {
|
||||
throw new WorldLoadException(
|
||||
"NPC '" + dto.id() + "' reacts to unknown item '" + r.onReceive() + "'");
|
||||
}
|
||||
if (npc.getReactions().containsKey(r.onReceive())) {
|
||||
throw new WorldLoadException(
|
||||
"NPC '" + dto.id() + "' has duplicate reaction for item '" + r.onReceive() + "'");
|
||||
}
|
||||
Item gives = null;
|
||||
if (r.gives() != null) {
|
||||
gives = items.get(r.gives());
|
||||
if (gives == null) {
|
||||
throw new WorldLoadException(
|
||||
"NPC '" + dto.id() + "' gives unknown item '" + r.gives() + "'");
|
||||
}
|
||||
}
|
||||
Item consumes = null;
|
||||
if (r.consumes() != null) {
|
||||
consumes = items.get(r.consumes());
|
||||
if (consumes == null) {
|
||||
throw new WorldLoadException(
|
||||
"NPC '" + dto.id() + "' consumes unknown item '" + r.consumes() + "'");
|
||||
}
|
||||
}
|
||||
NpcReaction reaction = NpcReaction.builder()
|
||||
.consumes(consumes)
|
||||
.gives(gives)
|
||||
.response(r.response())
|
||||
.build();
|
||||
npc.putReaction(r.onReceive(), reaction);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package thb.jeanluc.adventure.loader;
|
||||
|
||||
import thb.jeanluc.adventure.loader.dto.RoomDto;
|
||||
import thb.jeanluc.adventure.model.Room;
|
||||
|
||||
/**
|
||||
* Builds an empty {@link Room} shell from a DTO. Exits, items, and NPCs
|
||||
* are wired in a later phase by {@link ReferenceResolver}.
|
||||
*/
|
||||
public final class RoomFactory {
|
||||
|
||||
private RoomFactory() {
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a fresh room with no exits, items, or NPCs.
|
||||
*
|
||||
* @param dto data read from {@code rooms.yaml}; must not be null
|
||||
* @return the freshly built room shell
|
||||
*/
|
||||
public static Room shellFromDto(RoomDto dto) {
|
||||
return new Room(dto.id(), dto.name(), dto.description());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package thb.jeanluc.adventure.loader;
|
||||
|
||||
/**
|
||||
* Thrown when world loading fails: missing YAML files, malformed data,
|
||||
* dangling references, or invariant violations.
|
||||
*/
|
||||
public class WorldLoadException extends RuntimeException {
|
||||
|
||||
/**
|
||||
* Creates an exception with a descriptive message.
|
||||
*
|
||||
* @param message human-readable explanation
|
||||
*/
|
||||
public WorldLoadException(String message) {
|
||||
super(message);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates an exception wrapping a lower-level cause.
|
||||
*
|
||||
* @param message human-readable explanation
|
||||
* @param cause underlying cause
|
||||
*/
|
||||
public WorldLoadException(String message, Throwable cause) {
|
||||
super(message, cause);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
package thb.jeanluc.adventure.loader;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.fasterxml.jackson.databind.type.CollectionType;
|
||||
import com.fasterxml.jackson.dataformat.yaml.YAMLFactory;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import thb.jeanluc.adventure.loader.dto.GameDto;
|
||||
import thb.jeanluc.adventure.loader.dto.ItemDto;
|
||||
import thb.jeanluc.adventure.loader.dto.NpcDto;
|
||||
import thb.jeanluc.adventure.loader.dto.RoomDto;
|
||||
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 java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* Orchestrates the five-phase load (see {@code loading-flow.md}). Reads
|
||||
* four YAML files from the classpath, builds DTOs, then domain shells,
|
||||
* resolves references, validates, and finally returns the assembled
|
||||
* {@link World} plus the freshly placed {@link Player}.
|
||||
*/
|
||||
@Slf4j
|
||||
@RequiredArgsConstructor
|
||||
public class WorldLoader {
|
||||
|
||||
/** Default classpath base directory for the YAML files. */
|
||||
public static final String DEFAULT_BASE = "/world";
|
||||
|
||||
/** Single Jackson mapper, configured once for YAML input. */
|
||||
private final ObjectMapper yaml = new ObjectMapper(new YAMLFactory());
|
||||
|
||||
/** Classpath base directory (typically {@link #DEFAULT_BASE} or a test override). */
|
||||
private final String basePath;
|
||||
|
||||
/**
|
||||
* Convenience constructor using {@link #DEFAULT_BASE}.
|
||||
*/
|
||||
public WorldLoader() {
|
||||
this(DEFAULT_BASE);
|
||||
}
|
||||
|
||||
/**
|
||||
* Result of a successful load: world plus the placed player.
|
||||
*
|
||||
* @param world the built world
|
||||
* @param player the player placed at {@code game.yaml.startRoom}
|
||||
*/
|
||||
public record LoadResult(World world, Player player) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads the world by walking all five phases.
|
||||
*
|
||||
* @return the world and a placed player
|
||||
* @throws WorldLoadException on any phase failure
|
||||
*/
|
||||
public LoadResult load() {
|
||||
log.info("Loading world from classpath base '{}'", basePath);
|
||||
|
||||
List<ItemDto> itemDtos = readList(basePath + "/items.yaml", ItemDto.class);
|
||||
List<NpcDto> npcDtos = readList(basePath + "/npcs.yaml", NpcDto.class);
|
||||
List<RoomDto> roomDtos = readList(basePath + "/rooms.yaml", RoomDto.class);
|
||||
GameDto gameDto = readSingle(basePath + "/game.yaml", GameDto.class);
|
||||
|
||||
requireUniqueIds("item", itemDtos.stream().map(ItemDto::id).toList());
|
||||
requireUniqueIds("npc", npcDtos.stream().map(NpcDto::id).toList());
|
||||
requireUniqueIds("room", roomDtos.stream().map(RoomDto::id).toList());
|
||||
|
||||
Map<String, Item> items = new HashMap<>();
|
||||
for (ItemDto dto : itemDtos) {
|
||||
items.put(dto.id(), ItemFactory.fromDto(dto));
|
||||
}
|
||||
Map<String, Npc> npcs = new HashMap<>();
|
||||
for (NpcDto dto : npcDtos) {
|
||||
npcs.put(dto.id(), NpcFactory.shellFromDto(dto));
|
||||
}
|
||||
Map<String, Room> rooms = new HashMap<>();
|
||||
for (RoomDto dto : roomDtos) {
|
||||
rooms.put(dto.id(), RoomFactory.shellFromDto(dto));
|
||||
}
|
||||
|
||||
ReferenceResolver resolver = new ReferenceResolver(items, npcs, rooms);
|
||||
resolver.resolveRooms(roomDtos);
|
||||
resolver.resolveNpcs(npcDtos);
|
||||
|
||||
new WorldValidator(items, npcs, rooms, gameDto).validate();
|
||||
|
||||
Room start = rooms.get(gameDto.startRoom());
|
||||
int gold = gameDto.startGold() == null ? 0 : gameDto.startGold();
|
||||
Player player = new Player(start, gold);
|
||||
|
||||
World world = new World(rooms, items, npcs,
|
||||
gameDto.title(), gameDto.welcomeMessage());
|
||||
log.info("World '{}' loaded: {} rooms, {} items, {} npcs",
|
||||
gameDto.title(), rooms.size(), items.size(), npcs.size());
|
||||
return new LoadResult(world, player);
|
||||
}
|
||||
|
||||
private <T> List<T> readList(String resource, Class<T> elementType) {
|
||||
try (InputStream in = openResource(resource)) {
|
||||
CollectionType type = yaml.getTypeFactory().constructCollectionType(List.class, elementType);
|
||||
List<T> result = yaml.readValue(in, type);
|
||||
return result == null ? List.of() : result;
|
||||
} catch (IOException e) {
|
||||
throw new WorldLoadException("Failed to parse " + resource, e);
|
||||
}
|
||||
}
|
||||
|
||||
private <T> T readSingle(String resource, Class<T> type) {
|
||||
try (InputStream in = openResource(resource)) {
|
||||
return yaml.readValue(in, type);
|
||||
} catch (IOException e) {
|
||||
throw new WorldLoadException("Failed to parse " + resource, e);
|
||||
}
|
||||
}
|
||||
|
||||
private InputStream openResource(String resource) {
|
||||
InputStream in = getClass().getResourceAsStream(resource);
|
||||
if (in == null) {
|
||||
throw new WorldLoadException("Resource not found on classpath: " + resource);
|
||||
}
|
||||
return in;
|
||||
}
|
||||
|
||||
private void requireUniqueIds(String kind, List<String> ids) {
|
||||
for (int i = 0; i < ids.size(); i++) {
|
||||
for (int j = i + 1; j < ids.size(); j++) {
|
||||
if (ids.get(i) != null && ids.get(i).equals(ids.get(j))) {
|
||||
throw new WorldLoadException("Duplicate " + kind + " id: '" + ids.get(i) + "'");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
package thb.jeanluc.adventure.loader;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import thb.jeanluc.adventure.loader.dto.GameDto;
|
||||
import thb.jeanluc.adventure.model.Npc;
|
||||
import thb.jeanluc.adventure.model.Room;
|
||||
import thb.jeanluc.adventure.model.item.Item;
|
||||
|
||||
import java.util.Map;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
/**
|
||||
* Phase 4 of the loading pipeline. Walks the fully wired world and
|
||||
* checks the invariants documented in {@code yaml-schemas.md}.
|
||||
*/
|
||||
@RequiredArgsConstructor
|
||||
public class WorldValidator {
|
||||
|
||||
/** Regex every entity id must match. */
|
||||
private static final Pattern ID_PATTERN = Pattern.compile("^[a-z][a-z0-9_]*$");
|
||||
|
||||
/** Item registry to validate. */
|
||||
private final Map<String, Item> items;
|
||||
|
||||
/** NPC registry to validate. */
|
||||
private final Map<String, Npc> npcs;
|
||||
|
||||
/** Room registry to validate. */
|
||||
private final Map<String, Room> rooms;
|
||||
|
||||
/** Loaded game.yaml whose {@code startRoom} must exist. */
|
||||
private final GameDto gameDto;
|
||||
|
||||
/**
|
||||
* Runs every validation rule.
|
||||
*
|
||||
* @throws WorldLoadException on the first failure
|
||||
*/
|
||||
public void validate() {
|
||||
validateIds();
|
||||
validateStartRoom();
|
||||
validateGreetings();
|
||||
}
|
||||
|
||||
private void validateIds() {
|
||||
items.keySet().forEach(this::requireValidId);
|
||||
npcs.keySet().forEach(this::requireValidId);
|
||||
rooms.keySet().forEach(this::requireValidId);
|
||||
}
|
||||
|
||||
private void requireValidId(String id) {
|
||||
if (id == null || !ID_PATTERN.matcher(id).matches()) {
|
||||
throw new WorldLoadException("Invalid id '" + id + "': must match " + ID_PATTERN);
|
||||
}
|
||||
}
|
||||
|
||||
private void validateStartRoom() {
|
||||
if (gameDto == null || gameDto.startRoom() == null) {
|
||||
throw new WorldLoadException("game.yaml is missing 'startRoom'");
|
||||
}
|
||||
if (!rooms.containsKey(gameDto.startRoom())) {
|
||||
throw new WorldLoadException(
|
||||
"game.yaml.startRoom '" + gameDto.startRoom() + "' does not match any room");
|
||||
}
|
||||
}
|
||||
|
||||
private void validateGreetings() {
|
||||
for (Npc npc : npcs.values()) {
|
||||
if (npc.getGreeting() == null || npc.getGreeting().isBlank()) {
|
||||
throw new WorldLoadException("NPC '" + npc.getId() + "' has an empty greeting");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package thb.jeanluc.adventure.loader.dto;
|
||||
|
||||
/**
|
||||
* YAML representation of {@code game.yaml}. Plain data only;
|
||||
* IDs are not yet resolved to domain references.
|
||||
*
|
||||
* @param title display title
|
||||
* @param version free-form version label
|
||||
* @param startRoom id of the room the player starts in
|
||||
* @param startGold initial gold; defaults to 0 if null
|
||||
* @param welcomeMessage shown to the player at startup
|
||||
*/
|
||||
public record GameDto(
|
||||
String title,
|
||||
String version,
|
||||
String startRoom,
|
||||
Integer startGold,
|
||||
String welcomeMessage
|
||||
) {
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
package thb.jeanluc.adventure.loader.dto;
|
||||
|
||||
/**
|
||||
* YAML representation of a single item. Fields outside of the type's
|
||||
* scope (e.g. {@code readText} on a switchable) are simply ignored.
|
||||
*
|
||||
* @param type discriminator: {@code plain | readable | switchable}
|
||||
* @param id unique slug
|
||||
* @param name display name
|
||||
* @param description examine description
|
||||
* @param readText text printed when a readable item is used
|
||||
* @param initialState initial on/off state of a switchable item
|
||||
* @param onText message printed when a switchable transitions to on
|
||||
* @param offText message printed when a switchable transitions to off
|
||||
*/
|
||||
public record ItemDto(
|
||||
String type,
|
||||
String id,
|
||||
String name,
|
||||
String description,
|
||||
String readText,
|
||||
Boolean initialState,
|
||||
String onText,
|
||||
String offText
|
||||
) {
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package thb.jeanluc.adventure.loader.dto;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* YAML representation of a single NPC.
|
||||
*
|
||||
* @param id unique slug
|
||||
* @param name display name
|
||||
* @param description examine description
|
||||
* @param greeting text the NPC says on {@code talk}
|
||||
* @param reactions list of trigger/response definitions; may be null
|
||||
*/
|
||||
public record NpcDto(
|
||||
String id,
|
||||
String name,
|
||||
String description,
|
||||
String greeting,
|
||||
List<ReactionDto> reactions
|
||||
) {
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package thb.jeanluc.adventure.loader.dto;
|
||||
|
||||
/**
|
||||
* YAML representation of a single NPC reaction.
|
||||
*
|
||||
* @param onReceive id of the item that triggers this reaction
|
||||
* @param response text the NPC says after the exchange
|
||||
* @param gives id of the item handed back; nullable
|
||||
* @param consumes id of the item taken from the player; nullable
|
||||
*/
|
||||
public record ReactionDto(
|
||||
String onReceive,
|
||||
String response,
|
||||
String gives,
|
||||
String consumes
|
||||
) {
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package thb.jeanluc.adventure.loader.dto;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* YAML representation of a single room.
|
||||
*
|
||||
* @param id unique slug
|
||||
* @param name display name
|
||||
* @param description description shown by {@code look}
|
||||
* @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
|
||||
*/
|
||||
public record RoomDto(
|
||||
String id,
|
||||
String name,
|
||||
String description,
|
||||
Map<String, String> exits,
|
||||
List<String> items,
|
||||
List<String> npcs
|
||||
) {
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
package thb.jeanluc.adventure.model;
|
||||
|
||||
/**
|
||||
* Compass directions used for navigating between rooms.
|
||||
* Each direction has an opposite, which is used when describing
|
||||
* how the player enters the next room (e.g. moving NORTH means
|
||||
* entering the next room from the SOUTH).
|
||||
*/
|
||||
public enum Direction {
|
||||
NORTH,
|
||||
SOUTH,
|
||||
EAST,
|
||||
WEST;
|
||||
|
||||
/**
|
||||
* Returns the opposite of this direction.
|
||||
*
|
||||
* @return the inverse direction
|
||||
*/
|
||||
public Direction getOpposite() {
|
||||
return switch (this) {
|
||||
case NORTH -> SOUTH;
|
||||
case SOUTH -> NORTH;
|
||||
case EAST -> WEST;
|
||||
case WEST -> EAST;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses a direction from a case-insensitive string. Used when
|
||||
* loading exits from YAML and when interpreting player input.
|
||||
*
|
||||
* @param s the direction name; surrounding whitespace is trimmed
|
||||
* @return the matching Direction
|
||||
* @throws IllegalArgumentException if s is null or does not match a known direction
|
||||
*/
|
||||
public static Direction fromString(String s) {
|
||||
if (s == null) {
|
||||
throw new IllegalArgumentException("Direction must not be null");
|
||||
}
|
||||
try {
|
||||
return Direction.valueOf(s.trim().toUpperCase());
|
||||
} catch (IllegalArgumentException e) {
|
||||
throw new IllegalArgumentException("Unknown direction: '" + s + "'");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the human-readable lowercase label, suitable for display
|
||||
* to the player (e.g. "north"). Intentionally not a toString override
|
||||
* so that debug logs keep the enum constant form.
|
||||
*
|
||||
* @return lowercase direction name
|
||||
*/
|
||||
public String getLabel() {
|
||||
return name().toLowerCase();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
package thb.jeanluc.adventure.model;
|
||||
|
||||
import lombok.Builder;
|
||||
import lombok.Getter;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
* Non-player character. Talks via a fixed {@link #greeting} and may react
|
||||
* to items given to it through entries in {@link #reactions}.
|
||||
*/
|
||||
@Getter
|
||||
@Builder
|
||||
public class Npc {
|
||||
|
||||
/** Unique identifier of this NPC, used for lookup and player input matching. */
|
||||
private final String id;
|
||||
|
||||
/** Human-readable display name shown to the player. */
|
||||
private final String name;
|
||||
|
||||
/** Long description shown by the {@code examine} command. */
|
||||
private final String description;
|
||||
|
||||
/** Text the NPC says when talked to. */
|
||||
private final String greeting;
|
||||
|
||||
/**
|
||||
* Reactions keyed by the trigger item's id. {@code HashMap} gives O(1)
|
||||
* lookup on {@code give X to Y}. The loader installs entries during
|
||||
* reference resolution after all items have been built.
|
||||
*/
|
||||
private final Map<String, NpcReaction> reactions;
|
||||
|
||||
/**
|
||||
* Looks up a reaction triggered by an item.
|
||||
*
|
||||
* @param itemId id of the item being offered
|
||||
* @return the matching reaction, or empty if the NPC does not react
|
||||
*/
|
||||
public Optional<NpcReaction> reactionFor(String itemId) {
|
||||
return Optional.ofNullable(reactions.get(itemId));
|
||||
}
|
||||
|
||||
/**
|
||||
* Convenience factory used by the loader before reactions are resolved.
|
||||
* Returns an NPC carrying a fresh, mutable reactions map.
|
||||
*
|
||||
* @param id unique id
|
||||
* @param name display name
|
||||
* @param description long description
|
||||
* @param greeting greeting text
|
||||
* @return a new NPC shell
|
||||
*/
|
||||
public static Npc shell(String id, String name, String description, String greeting) {
|
||||
return Npc.builder()
|
||||
.id(id)
|
||||
.name(name)
|
||||
.description(description)
|
||||
.greeting(greeting)
|
||||
.reactions(new HashMap<>())
|
||||
.build();
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds or replaces a reaction. Used by the loader during reference
|
||||
* resolution.
|
||||
*
|
||||
* @param triggerItemId id of the item that triggers the reaction
|
||||
* @param reaction the reaction to attach
|
||||
*/
|
||||
public void putReaction(String triggerItemId, NpcReaction reaction) {
|
||||
reactions.put(triggerItemId, reaction);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package thb.jeanluc.adventure.model;
|
||||
|
||||
import lombok.Builder;
|
||||
import lombok.Getter;
|
||||
import thb.jeanluc.adventure.model.item.Item;
|
||||
|
||||
/**
|
||||
* A single trigger/response pair on an NPC. When the player gives the
|
||||
* NPC an item matching {@link #consumes}, the NPC writes {@link #response}
|
||||
* and optionally hands back {@link #gives}.
|
||||
*/
|
||||
@Getter
|
||||
@Builder
|
||||
public class NpcReaction {
|
||||
|
||||
/** Item taken from the player's inventory; may be {@code null} if nothing is consumed. */
|
||||
private final Item consumes;
|
||||
|
||||
/** Item handed back to the player; may be {@code null} if nothing is given. */
|
||||
private final Item gives;
|
||||
|
||||
/** Text the NPC says after a successful exchange. */
|
||||
private final String response;
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
package thb.jeanluc.adventure.model;
|
||||
|
||||
import lombok.Getter;
|
||||
import lombok.Setter;
|
||||
import thb.jeanluc.adventure.model.item.Item;
|
||||
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
* The player avatar. Tracks the current room, an ordered inventory, and a
|
||||
* gold counter. Mutated by the various command implementations.
|
||||
*/
|
||||
@Getter
|
||||
public class Player {
|
||||
|
||||
/** Room the player is currently standing in. */
|
||||
@Setter
|
||||
private Room currentRoom;
|
||||
|
||||
/**
|
||||
* Inventory keyed by item id. {@link LinkedHashMap} keeps insertion
|
||||
* order for stable {@code inventory} listings while still providing
|
||||
* O(1) lookup on {@code drop}/{@code use}.
|
||||
*/
|
||||
private final LinkedHashMap<String, Item> inventory = new LinkedHashMap<>();
|
||||
|
||||
/** Gold currency. Currently informational; no commands spend it yet. */
|
||||
@Setter
|
||||
private int gold;
|
||||
|
||||
/**
|
||||
* Creates a player at the given starting room with the given starting gold.
|
||||
*
|
||||
* @param startRoom room the player starts in; must not be null
|
||||
* @param startGold initial gold amount; must be non-negative
|
||||
*/
|
||||
public Player(Room startRoom, int startGold) {
|
||||
if (startRoom == null) {
|
||||
throw new IllegalArgumentException("startRoom must not be null");
|
||||
}
|
||||
if (startGold < 0) {
|
||||
throw new IllegalArgumentException("startGold must not be negative");
|
||||
}
|
||||
this.currentRoom = startRoom;
|
||||
this.gold = startGold;
|
||||
}
|
||||
|
||||
/**
|
||||
* Places an item into the inventory.
|
||||
*
|
||||
* @param item the item to add
|
||||
*/
|
||||
public void addItem(Item item) {
|
||||
inventory.put(item.getId(), item);
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes and returns an item from the inventory.
|
||||
*
|
||||
* @param itemId id of the item to remove
|
||||
* @return the removed item, or empty if it was not held
|
||||
*/
|
||||
public Optional<Item> removeItem(String itemId) {
|
||||
return Optional.ofNullable(inventory.remove(itemId));
|
||||
}
|
||||
|
||||
/**
|
||||
* Looks up an inventory item without removing it.
|
||||
*
|
||||
* @param itemId id of the item to find
|
||||
* @return the item, or empty if it is not in the inventory
|
||||
*/
|
||||
public Optional<Item> findItem(String itemId) {
|
||||
return Optional.ofNullable(inventory.get(itemId));
|
||||
}
|
||||
|
||||
/**
|
||||
* Convenience predicate for inventory containment.
|
||||
*
|
||||
* @param itemId id of the item to test
|
||||
* @return {@code true} if the player holds the item
|
||||
*/
|
||||
public boolean hasItem(String itemId) {
|
||||
return inventory.containsKey(itemId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
package thb.jeanluc.adventure.model;
|
||||
|
||||
import lombok.Getter;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import thb.jeanluc.adventure.model.item.Item;
|
||||
|
||||
import java.util.EnumMap;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
* A location in the game world. Rooms are connected to neighbouring rooms
|
||||
* through {@link Direction}-keyed exits, and may contain items and NPCs
|
||||
* that the player can interact with.
|
||||
* <p>
|
||||
* Identity, name, and description are fixed after construction; the
|
||||
* exits, items, and NPCs collections are mutable to support the player
|
||||
* picking up items, dropping them, and the world otherwise evolving.
|
||||
* <p>
|
||||
* Mutation should go through the convenience methods on this class so
|
||||
* that ids stay consistent with the contained objects.
|
||||
*/
|
||||
@Getter
|
||||
@RequiredArgsConstructor
|
||||
public class Room {
|
||||
|
||||
/** Unique identifier of this room within the world. */
|
||||
private final String id;
|
||||
|
||||
/** Human-readable display name shown to the player. */
|
||||
private final String name;
|
||||
|
||||
/** Description shown when the player enters the room or types {@code look}. */
|
||||
private final String description;
|
||||
|
||||
/** Outgoing exits from this room, keyed by direction. */
|
||||
private final EnumMap<Direction, Room> exits = new EnumMap<>(Direction.class);
|
||||
|
||||
/** Items currently in this room, keyed by item id. Insertion-ordered for stable display. */
|
||||
private final LinkedHashMap<String, Item> items = new LinkedHashMap<>();
|
||||
|
||||
/** NPCs currently in this room, keyed by npc id. Insertion-ordered for stable display. */
|
||||
private final LinkedHashMap<String, Npc> npcs = new LinkedHashMap<>();
|
||||
|
||||
/**
|
||||
* Connects this room to another in the given direction. Does not
|
||||
* create the reverse connection — callers must set that up
|
||||
* explicitly if a bidirectional path is desired.
|
||||
*
|
||||
* @param direction the direction in which the neighbour lies
|
||||
* @param neighbour the room reachable from here in that direction
|
||||
*/
|
||||
public void addExit(Direction direction, Room neighbour) {
|
||||
exits.put(direction, neighbour);
|
||||
}
|
||||
|
||||
/**
|
||||
* Looks up the room reachable in the given direction.
|
||||
*
|
||||
* @param direction the direction to query
|
||||
* @return the connected room, or empty if there is no exit that way
|
||||
*/
|
||||
public Optional<Room> getExit(Direction direction) {
|
||||
return Optional.ofNullable(exits.get(direction));
|
||||
}
|
||||
|
||||
/**
|
||||
* Places an item in this room.
|
||||
*
|
||||
* @param item the item to add; its id is used as the map key
|
||||
*/
|
||||
public void addItem(Item item) {
|
||||
items.put(item.getId(), item);
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes an item from this room by id.
|
||||
*
|
||||
* @param itemId id of the item to remove
|
||||
* @return the removed item, or empty if no such item was present
|
||||
*/
|
||||
public Optional<Item> removeItem(String itemId) {
|
||||
return Optional.ofNullable(items.remove(itemId));
|
||||
}
|
||||
|
||||
/**
|
||||
* Looks up an item in this room without removing it.
|
||||
*
|
||||
* @param itemId id of the item to find
|
||||
* @return the item, or empty if no such item is present
|
||||
*/
|
||||
public Optional<Item> findItem(String itemId) {
|
||||
return Optional.ofNullable(items.get(itemId));
|
||||
}
|
||||
|
||||
/**
|
||||
* Places an NPC in this room.
|
||||
*
|
||||
* @param npc the NPC to add; its id is used as the map key
|
||||
*/
|
||||
public void addNpc(Npc npc) {
|
||||
npcs.put(npc.getId(), npc);
|
||||
}
|
||||
|
||||
/**
|
||||
* Looks up an NPC in this room.
|
||||
*
|
||||
* @param npcId id of the NPC to find
|
||||
* @return the NPC, or empty if no such NPC is present
|
||||
*/
|
||||
public Optional<Npc> findNpc(String npcId) {
|
||||
return Optional.ofNullable(npcs.get(npcId));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
package thb.jeanluc.adventure.model;
|
||||
|
||||
import lombok.Getter;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import thb.jeanluc.adventure.model.item.Item;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* Aggregate root holding every entity in the game world plus global
|
||||
* metadata read from {@code game.yaml}. Produced by the
|
||||
* {@code WorldLoader} and handed to the game loop.
|
||||
*/
|
||||
@Getter
|
||||
@RequiredArgsConstructor
|
||||
public class World {
|
||||
|
||||
/** Global lookup of rooms by id. */
|
||||
private final Map<String, Room> rooms;
|
||||
|
||||
/** Global lookup of item prototypes by id. */
|
||||
private final Map<String, Item> items;
|
||||
|
||||
/** Global lookup of NPCs by id. */
|
||||
private final Map<String, Npc> npcs;
|
||||
|
||||
/** Display title shown to the player at startup. */
|
||||
private final String title;
|
||||
|
||||
/** Welcome message printed once when the game starts. */
|
||||
private final String welcomeMessage;
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
package thb.jeanluc.adventure.model.item;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonSubTypes;
|
||||
import com.fasterxml.jackson.annotation.JsonTypeInfo;
|
||||
import lombok.Getter;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.experimental.SuperBuilder;
|
||||
import thb.jeanluc.adventure.game.GameContext;
|
||||
|
||||
/**
|
||||
* Abstract base class for every interactive object in the world.
|
||||
* Concrete subclasses implement {@link #use(GameContext)} to provide
|
||||
* type-specific behaviour. Items are intentionally "dumb": they do not
|
||||
* know which room or inventory currently holds them.
|
||||
*/
|
||||
@Getter
|
||||
@SuperBuilder
|
||||
@RequiredArgsConstructor
|
||||
@JsonTypeInfo(use = JsonTypeInfo.Id.NAME, property = "type")
|
||||
@JsonSubTypes({
|
||||
@JsonSubTypes.Type(value = ReadableItem.class, name = "readable"),
|
||||
@JsonSubTypes.Type(value = SwitchableItem.class, name = "switchable"),
|
||||
@JsonSubTypes.Type(value = PlainItem.class, name = "plain")
|
||||
})
|
||||
public abstract class Item {
|
||||
|
||||
/** Unique identifier of this item, used for lookup and player input matching. */
|
||||
protected final String id;
|
||||
|
||||
/** Human-readable display name shown to the player. */
|
||||
protected final String name;
|
||||
|
||||
/** Long description shown by the {@code examine} command. */
|
||||
protected final String description;
|
||||
|
||||
/**
|
||||
* Executes the item's primary action. Side effects (text output,
|
||||
* mutation of player state) flow through the given context.
|
||||
*
|
||||
* @param ctx active game context providing player, world, and IO
|
||||
*/
|
||||
public abstract void use(GameContext ctx);
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package thb.jeanluc.adventure.model.item;
|
||||
|
||||
import lombok.experimental.SuperBuilder;
|
||||
import thb.jeanluc.adventure.game.GameContext;
|
||||
|
||||
/**
|
||||
* Item with no inherent behaviour. Exists so that every item in the
|
||||
* world can polymorphically answer {@link #use(GameContext)} and so the
|
||||
* YAML schema can rely on a uniform {@code type} discriminator.
|
||||
*/
|
||||
@SuperBuilder
|
||||
public class PlainItem extends Item {
|
||||
|
||||
/**
|
||||
* Writes a generic message that this item cannot be used on its own.
|
||||
*
|
||||
* @param ctx active game context
|
||||
*/
|
||||
@Override
|
||||
public void use(GameContext ctx) {
|
||||
ctx.getIo().write("You can't use the " + name + " by itself.");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package thb.jeanluc.adventure.model.item;
|
||||
|
||||
import lombok.Getter;
|
||||
import lombok.experimental.SuperBuilder;
|
||||
import thb.jeanluc.adventure.game.GameContext;
|
||||
|
||||
/**
|
||||
* Item that displays a piece of text when used or read. Typical examples
|
||||
* include letters, signs, and books.
|
||||
*/
|
||||
@Getter
|
||||
@SuperBuilder
|
||||
public class ReadableItem extends Item {
|
||||
|
||||
/** Text written on the item; printed to the player on {@code use}/{@code read}. */
|
||||
private final String readText;
|
||||
|
||||
/**
|
||||
* Writes the item's text to the player's output.
|
||||
*
|
||||
* @param ctx active game context
|
||||
*/
|
||||
@Override
|
||||
public void use(GameContext ctx) {
|
||||
ctx.getIo().write(readText);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
package thb.jeanluc.adventure.model.item;
|
||||
|
||||
import lombok.Getter;
|
||||
import lombok.experimental.SuperBuilder;
|
||||
import thb.jeanluc.adventure.game.GameContext;
|
||||
|
||||
/**
|
||||
* Item with two boolean states (on / off). Each {@code use} toggles the
|
||||
* state and prints the corresponding transition message.
|
||||
*/
|
||||
@Getter
|
||||
@SuperBuilder
|
||||
public class SwitchableItem extends Item {
|
||||
|
||||
/** Text written when the item transitions to on. */
|
||||
private final String onText;
|
||||
|
||||
/** Text written when the item transitions to off. */
|
||||
private final String offText;
|
||||
|
||||
/**
|
||||
* Current on/off state. Seeded by the builder from the YAML
|
||||
* {@code initialState} field via the item factory.
|
||||
*/
|
||||
private boolean state;
|
||||
|
||||
/**
|
||||
* Toggles the state and writes the corresponding transition text.
|
||||
*
|
||||
* @param ctx active game context
|
||||
*/
|
||||
@Override
|
||||
public void use(GameContext ctx) {
|
||||
state = !state;
|
||||
ctx.getIo().write(state ? onText : offText);
|
||||
}
|
||||
|
||||
/**
|
||||
* Convenience predicate for the current state.
|
||||
*
|
||||
* @return {@code true} if the item is currently on
|
||||
*/
|
||||
public boolean isOn() {
|
||||
return state;
|
||||
}
|
||||
}
|
||||
12
Semesterprojekt/src/main/resources/logback.xml
Normal file
12
Semesterprojekt/src/main/resources/logback.xml
Normal file
@@ -0,0 +1,12 @@
|
||||
<configuration>
|
||||
<appender name="STDERR" class="ch.qos.logback.core.ConsoleAppender">
|
||||
<target>System.err</target>
|
||||
<encoder>
|
||||
<pattern>%d{HH:mm:ss.SSS} %-5level %logger{0} - %msg%n</pattern>
|
||||
</encoder>
|
||||
</appender>
|
||||
|
||||
<root level="INFO">
|
||||
<appender-ref ref="STDERR"/>
|
||||
</root>
|
||||
</configuration>
|
||||
7
Semesterprojekt/src/main/resources/world/game.yaml
Normal file
7
Semesterprojekt/src/main/resources/world/game.yaml
Normal file
@@ -0,0 +1,7 @@
|
||||
title: Haunted Manor
|
||||
version: "1.0"
|
||||
startRoom: kitchen
|
||||
startGold: 0
|
||||
welcomeMessage: |
|
||||
Welcome to the Haunted Manor.
|
||||
Type 'help' to see available commands.
|
||||
24
Semesterprojekt/src/main/resources/world/items.yaml
Normal file
24
Semesterprojekt/src/main/resources/world/items.yaml
Normal file
@@ -0,0 +1,24 @@
|
||||
- type: readable
|
||||
id: letter
|
||||
name: Letter
|
||||
description: A crumpled piece of paper.
|
||||
readText: |
|
||||
"Meet me at midnight in the cellar. - A."
|
||||
|
||||
- type: switchable
|
||||
id: lamp
|
||||
name: Oil Lamp
|
||||
description: An old oil lamp, heavy with fuel.
|
||||
initialState: false
|
||||
onText: The lamp flares to life, casting a warm glow.
|
||||
offText: You snuff out the lamp.
|
||||
|
||||
- type: plain
|
||||
id: shovel
|
||||
name: Shovel
|
||||
description: A rusty shovel. Sturdy enough to dig.
|
||||
|
||||
- type: plain
|
||||
id: key
|
||||
name: Brass Key
|
||||
description: A small brass key, polished from use.
|
||||
12
Semesterprojekt/src/main/resources/world/npcs.yaml
Normal file
12
Semesterprojekt/src/main/resources/world/npcs.yaml
Normal file
@@ -0,0 +1,12 @@
|
||||
- id: old_man
|
||||
name: Old Man
|
||||
description: A stooped old man with a grey beard.
|
||||
greeting: |
|
||||
"Hello, traveller. If you bring me the oil lamp,
|
||||
I will give you the key to the cellar."
|
||||
reactions:
|
||||
- onReceive: lamp
|
||||
response: |
|
||||
"Thank you! Here, take this key."
|
||||
gives: key
|
||||
consumes: lamp
|
||||
41
Semesterprojekt/src/main/resources/world/rooms.yaml
Normal file
41
Semesterprojekt/src/main/resources/world/rooms.yaml
Normal file
@@ -0,0 +1,41 @@
|
||||
- id: kitchen
|
||||
name: Old Kitchen
|
||||
description: |
|
||||
A dusty kitchen. Cobwebs hang from the rafters
|
||||
and a letter lies on the table.
|
||||
exits:
|
||||
north: hallway
|
||||
east: cellar
|
||||
items: [letter, lamp]
|
||||
npcs: [old_man]
|
||||
|
||||
- id: hallway
|
||||
name: Dark Hallway
|
||||
description: |
|
||||
A long, dimly lit hallway. It smells musty.
|
||||
Faint footsteps echo somewhere far away.
|
||||
exits:
|
||||
south: kitchen
|
||||
west: library
|
||||
items: []
|
||||
npcs: []
|
||||
|
||||
- id: library
|
||||
name: Library
|
||||
description: |
|
||||
Tall shelves crammed with mouldering books.
|
||||
An old chest stands in the corner.
|
||||
exits:
|
||||
east: hallway
|
||||
items: [shovel]
|
||||
npcs: []
|
||||
|
||||
- id: cellar
|
||||
name: Damp Cellar
|
||||
description: |
|
||||
Cold, damp, and very dark. You can barely
|
||||
make out shapes against the far wall.
|
||||
exits:
|
||||
west: kitchen
|
||||
items: []
|
||||
npcs: []
|
||||
@@ -0,0 +1,38 @@
|
||||
package thb.jeanluc.adventure.command;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
class CommandParserTest {
|
||||
|
||||
private final CommandParser parser = new CommandParser();
|
||||
|
||||
@Test
|
||||
void parse_blankInput_yieldsEmptyVerb() {
|
||||
assertThat(parser.parse("").verb()).isEmpty();
|
||||
assertThat(parser.parse(" ").verb()).isEmpty();
|
||||
assertThat(parser.parse(null).verb()).isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
void parse_lowercases() {
|
||||
ParsedCommand p = parser.parse("Go NORTH");
|
||||
assertThat(p.verb()).isEqualTo("go");
|
||||
assertThat(p.args()).containsExactly("north");
|
||||
}
|
||||
|
||||
@Test
|
||||
void parse_dropsFillers() {
|
||||
ParsedCommand p = parser.parse("go to the north");
|
||||
assertThat(p.verb()).isEqualTo("go");
|
||||
assertThat(p.args()).containsExactly("north");
|
||||
}
|
||||
|
||||
@Test
|
||||
void parse_giveItemToNpc_keepsBoth() {
|
||||
ParsedCommand p = parser.parse("give lamp to old_man");
|
||||
assertThat(p.verb()).isEqualTo("give");
|
||||
assertThat(p.args()).containsExactly("lamp", "old_man");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
package thb.jeanluc.adventure.command;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import thb.jeanluc.adventure.game.GameContext;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
class CommandRegistryTest {
|
||||
|
||||
private static class StubCommand implements Command {
|
||||
@Override public void execute(GameContext ctx, List<String> args) {}
|
||||
@Override public String help() { return "stub"; }
|
||||
}
|
||||
|
||||
@Test
|
||||
void register_andFindByEachAlias() {
|
||||
CommandRegistry r = new CommandRegistry();
|
||||
StubCommand cmd = new StubCommand();
|
||||
r.register(cmd, "go", "move", "walk");
|
||||
|
||||
assertThat(r.find("go")).contains(cmd);
|
||||
assertThat(r.find("MOVE")).contains(cmd);
|
||||
assertThat(r.find("walk")).contains(cmd);
|
||||
}
|
||||
|
||||
@Test
|
||||
void find_unknownVerb_returnsEmpty() {
|
||||
assertThat(new CommandRegistry().find("nope")).isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
void distinctCommands_deduplicatesAliases() {
|
||||
CommandRegistry r = new CommandRegistry();
|
||||
StubCommand a = new StubCommand();
|
||||
StubCommand b = new StubCommand();
|
||||
r.register(a, "x", "y");
|
||||
r.register(b, "z");
|
||||
|
||||
assertThat(r.distinctCommands()).containsExactlyInAnyOrder(a, b);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
package thb.jeanluc.adventure.command.impl;
|
||||
|
||||
import thb.jeanluc.adventure.game.GameContext;
|
||||
import thb.jeanluc.adventure.io.TestIO;
|
||||
import thb.jeanluc.adventure.model.Direction;
|
||||
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.Item;
|
||||
import thb.jeanluc.adventure.model.item.PlainItem;
|
||||
import thb.jeanluc.adventure.model.item.ReadableItem;
|
||||
import thb.jeanluc.adventure.model.item.SwitchableItem;
|
||||
|
||||
/**
|
||||
* Tiny world used by the command tests. Two rooms (kitchen → hallway),
|
||||
* a few items, and one NPC with one reaction.
|
||||
*/
|
||||
public final class CommandTestSupport {
|
||||
|
||||
private CommandTestSupport() {}
|
||||
|
||||
public static class World {
|
||||
public final Room kitchen;
|
||||
public final Room hallway;
|
||||
public final Item lamp;
|
||||
public final Item letter;
|
||||
public final Item key;
|
||||
public final Item shovel;
|
||||
public final Npc oldMan;
|
||||
public final Player player;
|
||||
public final TestIO io = new TestIO();
|
||||
public final GameContext ctx;
|
||||
|
||||
public World() {
|
||||
kitchen = new Room("kitchen", "Kitchen", "kitchen desc");
|
||||
hallway = new Room("hallway", "Hallway", "hallway desc");
|
||||
kitchen.addExit(Direction.NORTH, hallway);
|
||||
hallway.addExit(Direction.SOUTH, kitchen);
|
||||
|
||||
lamp = SwitchableItem.builder().id("lamp").name("Lamp").description("a lamp")
|
||||
.state(false).onText("on").offText("off").build();
|
||||
letter = ReadableItem.builder().id("letter").name("Letter").description("a letter")
|
||||
.readText("dear reader").build();
|
||||
key = PlainItem.builder().id("key").name("Key").description("brass key").build();
|
||||
shovel = PlainItem.builder().id("shovel").name("Shovel").description("rusty").build();
|
||||
|
||||
kitchen.addItem(lamp);
|
||||
kitchen.addItem(letter);
|
||||
|
||||
oldMan = Npc.shell("old_man", "Old Man", "stooped", "greetings");
|
||||
oldMan.putReaction("lamp", NpcReaction.builder()
|
||||
.consumes(lamp).gives(key).response("Take the key.").build());
|
||||
kitchen.addNpc(oldMan);
|
||||
|
||||
player = new Player(kitchen, 0);
|
||||
ctx = new GameContext(null, player, io);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
package thb.jeanluc.adventure.command.impl;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
class GoCommandTest {
|
||||
|
||||
@Test
|
||||
void execute_validDirection_movesPlayer() {
|
||||
CommandTestSupport.World w = new CommandTestSupport.World();
|
||||
|
||||
new GoCommand().execute(w.ctx, List.of("north"));
|
||||
|
||||
assertThat(w.player.getCurrentRoom()).isEqualTo(w.hallway);
|
||||
}
|
||||
|
||||
@Test
|
||||
void execute_noExit_complains() {
|
||||
CommandTestSupport.World w = new CommandTestSupport.World();
|
||||
|
||||
new GoCommand().execute(w.ctx, List.of("south"));
|
||||
|
||||
assertThat(w.player.getCurrentRoom()).isEqualTo(w.kitchen);
|
||||
assertThat(w.io.allOutput()).contains("can't go south");
|
||||
}
|
||||
|
||||
@Test
|
||||
void execute_unknownDirection_complains() {
|
||||
CommandTestSupport.World w = new CommandTestSupport.World();
|
||||
|
||||
new GoCommand().execute(w.ctx, List.of("upwards"));
|
||||
|
||||
assertThat(w.player.getCurrentRoom()).isEqualTo(w.kitchen);
|
||||
assertThat(w.io.allOutput()).contains("upwards");
|
||||
}
|
||||
|
||||
@Test
|
||||
void execute_noArg_complains() {
|
||||
CommandTestSupport.World w = new CommandTestSupport.World();
|
||||
|
||||
new GoCommand().execute(w.ctx, List.of());
|
||||
|
||||
assertThat(w.io.allOutput()).contains("Go where");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package thb.jeanluc.adventure.command.impl;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
class InventoryCommandTest {
|
||||
|
||||
@Test
|
||||
void empty_writesEmptyMessage() {
|
||||
CommandTestSupport.World w = new CommandTestSupport.World();
|
||||
new InventoryCommand().execute(w.ctx, List.of());
|
||||
assertThat(w.io.lastOutput()).contains("not carrying");
|
||||
}
|
||||
|
||||
@Test
|
||||
void withItems_listsByName() {
|
||||
CommandTestSupport.World w = new CommandTestSupport.World();
|
||||
new TakeCommand().execute(w.ctx, List.of("letter"));
|
||||
new TakeCommand().execute(w.ctx, List.of("lamp"));
|
||||
|
||||
new InventoryCommand().execute(w.ctx, List.of());
|
||||
|
||||
assertThat(w.io.lastOutput()).contains("Letter").contains("Lamp");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
package thb.jeanluc.adventure.command.impl;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
class LookCommandTest {
|
||||
|
||||
@Test
|
||||
void look_writesNameDescriptionItemsNpcsAndExits() {
|
||||
CommandTestSupport.World w = new CommandTestSupport.World();
|
||||
|
||||
new LookCommand().execute(w.ctx, List.of());
|
||||
|
||||
String text = w.io.lastOutput();
|
||||
assertThat(text).contains("Kitchen");
|
||||
assertThat(text).contains("kitchen desc");
|
||||
assertThat(text).contains("Lamp").contains("Letter");
|
||||
assertThat(text).contains("Old Man");
|
||||
assertThat(text).contains("north");
|
||||
}
|
||||
|
||||
@Test
|
||||
void look_noItemsNoNpcs_stillWorks() {
|
||||
CommandTestSupport.World w = new CommandTestSupport.World();
|
||||
w.player.setCurrentRoom(w.hallway);
|
||||
|
||||
new LookCommand().execute(w.ctx, List.of());
|
||||
|
||||
String text = w.io.lastOutput();
|
||||
assertThat(text).contains("Hallway");
|
||||
assertThat(text).contains("south");
|
||||
assertThat(text).doesNotContain("You see");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
package thb.jeanluc.adventure.command.impl;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
class TakeDropCommandTest {
|
||||
|
||||
@Test
|
||||
void take_movesItemFromRoomToInventory() {
|
||||
CommandTestSupport.World w = new CommandTestSupport.World();
|
||||
|
||||
new TakeCommand().execute(w.ctx, List.of("lamp"));
|
||||
|
||||
assertThat(w.player.hasItem("lamp")).isTrue();
|
||||
assertThat(w.kitchen.findItem("lamp")).isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
void take_unknownItem_complains() {
|
||||
CommandTestSupport.World w = new CommandTestSupport.World();
|
||||
new TakeCommand().execute(w.ctx, List.of("dragon"));
|
||||
assertThat(w.io.allOutput()).contains("no 'dragon'");
|
||||
}
|
||||
|
||||
@Test
|
||||
void drop_movesItemFromInventoryToRoom() {
|
||||
CommandTestSupport.World w = new CommandTestSupport.World();
|
||||
new TakeCommand().execute(w.ctx, List.of("lamp"));
|
||||
|
||||
new DropCommand().execute(w.ctx, List.of("lamp"));
|
||||
|
||||
assertThat(w.player.hasItem("lamp")).isFalse();
|
||||
assertThat(w.kitchen.findItem("lamp")).isPresent();
|
||||
}
|
||||
|
||||
@Test
|
||||
void drop_notInInventory_complains() {
|
||||
CommandTestSupport.World w = new CommandTestSupport.World();
|
||||
new DropCommand().execute(w.ctx, List.of("lamp"));
|
||||
assertThat(w.io.allOutput()).contains("not carrying");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
package thb.jeanluc.adventure.command.impl;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
class TalkGiveCommandTest {
|
||||
|
||||
@Test
|
||||
void talk_existingNpc_writesGreeting() {
|
||||
CommandTestSupport.World w = new CommandTestSupport.World();
|
||||
new TalkCommand().execute(w.ctx, List.of("old_man"));
|
||||
assertThat(w.io.lastOutput()).contains("Old Man").contains("greetings");
|
||||
}
|
||||
|
||||
@Test
|
||||
void talk_missingNpc_complains() {
|
||||
CommandTestSupport.World w = new CommandTestSupport.World();
|
||||
new TalkCommand().execute(w.ctx, List.of("ghost"));
|
||||
assertThat(w.io.allOutput()).contains("no 'ghost'");
|
||||
}
|
||||
|
||||
@Test
|
||||
void give_matchingReaction_swapsItems() {
|
||||
CommandTestSupport.World w = new CommandTestSupport.World();
|
||||
new TakeCommand().execute(w.ctx, List.of("lamp"));
|
||||
|
||||
new GiveCommand().execute(w.ctx, List.of("lamp", "old_man"));
|
||||
|
||||
assertThat(w.player.hasItem("lamp")).isFalse();
|
||||
assertThat(w.player.hasItem("key")).isTrue();
|
||||
assertThat(w.io.lastOutput()).contains("Take the key");
|
||||
}
|
||||
|
||||
@Test
|
||||
void give_noReaction_complains() {
|
||||
CommandTestSupport.World w = new CommandTestSupport.World();
|
||||
new TakeCommand().execute(w.ctx, List.of("letter"));
|
||||
|
||||
new GiveCommand().execute(w.ctx, List.of("letter", "old_man"));
|
||||
|
||||
assertThat(w.player.hasItem("letter")).isTrue();
|
||||
assertThat(w.io.lastOutput()).contains("does not react");
|
||||
}
|
||||
|
||||
@Test
|
||||
void give_unknownNpc_complains() {
|
||||
CommandTestSupport.World w = new CommandTestSupport.World();
|
||||
new TakeCommand().execute(w.ctx, List.of("lamp"));
|
||||
|
||||
new GiveCommand().execute(w.ctx, List.of("lamp", "ghost"));
|
||||
|
||||
assertThat(w.io.allOutput()).contains("no 'ghost'");
|
||||
}
|
||||
|
||||
@Test
|
||||
void give_itemNotHeld_complains() {
|
||||
CommandTestSupport.World w = new CommandTestSupport.World();
|
||||
new GiveCommand().execute(w.ctx, List.of("lamp", "old_man"));
|
||||
assertThat(w.io.allOutput()).contains("not carrying");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
package thb.jeanluc.adventure.command.impl;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
class UseReadExamineCommandTest {
|
||||
|
||||
@Test
|
||||
void use_readableInInventory_writesText() {
|
||||
CommandTestSupport.World w = new CommandTestSupport.World();
|
||||
new TakeCommand().execute(w.ctx, List.of("letter"));
|
||||
|
||||
new UseCommand().execute(w.ctx, List.of("letter"));
|
||||
|
||||
assertThat(w.io.lastOutput()).isEqualTo("dear reader");
|
||||
}
|
||||
|
||||
@Test
|
||||
void use_unknownItem_complains() {
|
||||
CommandTestSupport.World w = new CommandTestSupport.World();
|
||||
new UseCommand().execute(w.ctx, List.of("dragon"));
|
||||
assertThat(w.io.allOutput()).contains("no 'dragon'");
|
||||
}
|
||||
|
||||
@Test
|
||||
void read_nonReadable_complains() {
|
||||
CommandTestSupport.World w = new CommandTestSupport.World();
|
||||
new TakeCommand().execute(w.ctx, List.of("lamp"));
|
||||
|
||||
new ReadCommand().execute(w.ctx, List.of("lamp"));
|
||||
|
||||
assertThat(w.io.lastOutput()).contains("nothing to read");
|
||||
}
|
||||
|
||||
@Test
|
||||
void examine_item_writesDescription() {
|
||||
CommandTestSupport.World w = new CommandTestSupport.World();
|
||||
new ExamineCommand().execute(w.ctx, List.of("letter"));
|
||||
assertThat(w.io.lastOutput()).isEqualTo("a letter");
|
||||
}
|
||||
|
||||
@Test
|
||||
void examine_npc_writesDescription() {
|
||||
CommandTestSupport.World w = new CommandTestSupport.World();
|
||||
new ExamineCommand().execute(w.ctx, List.of("old_man"));
|
||||
assertThat(w.io.lastOutput()).isEqualTo("stooped");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
package thb.jeanluc.adventure.game;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import thb.jeanluc.adventure.command.CommandParser;
|
||||
import thb.jeanluc.adventure.command.CommandRegistry;
|
||||
import thb.jeanluc.adventure.command.impl.GoCommand;
|
||||
import thb.jeanluc.adventure.command.impl.LookCommand;
|
||||
import thb.jeanluc.adventure.command.impl.QuitCommand;
|
||||
import thb.jeanluc.adventure.io.TestIO;
|
||||
import thb.jeanluc.adventure.model.Direction;
|
||||
import thb.jeanluc.adventure.model.Player;
|
||||
import thb.jeanluc.adventure.model.Room;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
class GameTest {
|
||||
|
||||
@Test
|
||||
void run_dispatchesCommands_andQuitsCleanly() {
|
||||
Room kitchen = new Room("kitchen", "Kitchen", "kd");
|
||||
Room hallway = new Room("hallway", "Hallway", "hd");
|
||||
kitchen.addExit(Direction.NORTH, hallway);
|
||||
Player player = new Player(kitchen, 0);
|
||||
TestIO io = new TestIO()
|
||||
.enqueue("go north")
|
||||
.enqueue("quit");
|
||||
GameContext ctx = new GameContext(null, player, io);
|
||||
|
||||
CommandRegistry registry = new CommandRegistry();
|
||||
registry.register(new GoCommand(), "go");
|
||||
registry.register(new LookCommand(), "look");
|
||||
QuitCommand quit = new QuitCommand();
|
||||
registry.register(quit, "quit");
|
||||
|
||||
Game game = new Game(ctx, registry, new CommandParser());
|
||||
quit.bind(game);
|
||||
game.run();
|
||||
|
||||
assertThat(player.getCurrentRoom()).isEqualTo(hallway);
|
||||
assertThat(io.allOutput()).contains("Goodbye");
|
||||
}
|
||||
|
||||
@Test
|
||||
void run_unknownVerb_writesHint() {
|
||||
Player player = new Player(new Room("r", "R", "d"), 0);
|
||||
TestIO io = new TestIO().enqueue("dance").enqueue("quit");
|
||||
GameContext ctx = new GameContext(null, player, io);
|
||||
|
||||
CommandRegistry registry = new CommandRegistry();
|
||||
QuitCommand quit = new QuitCommand();
|
||||
registry.register(quit, "quit");
|
||||
Game game = new Game(ctx, registry, new CommandParser());
|
||||
quit.bind(game);
|
||||
game.run();
|
||||
|
||||
assertThat(io.allOutput()).contains("don't understand 'dance'");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
package thb.jeanluc.adventure.io;
|
||||
|
||||
import java.util.ArrayDeque;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Deque;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Simple in-memory {@link GameIO} for tests: queued inputs, captured outputs.
|
||||
*/
|
||||
public class TestIO implements GameIO {
|
||||
|
||||
private final Deque<String> inputs = new ArrayDeque<>();
|
||||
private final List<String> outputs = new ArrayList<>();
|
||||
|
||||
public TestIO enqueue(String line) {
|
||||
inputs.add(line);
|
||||
return this;
|
||||
}
|
||||
|
||||
public List<String> outputs() {
|
||||
return outputs;
|
||||
}
|
||||
|
||||
public String lastOutput() {
|
||||
return outputs.isEmpty() ? null : outputs.getLast();
|
||||
}
|
||||
|
||||
public String allOutput() {
|
||||
return String.join("\n", outputs);
|
||||
}
|
||||
|
||||
@Override
|
||||
public String readLine() {
|
||||
return inputs.pollFirst();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void write(String s) {
|
||||
outputs.add(s);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
package thb.jeanluc.adventure.loader;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
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.Direction;
|
||||
import thb.jeanluc.adventure.model.Npc;
|
||||
import thb.jeanluc.adventure.model.Room;
|
||||
import thb.jeanluc.adventure.model.item.Item;
|
||||
import thb.jeanluc.adventure.model.item.PlainItem;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
class ReferenceResolverTest {
|
||||
|
||||
private static Map<String, Item> oneItem(String id) {
|
||||
Map<String, Item> m = new HashMap<>();
|
||||
m.put(id, PlainItem.builder().id(id).name(id).description("d").build());
|
||||
return m;
|
||||
}
|
||||
|
||||
@Test
|
||||
void resolveRooms_wiresExitsAndItems() {
|
||||
Map<String, Room> rooms = new HashMap<>();
|
||||
rooms.put("a", new Room("a", "A", "d"));
|
||||
rooms.put("b", new Room("b", "B", "d"));
|
||||
Map<String, Item> items = oneItem("key");
|
||||
|
||||
new ReferenceResolver(items, new HashMap<>(), rooms).resolveRooms(List.of(
|
||||
new RoomDto("a", "A", "d", Map.of("north", "b"), List.of("key"), List.of()),
|
||||
new RoomDto("b", "B", "d", Map.of("south", "a"), List.of(), List.of())
|
||||
));
|
||||
|
||||
assertThat(rooms.get("a").getExit(Direction.NORTH)).contains(rooms.get("b"));
|
||||
assertThat(rooms.get("a").findItem("key")).isPresent();
|
||||
}
|
||||
|
||||
@Test
|
||||
void resolveRooms_unknownExitTarget_throws() {
|
||||
Map<String, Room> rooms = new HashMap<>();
|
||||
rooms.put("a", new Room("a", "A", "d"));
|
||||
|
||||
assertThatThrownBy(() ->
|
||||
new ReferenceResolver(Map.of(), Map.of(), rooms).resolveRooms(List.of(
|
||||
new RoomDto("a", "A", "d", Map.of("north", "ghost"), List.of(), List.of())
|
||||
)))
|
||||
.isInstanceOf(WorldLoadException.class)
|
||||
.hasMessageContaining("ghost");
|
||||
}
|
||||
|
||||
@Test
|
||||
void resolveRooms_unknownDirection_throws() {
|
||||
Map<String, Room> rooms = new HashMap<>();
|
||||
rooms.put("a", new Room("a", "A", "d"));
|
||||
|
||||
assertThatThrownBy(() ->
|
||||
new ReferenceResolver(Map.of(), Map.of(), rooms).resolveRooms(List.of(
|
||||
new RoomDto("a", "A", "d", Map.of("up", "a"), List.of(), List.of())
|
||||
)))
|
||||
.isInstanceOf(WorldLoadException.class)
|
||||
.hasMessageContaining("up");
|
||||
}
|
||||
|
||||
@Test
|
||||
void resolveNpcs_wiresReactionItems() {
|
||||
Map<String, Item> items = oneItem("lamp");
|
||||
items.put("key", PlainItem.builder().id("key").name("Key").description("d").build());
|
||||
Map<String, Npc> npcs = new HashMap<>();
|
||||
npcs.put("man", Npc.shell("man", "Man", "d", "hi"));
|
||||
|
||||
new ReferenceResolver(items, npcs, Map.of()).resolveNpcs(List.of(
|
||||
new NpcDto("man", "Man", "d", "hi", List.of(
|
||||
new ReactionDto("lamp", "thx", "key", "lamp")
|
||||
))
|
||||
));
|
||||
|
||||
var reaction = npcs.get("man").reactionFor("lamp").orElseThrow();
|
||||
assertThat(reaction.getGives()).isEqualTo(items.get("key"));
|
||||
assertThat(reaction.getConsumes()).isEqualTo(items.get("lamp"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void resolveNpcs_duplicateTrigger_throws() {
|
||||
Map<String, Item> items = oneItem("lamp");
|
||||
Map<String, Npc> npcs = new HashMap<>();
|
||||
npcs.put("man", Npc.shell("man", "Man", "d", "hi"));
|
||||
|
||||
assertThatThrownBy(() ->
|
||||
new ReferenceResolver(items, npcs, Map.of()).resolveNpcs(List.of(
|
||||
new NpcDto("man", "Man", "d", "hi", List.of(
|
||||
new ReactionDto("lamp", "a", null, null),
|
||||
new ReactionDto("lamp", "b", null, null)
|
||||
))
|
||||
)))
|
||||
.isInstanceOf(WorldLoadException.class)
|
||||
.hasMessageContaining("duplicate");
|
||||
}
|
||||
|
||||
@Test
|
||||
void resolveNpcs_unknownGivesItem_throws() {
|
||||
Map<String, Item> items = oneItem("lamp");
|
||||
Map<String, Npc> npcs = new HashMap<>();
|
||||
npcs.put("man", Npc.shell("man", "Man", "d", "hi"));
|
||||
|
||||
assertThatThrownBy(() ->
|
||||
new ReferenceResolver(items, npcs, Map.of()).resolveNpcs(List.of(
|
||||
new NpcDto("man", "Man", "d", "hi", List.of(
|
||||
new ReactionDto("lamp", "a", "ghost", null)
|
||||
))
|
||||
)))
|
||||
.isInstanceOf(WorldLoadException.class)
|
||||
.hasMessageContaining("ghost");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
package thb.jeanluc.adventure.loader;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import thb.jeanluc.adventure.model.Direction;
|
||||
import thb.jeanluc.adventure.model.NpcReaction;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
class WorldLoaderTest {
|
||||
|
||||
@Test
|
||||
void load_happyPath_buildsWorldFromTestFixtures() {
|
||||
WorldLoader.LoadResult result = new WorldLoader().load();
|
||||
|
||||
assertThat(result.world().getTitle()).isEqualTo("Test Manor");
|
||||
assertThat(result.world().getRooms().keySet()).containsExactlyInAnyOrder("kitchen", "hallway");
|
||||
assertThat(result.world().getItems().keySet()).containsExactlyInAnyOrder("letter", "lamp", "key");
|
||||
assertThat(result.world().getNpcs().keySet()).containsExactly("old_man");
|
||||
|
||||
assertThat(result.player().getCurrentRoom().getId()).isEqualTo("kitchen");
|
||||
assertThat(result.player().getGold()).isEqualTo(5);
|
||||
}
|
||||
|
||||
@Test
|
||||
void load_resolvesExitsBidirectionally_whenYamlDeclaresThem() {
|
||||
var loaded = new WorldLoader().load().world();
|
||||
var kitchen = loaded.getRooms().get("kitchen");
|
||||
var hallway = loaded.getRooms().get("hallway");
|
||||
|
||||
assertThat(kitchen.getExit(Direction.NORTH)).contains(hallway);
|
||||
assertThat(hallway.getExit(Direction.SOUTH)).contains(kitchen);
|
||||
}
|
||||
|
||||
@Test
|
||||
void load_resolvesNpcReactionsToItemReferences() {
|
||||
var loaded = new WorldLoader().load().world();
|
||||
var oldMan = loaded.getNpcs().get("old_man");
|
||||
|
||||
assertThat(oldMan.getReactions()).containsKey("lamp");
|
||||
NpcReaction r = oldMan.reactionFor("lamp").orElseThrow();
|
||||
assertThat(r.getConsumes()).isEqualTo(loaded.getItems().get("lamp"));
|
||||
assertThat(r.getGives()).isEqualTo(loaded.getItems().get("key"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
package thb.jeanluc.adventure.loader;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import thb.jeanluc.adventure.loader.dto.GameDto;
|
||||
import thb.jeanluc.adventure.model.Npc;
|
||||
import thb.jeanluc.adventure.model.Room;
|
||||
import thb.jeanluc.adventure.model.item.Item;
|
||||
import thb.jeanluc.adventure.model.item.PlainItem;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
class WorldValidatorTest {
|
||||
|
||||
private Map<String, Item> items() {
|
||||
Map<String, Item> m = new HashMap<>();
|
||||
m.put("lamp", PlainItem.builder().id("lamp").name("Lamp").description("d").build());
|
||||
return m;
|
||||
}
|
||||
|
||||
private Map<String, Room> rooms() {
|
||||
Map<String, Room> m = new HashMap<>();
|
||||
m.put("kitchen", new Room("kitchen", "K", "d"));
|
||||
return m;
|
||||
}
|
||||
|
||||
private Map<String, Npc> npcs(String greeting) {
|
||||
Map<String, Npc> m = new HashMap<>();
|
||||
m.put("man", Npc.shell("man", "Man", "d", greeting));
|
||||
return m;
|
||||
}
|
||||
|
||||
@Test
|
||||
void happyPath_doesNotThrow() {
|
||||
var v = new WorldValidator(items(), npcs("hi"), rooms(),
|
||||
new GameDto("t", "1", "kitchen", 0, "w"));
|
||||
assertThat(v).isNotNull();
|
||||
v.validate();
|
||||
}
|
||||
|
||||
@Test
|
||||
void invalidId_throws() {
|
||||
Map<String, Room> r = new HashMap<>();
|
||||
r.put("Kitchen-1", new Room("Kitchen-1", "K", "d"));
|
||||
var v = new WorldValidator(items(), npcs("hi"), r,
|
||||
new GameDto("t", "1", "kitchen", 0, "w"));
|
||||
assertThatThrownBy(v::validate)
|
||||
.isInstanceOf(WorldLoadException.class)
|
||||
.hasMessageContaining("Invalid id");
|
||||
}
|
||||
|
||||
@Test
|
||||
void unknownStartRoom_throws() {
|
||||
var v = new WorldValidator(items(), npcs("hi"), rooms(),
|
||||
new GameDto("t", "1", "ghost_room", 0, "w"));
|
||||
assertThatThrownBy(v::validate)
|
||||
.isInstanceOf(WorldLoadException.class)
|
||||
.hasMessageContaining("ghost_room");
|
||||
}
|
||||
|
||||
@Test
|
||||
void emptyGreeting_throws() {
|
||||
var v = new WorldValidator(items(), npcs(""), rooms(),
|
||||
new GameDto("t", "1", "kitchen", 0, "w"));
|
||||
assertThatThrownBy(v::validate)
|
||||
.isInstanceOf(WorldLoadException.class)
|
||||
.hasMessageContaining("greeting");
|
||||
}
|
||||
|
||||
@Test
|
||||
void missingStartRoomField_throws() {
|
||||
var v = new WorldValidator(items(), npcs("hi"), rooms(),
|
||||
new GameDto("t", "1", null, 0, "w"));
|
||||
assertThatThrownBy(v::validate)
|
||||
.isInstanceOf(WorldLoadException.class)
|
||||
.hasMessageContaining("startRoom");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
package thb.jeanluc.adventure.model;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
class DirectionTest {
|
||||
|
||||
@Test
|
||||
void getOpposite_pairsAreSymmetric() {
|
||||
for (Direction d : Direction.values()) {
|
||||
assertThat(d.getOpposite().getOpposite()).isEqualTo(d);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void fromString_caseInsensitive_returnsEnum() {
|
||||
assertThat(Direction.fromString("north")).isEqualTo(Direction.NORTH);
|
||||
assertThat(Direction.fromString("EAST")).isEqualTo(Direction.EAST);
|
||||
assertThat(Direction.fromString(" west ")).isEqualTo(Direction.WEST);
|
||||
}
|
||||
|
||||
@Test
|
||||
void fromString_unknown_throws() {
|
||||
assertThatThrownBy(() -> Direction.fromString("up"))
|
||||
.isInstanceOf(IllegalArgumentException.class)
|
||||
.hasMessageContaining("up");
|
||||
}
|
||||
|
||||
@Test
|
||||
void fromString_null_throws() {
|
||||
assertThatThrownBy(() -> Direction.fromString(null))
|
||||
.isInstanceOf(IllegalArgumentException.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
void getLabel_isLowercase() {
|
||||
assertThat(Direction.NORTH.getLabel()).isEqualTo("north");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package thb.jeanluc.adventure.model;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import thb.jeanluc.adventure.model.item.Item;
|
||||
import thb.jeanluc.adventure.model.item.PlainItem;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
class NpcTest {
|
||||
|
||||
@Test
|
||||
void shell_isMutableForReactions() {
|
||||
Npc man = Npc.shell("man", "Man", "d", "hi");
|
||||
Item lamp = PlainItem.builder().id("lamp").name("Lamp").description("d").build();
|
||||
|
||||
man.putReaction("lamp", NpcReaction.builder()
|
||||
.consumes(lamp).response("thanks").build());
|
||||
|
||||
assertThat(man.reactionFor("lamp")).isPresent();
|
||||
assertThat(man.reactionFor("nope")).isEmpty();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
package thb.jeanluc.adventure.model;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import thb.jeanluc.adventure.model.item.Item;
|
||||
import thb.jeanluc.adventure.model.item.PlainItem;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
class PlayerTest {
|
||||
|
||||
@Test
|
||||
void constructor_nullRoom_throws() {
|
||||
assertThatThrownBy(() -> new Player(null, 0))
|
||||
.isInstanceOf(IllegalArgumentException.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
void constructor_negativeGold_throws() {
|
||||
Room r = new Room("r", "R", "d");
|
||||
assertThatThrownBy(() -> new Player(r, -1))
|
||||
.isInstanceOf(IllegalArgumentException.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
void addItem_andRemoveItem_areConsistent() {
|
||||
Player p = new Player(new Room("r", "R", "d"), 0);
|
||||
Item it = PlainItem.builder().id("key").name("Key").description("d").build();
|
||||
|
||||
p.addItem(it);
|
||||
assertThat(p.hasItem("key")).isTrue();
|
||||
|
||||
assertThat(p.removeItem("key")).contains(it);
|
||||
assertThat(p.hasItem("key")).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
void inventory_preservesInsertionOrder() {
|
||||
Player p = new Player(new Room("r", "R", "d"), 0);
|
||||
p.addItem(PlainItem.builder().id("a").name("A").description("d").build());
|
||||
p.addItem(PlainItem.builder().id("b").name("B").description("d").build());
|
||||
p.addItem(PlainItem.builder().id("c").name("C").description("d").build());
|
||||
|
||||
assertThat(p.getInventory().keySet()).containsExactly("a", "b", "c");
|
||||
}
|
||||
|
||||
@Test
|
||||
void setCurrentRoom_updatesPosition() {
|
||||
Room a = new Room("a", "A", "d");
|
||||
Room b = new Room("b", "B", "d");
|
||||
Player p = new Player(a, 0);
|
||||
|
||||
p.setCurrentRoom(b);
|
||||
|
||||
assertThat(p.getCurrentRoom()).isEqualTo(b);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
package thb.jeanluc.adventure.model;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import thb.jeanluc.adventure.model.item.Item;
|
||||
import thb.jeanluc.adventure.model.item.PlainItem;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
class RoomTest {
|
||||
|
||||
@Test
|
||||
void addExit_withNewDirection_storesNeighbour() {
|
||||
Room a = new Room("a", "A", "first");
|
||||
Room b = new Room("b", "B", "second");
|
||||
|
||||
a.addExit(Direction.NORTH, b);
|
||||
|
||||
assertThat(a.getExit(Direction.NORTH)).contains(b);
|
||||
assertThat(a.getExit(Direction.SOUTH)).isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
void addItem_storesUnderItemId() {
|
||||
Room room = new Room("r", "R", "d");
|
||||
Item lamp = PlainItem.builder().id("lamp").name("Lamp").description("d").build();
|
||||
|
||||
room.addItem(lamp);
|
||||
|
||||
assertThat(room.findItem("lamp")).contains(lamp);
|
||||
}
|
||||
|
||||
@Test
|
||||
void removeItem_returnsAndDetachesItem() {
|
||||
Room room = new Room("r", "R", "d");
|
||||
Item lamp = PlainItem.builder().id("lamp").name("Lamp").description("d").build();
|
||||
room.addItem(lamp);
|
||||
|
||||
var removed = room.removeItem("lamp");
|
||||
|
||||
assertThat(removed).contains(lamp);
|
||||
assertThat(room.findItem("lamp")).isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
void removeItem_unknownId_returnsEmpty() {
|
||||
Room room = new Room("r", "R", "d");
|
||||
assertThat(room.removeItem("nope")).isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
void addNpc_andFindById() {
|
||||
Room room = new Room("r", "R", "d");
|
||||
Npc npc = Npc.shell("man", "Man", "d", "hi");
|
||||
|
||||
room.addNpc(npc);
|
||||
|
||||
assertThat(room.findNpc("man")).contains(npc);
|
||||
}
|
||||
|
||||
@Test
|
||||
void items_preserveInsertionOrder() {
|
||||
Room room = new Room("r", "R", "d");
|
||||
room.addItem(PlainItem.builder().id("a").name("A").description("d").build());
|
||||
room.addItem(PlainItem.builder().id("b").name("B").description("d").build());
|
||||
room.addItem(PlainItem.builder().id("c").name("C").description("d").build());
|
||||
|
||||
assertThat(room.getItems().keySet()).containsExactly("a", "b", "c");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
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.Player;
|
||||
import thb.jeanluc.adventure.model.Room;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
class ItemTest {
|
||||
|
||||
private GameContext newCtx(TestIO io) {
|
||||
Player p = new Player(new Room("r", "R", "d"), 0);
|
||||
return new GameContext(null, p, io);
|
||||
}
|
||||
|
||||
@Test
|
||||
void readableItem_use_writesReadText() {
|
||||
TestIO io = new TestIO();
|
||||
ReadableItem letter = ReadableItem.builder()
|
||||
.id("letter").name("Letter").description("d")
|
||||
.readText("hello world").build();
|
||||
|
||||
letter.use(newCtx(io));
|
||||
|
||||
assertThat(io.lastOutput()).isEqualTo("hello world");
|
||||
}
|
||||
|
||||
@Test
|
||||
void switchableItem_use_togglesAndPrintsAppropriateText() {
|
||||
TestIO io = new TestIO();
|
||||
SwitchableItem lamp = SwitchableItem.builder()
|
||||
.id("lamp").name("Lamp").description("d")
|
||||
.state(false).onText("on").offText("off").build();
|
||||
|
||||
lamp.use(newCtx(io));
|
||||
assertThat(lamp.isOn()).isTrue();
|
||||
assertThat(io.outputs()).containsExactly("on");
|
||||
|
||||
lamp.use(newCtx(io));
|
||||
assertThat(lamp.isOn()).isFalse();
|
||||
assertThat(io.outputs()).endsWith("off");
|
||||
}
|
||||
|
||||
@Test
|
||||
void switchableItem_initialStateTrue_startsOn() {
|
||||
SwitchableItem lamp = SwitchableItem.builder()
|
||||
.id("lamp").name("Lamp").description("d")
|
||||
.state(true).onText("on").offText("off").build();
|
||||
|
||||
assertThat(lamp.isOn()).isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
void plainItem_use_writesGenericMessage() {
|
||||
TestIO io = new TestIO();
|
||||
PlainItem shovel = PlainItem.builder()
|
||||
.id("shovel").name("Shovel").description("d").build();
|
||||
|
||||
shovel.use(newCtx(io));
|
||||
|
||||
assertThat(io.lastOutput()).contains("Shovel");
|
||||
assertThat(io.lastOutput()).contains("can't use");
|
||||
}
|
||||
}
|
||||
6
Semesterprojekt/src/test/resources/world/game.yaml
Normal file
6
Semesterprojekt/src/test/resources/world/game.yaml
Normal file
@@ -0,0 +1,6 @@
|
||||
title: Test Manor
|
||||
version: "test"
|
||||
startRoom: kitchen
|
||||
startGold: 5
|
||||
welcomeMessage: |
|
||||
Welcome to the test.
|
||||
19
Semesterprojekt/src/test/resources/world/items.yaml
Normal file
19
Semesterprojekt/src/test/resources/world/items.yaml
Normal file
@@ -0,0 +1,19 @@
|
||||
- type: readable
|
||||
id: letter
|
||||
name: Letter
|
||||
description: A note.
|
||||
readText: |
|
||||
hello
|
||||
|
||||
- type: switchable
|
||||
id: lamp
|
||||
name: Lamp
|
||||
description: A lamp.
|
||||
initialState: false
|
||||
onText: on
|
||||
offText: off
|
||||
|
||||
- type: plain
|
||||
id: key
|
||||
name: Key
|
||||
description: A key.
|
||||
10
Semesterprojekt/src/test/resources/world/npcs.yaml
Normal file
10
Semesterprojekt/src/test/resources/world/npcs.yaml
Normal file
@@ -0,0 +1,10 @@
|
||||
- id: old_man
|
||||
name: Old Man
|
||||
description: stooped
|
||||
greeting: |
|
||||
greetings
|
||||
reactions:
|
||||
- onReceive: lamp
|
||||
response: thanks
|
||||
gives: key
|
||||
consumes: lamp
|
||||
15
Semesterprojekt/src/test/resources/world/rooms.yaml
Normal file
15
Semesterprojekt/src/test/resources/world/rooms.yaml
Normal file
@@ -0,0 +1,15 @@
|
||||
- id: kitchen
|
||||
name: Kitchen
|
||||
description: kitchen desc
|
||||
exits:
|
||||
north: hallway
|
||||
items: [letter, lamp]
|
||||
npcs: [old_man]
|
||||
|
||||
- id: hallway
|
||||
name: Hallway
|
||||
description: hallway desc
|
||||
exits:
|
||||
south: kitchen
|
||||
items: []
|
||||
npcs: []
|
||||
Reference in New Issue
Block a user