docs(javadoc): document every class, method and instance variable

The assignment requires Javadoc on classes, methods and instance variables.
Coverage was at roughly 45%: enum constants were undocumented across the board
(0 of 37), record components — which are a record's instance variables — sat at
24%, and instance fields at 52%. GameSession was @Getter-exposed with all six
fields undocumented, the exact case docs/conventions.md calls out by name.

Backfilled across model, io, game, loader, command, save and menu:
all enum constants, all record components (@param blocks), all instance fields,
and the public/protected methods and constructors that lacked tags.

'javadoc -Xdoclint:missing' now reports zero missing-doc warnings for the public
API. The 30 remaining warnings are all "use of default constructor" on
Lombok-generated constructors, which would require adding code to silence.
This commit is contained in:
2026-07-14 12:39:25 +02:00
parent 1b39eac90e
commit 526e3d4ee7
75 changed files with 1316 additions and 102 deletions

View File

@@ -16,10 +16,17 @@ import java.util.List;
@RequiredArgsConstructor @RequiredArgsConstructor
public class MenuCommand implements Command { public class MenuCommand implements Command {
/** Persistence backend the active slot is written to before leaving. */
private final SaveService saves; private final SaveService saves;
/** Running game whose loop is stopped; null until {@link #bind(Game)} is called. */
private Game game; private Game game;
/** Binds the running game (two-phase wiring, as the registry is built first). */ /**
* Binds the running game (two-phase wiring, as the registry is built first).
*
* @param game the game loop to stop when this command runs
*/
public void bind(Game game) { public void bind(Game game) {
this.game = game; this.game = game;
} }

View File

@@ -12,6 +12,7 @@ import java.util.List;
@RequiredArgsConstructor @RequiredArgsConstructor
public class SaveCommand implements Command { public class SaveCommand implements Command {
/** Persistence backend the active slot is written to. */
private final SaveService saves; private final SaveService saves;
@Override @Override

View File

@@ -10,6 +10,13 @@ public final class Conditions {
private Conditions() { private Conditions() {
} }
/**
* Evaluates a single condition.
*
* @param c the condition to evaluate
* @param ctx the active game context
* @return true if the condition holds
*/
public static boolean holds(Condition c, GameContext ctx) { public static boolean holds(Condition c, GameContext ctx) {
return switch (c.type()) { return switch (c.type()) {
case FLAG -> ctx.getState().isSet(c.arg()); case FLAG -> ctx.getState().isSet(c.arg());
@@ -18,7 +25,13 @@ public final class Conditions {
}; };
} }
/** True iff every condition holds. A null or empty list is vacuously true. */ /**
* True iff every condition holds. A null or empty list is vacuously true.
*
* @param conditions the conditions to evaluate; may be null
* @param ctx the active game context
* @return true if all conditions hold
*/
public static boolean all(List<Condition> conditions, GameContext ctx) { public static boolean all(List<Condition> conditions, GameContext ctx) {
if (conditions == null) { if (conditions == null) {
return true; return true;

View File

@@ -13,6 +13,13 @@ public final class Effects {
private Effects() { private Effects() {
} }
/**
* Applies a single effect. An effect referencing an unknown item is logged
* and skipped rather than failing the turn.
*
* @param e the effect to apply
* @param ctx the active game context
*/
public static void apply(Effect e, GameContext ctx) { public static void apply(Effect e, GameContext ctx) {
switch (e.type()) { switch (e.type()) {
case SET_FLAG -> ctx.getState().set(e.arg()); case SET_FLAG -> ctx.getState().set(e.arg());
@@ -31,7 +38,12 @@ public final class Effects {
} }
} }
/** Applies each effect in order. A null list is a no-op. */ /**
* Applies each effect in order. A null list is a no-op.
*
* @param effects the effects to apply; may be null
* @param ctx the active game context
*/
public static void applyAll(List<Effect> effects, GameContext ctx) { public static void applyAll(List<Effect> effects, GameContext ctx) {
if (effects == null) { if (effects == null) {
return; return;

View File

@@ -9,7 +9,12 @@ public final class EndingEngine {
private EndingEngine() { private EndingEngine() {
} }
/** First ending whose conditions hold, in list order; null if none. */ /**
* First ending whose conditions hold, in list order; null if none.
*
* @param ctx the active game context
* @return the triggered ending, or null if no ending's conditions hold
*/
public static Ending triggered(GameContext ctx) { public static Ending triggered(GameContext ctx) {
for (Ending e : ctx.getWorld().getEndings()) { for (Ending e : ctx.getWorld().getEndings()) {
if (Conditions.all(e.when(), ctx)) { if (Conditions.all(e.when(), ctx)) {
@@ -19,7 +24,14 @@ public final class EndingEngine {
return null; return null;
} }
/** Builds the ending screen: title banner, text, and a run summary. */ /**
* Builds the ending screen: title banner, text, and a run summary.
*
* @param e the triggered ending
* @param ctx the active game context
* @param turns number of turns the run took
* @return the rendered end-of-game screen
*/
public static StyledText render(Ending e, GameContext ctx, int turns) { public static StyledText render(Ending e, GameContext ctx, int turns) {
int total = ctx.getWorld().getQuests().size(); int total = ctx.getWorld().getQuests().size();
int done = ctx.getQuestLog().completed().size(); int done = ctx.getQuestLog().completed().size();
@@ -34,6 +46,7 @@ public final class EndingEngine {
return b.build(); return b.build();
} }
/** Rank line for the summary: derived from the ending's outcome and quest completeness. */
private static String rank(Ending e, int total, int done) { private static String rank(Ending e, int total, int done) {
if (e.victory() && total > 0 && done == total) { if (e.victory() && total > 0 && done == total) {
return "Master of the Manor"; return "Master of the Manor";

View File

@@ -39,7 +39,13 @@ public final class EscortEngine {
private EscortEngine() { private EscortEngine() {
} }
/** A recruitable twin and the state that tracks their escort. */ /**
* A recruitable twin and the state that tracks their escort.
*
* @param npcId id of the twin's NPC in the world
* @param followingFlag flag that is set while this twin follows the player
* @param homeRoomId id of the room the twin waits in until recruited
*/
private record Twin(String npcId, String followingFlag, String homeRoomId) { private record Twin(String npcId, String followingFlag, String homeRoomId) {
} }
@@ -87,6 +93,11 @@ public final class EscortEngine {
return String.join(" and ", names) + " keep close at your shoulders."; return String.join(" and ", names) + " keep close at your shoulders.";
} }
/**
* Reunites the brothers once both follow the player into the reunion room:
* sets {@link #REUNITED_FLAG}, grants the reward, and prints the scene. Runs
* at most once per playthrough.
*/
private static void maybeReunite(GameContext ctx) { private static void maybeReunite(GameContext ctx) {
if (ctx.getState().isSet(REUNITED_FLAG)) { if (ctx.getState().isSet(REUNITED_FLAG)) {
return; return;
@@ -107,6 +118,7 @@ public final class EscortEngine {
.build()); .build());
} }
/** Adds the reward item to the inventory unless the player already carries it. */
private static void grantReward(GameContext ctx) { private static void grantReward(GameContext ctx) {
Item reward = ctx.getWorld().getItems().get(REWARD_ITEM_ID); Item reward = ctx.getWorld().getItems().get(REWARD_ITEM_ID);
if (reward != null && !ctx.getPlayer().hasItem(REWARD_ITEM_ID)) { if (reward != null && !ctx.getPlayer().hasItem(REWARD_ITEM_ID)) {
@@ -118,6 +130,7 @@ public final class EscortEngine {
return ctx.getState().isSet(twin.followingFlag()); return ctx.getState().isSet(twin.followingFlag());
} }
/** The twin's NPC name, falling back to their id if the NPC is missing from the world. */
private static String displayName(GameContext ctx, Twin twin) { private static String displayName(GameContext ctx, Twin twin) {
Npc npc = ctx.getWorld().getNpcs().get(twin.npcId()); Npc npc = ctx.getWorld().getNpcs().get(twin.npcId());
return npc != null ? npc.getName() : twin.npcId(); return npc != null ? npc.getName() : twin.npcId();

View File

@@ -11,47 +11,96 @@ import thb.jeanluc.adventure.model.World;
*/ */
public class GameContext { public class GameContext {
/** The mutable state of the current playthrough. */
private final GameSession session; private final GameSession session;
/** Channel commands read input from and write output to. */
private final GameIO io; private final GameIO io;
/** Silent autosave hook, wired by the app; no-op by default (tests). */ /** Silent autosave hook, wired by the app; no-op by default (tests). */
private Runnable saveCallback = () -> { }; private Runnable saveCallback = () -> { };
/**
* Creates a context over an existing session.
*
* @param session the playthrough state
* @param io the IO channel
*/
public GameContext(GameSession session, GameIO io) { public GameContext(GameSession session, GameIO io) {
this.session = session; this.session = session;
this.io = io; this.io = io;
} }
/** Convenience for tests/legacy callers: wraps a fresh single-use session. */ /**
* Convenience for tests/legacy callers: wraps a fresh single-use session.
*
* @param world the loaded world
* @param player the player character
* @param io the IO channel
*/
public GameContext(World world, Player player, GameIO io) { public GameContext(World world, Player player, GameIO io) {
this(new GameSession(world, player, "session"), io); this(new GameSession(world, player, "session"), io);
} }
/**
* Returns the session this context was built over.
*
* @return the playthrough state backing this context
*/
public GameSession getSession() { public GameSession getSession() {
return session; return session;
} }
/**
* Delegates to the session.
*
* @return the loaded world
*/
public World getWorld() { public World getWorld() {
return session.getWorld(); return session.getWorld();
} }
/**
* Delegates to the session.
*
* @return the player character
*/
public Player getPlayer() { public Player getPlayer() {
return session.getPlayer(); return session.getPlayer();
} }
/**
* Delegates to the session.
*
* @return the world flags
*/
public GameState getState() { public GameState getState() {
return session.getState(); return session.getState();
} }
/**
* Delegates to the session.
*
* @return the quest progress
*/
public QuestLog getQuestLog() { public QuestLog getQuestLog() {
return session.getQuestLog(); return session.getQuestLog();
} }
/**
* Returns the channel commands read from and write to.
*
* @return the IO channel
*/
public GameIO getIo() { public GameIO getIo() {
return io; return io;
} }
/** Sets the silent autosave hook (called by interval/quest autosave). */ /**
* Sets the silent autosave hook (called by interval/quest autosave).
*
* @param saveCallback the hook to run on {@link #save()}
*/
public void setSaveCallback(Runnable saveCallback) { public void setSaveCallback(Runnable saveCallback) {
this.saveCallback = saveCallback; this.saveCallback = saveCallback;
} }

View File

@@ -13,23 +13,47 @@ import thb.jeanluc.adventure.model.World;
@Getter @Getter
public class GameSession { public class GameSession {
/** The loaded world this playthrough runs on. */
private final World world; private final World world;
/** The player character, including inventory, position, and visited rooms. */
private final Player player; private final Player player;
/** World flags set and cleared by effects and conditions. */
private final GameState state = new GameState(); private final GameState state = new GameState();
/** Quest progress: active stages and completed quests. */
private final QuestLog questLog = new QuestLog(); private final QuestLog questLog = new QuestLog();
/** Name of the save slot this session is bound to; save/load target. */
private final String slotName; private final String slotName;
/** Number of commands executed so far; shown in the HUD and the ending summary. */
private int turn; private int turn;
/**
* Creates a session bound to a save slot.
*
* @param world the loaded world
* @param player the player character
* @param slotName the save slot this session reads from and writes to
*/
public GameSession(World world, Player player, String slotName) { public GameSession(World world, Player player, String slotName) {
this.world = world; this.world = world;
this.player = player; this.player = player;
this.slotName = slotName; this.slotName = slotName;
} }
/**
* Overwrites the turn counter (used when restoring a save).
*
* @param turn the turn count to restore
*/
public void setTurn(int turn) { public void setTurn(int turn) {
this.turn = turn; this.turn = turn;
} }
/** Advances the turn counter by one. */
public void incrementTurn() { public void incrementTurn() {
this.turn++; this.turn++;
} }

View File

@@ -8,25 +8,51 @@ import java.util.Set;
/** Named world flags. A flag is "set" (true) when present in the set. */ /** Named world flags. A flag is "set" (true) when present in the set. */
public class GameState { public class GameState {
/** Ids of the currently set flags. Insertion-ordered for stable saves. */
private final Set<String> flags = new LinkedHashSet<>(); private final Set<String> flags = new LinkedHashSet<>();
/**
* Tests whether a flag is set.
*
* @param flag the flag id
* @return true if the flag is set
*/
public boolean isSet(String flag) { public boolean isSet(String flag) {
return flags.contains(flag); return flags.contains(flag);
} }
/**
* Sets a flag. Setting an already-set flag has no effect.
*
* @param flag the flag id
*/
public void set(String flag) { public void set(String flag) {
flags.add(flag); flags.add(flag);
} }
/**
* Clears a flag. Clearing an unset flag has no effect.
*
* @param flag the flag id
*/
public void clear(String flag) { public void clear(String flag) {
flags.remove(flag); flags.remove(flag);
} }
/**
* All currently set flags.
*
* @return an unmodifiable view of the set flags
*/
public Set<String> all() { public Set<String> all() {
return Collections.unmodifiableSet(flags); return Collections.unmodifiableSet(flags);
} }
/** Replaces all flags with the given collection (used by load). */ /**
* Replaces all flags with the given collection (used by load).
*
* @param newFlags the flags to restore
*/
public void restore(Collection<String> newFlags) { public void restore(Collection<String> newFlags) {
flags.clear(); flags.clear();
flags.addAll(newFlags); flags.addAll(newFlags);

View File

@@ -16,6 +16,15 @@ public final class QuestEngine {
private QuestEngine() { private QuestEngine() {
} }
/**
* Per-turn quest progression: starts every auto-start quest that is neither
* active nor completed, then repeatedly advances active quests whose current
* stage's completion conditions hold, announcing each finished objective and
* quest. Repeats until nothing changes, so a stage whose conditions are met
* by another stage's effects still resolves in the same turn.
*
* @param ctx the active game context
*/
public static void tick(GameContext ctx) { public static void tick(GameContext ctx) {
Map<String, Quest> quests = ctx.getWorld().getQuests(); Map<String, Quest> quests = ctx.getWorld().getQuests();
for (Quest q : quests.values()) { for (Quest q : quests.values()) {
@@ -54,6 +63,7 @@ public final class QuestEngine {
} }
} }
/** Applies a quest's completion effects, marks it done, announces it, and autosaves. */
private static void finish(GameContext ctx, Quest q) { private static void finish(GameContext ctx, Quest q) {
if (ctx.getQuestLog().isCompleted(q.id())) { if (ctx.getQuestLog().isCompleted(q.id())) {
return; return;
@@ -65,6 +75,13 @@ public final class QuestEngine {
ctx.save(); ctx.save();
} }
/**
* Builds the quest panel view: each active quest with its current objective,
* plus the titles of the completed quests.
*
* @param ctx the active game context
* @return the current quest view
*/
public static QuestView viewOf(GameContext ctx) { public static QuestView viewOf(GameContext ctx) {
Map<String, Quest> quests = ctx.getWorld().getQuests(); Map<String, Quest> quests = ctx.getWorld().getQuests();
List<QuestEntry> active = new ArrayList<>(); List<QuestEntry> active = new ArrayList<>();
@@ -85,6 +102,7 @@ public final class QuestEngine {
return new QuestView(active, completed); return new QuestView(active, completed);
} }
/** Stage count across all quests; bounds the iteration guard in {@link #tick}. */
private static int totalStages(Map<String, Quest> quests) { private static int totalStages(Map<String, Quest> quests) {
int total = 0; int total = 0;
for (Quest q : quests.values()) { for (Quest q : quests.values()) {

View File

@@ -10,45 +10,97 @@ import java.util.Set;
/** Runtime quest progress: current stage per active quest, plus completed ids. */ /** Runtime quest progress: current stage per active quest, plus completed ids. */
public class QuestLog { public class QuestLog {
/** Index of the current stage per active quest id. Insertion-ordered for stable saves. */
private final Map<String, Integer> stageIndex = new LinkedHashMap<>(); private final Map<String, Integer> stageIndex = new LinkedHashMap<>();
/** Ids of quests that have been completed. Insertion-ordered for stable saves. */
private final Set<String> completed = new LinkedHashSet<>(); private final Set<String> completed = new LinkedHashSet<>();
/**
* Activates a quest at its first stage. Already active or completed quests
* are left untouched.
*
* @param id the quest id
*/
public void start(String id) { public void start(String id) {
if (!completed.contains(id)) { if (!completed.contains(id)) {
stageIndex.putIfAbsent(id, 0); stageIndex.putIfAbsent(id, 0);
} }
} }
/**
* Tests whether a quest is currently active.
*
* @param id the quest id
* @return true if the quest is active
*/
public boolean isActive(String id) { public boolean isActive(String id) {
return stageIndex.containsKey(id); return stageIndex.containsKey(id);
} }
/**
* Tests whether a quest has been completed.
*
* @param id the quest id
* @return true if the quest is completed
*/
public boolean isCompleted(String id) { public boolean isCompleted(String id) {
return completed.contains(id); return completed.contains(id);
} }
/**
* Current stage index of a quest.
*
* @param id the quest id
* @return the stage index, or 0 if the quest is not active
*/
public int stageIndex(String id) { public int stageIndex(String id) {
return stageIndex.getOrDefault(id, 0); return stageIndex.getOrDefault(id, 0);
} }
/**
* Moves a quest on to its next stage.
*
* @param id the quest id
*/
public void advance(String id) { public void advance(String id) {
stageIndex.merge(id, 1, Integer::sum); stageIndex.merge(id, 1, Integer::sum);
} }
/**
* Marks a quest completed and removes it from the active set.
*
* @param id the quest id
*/
public void complete(String id) { public void complete(String id) {
stageIndex.remove(id); stageIndex.remove(id);
completed.add(id); completed.add(id);
} }
/**
* Ids of all currently active quests.
*
* @return an unmodifiable view of the active quest ids
*/
public Set<String> active() { public Set<String> active() {
return Collections.unmodifiableSet(stageIndex.keySet()); return Collections.unmodifiableSet(stageIndex.keySet());
} }
/**
* Ids of all completed quests.
*
* @return an unmodifiable view of the completed quest ids
*/
public Set<String> completed() { public Set<String> completed() {
return Collections.unmodifiableSet(completed); return Collections.unmodifiableSet(completed);
} }
/** Replaces all progress with the given stage map and completed set (load). */ /**
* Replaces all progress with the given stage map and completed set (load).
*
* @param stages stage index per active quest id
* @param completedIds ids of the completed quests
*/
public void restore(Map<String, Integer> stages, Collection<String> completedIds) { public void restore(Map<String, Integer> stages, Collection<String> completedIds) {
stageIndex.clear(); stageIndex.clear();
stageIndex.putAll(stages); stageIndex.putAll(stages);

View File

@@ -18,25 +18,51 @@ import java.util.Optional;
*/ */
public final class TutorialGuide { public final class TutorialGuide {
/** The walkthrough content: intro, steps, and closing tips. */
private final Tutorial tutorial; private final Tutorial tutorial;
/** Command lookup, used to compare a typed verb against a step's expected command via aliases. */
private final CommandRegistry registry; private final CommandRegistry registry;
/** Index of the step currently being taught. */
private int index; private int index;
/** False once all steps are done or the walkthrough was skipped. */
private boolean active; private boolean active;
/** True once {@link #begin(GameContext)} has printed the intro; guards against a second run. */
private boolean begun; private boolean begun;
/** Room the player stood in when the current step began; used to detect movement steps. */
private Room roomAtStepStart; private Room roomAtStepStart;
/**
* Creates a guide for the given walkthrough. The guide starts inactive if
* the tutorial has no steps.
*
* @param tutorial the walkthrough content
* @param registry command lookup for alias-aware verb comparison
*/
public TutorialGuide(Tutorial tutorial, CommandRegistry registry) { public TutorialGuide(Tutorial tutorial, CommandRegistry registry) {
this.tutorial = tutorial; this.tutorial = tutorial;
this.registry = registry; this.registry = registry;
this.active = !tutorial.isEmpty(); this.active = !tutorial.isEmpty();
} }
/** @return true while the walkthrough is still running. */ /**
* Reports whether the guide still has steps left to hand out.
*
* @return true while the walkthrough is still running
*/
public boolean isActive() { public boolean isActive() {
return active; return active;
} }
/** Prints the intro and the first instruction (call once at loop start). */ /**
* Prints the intro and the first instruction (call once at loop start).
*
* @param ctx the active game context
*/
public void begin(GameContext ctx) { public void begin(GameContext ctx) {
if (!active || begun) { if (!active || begun) {
return; return;
@@ -48,7 +74,14 @@ public final class TutorialGuide {
enterStep(ctx); enterStep(ctx);
} }
/** Evaluates the current step against the command that was just executed. */ /**
* Evaluates the current step against the command that was just executed:
* on success prints the confirmation and advances (ending the walkthrough
* after the last step), otherwise prints the step's hint.
*
* @param ctx the active game context
* @param parsed the command that was just executed
*/
public void onCommand(GameContext ctx, ParsedCommand parsed) { public void onCommand(GameContext ctx, ParsedCommand parsed) {
if (!active) { if (!active) {
return; return;
@@ -70,7 +103,11 @@ public final class TutorialGuide {
} }
} }
/** Ends the tutorial early (the 'skip' command). */ /**
* Ends the tutorial early (the 'skip' command).
*
* @param ctx the active game context
*/
public void skip(GameContext ctx) { public void skip(GameContext ctx) {
if (active) { if (active) {
ctx.getIo().write("Tutorial skipped."); ctx.getIo().write("Tutorial skipped.");
@@ -78,11 +115,18 @@ public final class TutorialGuide {
} }
} }
/** Prints the current step's instruction and records the room it started in. */
private void enterStep(GameContext ctx) { private void enterStep(GameContext ctx) {
roomAtStepStart = ctx.getPlayer().getCurrentRoom(); roomAtStepStart = ctx.getPlayer().getCurrentRoom();
ctx.getIo().write(tutorial.steps().get(index).instruction()); ctx.getIo().write(tutorial.steps().get(index).instruction());
} }
/**
* Whether the executed command fulfils the step. The pseudo-expectations
* {@code go_direction} and {@code go_to_room} additionally require an actual
* room change; every other expectation matches on the command (aliases
* included) and the step's minimum argument count.
*/
private boolean satisfies(TutorialStep step, GameContext ctx, ParsedCommand parsed) { private boolean satisfies(TutorialStep step, GameContext ctx, ParsedCommand parsed) {
String expect = step.expect(); String expect = step.expect();
if ("go_direction".equals(expect)) { if ("go_direction".equals(expect)) {
@@ -98,6 +142,7 @@ public final class TutorialGuide {
return sameCommand(p.verb(), "go") && !p.args().isEmpty(); return sameCommand(p.verb(), "go") && !p.args().isEmpty();
} }
/** Whether the first argument parses as a {@link Direction} (rather than a room name). */
private boolean firstIsDirection(ParsedCommand p) { private boolean firstIsDirection(ParsedCommand p) {
if (p.args().isEmpty()) { if (p.args().isEmpty()) {
return false; return false;
@@ -110,11 +155,13 @@ public final class TutorialGuide {
} }
} }
/** Whether the player left the room the current step started in. */
private boolean moved(GameContext ctx) { private boolean moved(GameContext ctx) {
return roomAtStepStart != null return roomAtStepStart != null
&& ctx.getPlayer().getCurrentRoom() != roomAtStepStart; && ctx.getPlayer().getCurrentRoom() != roomAtStepStart;
} }
/** Whether both verbs resolve to the same registered command, so aliases count as a match. */
private boolean sameCommand(String a, String b) { private boolean sameCommand(String a, String b) {
Optional<Command> ca = registry.find(a); Optional<Command> ca = registry.find(a);
Optional<Command> cb = registry.find(b); Optional<Command> cb = registry.find(b);

View File

@@ -23,28 +23,70 @@ import java.util.List;
public class ConsoleIO implements GameIO { public class ConsoleIO implements GameIO {
/** Whether ANSI colour is emitted. */ /** Whether ANSI colour is emitted. */
public enum ColorMode { ON, OFF, AUTO } public enum ColorMode {
/** Always emit ANSI escape codes. */
ON,
/** Never emit ANSI escape codes; plain text only. */
OFF,
/** Emit colour only when attached to a real console, not when piped. */
AUTO
}
/** Frame/glyph fidelity tier. */ /** Frame/glyph fidelity tier. */
public enum GlyphMode { ASCII, UNICODE, GLYPH } public enum GlyphMode {
/** Frames drawn with {@code +} and {@code -}, for terminals without Unicode. */
ASCII,
/** Frames drawn with Unicode box-drawing characters. */
UNICODE,
/** Highest tier: Unicode frames plus decorative glyphs. */
GLYPH
}
/** Total width of a room frame, in characters. */
private static final int FRAME_WIDTH = 44; private static final int FRAME_WIDTH = 44;
/** Source the player's commands are read from. */
private final BufferedReader in; private final BufferedReader in;
/** Sink all output is written to. */
private final PrintStream out; private final PrintStream out;
/** Whether ANSI colour codes are currently emitted; resolved from a {@link ColorMode}. */
private boolean useColor; private boolean useColor;
/** Frame/glyph fidelity tier currently in use. */
private GlyphMode glyphs; private GlyphMode glyphs;
/** Reads from {@code System.in} and writes to {@code System.out}, colour auto-detected. */
public ConsoleIO() { public ConsoleIO() {
this(new BufferedReader(new InputStreamReader(System.in, StandardCharsets.UTF_8)), this(new BufferedReader(new InputStreamReader(System.in, StandardCharsets.UTF_8)),
System.out, ColorMode.AUTO, GlyphMode.UNICODE); System.out, ColorMode.AUTO, GlyphMode.UNICODE);
} }
/** Backward-compatible stream constructor (colour off, unicode frames). */ /**
* Backward-compatible stream constructor (colour off, unicode frames).
*
* @param in source of the player's commands
* @param out sink for all output
*/
public ConsoleIO(BufferedReader in, PrintStream out) { public ConsoleIO(BufferedReader in, PrintStream out) {
this(in, out, ColorMode.OFF, GlyphMode.UNICODE); this(in, out, ColorMode.OFF, GlyphMode.UNICODE);
} }
/**
* Creates a console IO bound to the given streams and fidelity settings.
*
* @param in source of the player's commands
* @param out sink for all output
* @param colorMode whether ANSI colour is emitted
* @param glyphs frame/glyph fidelity tier
*/
public ConsoleIO(BufferedReader in, PrintStream out, ColorMode colorMode, GlyphMode glyphs) { public ConsoleIO(BufferedReader in, PrintStream out, ColorMode colorMode, GlyphMode glyphs) {
this.in = in; this.in = in;
this.out = out; this.out = out;
@@ -52,7 +94,11 @@ public class ConsoleIO implements GameIO {
setColorMode(colorMode); setColorMode(colorMode);
} }
/** Re-resolves colour emission from the given mode (AUTO checks the console). */ /**
* Re-resolves colour emission from the given mode (AUTO checks the console).
*
* @param mode the colour mode to apply
*/
public void setColorMode(ColorMode mode) { public void setColorMode(ColorMode mode) {
this.useColor = switch (mode) { this.useColor = switch (mode) {
case ON -> true; case ON -> true;
@@ -61,7 +107,11 @@ public class ConsoleIO implements GameIO {
}; };
} }
/** Switches the frame/glyph fidelity tier live. */ /**
* Switches the frame/glyph fidelity tier live.
*
* @param mode the tier to apply
*/
public void setGlyphMode(GlyphMode mode) { public void setGlyphMode(GlyphMode mode) {
this.glyphs = mode; this.glyphs = mode;
} }
@@ -118,6 +168,14 @@ public class ConsoleIO implements GameIO {
print(AsciiMap.render(view, glyphs != GlyphMode.ASCII)); print(AsciiMap.render(view, glyphs != GlyphMode.ASCII));
} }
/**
* Builds one indented, labelled line listing the given entries.
*
* @param label dim heading in front of the entries
* @param xs the entries
* @param st style applied to each entry
* @return the rendered line
*/
private String section(String label, List<String> xs, Style st) { private String section(String label, List<String> xs, Style st) {
StringBuilder sb = new StringBuilder(" ").append(paint(Style.DIM, label)).append(" "); StringBuilder sb = new StringBuilder(" ").append(paint(Style.DIM, label)).append(" ");
for (int i = 0; i < xs.size(); i++) { for (int i = 0; i < xs.size(); i++) {
@@ -129,6 +187,10 @@ public class ConsoleIO implements GameIO {
return sb.toString(); return sb.toString();
} }
/**
* @param title room name embedded in the top border
* @return the top border of a room frame, {@link #FRAME_WIDTH} characters wide
*/
private String frameTop(String title) { private String frameTop(String title) {
String tl = glyphs == GlyphMode.ASCII ? "+" : ""; String tl = glyphs == GlyphMode.ASCII ? "+" : "";
String tr = glyphs == GlyphMode.ASCII ? "+" : ""; String tr = glyphs == GlyphMode.ASCII ? "+" : "";
@@ -141,6 +203,9 @@ public class ConsoleIO implements GameIO {
return sb.append(tr).toString(); return sb.append(tr).toString();
} }
/**
* @return the bottom border of a room frame, {@link #FRAME_WIDTH} characters wide
*/
private String frameBottom() { private String frameBottom() {
String bl = glyphs == GlyphMode.ASCII ? "+" : ""; String bl = glyphs == GlyphMode.ASCII ? "+" : "";
String br = glyphs == GlyphMode.ASCII ? "+" : ""; String br = glyphs == GlyphMode.ASCII ? "+" : "";
@@ -152,6 +217,13 @@ public class ConsoleIO implements GameIO {
return sb.append(br).toString(); return sb.append(br).toString();
} }
/**
* Wraps the text in the ANSI escape codes of the given role.
*
* @param st the semantic role
* @param s the text
* @return the text unchanged when colour is disabled, else the coloured text
*/
private String paint(Style st, String s) { private String paint(Style st, String s) {
if (!useColor) { if (!useColor) {
return s; return s;

View File

@@ -18,28 +18,52 @@ import java.util.List;
*/ */
public interface GameIO { public interface GameIO {
/** Blocks until a line of input is available; empty string at end of input. */ /**
* Blocks until a line of input is available; empty string at end of input.
*
* @return the line the player entered
*/
String readLine(); String readLine();
/** Renders one styled output block. The only method implementors must provide. */ /**
* Renders one styled output block. The only method implementors must provide.
*
* @param text the block to render
*/
void print(StyledText text); void print(StyledText text);
/** Convenience: a plain, unstyled line. */ /**
* Convenience: a plain, unstyled line.
*
* @param s the text to write
*/
default void write(String s) { default void write(String s) {
print(StyledText.of(s)); print(StyledText.of(s));
} }
/** Renders a room. Default flattens via {@link Renderings}; renderers may override. */ /**
* Renders a room. Default flattens via {@link Renderings}; renderers may override.
*
* @param room the room snapshot to render
*/
default void showRoom(RoomView room) { default void showRoom(RoomView room) {
print(Renderings.roomToStyledText(room)); print(Renderings.roomToStyledText(room));
} }
/** Updates the status/HUD region. No-op by default; renderers may override. */ /**
* Updates the status/HUD region. No-op by default; renderers may override.
*
* @param hud the status snapshot
*/
default void setHud(Hud hud) { default void setHud(Hud hud) {
// no-op // no-op
} }
/** Per-turn push of the current map. GUI repaints its panel; console ignores. */ /**
* Per-turn push of the current map. GUI repaints its panel; console ignores.
*
* @param view the map snapshot
*/
default void setMap(MapView view) { default void setMap(MapView view) {
// no-op // no-op
} }
@@ -55,27 +79,47 @@ public interface GameIO {
// no animation in text mode // no animation in text mode
} }
/** On-demand map (the 'map' command). Default renders ASCII (Unicode frames). */ /**
* On-demand map (the 'map' command). Default renders ASCII (Unicode frames).
*
* @param view the map snapshot
*/
default void showMap(MapView view) { default void showMap(MapView view) {
print(AsciiMap.render(view, true)); print(AsciiMap.render(view, true));
} }
/** Per-turn push of the quest log. GUI repaints its box; console ignores. */ /**
* Per-turn push of the quest log. GUI repaints its box; console ignores.
*
* @param view the quest snapshot
*/
default void setQuests(QuestView view) { default void setQuests(QuestView view) {
// no-op // no-op
} }
/** Per-turn current-room music track; null/blank = silence. Console no-op; GUI plays. */ /**
* Per-turn current-room music track; null/blank = silence. Console no-op; GUI plays.
*
* @param track the current room's track name; null or blank means silence
*/
default void setMusic(String track) { default void setMusic(String track) {
// no audio in text mode // no audio in text mode
} }
/** Applies the music volume/level. Console no-op; GUI sets it. */ /**
* Applies the music volume/level. Console no-op; GUI sets it.
*
* @param level the level to apply
*/
default void setMusicLevel(MusicLevel level) { default void setMusicLevel(MusicLevel level) {
// no audio in text mode // no audio in text mode
} }
/** On-demand quest log (the 'quests' command). Default renders styled text. */ /**
* On-demand quest log (the 'quests' command). Default renders styled text.
*
* @param view the quest snapshot
*/
default void showQuests(QuestView view) { default void showQuests(QuestView view) {
print(QuestText.render(view)); print(QuestText.render(view));
} }

View File

@@ -31,30 +31,64 @@ import java.util.List;
*/ */
public class MapPanel extends JComponent { public class MapPanel extends JComponent {
/** Unscaled room width, in pixels. */
private static final int BASE_CELL_W = 96; private static final int BASE_CELL_W = 96;
/** Unscaled room height, in pixels. */
private static final int BASE_CELL_H = 46; private static final int BASE_CELL_H = 46;
/** Unscaled horizontal gap between two rooms, in pixels. */
private static final int BASE_GAP_X = 34; private static final int BASE_GAP_X = 34;
/** Unscaled vertical gap between two rooms, in pixels. */
private static final int BASE_GAP_Y = 30; private static final int BASE_GAP_Y = 30;
/** Unscaled padding around the whole map, in pixels. */
private static final int BASE_PAD = 16; private static final int BASE_PAD = 16;
/** Unscaled size of the room-label font, in points. */
private static final float BASE_FONT = 11f; private static final float BASE_FONT = 11f;
/** Panel background. */
private static final Color BG = new Color(0x0e, 0x12, 0x18); private static final Color BG = new Color(0x0e, 0x12, 0x18);
/** Line colour of a corridor between two visited rooms. */
private static final Color CORRIDOR = new Color(0x46, 0x70, 0x8a); private static final Color CORRIDOR = new Color(0x46, 0x70, 0x8a);
/** Outline colour of a {@link CellState#VISITED} room. */
private static final Color VISITED = new Color(0xcf, 0xd6, 0xe0); private static final Color VISITED = new Color(0xcf, 0xd6, 0xe0);
/** Outline colour of the {@link CellState#CURRENT} room. */
private static final Color CURRENT = new Color(0xe6, 0xc3, 0x4a); private static final Color CURRENT = new Color(0xe6, 0xc3, 0x4a);
/** Outline colour of a {@link CellState#KNOWN} but unvisited room. */
private static final Color KNOWN = new Color(0x3a, 0x46, 0x55); private static final Color KNOWN = new Color(0x3a, 0x46, 0x55);
/** Font the room labels are derived from. */
private final Font baseFont; private final Font baseFont;
/** User zoom multiplier, applied on top of the fit-to-panel scale. */
private float zoom = 1f; private float zoom = 1f;
/** The map currently drawn; empty until the first {@link #show(MapView)}. */
private MapView view = new MapView(List.of(), List.of()); private MapView view = new MapView(List.of(), List.of());
/**
* Creates an empty map panel; a map appears with the first {@link #show(MapView)}.
*
* @param font font the room labels are derived from
*/
public MapPanel(Font font) { public MapPanel(Font font) {
this.baseFont = font; this.baseFont = font;
setBackground(BG); setBackground(BG);
setOpaque(true); setOpaque(true);
} }
/** Sets the user zoom multiplier (applied on top of the fit-to-panel scale). */ /**
* Sets the user zoom multiplier (applied on top of the fit-to-panel scale).
*
* @param zoom the multiplier; clamped to a minimum of 0.4
*/
public void setZoom(float zoom) { public void setZoom(float zoom) {
SwingUtilities.invokeLater(() -> { SwingUtilities.invokeLater(() -> {
this.zoom = Math.max(0.4f, zoom); this.zoom = Math.max(0.4f, zoom);
@@ -63,7 +97,11 @@ public class MapPanel extends JComponent {
}); });
} }
/** Replaces the rendered map. */ /**
* Replaces the rendered map.
*
* @param v the new map snapshot
*/
public void show(MapView v) { public void show(MapView v) {
SwingUtilities.invokeLater(() -> { SwingUtilities.invokeLater(() -> {
this.view = v; this.view = v;
@@ -72,7 +110,11 @@ public class MapPanel extends JComponent {
}); });
} }
/** The space the map should fit into: the enclosing scroll pane's viewport area. */ /**
* The space the map should fit into: the enclosing scroll pane's viewport area.
*
* @return the viewport area, this panel's own size, or a fixed fallback
*/
private Dimension available() { private Dimension available() {
Container p = getParent(); Container p = getParent();
if (p instanceof JViewport && p.getParent() instanceof JScrollPane sp) { if (p instanceof JViewport && p.getParent() instanceof JScrollPane sp) {
@@ -87,6 +129,10 @@ public class MapPanel extends JComponent {
return (s.width > 0 && s.height > 0) ? s : new Dimension(220, 320); return (s.width > 0 && s.height > 0) ? s : new Dimension(220, 320);
} }
/**
* @return the highest occupied column and row of the map grid, as
* {@code [maxX, maxY]}
*/
private int[] gridBounds() { private int[] gridBounds() {
int maxX = 0; int maxX = 0;
int maxY = 0; int maxY = 0;
@@ -97,7 +143,13 @@ public class MapPanel extends JComponent {
return new int[]{maxX, maxY}; return new int[]{maxX, maxY};
} }
/** Fit-to-panel scale times the user zoom. */ /**
* Fit-to-panel scale times the user zoom.
*
* @param avail the space available to the map
* @param b grid bounds as returned by {@link #gridBounds()}
* @return the effective drawing scale
*/
private float effScale(Dimension avail, int[] b) { private float effScale(Dimension avail, int[] b) {
double natW = 2.0 * BASE_PAD + (b[0] + 1) * BASE_CELL_W + b[0] * BASE_GAP_X; double natW = 2.0 * BASE_PAD + (b[0] + 1) * BASE_CELL_W + b[0] * BASE_GAP_X;
double natH = 2.0 * BASE_PAD + (b[1] + 1) * BASE_CELL_H + b[1] * BASE_GAP_Y; double natH = 2.0 * BASE_PAD + (b[1] + 1) * BASE_CELL_H + b[1] * BASE_GAP_Y;
@@ -109,6 +161,11 @@ public class MapPanel extends JComponent {
return (float) (fit * zoom); return (float) (fit * zoom);
} }
/**
* @param eff the effective drawing scale
* @param b grid bounds as returned by {@link #gridBounds()}
* @return the size of the drawn map at that scale, including padding
*/
private Dimension content(float eff, int[] b) { private Dimension content(float eff, int[] b) {
int pad = Math.round(BASE_PAD * eff); int pad = Math.round(BASE_PAD * eff);
int cw = Math.round(BASE_CELL_W * eff); int cw = Math.round(BASE_CELL_W * eff);

View File

@@ -3,13 +3,22 @@ package thb.jeanluc.adventure.io;
/** Raw audio operations — the only part that touches sound hardware. */ /** Raw audio operations — the only part that touches sound hardware. */
public interface MusicBackend { public interface MusicBackend {
/** Fade the current track out, then start {@code track} (looping) and fade in to {@code gainDb}. */ /**
* Fade the current track out, then start {@code track} (looping) and fade in to {@code gainDb}.
*
* @param track name of the track to play
* @param gainDb target volume in decibels
*/
void fadeTo(String track, float gainDb); void fadeTo(String track, float gainDb);
/** Fade the current track out and stop. */ /** Fade the current track out and stop. */
void fadeOut(); void fadeOut();
/** Set the playback volume immediately. */ /**
* Set the playback volume immediately.
*
* @param gainDb target volume in decibels
*/
void setGainDb(float gainDb); void setGainDb(float gainDb);
/** Stop playback and release resources. */ /** Stop playback and release resources. */

View File

@@ -10,15 +10,31 @@ import java.util.Objects;
*/ */
public final class MusicController { public final class MusicController {
/** Backend the raw playback operations are delegated to. */
private final MusicBackend backend; private final MusicBackend backend;
/** Volume level currently selected in the Settings menu. */
private MusicLevel level = MusicLevel.MEDIUM; private MusicLevel level = MusicLevel.MEDIUM;
/** Track currently selected, or null when silent. */
private String current; // currently selected track, or null = silence private String current; // currently selected track, or null = silence
/**
* Creates a controller starting silent at {@link MusicLevel#MEDIUM}.
*
* @param backend backend that performs the actual playback
*/
public MusicController(MusicBackend backend) { public MusicController(MusicBackend backend) {
this.backend = backend; this.backend = backend;
} }
/** Called per turn with the current room's music field (may be null/blank). */ /**
* Called per turn with the current room's music field (may be null/blank).
* Does nothing while the level is {@link MusicLevel#OFF} or the track is
* already playing.
*
* @param track the room's track name; null or blank means silence
*/
public void room(String track) { public void room(String track) {
if (!level.isOn()) { if (!level.isOn()) {
return; return;
@@ -35,7 +51,13 @@ public final class MusicController {
} }
} }
/** Applies a new music level (volume / off). */ /**
* Applies a new music level (volume / off). Switching to
* {@link MusicLevel#OFF} fades out; switching back on replays on the next
* {@link #room(String)} call.
*
* @param newLevel the level to apply
*/
public void level(MusicLevel newLevel) { public void level(MusicLevel newLevel) {
if (newLevel == this.level) { if (newLevel == this.level) {
return; return;
@@ -49,6 +71,7 @@ public final class MusicController {
} }
} }
/** Stops playback and releases the backend's resources. */
public void shutdown() { public void shutdown() {
backend.shutdown(); backend.shutdown();
} }

View File

@@ -2,9 +2,23 @@ package thb.jeanluc.adventure.io;
/** Background-music volume level, cycled in the Settings menu. */ /** Background-music volume level, cycled in the Settings menu. */
public enum MusicLevel { public enum MusicLevel {
OFF, LOW, MEDIUM, HIGH; /** Music disabled; nothing is played. */
OFF,
/** Target MASTER_GAIN in decibels. Irrelevant for {@link #OFF} (playback disabled). */ /** Quiet background music. */
LOW,
/** Default volume. */
MEDIUM,
/** Full volume, without attenuation. */
HIGH;
/**
* Target MASTER_GAIN in decibels. Irrelevant for {@link #OFF} (playback disabled).
*
* @return the gain of this level
*/
public float gainDb() { public float gainDb() {
return switch (this) { return switch (this) {
case OFF -> Float.NEGATIVE_INFINITY; case OFF -> Float.NEGATIVE_INFINITY;
@@ -14,12 +28,20 @@ public enum MusicLevel {
}; };
} }
/** @return true when music should play. */ /**
* Reports whether this level permits playback.
*
* @return true when music should play
*/
public boolean isOn() { public boolean isOn() {
return this != OFF; return this != OFF;
} }
/** Next level in the cycle (wraps HIGH → OFF). */ /**
* Next level in the cycle (wraps HIGH → OFF).
*
* @return the following level
*/
public MusicLevel next() { public MusicLevel next() {
return values()[(ordinal() + 1) % values().length]; return values()[(ordinal() + 1) % values().length];
} }

View File

@@ -18,6 +18,11 @@ import java.awt.Insets;
/** Read-only styled view of the quest log, shown under the map. */ /** Read-only styled view of the quest log, shown under the map. */
public class QuestPanel extends JTextPane { public class QuestPanel extends JTextPane {
/**
* Creates an empty, read-only panel; content arrives via {@link #show(QuestView)}.
*
* @param font font the panel text is derived from
*/
public QuestPanel(Font font) { public QuestPanel(Font font) {
setEditable(false); setEditable(false);
setBackground(new Color(0x0b, 0x0e, 0x13)); setBackground(new Color(0x0b, 0x0e, 0x13));
@@ -26,6 +31,11 @@ public class QuestPanel extends JTextPane {
setMargin(new Insets(8, 10, 8, 10)); setMargin(new Insets(8, 10, 8, 10));
} }
/**
* Replaces the panel's content with the given quest log.
*
* @param view the quest snapshot to render
*/
public void show(QuestView view) { public void show(QuestView view) {
SwingUtilities.invokeLater(() -> { SwingUtilities.invokeLater(() -> {
StyledDocument doc = getStyledDocument(); StyledDocument doc = getStyledDocument();
@@ -46,6 +56,10 @@ public class QuestPanel extends JTextPane {
}); });
} }
/**
* @param s the semantic role
* @return the colour this panel paints that role in
*/
private Color colorFor(Style s) { private Color colorFor(Style s) {
return switch (s) { return switch (s) {
case HEADING -> new Color(0xc9, 0x8a, 0xe0); case HEADING -> new Color(0xc9, 0x8a, 0xe0);

View File

@@ -11,13 +11,26 @@ import java.util.Set;
*/ */
public final class AsciiMap { public final class AsciiMap {
/** Lower bound for the room-name field width, in characters. */
private static final int MIN_INNER = 7; // minimum room name field width private static final int MIN_INNER = 7; // minimum room name field width
/** Upper bound for the room-name field width, in characters. */
private static final int MAX_INNER = 14; // cap so the map stays compact private static final int MAX_INNER = 14; // cap so the map stays compact
/** Width of a horizontal corridor, in characters. */
private static final int GAP = 3; // horizontal corridor width private static final int GAP = 3; // horizontal corridor width
/** Utility class; not instantiable. */
private AsciiMap() { private AsciiMap() {
} }
/**
* Renders the map as a styled grid of boxed rooms and corridor lines.
*
* @param view the map snapshot to draw
* @param unicode true for box-drawing characters, false for pure ASCII
* @return the rendered grid, or a placeholder when the map is empty
*/
public static StyledText render(MapView view, boolean unicode) { public static StyledText render(MapView view, boolean unicode) {
if (view.cells().isEmpty()) { if (view.cells().isEmpty()) {
return StyledText.of("(no map yet)"); return StyledText.of("(no map yet)");
@@ -98,6 +111,13 @@ public final class AsciiMap {
return b.build(); return b.build();
} }
/**
* Appends the text in the style that matches the cell's fog-of-war state.
*
* @param b the builder to append to
* @param s the text
* @param state the cell's state
*/
private static void styled(StyledText.Builder b, String s, CellState state) { private static void styled(StyledText.Builder b, String s, CellState state) {
switch (state) { switch (state) {
case CURRENT -> b.heading(s); case CURRENT -> b.heading(s);
@@ -106,6 +126,11 @@ public final class AsciiMap {
} }
} }
/**
* @param c the cell
* @param inner width of the name field, in characters
* @return the room name, truncated to {@code inner}
*/
private static String label(RoomCell c, int inner) { private static String label(RoomCell c, int inner) {
String n = c.name(); String n = c.name();
if (n.length() > inner) { if (n.length() > inner) {
@@ -114,6 +139,11 @@ public final class AsciiMap {
return n; return n;
} }
/**
* @param s the text
* @param width target width, at least the text's length
* @return the text padded with spaces so it sits centred in {@code width}
*/
private static String center(String s, int width) { private static String center(String s, int width) {
int total = width - s.length(); int total = width - s.length();
int left = total / 2; int left = total / 2;
@@ -121,6 +151,11 @@ public final class AsciiMap {
return " ".repeat(left) + s + " ".repeat(right); return " ".repeat(left) + s + " ".repeat(right);
} }
/**
* @param x column on the grid
* @param y row on the grid
* @return the two coordinates packed into one map/set key
*/
private static long key(int x, int y) { private static long key(int x, int y) {
return (((long) x) << 32) ^ (y & 0xffffffffL); return (((long) x) << 32) ^ (y & 0xffffffffL);
} }

View File

@@ -3,10 +3,16 @@ package thb.jeanluc.adventure.io.text;
/** Builds styled banners for big moments (game start, finale). */ /** Builds styled banners for big moments (game start, finale). */
public final class Banner { public final class Banner {
/** Utility class; not instantiable. */
private Banner() { private Banner() {
} }
/** A framed, heading-styled welcome banner for the given title. */ /**
* A framed, heading-styled welcome banner for the given title.
*
* @param title the game title to frame
* @return the banner
*/
public static StyledText welcome(String title) { public static StyledText welcome(String title) {
String bar = "=".repeat(Math.max(8, title.length() + 8)); String bar = "=".repeat(Math.max(8, title.length() + 8));
return StyledText.builder() return StyledText.builder()

View File

@@ -1,4 +1,14 @@
package thb.jeanluc.adventure.io.text; package thb.jeanluc.adventure.io.text;
/** Fog-of-war state of a room on the map. */ /** Fog-of-war state of a room on the map. */
public enum CellState { CURRENT, VISITED, KNOWN } public enum CellState {
/** The room the player is standing in. */
CURRENT,
/** A room the player has already entered. */
VISITED,
/** A room that is only known as the neighbour of a visited room, never entered. */
KNOWN
}

View File

@@ -1,4 +1,9 @@
package thb.jeanluc.adventure.io.text; package thb.jeanluc.adventure.io.text;
/** A corridor between two placed rooms. */ /**
* A corridor between two placed rooms.
*
* @param from cell the corridor starts at
* @param to cell the corridor leads to
*/
public record Connection(RoomCell from, RoomCell to) {} public record Connection(RoomCell from, RoomCell to) {}

View File

@@ -1,4 +1,11 @@
package thb.jeanluc.adventure.io.text; package thb.jeanluc.adventure.io.text;
/** Status snapshot for the HUD region. */ /**
* Status snapshot for the HUD region.
*
* @param location display name of the room the player is in
* @param gold gold currently carried by the player
* @param turn number of turns played so far
* @param lightOn whether the player carries an active light source
*/
public record Hud(String location, int gold, int turn, boolean lightOn) {} public record Hud(String location, int gold, int turn, boolean lightOn) {}

View File

@@ -2,8 +2,14 @@ package thb.jeanluc.adventure.io.text;
import java.util.List; import java.util.List;
/** Frontend-agnostic snapshot of the visible map: placed cells + connections. */ /**
* Frontend-agnostic snapshot of the visible map: placed cells + connections.
*
* @param cells the rooms the player knows about, placed on the grid
* @param connections the corridors between those rooms
*/
public record MapView(List<RoomCell> cells, List<Connection> connections) { public record MapView(List<RoomCell> cells, List<Connection> connections) {
/** Defensively copies both lists, making the snapshot immutable. */
public MapView { public MapView {
cells = List.copyOf(cells); cells = List.copyOf(cells);
connections = List.copyOf(connections); connections = List.copyOf(connections);

View File

@@ -1,4 +1,9 @@
package thb.jeanluc.adventure.io.text; package thb.jeanluc.adventure.io.text;
/** An active quest's title and its current objective. */ /**
* An active quest's title and its current objective.
*
* @param title display name of the quest
* @param objective description of the step the player has to complete next
*/
public record QuestEntry(String title, String objective) {} public record QuestEntry(String title, String objective) {}

View File

@@ -3,9 +3,17 @@ package thb.jeanluc.adventure.io.text;
/** Renders a {@link QuestView} as styled text for the console quest log. */ /** Renders a {@link QuestView} as styled text for the console quest log. */
public final class QuestText { public final class QuestText {
/** Utility class; not instantiable. */
private QuestText() { private QuestText() {
} }
/**
* Renders the quest log: active quests with their objective, then the
* completed titles.
*
* @param view the quest snapshot to render
* @return the rendered log, or a placeholder when there are no quests
*/
public static StyledText render(QuestView view) { public static StyledText render(QuestView view) {
if (view.active().isEmpty() && view.completed().isEmpty()) { if (view.active().isEmpty() && view.completed().isEmpty()) {
return StyledText.of("You have no quests yet."); return StyledText.of("You have no quests yet.");

View File

@@ -2,8 +2,14 @@ package thb.jeanluc.adventure.io.text;
import java.util.List; import java.util.List;
/** Frontend-agnostic snapshot of the quest log. */ /**
* Frontend-agnostic snapshot of the quest log.
*
* @param active quests in progress, with their current objective
* @param completed titles of the quests already finished
*/
public record QuestView(List<QuestEntry> active, List<String> completed) { public record QuestView(List<QuestEntry> active, List<String> completed) {
/** Defensively copies both lists, making the snapshot immutable. */
public QuestView { public QuestView {
active = List.copyOf(active); active = List.copyOf(active);
completed = List.copyOf(completed); completed = List.copyOf(completed);

View File

@@ -5,10 +5,16 @@ import java.util.List;
/** Default, layout-free rendering of high-level views into {@link StyledText}. */ /** Default, layout-free rendering of high-level views into {@link StyledText}. */
public final class Renderings { public final class Renderings {
/** Utility class; not instantiable. */
private Renderings() { private Renderings() {
} }
/** Generic room rendering used by non-overriding {@code GameIO}s (e.g. tests). */ /**
* Generic room rendering used by non-overriding {@code GameIO}s (e.g. tests).
*
* @param r the room snapshot to render
* @return name, description, items, NPCs and exits as styled text
*/
public static StyledText roomToStyledText(RoomView r) { public static StyledText roomToStyledText(RoomView r) {
StyledText.Builder b = StyledText.builder(); StyledText.Builder b = StyledText.builder();
b.heading(r.name()).plain("\n"); b.heading(r.name()).plain("\n");
@@ -33,6 +39,13 @@ public final class Renderings {
return b.build(); return b.build();
} }
/**
* Appends the entries as a comma-separated list in the given style.
*
* @param b the builder to append to
* @param xs the entries
* @param st style applied to each entry
*/
private static void appendList(StyledText.Builder b, List<String> xs, Style st) { private static void appendList(StyledText.Builder b, List<String> xs, Style st) {
for (int i = 0; i < xs.size(); i++) { for (int i = 0; i < xs.size(); i++) {
if (i > 0) { if (i > 0) {

View File

@@ -1,4 +1,12 @@
package thb.jeanluc.adventure.io.text; package thb.jeanluc.adventure.io.text;
/** A room placed on the map grid at integer coords, with its fog-of-war state. */ /**
* A room placed on the map grid at integer coords, with its fog-of-war state.
*
* @param id unique slug of the room
* @param name display name shown inside the cell
* @param x column on the map grid, 0-based
* @param y row on the map grid, 0-based
* @param state how much the player knows about this room
*/
public record RoomCell(String id, String name, int x, int y, CellState state) {} public record RoomCell(String id, String name, int x, int y, CellState state) {}

View File

@@ -2,9 +2,18 @@ package thb.jeanluc.adventure.io.text;
import java.util.List; import java.util.List;
/** Semantic snapshot of a room for rendering. Display-ready strings, no layout. */ /**
* Semantic snapshot of a room for rendering. Display-ready strings, no layout.
*
* @param name display name of the room
* @param description room description text
* @param items display names of the items visible in the room
* @param npcs display names of the NPCs present in the room
* @param exits directions the player can leave by
*/
public record RoomView(String name, String description, public record RoomView(String name, String description,
List<String> items, List<String> npcs, List<String> exits) { List<String> items, List<String> npcs, List<String> exits) {
/** Defensively copies the lists, making the snapshot immutable. */
public RoomView { public RoomView {
items = List.copyOf(items); items = List.copyOf(items);
npcs = List.copyOf(npcs); npcs = List.copyOf(npcs);

View File

@@ -1,7 +1,17 @@
package thb.jeanluc.adventure.io.text; package thb.jeanluc.adventure.io.text;
/** A run of text carrying a single semantic {@link Style}. */ /**
* A run of text carrying a single semantic {@link Style}.
*
* @param text the text of this run; never null
* @param style the semantic role applied to the whole run; never null
*/
public record Span(String text, Style style) { public record Span(String text, Style style) {
/**
* Rejects null components.
*
* @throws IllegalArgumentException if {@code text} or {@code style} is null
*/
public Span { public Span {
if (text == null) { if (text == null) {
throw new IllegalArgumentException("text must not be null"); throw new IllegalArgumentException("text must not be null");

View File

@@ -1,4 +1,26 @@
package thb.jeanluc.adventure.io.text; package thb.jeanluc.adventure.io.text;
/** Semantic output roles. Renderers map each role to concrete colours/attributes. */ /** Semantic output roles. Renderers map each role to concrete colours/attributes. */
public enum Style { PLAIN, HEADING, ITEM, NPC, EXIT, DANGER, DIM } public enum Style {
/** Body text without emphasis. */
PLAIN,
/** Titles and section headers. */
HEADING,
/** Item names. */
ITEM,
/** NPC names. */
NPC,
/** Exit directions and room links. */
EXIT,
/** Warnings, failures and threats. */
DANGER,
/** Secondary text of lower importance, e.g. the HUD line or echoed input. */
DIM
}

View File

@@ -6,18 +6,30 @@ import java.util.List;
/** An ordered, immutable list of styled {@link Span}s. Built via {@link Builder}. */ /** An ordered, immutable list of styled {@link Span}s. Built via {@link Builder}. */
public final class StyledText { public final class StyledText {
/** The spans of this text, in output order; unmodifiable. */
private final List<Span> spans; private final List<Span> spans;
/**
* @param spans the spans to copy into this text
*/
private StyledText(List<Span> spans) { private StyledText(List<Span> spans) {
this.spans = List.copyOf(spans); this.spans = List.copyOf(spans);
} }
/** @return the spans in order (unmodifiable). */ /**
* Exposes the spans for rendering.
*
* @return the spans in order (unmodifiable)
*/
public List<Span> spans() { public List<Span> spans() {
return spans; return spans;
} }
/** @return all span text concatenated, without any styling. */ /**
* Flattens this text for frontends that cannot render styles.
*
* @return all span text concatenated, without any styling
*/
public String plainText() { public String plainText() {
StringBuilder sb = new StringBuilder(); StringBuilder sb = new StringBuilder();
for (Span s : spans) { for (Span s : spans) {
@@ -26,32 +38,104 @@ public final class StyledText {
return sb.toString(); return sb.toString();
} }
/** @return a styled text of a single {@link Style#PLAIN} span. */ /**
* Wraps an unstyled string.
*
* @param s the text
* @return a styled text of a single {@link Style#PLAIN} span
*/
public static StyledText of(String s) { public static StyledText of(String s) {
return new StyledText(List.of(new Span(s, Style.PLAIN))); return new StyledText(List.of(new Span(s, Style.PLAIN)));
} }
/**
* Starts assembling a styled text span by span.
*
* @return a new, empty builder
*/
public static Builder builder() { public static Builder builder() {
return new Builder(); return new Builder();
} }
/** Fluent builder; one method per {@link Style} role. */ /** Fluent builder; one method per {@link Style} role. */
public static final class Builder { public static final class Builder {
/** Spans collected so far, in the order the append methods were called. */
private final List<Span> spans = new ArrayList<>(); private final List<Span> spans = new ArrayList<>();
/**
* Appends one span.
*
* @param text the text to append
* @param style the role to apply to it
* @return this builder
*/
private Builder add(String text, Style style) { private Builder add(String text, Style style) {
spans.add(new Span(text, style)); spans.add(new Span(text, style));
return this; return this;
} }
/**
* Appends a span in the {@link Style#PLAIN} role.
*
* @param s text to append as {@link Style#PLAIN}
* @return this builder
*/
public Builder plain(String s) { return add(s, Style.PLAIN); } public Builder plain(String s) { return add(s, Style.PLAIN); }
/**
* Appends a span in the {@link Style#HEADING} role.
*
* @param s text to append as {@link Style#HEADING}
* @return this builder
*/
public Builder heading(String s) { return add(s, Style.HEADING); } public Builder heading(String s) { return add(s, Style.HEADING); }
/**
* Appends a span in the {@link Style#ITEM} role.
*
* @param s text to append as {@link Style#ITEM}
* @return this builder
*/
public Builder item(String s) { return add(s, Style.ITEM); } public Builder item(String s) { return add(s, Style.ITEM); }
/**
* Appends a span in the {@link Style#NPC} role.
*
* @param s text to append as {@link Style#NPC}
* @return this builder
*/
public Builder npc(String s) { return add(s, Style.NPC); } public Builder npc(String s) { return add(s, Style.NPC); }
/**
* Appends a span in the {@link Style#EXIT} role.
*
* @param s text to append as {@link Style#EXIT}
* @return this builder
*/
public Builder exit(String s) { return add(s, Style.EXIT); } public Builder exit(String s) { return add(s, Style.EXIT); }
/**
* Appends a span in the {@link Style#DANGER} role.
*
* @param s text to append as {@link Style#DANGER}
* @return this builder
*/
public Builder danger(String s) { return add(s, Style.DANGER); } public Builder danger(String s) { return add(s, Style.DANGER); }
/**
* Appends a span in the {@link Style#DIM} role.
*
* @param s text to append as {@link Style#DIM}
* @return this builder
*/
public Builder dim(String s) { return add(s, Style.DIM); } public Builder dim(String s) { return add(s, Style.DIM); }
/**
* Finishes the build; the builder may not be reused meaningfully afterwards.
*
* @return an immutable styled text of the collected spans
*/
public StyledText build() { public StyledText build() {
return new StyledText(spans); return new StyledText(spans);
} }

View File

@@ -12,9 +12,19 @@ import java.util.Map;
/** Builds {@link Combination}s from {@link CombinationDto}s, validating item ids. */ /** Builds {@link Combination}s from {@link CombinationDto}s, validating item ids. */
public final class CombinationFactory { public final class CombinationFactory {
/** Utility class; not instantiable. */
private CombinationFactory() { private CombinationFactory() {
} }
/**
* Builds a combination and checks every referenced item id against the registry.
*
* @param dto data read from {@code combinations.yaml}; must not be null
* @param items the global item registry
* @return the freshly built combination
* @throws WorldLoadException if an item id is missing, unknown, or both
* ingredients are the same item
*/
public static Combination fromDto(CombinationDto dto, Map<String, Item> items) { public static Combination fromDto(CombinationDto dto, Map<String, Item> items) {
requireItem(items, dto.a(), "a"); requireItem(items, dto.a(), "a");
requireItem(items, dto.b(), "b"); requireItem(items, dto.b(), "b");
@@ -41,6 +51,14 @@ public final class CombinationFactory {
dto.failText()); dto.failText());
} }
/**
* Asserts that {@code id} is present and known to the item registry.
*
* @param items the global item registry
* @param id the item id to check
* @param field name of the DTO field, used in the error message
* @throws WorldLoadException if the id is null or unknown
*/
private static void requireItem(Map<String, Item> items, String id, String field) { private static void requireItem(Map<String, Item> items, String id, String field) {
if (id == null) { if (id == null) {
throw new WorldLoadException("Combination '" + field + "' is missing an item id"); throw new WorldLoadException("Combination '" + field + "' is missing an item id");

View File

@@ -7,9 +7,17 @@ import thb.jeanluc.adventure.model.Ending;
/** Builds {@link Ending} objects from {@link EndingDto}s. */ /** Builds {@link Ending} objects from {@link EndingDto}s. */
public final class EndingFactory { public final class EndingFactory {
/** Utility class; not instantiable. */
private EndingFactory() { private EndingFactory() {
} }
/**
* Builds an ending, defaulting a null {@code victory} flag to false.
*
* @param dto data read from {@code endings.yaml}; must not be null
* @return the freshly built ending
* @throws WorldLoadException if a condition sets none of its fields
*/
public static Ending fromDto(EndingDto dto) { public static Ending fromDto(EndingDto dto) {
return new Ending( return new Ending(
dto.id(), dto.id(),

View File

@@ -9,6 +9,7 @@ import thb.jeanluc.adventure.model.Npc;
*/ */
public final class NpcFactory { public final class NpcFactory {
/** Utility class; not instantiable. */
private NpcFactory() { private NpcFactory() {
} }

View File

@@ -13,9 +13,17 @@ import java.util.List;
/** Builds {@link Quest} objects from {@link QuestDto}s (no reference resolution needed). */ /** Builds {@link Quest} objects from {@link QuestDto}s (no reference resolution needed). */
public final class QuestFactory { public final class QuestFactory {
/** Utility class; not instantiable. */
private QuestFactory() { private QuestFactory() {
} }
/**
* Builds a quest including all of its stages.
*
* @param dto data read from {@code quests.yaml}; must not be null
* @return the freshly built quest
* @throws WorldLoadException if a condition or effect sets none of its fields
*/
public static Quest fromDto(QuestDto dto) { public static Quest fromDto(QuestDto dto) {
List<QuestStage> stages = new ArrayList<>(); List<QuestStage> stages = new ArrayList<>();
if (dto.stages() != null) { if (dto.stages() != null) {

View File

@@ -9,6 +9,7 @@ import thb.jeanluc.adventure.model.Room;
*/ */
public final class RoomFactory { public final class RoomFactory {
/** Utility class; not instantiable. */
private RoomFactory() { private RoomFactory() {
} }

View File

@@ -22,18 +22,33 @@ public class TutorialLoader {
/** Default classpath base directory. */ /** Default classpath base directory. */
public static final String DEFAULT_BASE = "/world"; public static final String DEFAULT_BASE = "/world";
/** Single Jackson mapper, configured once for YAML input. */
private final ObjectMapper yaml = new ObjectMapper(new YAMLFactory()); private final ObjectMapper yaml = new ObjectMapper(new YAMLFactory());
/** Classpath base directory (typically {@link #DEFAULT_BASE} or a test override). */
private final String basePath; private final String basePath;
/** Convenience constructor using {@link #DEFAULT_BASE}. */
public TutorialLoader() { public TutorialLoader() {
this(DEFAULT_BASE); this(DEFAULT_BASE);
} }
/**
* Creates a loader reading from the given classpath base directory.
*
* @param basePath classpath base directory holding {@code tutorial.yaml}
*/
public TutorialLoader(String basePath) { public TutorialLoader(String basePath) {
this.basePath = basePath; this.basePath = basePath;
} }
/** Reads the tutorial; absent file → {@link Tutorial#none()}. */ /**
* Reads the tutorial; absent file → {@link Tutorial#none()}.
*
* @return the parsed tutorial, or {@link Tutorial#none()} if the file is
* missing or empty
* @throws WorldLoadException if the file exists but cannot be parsed
*/
public Tutorial load() { public Tutorial load() {
String resource = basePath + "/tutorial.yaml"; String resource = basePath + "/tutorial.yaml";
try (InputStream in = getClass().getResourceAsStream(resource)) { try (InputStream in = getClass().getResourceAsStream(resource)) {

View File

@@ -2,7 +2,18 @@ package thb.jeanluc.adventure.loader.dto;
import java.util.List; import java.util.List;
/** YAML representation of an item-combination recipe. */ /**
* YAML representation of an item-combination recipe.
*
* @param a id of the first ingredient item
* @param b id of the second ingredient item
* @param requires conditions that must hold for the recipe to apply; nullable
* @param consume ids of the items removed from the inventory on success; nullable
* @param produce id of the item handed to the player on success; nullable
* @param effects effects applied on success; nullable
* @param response text printed on success
* @param failText text printed when the required conditions are not met; nullable
*/
public record CombinationDto( public record CombinationDto(
String a, String a,
String b, String b,

View File

@@ -6,9 +6,21 @@ import thb.jeanluc.adventure.model.Condition;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.List; import java.util.List;
/** YAML condition: exactly one of the fields is set. */ /**
* YAML condition: exactly one of the fields is set.
*
* @param flag name of a flag that must be set; nullable
* @param notFlag name of a flag that must not be set; nullable
* @param hasItem id of an item the player must carry; nullable
*/
public record ConditionDto(String flag, String notFlag, String hasItem) { public record ConditionDto(String flag, String notFlag, String hasItem) {
/**
* Converts this DTO into its domain condition.
*
* @return the matching {@link Condition}
* @throws WorldLoadException if none of the fields is set
*/
public Condition toModel() { public Condition toModel() {
if (flag != null) { if (flag != null) {
return new Condition(Condition.Type.FLAG, flag); return new Condition(Condition.Type.FLAG, flag);
@@ -22,6 +34,13 @@ public record ConditionDto(String flag, String notFlag, String hasItem) {
throw new WorldLoadException("Condition must set one of flag/notFlag/hasItem"); throw new WorldLoadException("Condition must set one of flag/notFlag/hasItem");
} }
/**
* Converts a list of DTOs into domain conditions.
*
* @param dtos the DTOs to convert; null is treated as empty
* @return the converted conditions, never null
* @throws WorldLoadException if any DTO sets none of its fields
*/
public static List<Condition> toModelList(List<ConditionDto> dtos) { public static List<Condition> toModelList(List<ConditionDto> dtos) {
List<Condition> out = new ArrayList<>(); List<Condition> out = new ArrayList<>();
if (dtos != null) { if (dtos != null) {

View File

@@ -2,6 +2,11 @@ package thb.jeanluc.adventure.loader.dto;
import java.util.List; import java.util.List;
/** YAML representation of a conditional room description. */ /**
* YAML representation of a conditional room description.
*
* @param when conditions that must all hold for this variant to be used
* @param text description text used instead of the room's default
*/
public record DescriptionStateDto(List<ConditionDto> when, String text) { public record DescriptionStateDto(List<ConditionDto> when, String text) {
} }

View File

@@ -2,6 +2,11 @@ package thb.jeanluc.adventure.loader.dto;
import java.util.List; import java.util.List;
/** YAML representation of a conditional NPC line. */ /**
* YAML representation of a conditional NPC line.
*
* @param when conditions that must all hold for this line to be spoken
* @param text text the NPC says
*/
public record DialogueLineDto(List<ConditionDto> when, String text) { public record DialogueLineDto(List<ConditionDto> when, String text) {
} }

View File

@@ -6,15 +6,38 @@ import thb.jeanluc.adventure.model.Effect;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.List; import java.util.List;
/** YAML effect: exactly one of the fields is set. */ /**
* YAML effect: exactly one of the fields is set.
*
* @param setFlag name of a flag to set; nullable
* @param clearFlag name of a flag to clear; nullable
* @param giveItem id of an item to add to the inventory; nullable
* @param removeItem id of an item to take from the inventory; nullable
* @param say text to print; nullable
* @param startQuest id of a quest to start; nullable
*/
public record EffectDto(String setFlag, String clearFlag, String giveItem, String removeItem, public record EffectDto(String setFlag, String clearFlag, String giveItem, String removeItem,
String say, String startQuest) { String say, String startQuest) {
/** Backward-compatible constructor without startQuest. */ /**
* Backward-compatible constructor without startQuest.
*
* @param setFlag name of a flag to set; nullable
* @param clearFlag name of a flag to clear; nullable
* @param giveItem id of an item to add to the inventory; nullable
* @param removeItem id of an item to take from the inventory; nullable
* @param say text to print; nullable
*/
public EffectDto(String setFlag, String clearFlag, String giveItem, String removeItem, String say) { public EffectDto(String setFlag, String clearFlag, String giveItem, String removeItem, String say) {
this(setFlag, clearFlag, giveItem, removeItem, say, null); this(setFlag, clearFlag, giveItem, removeItem, say, null);
} }
/**
* Converts this DTO into its domain effect.
*
* @return the matching {@link Effect}
* @throws WorldLoadException if none of the fields is set
*/
public Effect toModel() { public Effect toModel() {
if (setFlag != null) { if (setFlag != null) {
return new Effect(Effect.Type.SET_FLAG, setFlag); return new Effect(Effect.Type.SET_FLAG, setFlag);
@@ -38,6 +61,13 @@ public record EffectDto(String setFlag, String clearFlag, String giveItem, Strin
"Effect must set one of setFlag/clearFlag/giveItem/removeItem/say/startQuest"); "Effect must set one of setFlag/clearFlag/giveItem/removeItem/say/startQuest");
} }
/**
* Converts a list of DTOs into domain effects.
*
* @param dtos the DTOs to convert; null is treated as empty
* @return the converted effects, never null
* @throws WorldLoadException if any DTO sets none of its fields
*/
public static List<Effect> toModelList(List<EffectDto> dtos) { public static List<Effect> toModelList(List<EffectDto> dtos) {
List<Effect> out = new ArrayList<>(); List<Effect> out = new ArrayList<>();
if (dtos != null) { if (dtos != null) {

View File

@@ -2,6 +2,14 @@ package thb.jeanluc.adventure.loader.dto;
import java.util.List; import java.util.List;
/** YAML representation of a game ending. */ /**
* YAML representation of a game ending.
*
* @param id unique slug
* @param title display title of the ending
* @param victory whether this ending counts as a win; defaults to false if null
* @param when conditions that must all hold for this ending to trigger
* @param text text printed when the ending triggers
*/
public record EndingDto(String id, String title, Boolean victory, List<ConditionDto> when, String text) { public record EndingDto(String id, String title, Boolean victory, List<ConditionDto> when, String text) {
} }

View File

@@ -2,6 +2,12 @@ package thb.jeanluc.adventure.loader.dto;
import java.util.List; import java.util.List;
/** YAML representation of a single condition-gated exit. */ /**
* YAML representation of a single condition-gated exit.
*
* @param direction name of the gated exit direction
* @param requires conditions that must all hold for the exit to be passable
* @param blocked text printed when the exit is used while still locked
*/
public record ExitLockDto(String direction, List<ConditionDto> requires, String blocked) { public record ExitLockDto(String direction, List<ConditionDto> requires, String blocked) {
} }

View File

@@ -20,7 +20,15 @@ public record NpcDto(
List<ReactionDto> reactions, List<ReactionDto> reactions,
List<DialogueLineDto> dialogue List<DialogueLineDto> dialogue
) { ) {
/** Backward-compatible constructor without dialogue. */ /**
* Backward-compatible constructor without dialogue, which defaults to null.
*
* @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 NpcDto(String id, String name, String description, String greeting, List<ReactionDto> reactions) { public NpcDto(String id, String name, String description, String greeting, List<ReactionDto> reactions) {
this(id, name, description, greeting, reactions, null); this(id, name, description, greeting, reactions, null);
} }

View File

@@ -2,7 +2,15 @@ package thb.jeanluc.adventure.loader.dto;
import java.util.List; import java.util.List;
/** YAML representation of a quest. */ /**
* YAML representation of a quest.
*
* @param id unique slug
* @param title display title
* @param autoStart whether the quest starts with the game; defaults to false if null
* @param stages ordered stages the player works through
* @param onComplete effects applied once the last stage is done; nullable
*/
public record QuestDto(String id, String title, Boolean autoStart, public record QuestDto(String id, String title, Boolean autoStart,
List<QuestStageDto> stages, List<EffectDto> onComplete) { List<QuestStageDto> stages, List<EffectDto> onComplete) {
} }

View File

@@ -2,6 +2,12 @@ package thb.jeanluc.adventure.loader.dto;
import java.util.List; import java.util.List;
/** YAML representation of a quest stage. */ /**
* YAML representation of a quest stage.
*
* @param objective objective text shown in the quest log
* @param completion conditions that must all hold for the stage to be done
* @param onComplete effects applied when the stage completes; nullable
*/
public record QuestStageDto(String objective, List<ConditionDto> completion, List<EffectDto> onComplete) { public record QuestStageDto(String objective, List<ConditionDto> completion, List<EffectDto> onComplete) {
} }

View File

@@ -20,7 +20,14 @@ public record ReactionDto(
List<ConditionDto> requires, List<ConditionDto> requires,
List<EffectDto> effects List<EffectDto> effects
) { ) {
/** Backward-compatible constructor without requires/effects. */ /**
* Backward-compatible constructor without requires/effects, which default to null.
*
* @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 ReactionDto(String onReceive, String response, String gives, String consumes) { public ReactionDto(String onReceive, String response, String gives, String consumes) {
this(onReceive, response, gives, consumes, null, null); this(onReceive, response, gives, consumes, null, null);
} }

View File

@@ -29,7 +29,16 @@ public record RoomDto(
Boolean dark, Boolean dark,
String music String music
) { ) {
/** Backward-compatible constructor without the optional state fields. */ /**
* Backward-compatible constructor without the optional state fields, which default to null.
*
* @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 RoomDto(String id, String name, String description, public RoomDto(String id, String name, String description,
Map<String, String> exits, List<String> items, List<String> npcs) { Map<String, String> exits, List<String> items, List<String> npcs) {
this(id, name, description, exits, items, npcs, null, null, null, null); this(id, name, description, exits, items, npcs, null, null, null, null);

View File

@@ -2,6 +2,12 @@ package thb.jeanluc.adventure.loader.dto;
import java.util.List; import java.util.List;
/** YAML representation of the tutorial document. */ /**
* YAML representation of the tutorial document.
*
* @param intro text shown before the first step
* @param steps ordered tutorial steps
* @param closingTips text shown after the last step
*/
public record TutorialDto(String intro, List<TutorialStepDto> steps, String closingTips) { public record TutorialDto(String intro, List<TutorialStepDto> steps, String closingTips) {
} }

View File

@@ -1,6 +1,14 @@
package thb.jeanluc.adventure.loader.dto; package thb.jeanluc.adventure.loader.dto;
/** YAML representation of a tutorial step. */ /**
* YAML representation of a tutorial step.
*
* @param instruction text telling the player what to type
* @param expect command verb the step accepts
* @param minArgs minimum number of arguments the command must carry; 0 if null
* @param confirm text printed once the step is passed
* @param hint text printed when the player types something else
*/
public record TutorialStepDto( public record TutorialStepDto(
String instruction, String instruction,
String expect, String expect,

View File

@@ -10,17 +10,29 @@ import java.util.List;
/** Frontend-agnostic main menu, slot picker, and new-game name prompt. */ /** Frontend-agnostic main menu, slot picker, and new-game name prompt. */
public final class MainMenu { public final class MainMenu {
/** Utility class; not instantiable. */
private MainMenu() { private MainMenu() {
} }
/** Shows the main menu and returns the chosen action. */ /**
* Shows the main menu and returns the chosen action.
*
* @param io frontend to render the menu on
* @return the action the player picked
*/
public static MenuAction show(GameIO io) { public static MenuAction show(GameIO io) {
int i = io.choose("HAUNTED MANOR", int i = io.choose("HAUNTED MANOR",
List.of("New Game", "Load Game", "Settings", "Quit")); List.of("New Game", "Load Game", "Settings", "Quit"));
return MenuAction.values()[i]; return MenuAction.values()[i];
} }
/** Prompts for a new save name; blank input yields {@code defaultName}. */ /**
* Prompts for a new save name; blank input yields {@code defaultName}.
*
* @param io frontend to prompt on
* @param defaultName name used when the player enters nothing
* @return the trimmed save name
*/
public static String promptName(GameIO io, String defaultName) { public static String promptName(GameIO io, String defaultName) {
io.write("Name your save (Enter for \"" + defaultName + "\"):"); io.write("Name your save (Enter for \"" + defaultName + "\"):");
String line = io.readLine(); String line = io.readLine();
@@ -31,6 +43,10 @@ public final class MainMenu {
* Lets the player pick a save slot to load. Returns the chosen * Lets the player pick a save slot to load. Returns the chosen
* {@link SaveSlotInfo}, or {@code null} if there are none or the player * {@link SaveSlotInfo}, or {@code null} if there are none or the player
* picks "Back". * picks "Back".
*
* @param io frontend to render the slot list on
* @param saves source of the available slots
* @return the selected slot, or {@code null} if nothing was selected
*/ */
public static SaveSlotInfo pickSlot(GameIO io, SaveService saves) { public static SaveSlotInfo pickSlot(GameIO io, SaveService saves) {
List<SaveSlotInfo> slots = saves.list(); List<SaveSlotInfo> slots = saves.list();

View File

@@ -2,5 +2,16 @@ package thb.jeanluc.adventure.menu;
/** Top-level main-menu choices, in display order. */ /** Top-level main-menu choices, in display order. */
public enum MenuAction { public enum MenuAction {
NEW_GAME, LOAD, SETTINGS, QUIT
/** Start a new game in a freshly named save slot. */
NEW_GAME,
/** Continue from an existing save slot. */
LOAD,
/** Open the settings screen. */
SETTINGS,
/** Leave the application. */
QUIT
} }

View File

@@ -10,10 +10,18 @@ import java.util.List;
/** Settings screen: cycle colour, glyph, and music level; persists + applies. */ /** Settings screen: cycle colour, glyph, and music level; persists + applies. */
public final class SettingsMenu { public final class SettingsMenu {
/** Utility class; not instantiable. */
private SettingsMenu() { private SettingsMenu() {
} }
/** Loops the settings screen until "Back", persisting + applying each change. */ /**
* Loops the settings screen until "Back", persisting + applying each change.
*
* @param io frontend to render the screen on
* @param current settings the screen starts from
* @param store destination the changed settings are written to
* @return the settings in effect when the player left the screen
*/
public static Settings show(GameIO io, Settings current, SettingsStore store) { public static Settings show(GameIO io, Settings current, SettingsStore store) {
Settings s = current; Settings s = current;
while (true) { while (true) {
@@ -36,7 +44,12 @@ public final class SettingsMenu {
} }
} }
/** Applies settings to the IO: music level always; colour/glyph only on the console. */ /**
* Applies settings to the IO: music level always; colour/glyph only on the console.
*
* @param s settings to apply
* @param io frontend the settings are applied to
*/
public static void apply(Settings s, GameIO io) { public static void apply(Settings s, GameIO io) {
io.setMusicLevel(s.musicLevel()); io.setMusicLevel(s.musicLevel());
if (io instanceof ConsoleIO c) { if (io instanceof ConsoleIO c) {
@@ -45,6 +58,7 @@ public final class SettingsMenu {
} }
} }
/** Next colour mode in the cycle AUTO {@literal ->} ON {@literal ->} OFF. */
private static ConsoleIO.ColorMode nextColor(ConsoleIO.ColorMode m) { private static ConsoleIO.ColorMode nextColor(ConsoleIO.ColorMode m) {
return switch (m) { return switch (m) {
case AUTO -> ConsoleIO.ColorMode.ON; case AUTO -> ConsoleIO.ColorMode.ON;
@@ -53,6 +67,7 @@ public final class SettingsMenu {
}; };
} }
/** Next glyph mode in the cycle UNICODE {@literal ->} ASCII {@literal ->} GLYPH. */
private static ConsoleIO.GlyphMode nextGlyph(ConsoleIO.GlyphMode m) { private static ConsoleIO.GlyphMode nextGlyph(ConsoleIO.GlyphMode m) {
return switch (m) { return switch (m) {
case UNICODE -> ConsoleIO.GlyphMode.ASCII; case UNICODE -> ConsoleIO.GlyphMode.ASCII;

View File

@@ -6,6 +6,15 @@ import java.util.List;
* A data-driven item-combination recipe. Identified by an unordered pair of * A data-driven item-combination recipe. Identified by an unordered pair of
* item ids ({@code a}, {@code b}). When applied it may require conditions, * item ids ({@code a}, {@code b}). When applied it may require conditions,
* consume items, produce an item, apply effects, and print a response. * consume items, produce an item, apply effects, and print a response.
*
* @param a id of the first item of the pair
* @param b id of the second item of the pair
* @param requires conditions that must hold for the recipe to apply; empty = always
* @param consume ids of the items removed from the player's inventory on success
* @param produce id of the item handed to the player on success; nullable
* @param effects effects applied on success
* @param response text written on success
* @param failText text written when {@code requires} does not hold; nullable
*/ */
public record Combination( public record Combination(
String a, String a,
@@ -17,18 +26,29 @@ public record Combination(
String response, String response,
String failText String failText
) { ) {
/** Normalises the list components to immutable, non-null lists. */
public Combination { public Combination {
requires = requires == null ? List.of() : List.copyOf(requires); requires = requires == null ? List.of() : List.copyOf(requires);
consume = consume == null ? List.of() : List.copyOf(consume); consume = consume == null ? List.of() : List.copyOf(consume);
effects = effects == null ? List.of() : List.copyOf(effects); effects = effects == null ? List.of() : List.copyOf(effects);
} }
/** Order-independent key for an item pair (sorted, '|'-joined). */ /**
* Order-independent key for an item pair (sorted, '|'-joined).
*
* @param x id of one item
* @param y id of the other item
* @return the canonical key identifying the pair regardless of argument order
*/
public static String key(String x, String y) { public static String key(String x, String y) {
return x.compareTo(y) <= 0 ? x + "|" + y : y + "|" + x; return x.compareTo(y) <= 0 ? x + "|" + y : y + "|" + x;
} }
/** This recipe's canonical pair key. */ /**
* This recipe's canonical pair key.
*
* @return the key under which the recipe is registered in the world
*/
public String key() { public String key() {
return key(a, b); return key(a, b);
} }

View File

@@ -1,6 +1,21 @@
package thb.jeanluc.adventure.model; package thb.jeanluc.adventure.model;
/** A single boolean test evaluated against world state and the player. */ /**
* A single boolean test evaluated against world state and the player.
*
* @param type the kind of test to perform
* @param arg the operand the test applies to: a flag name for {@link Type#FLAG}
* and {@link Type#NOT_FLAG}, an item id for {@link Type#HAS_ITEM}
*/
public record Condition(Type type, String arg) { public record Condition(Type type, String arg) {
public enum Type { FLAG, NOT_FLAG, HAS_ITEM }
/** The kinds of test a {@link Condition} can perform. */
public enum Type {
/** Holds while the named flag is set in the world state. */
FLAG,
/** Holds while the named flag is not set in the world state. */
NOT_FLAG,
/** Holds while the player carries the named item. */
HAS_ITEM
}
} }

View File

@@ -2,8 +2,14 @@ package thb.jeanluc.adventure.model;
import java.util.List; import java.util.List;
/** An alternate room description shown while its conditions hold. */ /**
* An alternate room description shown while its conditions hold.
*
* @param when conditions that must all hold for this variant to be selected; empty = always
* @param text description text shown instead of the room's default description
*/
public record DescriptionState(List<Condition> when, String text) { public record DescriptionState(List<Condition> when, String text) {
/** Normalises {@code when} to an immutable, non-null list. */
public DescriptionState { public DescriptionState {
when = when == null ? List.of() : List.copyOf(when); when = when == null ? List.of() : List.copyOf(when);
} }

View File

@@ -2,8 +2,14 @@ package thb.jeanluc.adventure.model;
import java.util.List; import java.util.List;
/** A conditional line an NPC says on {@code talk}, first match wins. */ /**
* A conditional line an NPC says on {@code talk}, first match wins.
*
* @param when conditions that must all hold for this line to be selected; empty = always
* @param text text the NPC says when this line is selected
*/
public record DialogueLine(List<Condition> when, String text) { public record DialogueLine(List<Condition> when, String text) {
/** Normalises {@code when} to an immutable, non-null list. */
public DialogueLine { public DialogueLine {
when = when == null ? List.of() : List.copyOf(when); when = when == null ? List.of() : List.copyOf(when);
} }

View File

@@ -1,6 +1,29 @@
package thb.jeanluc.adventure.model; package thb.jeanluc.adventure.model;
/** A single state mutation or message. */ /**
* A single state mutation or message.
*
* @param type the kind of mutation to apply
* @param arg the operand the mutation applies to: a flag name for
* {@link Type#SET_FLAG} and {@link Type#CLEAR_FLAG}, an item id for
* {@link Type#GIVE_ITEM} and {@link Type#REMOVE_ITEM}, the literal text
* for {@link Type#SAY}, a quest id for {@link Type#START_QUEST}
*/
public record Effect(Type type, String arg) { public record Effect(Type type, String arg) {
public enum Type { SET_FLAG, CLEAR_FLAG, GIVE_ITEM, REMOVE_ITEM, SAY, START_QUEST }
/** The kinds of mutation an {@link Effect} can apply. */
public enum Type {
/** Sets the named flag in the world state. */
SET_FLAG,
/** Clears the named flag from the world state. */
CLEAR_FLAG,
/** Adds the named item to the player's inventory. */
GIVE_ITEM,
/** Takes the named item out of the player's inventory. */
REMOVE_ITEM,
/** Writes the argument to the player's output. */
SAY,
/** Activates the named quest. */
START_QUEST
}
} }

View File

@@ -2,8 +2,17 @@ package thb.jeanluc.adventure.model;
import java.util.List; import java.util.List;
/** A game ending: shown (and ends the game) when its conditions hold. */ /**
* A game ending: shown (and ends the game) when its conditions hold.
*
* @param id unique identifier of this ending
* @param title headline shown above the ending text
* @param victory whether reaching this ending counts as winning
* @param when conditions that must all hold for this ending to trigger
* @param text closing text shown to the player
*/
public record Ending(String id, String title, boolean victory, List<Condition> when, String text) { public record Ending(String id, String title, boolean victory, List<Condition> when, String text) {
/** Normalises {@code when} to an immutable, non-null list. */
public Ending { public Ending {
when = when == null ? List.of() : List.copyOf(when); when = when == null ? List.of() : List.copyOf(when);
} }

View File

@@ -2,8 +2,14 @@ package thb.jeanluc.adventure.model;
import java.util.List; import java.util.List;
/** A condition-gated exit with a message shown when it is blocked. */ /**
* A condition-gated exit with a message shown when it is blocked.
*
* @param requires conditions that must all hold for the exit to be passable
* @param blocked text written when the player tries the exit while it is blocked
*/
public record ExitLock(List<Condition> requires, String blocked) { public record ExitLock(List<Condition> requires, String blocked) {
/** Normalises {@code requires} to an immutable, non-null list. */
public ExitLock { public ExitLock {
requires = requires == null ? List.of() : List.copyOf(requires); requires = requires == null ? List.of() : List.copyOf(requires);
} }

View File

@@ -2,9 +2,19 @@ package thb.jeanluc.adventure.model;
import java.util.List; import java.util.List;
/** A multi-stage quest. Stage completion is condition-driven; rewards are effects. */ /**
* A multi-stage quest. Stage completion is condition-driven; rewards are effects.
*
* @param id unique identifier of this quest, referenced by
* {@link Effect.Type#START_QUEST}
* @param title display name shown in the quest log
* @param autoStart whether the quest is active from the start of the game
* @param stages the stages in the order they must be completed
* @param onComplete effects applied once the final stage is completed
*/
public record Quest(String id, String title, boolean autoStart, public record Quest(String id, String title, boolean autoStart,
List<QuestStage> stages, List<Effect> onComplete) { List<QuestStage> stages, List<Effect> onComplete) {
/** Normalises the list components to immutable, non-null lists. */
public Quest { public Quest {
stages = stages == null ? List.of() : List.copyOf(stages); stages = stages == null ? List.of() : List.copyOf(stages);
onComplete = onComplete == null ? List.of() : List.copyOf(onComplete); onComplete = onComplete == null ? List.of() : List.copyOf(onComplete);

View File

@@ -2,8 +2,15 @@ package thb.jeanluc.adventure.model;
import java.util.List; import java.util.List;
/** One stage of a {@link Quest}: an objective, a completion condition, optional rewards. */ /**
* One stage of a {@link Quest}: an objective, a completion condition, optional rewards.
*
* @param objective task description shown in the quest log while this stage is active
* @param completion conditions that must all hold for this stage to count as done
* @param onComplete effects applied when this stage is completed
*/
public record QuestStage(String objective, List<Condition> completion, List<Effect> onComplete) { public record QuestStage(String objective, List<Condition> completion, List<Effect> onComplete) {
/** Normalises the list components to immutable, non-null lists. */
public QuestStage { public QuestStage {
completion = completion == null ? List.of() : List.copyOf(completion); completion = completion == null ? List.of() : List.copyOf(completion);
onComplete = onComplete == null ? List.of() : List.copyOf(onComplete); onComplete = onComplete == null ? List.of() : List.copyOf(onComplete);

View File

@@ -2,18 +2,34 @@ package thb.jeanluc.adventure.model;
import java.util.List; import java.util.List;
/** Onboarding walkthrough: an intro, ordered steps, and closing tips. */ /**
* Onboarding walkthrough: an intro, ordered steps, and closing tips.
*
* @param intro text shown before the first step; nullable
* @param steps the steps in the order the player must complete them
* @param closingTips text shown after the last step; nullable
*/
public record Tutorial(String intro, List<TutorialStep> steps, String closingTips) { public record Tutorial(String intro, List<TutorialStep> steps, String closingTips) {
/** Normalises {@code steps} to an immutable, non-null list. */
public Tutorial { public Tutorial {
steps = steps == null ? List.of() : List.copyOf(steps); steps = steps == null ? List.of() : List.copyOf(steps);
} }
/**
* Whether this tutorial has anything to walk the player through.
*
* @return {@code true} if there are no steps
*/
public boolean isEmpty() { public boolean isEmpty() {
return steps.isEmpty(); return steps.isEmpty();
} }
/** An absent tutorial (no steps). */ /**
* An absent tutorial (no steps).
*
* @return a tutorial that {@link #isEmpty()} reports as empty
*/
public static Tutorial none() { public static Tutorial none() {
return new Tutorial(null, List.of(), null); return new Tutorial(null, List.of(), null);
} }

View File

@@ -5,6 +5,12 @@ package thb.jeanluc.adventure.model;
* ({@code expect} is a verb name like "look", or "go_direction"/"go_to_room"), * ({@code expect} is a verb name like "look", or "go_direction"/"go_to_room"),
* a minimum argument count, a success confirmation, and a hint for a valid but * a minimum argument count, a success confirmation, and a hint for a valid but
* wrong command. * wrong command.
*
* @param instruction task text shown to the player while this step is active
* @param expect name of the command that advances this step
* @param minArgs number of arguments the command must carry to count
* @param confirm text written once the step is satisfied
* @param hint text written when the player runs a valid but different command
*/ */
public record TutorialStep( public record TutorialStep(
String instruction, String instruction,

View File

@@ -40,19 +40,46 @@ public class World {
/** Global lookup of combination recipes by canonical pair key. */ /** Global lookup of combination recipes by canonical pair key. */
private final Map<String, Combination> combinations; private final Map<String, Combination> combinations;
/** Backward-compatible constructor for worlds without quests, endings, or combinations. */ /**
* Backward-compatible constructor for worlds without quests, endings, or combinations.
*
* @param rooms rooms by id
* @param items item prototypes by id
* @param npcs NPCs by id
* @param title display title
* @param welcomeMessage welcome message printed at startup
*/
public World(Map<String, Room> rooms, Map<String, Item> items, Map<String, Npc> npcs, public World(Map<String, Room> rooms, Map<String, Item> items, Map<String, Npc> npcs,
String title, String welcomeMessage) { String title, String welcomeMessage) {
this(rooms, items, npcs, title, welcomeMessage, Map.of(), List.of(), Map.of()); this(rooms, items, npcs, title, welcomeMessage, Map.of(), List.of(), Map.of());
} }
/** Backward-compatible constructor for worlds with quests but no endings/combinations. */ /**
* Backward-compatible constructor for worlds with quests but no endings/combinations.
*
* @param rooms rooms by id
* @param items item prototypes by id
* @param npcs NPCs by id
* @param title display title
* @param welcomeMessage welcome message printed at startup
* @param quests quests by id
*/
public World(Map<String, Room> rooms, Map<String, Item> items, Map<String, Npc> npcs, public World(Map<String, Room> rooms, Map<String, Item> items, Map<String, Npc> npcs,
String title, String welcomeMessage, Map<String, Quest> quests) { String title, String welcomeMessage, Map<String, Quest> quests) {
this(rooms, items, npcs, title, welcomeMessage, quests, List.of(), Map.of()); this(rooms, items, npcs, title, welcomeMessage, quests, List.of(), Map.of());
} }
/** Backward-compatible constructor for worlds with quests + endings but no combinations. */ /**
* Backward-compatible constructor for worlds with quests + endings but no combinations.
*
* @param rooms rooms by id
* @param items item prototypes by id
* @param npcs NPCs by id
* @param title display title
* @param welcomeMessage welcome message printed at startup
* @param quests quests by id
* @param endings endings in evaluation order
*/
public World(Map<String, Room> rooms, Map<String, Item> items, Map<String, Npc> npcs, public World(Map<String, Room> rooms, Map<String, Item> items, Map<String, Npc> npcs,
String title, String welcomeMessage, Map<String, Quest> quests, List<Ending> endings) { String title, String welcomeMessage, Map<String, Quest> quests, List<Ending> endings) {
this(rooms, items, npcs, title, welcomeMessage, quests, endings, Map.of()); this(rooms, items, npcs, title, welcomeMessage, quests, endings, Map.of());

View File

@@ -20,10 +20,16 @@ import java.util.Map;
*/ */
public final class SaveCodec { public final class SaveCodec {
/** Utility class; not instantiable. */
private SaveCodec() { private SaveCodec() {
} }
/** Reads the mutable state of {@code session} into a {@link SaveData}. */ /**
* Reads the mutable state of {@code session} into a {@link SaveData}.
*
* @param session the live session to snapshot
* @return a snapshot of the session's mutable state
*/
public static SaveData capture(GameSession session) { public static SaveData capture(GameSession session) {
World w = session.getWorld(); World w = session.getWorld();
Player p = session.getPlayer(); Player p = session.getPlayer();
@@ -67,6 +73,10 @@ public final class SaveCodec {
* Overlays {@code data} onto {@code session} (whose world was just loaded * Overlays {@code data} onto {@code session} (whose world was just loaded
* from YAML). Throws {@link SaveException} if the save references ids that * from YAML). Throws {@link SaveException} if the save references ids that
* no longer exist in the current world. * no longer exist in the current world.
*
* @param data the snapshot to restore
* @param session the session to overwrite
* @throws SaveException if the snapshot refers to a room or item the current world does not contain
*/ */
public static void apply(SaveData data, GameSession session) { public static void apply(SaveData data, GameSession session) {
World w = session.getWorld(); World w = session.getWorld();
@@ -106,6 +116,7 @@ public final class SaveCodec {
session.setTurn(data.turn()); session.setTurn(data.turn());
} }
/** Resolves a room id against the world, or fails with a {@link SaveException}. */
private static Room requireRoom(World w, String id) { private static Room requireRoom(World w, String id) {
Room r = w.getRooms().get(id); Room r = w.getRooms().get(id);
if (r == null) { if (r == null) {
@@ -115,6 +126,7 @@ public final class SaveCodec {
return r; return r;
} }
/** Resolves an item id against the world, or fails with a {@link SaveException}. */
private static Item requireItem(World w, String id) { private static Item requireItem(World w, String id) {
Item i = w.getItems().get(id); Item i = w.getItems().get(id);
if (i == null) { if (i == null) {

View File

@@ -7,6 +7,21 @@ import java.util.Map;
* JSON-serialised snapshot of the mutable game state — a delta laid over the * JSON-serialised snapshot of the mutable game state — a delta laid over the
* world freshly loaded from YAML. Header fields ({@code slotName}, room, * world freshly loaded from YAML. Header fields ({@code slotName}, room,
* {@code turn}, {@code savedAtEpochMillis}) double as the slot-list metadata. * {@code turn}, {@code savedAtEpochMillis}) double as the slot-list metadata.
*
* @param schemaVersion on-disk format version; must equal {@link #CURRENT_VERSION} to load
* @param worldTitle title of the world the snapshot was taken from
* @param slotName player-visible name of the save slot
* @param savedAtEpochMillis wall-clock time the snapshot was written, in epoch milliseconds
* @param turn turn counter at the time of saving
* @param currentRoomId id of the room the player stands in
* @param visitedRoomIds ids of all rooms the player has entered (fog of war)
* @param gold the player's gold amount
* @param inventoryItemIds ids of the items the player carries
* @param flags set world-state flags
* @param questStages stage index per active quest id
* @param questCompleted ids of completed quests
* @param roomItemIds item ids lying in each room, keyed by room id; rooms without items are omitted
* @param switchStates on/off state per switchable item id
*/ */
public record SaveData( public record SaveData(
int schemaVersion, int schemaVersion,

View File

@@ -3,15 +3,30 @@ package thb.jeanluc.adventure.save;
/** Thrown when a save cannot be written/read; carries a player-facing message. */ /** Thrown when a save cannot be written/read; carries a player-facing message. */
public class SaveException extends RuntimeException { public class SaveException extends RuntimeException {
/**
* Creates a failure that keeps the triggering exception as its cause.
*
* @param message player-facing description of the failure
* @param cause the underlying I/O or parse failure
*/
public SaveException(String message, Throwable cause) { public SaveException(String message, Throwable cause) {
super(message, cause); super(message, cause);
} }
/**
* Creates a failure without an underlying cause.
*
* @param message player-facing description of the failure
*/
public SaveException(String message) { public SaveException(String message) {
super(message); super(message);
} }
/** Player-facing message (the constructor message is already user-friendly). */ /**
* Player-facing message (the constructor message is already user-friendly).
*
* @return the message to show to the player
*/
public String getUserMessage() { public String getUserMessage() {
return getMessage(); return getMessage();
} }

View File

@@ -1,6 +1,14 @@
package thb.jeanluc.adventure.save; package thb.jeanluc.adventure.save;
/** Lightweight metadata for one save slot, shown in the Load menu. */ /**
* Lightweight metadata for one save slot, shown in the Load menu.
*
* @param slug filename stem of the slot on disk; the id used to load it
* @param slotName player-visible name of the slot
* @param roomId id of the room the player stands in
* @param turn turn counter at the time of saving
* @param savedAtEpochMillis wall-clock time the slot was written, in epoch milliseconds
*/
public record SaveSlotInfo(String slug, String slotName, String roomId, public record SaveSlotInfo(String slug, String slotName, String roomId,
int turn, long savedAtEpochMillis) { int turn, long savedAtEpochMillis) {
} }

View File

@@ -3,9 +3,16 @@ package thb.jeanluc.adventure.save;
import thb.jeanluc.adventure.io.ConsoleIO; import thb.jeanluc.adventure.io.ConsoleIO;
import thb.jeanluc.adventure.io.MusicLevel; import thb.jeanluc.adventure.io.MusicLevel;
/** Persisted user preferences (colour + glyph fidelity + music level). */ /**
* Persisted user preferences (colour + glyph fidelity + music level).
*
* @param colorMode ANSI colour policy of the console frontend
* @param glyphMode glyph fidelity used when rendering maps and frames
* @param musicLevel volume level of the background music
*/
public record Settings(ConsoleIO.ColorMode colorMode, ConsoleIO.GlyphMode glyphMode, MusicLevel musicLevel) { public record Settings(ConsoleIO.ColorMode colorMode, ConsoleIO.GlyphMode glyphMode, MusicLevel musicLevel) {
/** Compact canonical constructor: substitutes the defaults for any null component. */
public Settings { public Settings {
if (colorMode == null) { if (colorMode == null) {
colorMode = ConsoleIO.ColorMode.AUTO; colorMode = ConsoleIO.ColorMode.AUTO;
@@ -18,11 +25,21 @@ public record Settings(ConsoleIO.ColorMode colorMode, ConsoleIO.GlyphMode glyphM
} }
} }
/** Backward-compatible constructor for callers that don't set music. */ /**
* Backward-compatible constructor for callers that don't set music.
*
* @param colorMode ANSI colour policy of the console frontend
* @param glyphMode glyph fidelity used when rendering maps and frames
*/
public Settings(ConsoleIO.ColorMode colorMode, ConsoleIO.GlyphMode glyphMode) { public Settings(ConsoleIO.ColorMode colorMode, ConsoleIO.GlyphMode glyphMode) {
this(colorMode, glyphMode, MusicLevel.MEDIUM); this(colorMode, glyphMode, MusicLevel.MEDIUM);
} }
/**
* Settings used when nothing has been persisted yet.
*
* @return the default preferences
*/
public static Settings defaults() { public static Settings defaults() {
return new Settings(ConsoleIO.ColorMode.AUTO, ConsoleIO.GlyphMode.UNICODE, MusicLevel.MEDIUM); return new Settings(ConsoleIO.ColorMode.AUTO, ConsoleIO.GlyphMode.UNICODE, MusicLevel.MEDIUM);
} }

View File

@@ -11,18 +11,31 @@ import java.nio.file.Path;
@Slf4j @Slf4j
public class SettingsStore { public class SettingsStore {
/** Backing JSON file. */
private final Path file; private final Path file;
/** Jackson mapper used for both directions. */
private final ObjectMapper mapper = new ObjectMapper(); private final ObjectMapper mapper = new ObjectMapper();
/** Uses the default {@code saves/settings.json} file. */
public SettingsStore() { public SettingsStore() {
this(Path.of("saves", "settings.json")); this(Path.of("saves", "settings.json"));
} }
/**
* Uses a custom settings file.
*
* @param file JSON file to read from and write to
*/
public SettingsStore(Path file) { public SettingsStore(Path file) {
this.file = file; this.file = file;
} }
/** Returns persisted settings, or {@link Settings#defaults()} on any problem. */ /**
* Returns persisted settings, or {@link Settings#defaults()} on any problem.
*
* @return the stored settings, or the defaults if the file is missing or unreadable
*/
public Settings load() { public Settings load() {
if (!Files.isRegularFile(file)) { if (!Files.isRegularFile(file)) {
return Settings.defaults(); return Settings.defaults();
@@ -35,7 +48,11 @@ public class SettingsStore {
} }
} }
/** Persists settings; logs and swallows failures (non-fatal). */ /**
* Persists settings; logs and swallows failures (non-fatal).
*
* @param settings the preferences to write
*/
public void save(Settings settings) { public void save(Settings settings) {
try { try {
if (file.getParent() != null) { if (file.getParent() != null) {