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