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:
@@ -0,0 +1,52 @@
|
||||
package thb.jeanluc.adventure.loader;
|
||||
|
||||
import thb.jeanluc.adventure.loader.dto.ItemDto;
|
||||
import thb.jeanluc.adventure.model.item.Item;
|
||||
import thb.jeanluc.adventure.model.item.PlainItem;
|
||||
import thb.jeanluc.adventure.model.item.ReadableItem;
|
||||
import thb.jeanluc.adventure.model.item.SwitchableItem;
|
||||
|
||||
/**
|
||||
* Converts {@link ItemDto} instances coming from YAML into concrete
|
||||
* {@link Item} subclasses. The {@code type} discriminator picks the
|
||||
* subclass; unknown types fail fast with {@link WorldLoadException}.
|
||||
*/
|
||||
public final class ItemFactory {
|
||||
|
||||
private ItemFactory() {
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the appropriate {@link Item} subclass for the given DTO.
|
||||
*
|
||||
* @param dto data read from {@code items.yaml}; must not be null
|
||||
* @return the freshly built item
|
||||
* @throws WorldLoadException if {@code dto.type} is missing or unknown
|
||||
*/
|
||||
public static Item fromDto(ItemDto dto) {
|
||||
String type = dto.type() == null ? "plain" : dto.type().toLowerCase();
|
||||
return switch (type) {
|
||||
case "plain" -> PlainItem.builder()
|
||||
.id(dto.id())
|
||||
.name(dto.name())
|
||||
.description(dto.description())
|
||||
.build();
|
||||
case "readable" -> ReadableItem.builder()
|
||||
.id(dto.id())
|
||||
.name(dto.name())
|
||||
.description(dto.description())
|
||||
.readText(dto.readText())
|
||||
.build();
|
||||
case "switchable" -> SwitchableItem.builder()
|
||||
.id(dto.id())
|
||||
.name(dto.name())
|
||||
.description(dto.description())
|
||||
.state(Boolean.TRUE.equals(dto.initialState()))
|
||||
.onText(dto.onText())
|
||||
.offText(dto.offText())
|
||||
.build();
|
||||
default -> throw new WorldLoadException(
|
||||
"Unknown item type '" + dto.type() + "' on item '" + dto.id() + "'");
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package thb.jeanluc.adventure.loader;
|
||||
|
||||
import thb.jeanluc.adventure.loader.dto.NpcDto;
|
||||
import thb.jeanluc.adventure.model.Npc;
|
||||
|
||||
/**
|
||||
* Builds an NPC shell (without resolved reactions) from a DTO. Reactions
|
||||
* are wired in a later phase by {@link ReferenceResolver}.
|
||||
*/
|
||||
public final class NpcFactory {
|
||||
|
||||
private NpcFactory() {
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds an NPC carrying an empty reactions map.
|
||||
*
|
||||
* @param dto data read from {@code npcs.yaml}; must not be null
|
||||
* @return the freshly built NPC shell
|
||||
*/
|
||||
public static Npc shellFromDto(NpcDto dto) {
|
||||
return Npc.shell(dto.id(), dto.name(), dto.description(), dto.greeting());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
package thb.jeanluc.adventure.loader;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import thb.jeanluc.adventure.loader.dto.NpcDto;
|
||||
import thb.jeanluc.adventure.loader.dto.ReactionDto;
|
||||
import thb.jeanluc.adventure.loader.dto.RoomDto;
|
||||
import thb.jeanluc.adventure.model.Direction;
|
||||
import thb.jeanluc.adventure.model.Npc;
|
||||
import thb.jeanluc.adventure.model.NpcReaction;
|
||||
import thb.jeanluc.adventure.model.Room;
|
||||
import thb.jeanluc.adventure.model.item.Item;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* Phase 3 of the loading pipeline. Walks DTOs once more and resolves all
|
||||
* string ids to actual domain references on the prebuilt registries.
|
||||
*/
|
||||
@RequiredArgsConstructor
|
||||
public class ReferenceResolver {
|
||||
|
||||
/** Global item registry, prebuilt by phase 2. */
|
||||
private final Map<String, Item> items;
|
||||
|
||||
/** Global NPC registry, prebuilt by phase 2. */
|
||||
private final Map<String, Npc> npcs;
|
||||
|
||||
/** Global room registry, prebuilt by phase 2. */
|
||||
private final Map<String, Room> rooms;
|
||||
|
||||
/**
|
||||
* Wires exits, items, and NPCs into every room based on its DTO.
|
||||
*
|
||||
* @param roomDtos all room DTOs as read from YAML
|
||||
* @throws WorldLoadException on any unresolved id or unparsable direction
|
||||
*/
|
||||
public void resolveRooms(List<RoomDto> roomDtos) {
|
||||
for (RoomDto dto : roomDtos) {
|
||||
Room room = rooms.get(dto.id());
|
||||
if (dto.exits() != null) {
|
||||
for (Map.Entry<String, String> e : dto.exits().entrySet()) {
|
||||
Direction direction;
|
||||
try {
|
||||
direction = Direction.fromString(e.getKey());
|
||||
} catch (IllegalArgumentException ex) {
|
||||
throw new WorldLoadException(
|
||||
"Room '" + dto.id() + "' has unknown exit direction '" + e.getKey() + "'");
|
||||
}
|
||||
Room target = rooms.get(e.getValue());
|
||||
if (target == null) {
|
||||
throw new WorldLoadException(
|
||||
"Room '" + dto.id() + "' exits to unknown room '" + e.getValue() + "'");
|
||||
}
|
||||
room.addExit(direction, target);
|
||||
}
|
||||
}
|
||||
if (dto.items() != null) {
|
||||
for (String itemId : dto.items()) {
|
||||
Item item = items.get(itemId);
|
||||
if (item == null) {
|
||||
throw new WorldLoadException(
|
||||
"Room '" + dto.id() + "' references unknown item '" + itemId + "'");
|
||||
}
|
||||
room.addItem(item);
|
||||
}
|
||||
}
|
||||
if (dto.npcs() != null) {
|
||||
for (String npcId : dto.npcs()) {
|
||||
Npc npc = npcs.get(npcId);
|
||||
if (npc == null) {
|
||||
throw new WorldLoadException(
|
||||
"Room '" + dto.id() + "' references unknown npc '" + npcId + "'");
|
||||
}
|
||||
room.addNpc(npc);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Wires every NPC's reactions to actual item references.
|
||||
*
|
||||
* @param npcDtos all NPC DTOs as read from YAML
|
||||
* @throws WorldLoadException on any unresolved id or duplicate trigger
|
||||
*/
|
||||
public void resolveNpcs(List<NpcDto> npcDtos) {
|
||||
for (NpcDto dto : npcDtos) {
|
||||
if (dto.reactions() == null) {
|
||||
continue;
|
||||
}
|
||||
Npc npc = npcs.get(dto.id());
|
||||
for (ReactionDto r : dto.reactions()) {
|
||||
if (r.onReceive() == null) {
|
||||
throw new WorldLoadException(
|
||||
"NPC '" + dto.id() + "' has a reaction without onReceive");
|
||||
}
|
||||
if (!items.containsKey(r.onReceive())) {
|
||||
throw new WorldLoadException(
|
||||
"NPC '" + dto.id() + "' reacts to unknown item '" + r.onReceive() + "'");
|
||||
}
|
||||
if (npc.getReactions().containsKey(r.onReceive())) {
|
||||
throw new WorldLoadException(
|
||||
"NPC '" + dto.id() + "' has duplicate reaction for item '" + r.onReceive() + "'");
|
||||
}
|
||||
Item gives = null;
|
||||
if (r.gives() != null) {
|
||||
gives = items.get(r.gives());
|
||||
if (gives == null) {
|
||||
throw new WorldLoadException(
|
||||
"NPC '" + dto.id() + "' gives unknown item '" + r.gives() + "'");
|
||||
}
|
||||
}
|
||||
Item consumes = null;
|
||||
if (r.consumes() != null) {
|
||||
consumes = items.get(r.consumes());
|
||||
if (consumes == null) {
|
||||
throw new WorldLoadException(
|
||||
"NPC '" + dto.id() + "' consumes unknown item '" + r.consumes() + "'");
|
||||
}
|
||||
}
|
||||
NpcReaction reaction = NpcReaction.builder()
|
||||
.consumes(consumes)
|
||||
.gives(gives)
|
||||
.response(r.response())
|
||||
.build();
|
||||
npc.putReaction(r.onReceive(), reaction);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package thb.jeanluc.adventure.loader;
|
||||
|
||||
import thb.jeanluc.adventure.loader.dto.RoomDto;
|
||||
import thb.jeanluc.adventure.model.Room;
|
||||
|
||||
/**
|
||||
* Builds an empty {@link Room} shell from a DTO. Exits, items, and NPCs
|
||||
* are wired in a later phase by {@link ReferenceResolver}.
|
||||
*/
|
||||
public final class RoomFactory {
|
||||
|
||||
private RoomFactory() {
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a fresh room with no exits, items, or NPCs.
|
||||
*
|
||||
* @param dto data read from {@code rooms.yaml}; must not be null
|
||||
* @return the freshly built room shell
|
||||
*/
|
||||
public static Room shellFromDto(RoomDto dto) {
|
||||
return new Room(dto.id(), dto.name(), dto.description());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package thb.jeanluc.adventure.loader;
|
||||
|
||||
/**
|
||||
* Thrown when world loading fails: missing YAML files, malformed data,
|
||||
* dangling references, or invariant violations.
|
||||
*/
|
||||
public class WorldLoadException extends RuntimeException {
|
||||
|
||||
/**
|
||||
* Creates an exception with a descriptive message.
|
||||
*
|
||||
* @param message human-readable explanation
|
||||
*/
|
||||
public WorldLoadException(String message) {
|
||||
super(message);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates an exception wrapping a lower-level cause.
|
||||
*
|
||||
* @param message human-readable explanation
|
||||
* @param cause underlying cause
|
||||
*/
|
||||
public WorldLoadException(String message, Throwable cause) {
|
||||
super(message, cause);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
package thb.jeanluc.adventure.loader;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.fasterxml.jackson.databind.type.CollectionType;
|
||||
import com.fasterxml.jackson.dataformat.yaml.YAMLFactory;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import thb.jeanluc.adventure.loader.dto.GameDto;
|
||||
import thb.jeanluc.adventure.loader.dto.ItemDto;
|
||||
import thb.jeanluc.adventure.loader.dto.NpcDto;
|
||||
import thb.jeanluc.adventure.loader.dto.RoomDto;
|
||||
import thb.jeanluc.adventure.model.Npc;
|
||||
import thb.jeanluc.adventure.model.Player;
|
||||
import thb.jeanluc.adventure.model.Room;
|
||||
import thb.jeanluc.adventure.model.World;
|
||||
import thb.jeanluc.adventure.model.item.Item;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* Orchestrates the five-phase load (see {@code loading-flow.md}). Reads
|
||||
* four YAML files from the classpath, builds DTOs, then domain shells,
|
||||
* resolves references, validates, and finally returns the assembled
|
||||
* {@link World} plus the freshly placed {@link Player}.
|
||||
*/
|
||||
@Slf4j
|
||||
@RequiredArgsConstructor
|
||||
public class WorldLoader {
|
||||
|
||||
/** Default classpath base directory for the YAML files. */
|
||||
public static final String DEFAULT_BASE = "/world";
|
||||
|
||||
/** Single Jackson mapper, configured once for YAML input. */
|
||||
private final ObjectMapper yaml = new ObjectMapper(new YAMLFactory());
|
||||
|
||||
/** Classpath base directory (typically {@link #DEFAULT_BASE} or a test override). */
|
||||
private final String basePath;
|
||||
|
||||
/**
|
||||
* Convenience constructor using {@link #DEFAULT_BASE}.
|
||||
*/
|
||||
public WorldLoader() {
|
||||
this(DEFAULT_BASE);
|
||||
}
|
||||
|
||||
/**
|
||||
* Result of a successful load: world plus the placed player.
|
||||
*
|
||||
* @param world the built world
|
||||
* @param player the player placed at {@code game.yaml.startRoom}
|
||||
*/
|
||||
public record LoadResult(World world, Player player) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads the world by walking all five phases.
|
||||
*
|
||||
* @return the world and a placed player
|
||||
* @throws WorldLoadException on any phase failure
|
||||
*/
|
||||
public LoadResult load() {
|
||||
log.info("Loading world from classpath base '{}'", basePath);
|
||||
|
||||
List<ItemDto> itemDtos = readList(basePath + "/items.yaml", ItemDto.class);
|
||||
List<NpcDto> npcDtos = readList(basePath + "/npcs.yaml", NpcDto.class);
|
||||
List<RoomDto> roomDtos = readList(basePath + "/rooms.yaml", RoomDto.class);
|
||||
GameDto gameDto = readSingle(basePath + "/game.yaml", GameDto.class);
|
||||
|
||||
requireUniqueIds("item", itemDtos.stream().map(ItemDto::id).toList());
|
||||
requireUniqueIds("npc", npcDtos.stream().map(NpcDto::id).toList());
|
||||
requireUniqueIds("room", roomDtos.stream().map(RoomDto::id).toList());
|
||||
|
||||
Map<String, Item> items = new HashMap<>();
|
||||
for (ItemDto dto : itemDtos) {
|
||||
items.put(dto.id(), ItemFactory.fromDto(dto));
|
||||
}
|
||||
Map<String, Npc> npcs = new HashMap<>();
|
||||
for (NpcDto dto : npcDtos) {
|
||||
npcs.put(dto.id(), NpcFactory.shellFromDto(dto));
|
||||
}
|
||||
Map<String, Room> rooms = new HashMap<>();
|
||||
for (RoomDto dto : roomDtos) {
|
||||
rooms.put(dto.id(), RoomFactory.shellFromDto(dto));
|
||||
}
|
||||
|
||||
ReferenceResolver resolver = new ReferenceResolver(items, npcs, rooms);
|
||||
resolver.resolveRooms(roomDtos);
|
||||
resolver.resolveNpcs(npcDtos);
|
||||
|
||||
new WorldValidator(items, npcs, rooms, gameDto).validate();
|
||||
|
||||
Room start = rooms.get(gameDto.startRoom());
|
||||
int gold = gameDto.startGold() == null ? 0 : gameDto.startGold();
|
||||
Player player = new Player(start, gold);
|
||||
|
||||
World world = new World(rooms, items, npcs,
|
||||
gameDto.title(), gameDto.welcomeMessage());
|
||||
log.info("World '{}' loaded: {} rooms, {} items, {} npcs",
|
||||
gameDto.title(), rooms.size(), items.size(), npcs.size());
|
||||
return new LoadResult(world, player);
|
||||
}
|
||||
|
||||
private <T> List<T> readList(String resource, Class<T> elementType) {
|
||||
try (InputStream in = openResource(resource)) {
|
||||
CollectionType type = yaml.getTypeFactory().constructCollectionType(List.class, elementType);
|
||||
List<T> result = yaml.readValue(in, type);
|
||||
return result == null ? List.of() : result;
|
||||
} catch (IOException e) {
|
||||
throw new WorldLoadException("Failed to parse " + resource, e);
|
||||
}
|
||||
}
|
||||
|
||||
private <T> T readSingle(String resource, Class<T> type) {
|
||||
try (InputStream in = openResource(resource)) {
|
||||
return yaml.readValue(in, type);
|
||||
} catch (IOException e) {
|
||||
throw new WorldLoadException("Failed to parse " + resource, e);
|
||||
}
|
||||
}
|
||||
|
||||
private InputStream openResource(String resource) {
|
||||
InputStream in = getClass().getResourceAsStream(resource);
|
||||
if (in == null) {
|
||||
throw new WorldLoadException("Resource not found on classpath: " + resource);
|
||||
}
|
||||
return in;
|
||||
}
|
||||
|
||||
private void requireUniqueIds(String kind, List<String> ids) {
|
||||
for (int i = 0; i < ids.size(); i++) {
|
||||
for (int j = i + 1; j < ids.size(); j++) {
|
||||
if (ids.get(i) != null && ids.get(i).equals(ids.get(j))) {
|
||||
throw new WorldLoadException("Duplicate " + kind + " id: '" + ids.get(i) + "'");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
package thb.jeanluc.adventure.loader;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import thb.jeanluc.adventure.loader.dto.GameDto;
|
||||
import thb.jeanluc.adventure.model.Npc;
|
||||
import thb.jeanluc.adventure.model.Room;
|
||||
import thb.jeanluc.adventure.model.item.Item;
|
||||
|
||||
import java.util.Map;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
/**
|
||||
* Phase 4 of the loading pipeline. Walks the fully wired world and
|
||||
* checks the invariants documented in {@code yaml-schemas.md}.
|
||||
*/
|
||||
@RequiredArgsConstructor
|
||||
public class WorldValidator {
|
||||
|
||||
/** Regex every entity id must match. */
|
||||
private static final Pattern ID_PATTERN = Pattern.compile("^[a-z][a-z0-9_]*$");
|
||||
|
||||
/** Item registry to validate. */
|
||||
private final Map<String, Item> items;
|
||||
|
||||
/** NPC registry to validate. */
|
||||
private final Map<String, Npc> npcs;
|
||||
|
||||
/** Room registry to validate. */
|
||||
private final Map<String, Room> rooms;
|
||||
|
||||
/** Loaded game.yaml whose {@code startRoom} must exist. */
|
||||
private final GameDto gameDto;
|
||||
|
||||
/**
|
||||
* Runs every validation rule.
|
||||
*
|
||||
* @throws WorldLoadException on the first failure
|
||||
*/
|
||||
public void validate() {
|
||||
validateIds();
|
||||
validateStartRoom();
|
||||
validateGreetings();
|
||||
}
|
||||
|
||||
private void validateIds() {
|
||||
items.keySet().forEach(this::requireValidId);
|
||||
npcs.keySet().forEach(this::requireValidId);
|
||||
rooms.keySet().forEach(this::requireValidId);
|
||||
}
|
||||
|
||||
private void requireValidId(String id) {
|
||||
if (id == null || !ID_PATTERN.matcher(id).matches()) {
|
||||
throw new WorldLoadException("Invalid id '" + id + "': must match " + ID_PATTERN);
|
||||
}
|
||||
}
|
||||
|
||||
private void validateStartRoom() {
|
||||
if (gameDto == null || gameDto.startRoom() == null) {
|
||||
throw new WorldLoadException("game.yaml is missing 'startRoom'");
|
||||
}
|
||||
if (!rooms.containsKey(gameDto.startRoom())) {
|
||||
throw new WorldLoadException(
|
||||
"game.yaml.startRoom '" + gameDto.startRoom() + "' does not match any room");
|
||||
}
|
||||
}
|
||||
|
||||
private void validateGreetings() {
|
||||
for (Npc npc : npcs.values()) {
|
||||
if (npc.getGreeting() == null || npc.getGreeting().isBlank()) {
|
||||
throw new WorldLoadException("NPC '" + npc.getId() + "' has an empty greeting");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package thb.jeanluc.adventure.loader.dto;
|
||||
|
||||
/**
|
||||
* YAML representation of {@code game.yaml}. Plain data only;
|
||||
* IDs are not yet resolved to domain references.
|
||||
*
|
||||
* @param title display title
|
||||
* @param version free-form version label
|
||||
* @param startRoom id of the room the player starts in
|
||||
* @param startGold initial gold; defaults to 0 if null
|
||||
* @param welcomeMessage shown to the player at startup
|
||||
*/
|
||||
public record GameDto(
|
||||
String title,
|
||||
String version,
|
||||
String startRoom,
|
||||
Integer startGold,
|
||||
String welcomeMessage
|
||||
) {
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
package thb.jeanluc.adventure.loader.dto;
|
||||
|
||||
/**
|
||||
* YAML representation of a single item. Fields outside of the type's
|
||||
* scope (e.g. {@code readText} on a switchable) are simply ignored.
|
||||
*
|
||||
* @param type discriminator: {@code plain | readable | switchable}
|
||||
* @param id unique slug
|
||||
* @param name display name
|
||||
* @param description examine description
|
||||
* @param readText text printed when a readable item is used
|
||||
* @param initialState initial on/off state of a switchable item
|
||||
* @param onText message printed when a switchable transitions to on
|
||||
* @param offText message printed when a switchable transitions to off
|
||||
*/
|
||||
public record ItemDto(
|
||||
String type,
|
||||
String id,
|
||||
String name,
|
||||
String description,
|
||||
String readText,
|
||||
Boolean initialState,
|
||||
String onText,
|
||||
String offText
|
||||
) {
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package thb.jeanluc.adventure.loader.dto;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* YAML representation of a single NPC.
|
||||
*
|
||||
* @param id unique slug
|
||||
* @param name display name
|
||||
* @param description examine description
|
||||
* @param greeting text the NPC says on {@code talk}
|
||||
* @param reactions list of trigger/response definitions; may be null
|
||||
*/
|
||||
public record NpcDto(
|
||||
String id,
|
||||
String name,
|
||||
String description,
|
||||
String greeting,
|
||||
List<ReactionDto> reactions
|
||||
) {
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package thb.jeanluc.adventure.loader.dto;
|
||||
|
||||
/**
|
||||
* YAML representation of a single NPC reaction.
|
||||
*
|
||||
* @param onReceive id of the item that triggers this reaction
|
||||
* @param response text the NPC says after the exchange
|
||||
* @param gives id of the item handed back; nullable
|
||||
* @param consumes id of the item taken from the player; nullable
|
||||
*/
|
||||
public record ReactionDto(
|
||||
String onReceive,
|
||||
String response,
|
||||
String gives,
|
||||
String consumes
|
||||
) {
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package thb.jeanluc.adventure.loader.dto;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* YAML representation of a single room.
|
||||
*
|
||||
* @param id unique slug
|
||||
* @param name display name
|
||||
* @param description description shown by {@code look}
|
||||
* @param exits direction-name to target-room-id map
|
||||
* @param items ids of items initially in this room
|
||||
* @param npcs ids of NPCs initially in this room
|
||||
*/
|
||||
public record RoomDto(
|
||||
String id,
|
||||
String name,
|
||||
String description,
|
||||
Map<String, String> exits,
|
||||
List<String> items,
|
||||
List<String> npcs
|
||||
) {
|
||||
}
|
||||
Reference in New Issue
Block a user