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:
2026-05-25 21:37:59 +02:00
parent 9b6528d800
commit 83643a192f
85 changed files with 4453 additions and 0 deletions

View 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();
}
}

View File

@@ -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();
});
}
}

View File

@@ -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();
}

View File

@@ -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);
}
}

View File

@@ -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());
}
}

View File

@@ -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) {
}

View File

@@ -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";
}
}

View File

@@ -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)";
}
}

View File

@@ -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";
}
}

View File

@@ -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)";
}
}

View File

@@ -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: ?)";
}
}

View File

@@ -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)";
}
}

View File

@@ -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";
}
}

View File

@@ -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)";
}
}

View File

@@ -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";
}
}

View File

@@ -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)";
}
}

View File

@@ -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)";
}
}

View File

@@ -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, ...)";
}
}

View File

@@ -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;
}
}

View File

@@ -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;
}

View File

@@ -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 &gt; } 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);
}
}

View File

@@ -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);
}

View File

@@ -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"));
}
}

View File

@@ -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() + "'");
};
}
}

View File

@@ -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());
}
}

View File

@@ -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);
}
}
}
}

View File

@@ -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());
}
}

View File

@@ -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);
}
}

View File

@@ -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) + "'");
}
}
}
}
}

View File

@@ -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");
}
}
}
}

View File

@@ -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
) {
}

View File

@@ -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
) {
}

View File

@@ -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
) {
}

View File

@@ -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
) {
}

View File

@@ -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
) {
}

View File

@@ -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();
}
}

View File

@@ -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);
}
}

View File

@@ -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;
}

View File

@@ -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);
}
}

View File

@@ -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));
}
}

View File

@@ -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;
}

View File

@@ -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);
}

View File

@@ -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.");
}
}

View File

@@ -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);
}
}

View File

@@ -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;
}
}

View 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>

View 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.

View 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.

View 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

View 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: []

View File

@@ -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");
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}
}

View File

@@ -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");
}
}

View File

@@ -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");
}
}

View File

@@ -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");
}
}

View File

@@ -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");
}
}

View File

@@ -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");
}
}

View File

@@ -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");
}
}

View File

@@ -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'");
}
}

View File

@@ -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);
}
}

View File

@@ -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");
}
}

View File

@@ -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"));
}
}

View File

@@ -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");
}
}

View File

@@ -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");
}
}

View File

@@ -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();
}
}

View File

@@ -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);
}
}

View File

@@ -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");
}
}

View File

@@ -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");
}
}

View File

@@ -0,0 +1,6 @@
title: Test Manor
version: "test"
startRoom: kitchen
startGold: 5
welcomeMessage: |
Welcome to the test.

View 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.

View File

@@ -0,0 +1,10 @@
- id: old_man
name: Old Man
description: stooped
greeting: |
greetings
reactions:
- onReceive: lamp
response: thanks
gives: key
consumes: lamp

View 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: []