Compare commits

...

136 Commits

Author SHA1 Message Date
dacac81382 Merge GUI background music into develop
Per-room OGG Vorbis background music in the Swing GUI: a testable MusicController
(idempotent track switch, null->silence, OFF disables) over an isolated
OggMusicBackend (vorbisspi/jorbis, streamed PCM, ~400ms gain fades, graceful on
missing files). Driven from GameIO.setMusic per turn (console no-op) and a new
persisted Music level (Off/Low/Med/High, default Medium) in Settings. Tracks come
from an external gitignored music/ folder via an optional rooms.yaml `music:`
field. Console stays silent.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 23:29:11 +02:00
b5d603a7b1 docs: clarify music transition is a sequential fade, not cross-fade 2026-06-01 23:29:11 +02:00
08c92a57de docs: mark GUI background music implemented 2026-06-01 23:25:11 +02:00
2891553629 feat(io): OggMusicBackend + SwingIO wiring; music/ folder + docs
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-01 23:20:14 +02:00
bcd2ae9ddd refactor(settings): guard all Settings fields vs null; tidy import + test
Guard colorMode and glyphMode against null in the Settings compact
constructor (alongside the existing musicLevel guard), so a hand-edited
settings.json with a missing field no longer NPEs in SettingsMenu.
Remove the unused MusicLevel import from SettingsMenu. Assert that the
defaulted musicLevel survives a round-trip in SettingsStoreTest.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-01 23:17:26 +02:00
2bad875df1 feat(settings): persisted Music level; SettingsMenu applies via GameIO
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 23:08:27 +02:00
d0d8648bd0 feat(io): GameIO music seams; push room track per turn, silence at menu
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-01 23:02:44 +02:00
35ad4e6f1a feat(loader): per-room music field + demo rooms.yaml entries
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-01 22:58:13 +02:00
a38bb6e4f3 fix(io): idempotent MusicController.level; assert gain forwarding + shutdown
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-01 22:55:27 +02:00
7f64c9f9e3 feat(io): MusicController + MusicBackend (testable music logic)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-01 22:50:58 +02:00
a1fd56b389 test(io): assert MEDIUM.isOn() too 2026-06-01 22:49:20 +02:00
d0e290c723 feat(io): MusicLevel enum (Off/Low/Med/High)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-01 22:47:40 +02:00
36f60863ad docs: implementation plan for GUI background music
7 TDD tasks: MusicLevel, MusicController+MusicBackend, Room music field,
GameIO seams + loop/app wiring, Settings music level + SettingsMenu->GameIO
refactor, OggMusicBackend + deps + SwingIO wiring + docs, backlog note.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 22:46:15 +02:00
a0e21a9baa docs: spec for GUI background music
OGG Vorbis (vorbisspi+jorbis) per-room music via rooms.yaml, external gitignored
music/ folder. Testable MusicController (idempotent track switch, null->silence,
OFF disables) over an isolated OggMusicBackend; GameIO.setMusic/setMusicLevel
no-op on console, played by SwingIO. Settings gains a Music level Off/Low/Med/High
(default Medium). Console stays silent.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 22:21:10 +02:00
ffc4d7acab Merge: suppress endings during the tutorial 2026-06-01 22:09:06 +02:00
20d076832d fix(game): suppress endings while the tutorial is active
A command during the walkthrough (e.g. use front_door -> fled) no longer ends
the game mid-tutorial; a pending ending fires once the tutorial finishes or is
skipped.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 22:09:06 +02:00
c5993d1236 Merge interactive start-of-game tutorial into develop
A skippable interactive walkthrough shown on New Game: a TutorialGuide layered
onto the real game loop makes the player perform the core commands
(look→examine→take→inventory→use→go→go to) in the starting room, with verb-/
alias-aware completion (movement steps require an actual room change), gentle
hints on wrong-but-valid input, and closing tips for the rest. Data-driven
(tutorial.yaml + standalone TutorialLoader); 'skip' ends it; loaded games never
show it.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 22:03:59 +02:00
1e980a80e2 docs: note interactive start-of-game tutorial 2026-06-01 21:59:44 +02:00
19ea7667b0 refactor: consistent isActive() guard on tutorial onCommand; tidy App imports 2026-06-01 21:59:07 +02:00
2ed6a71cea feat: wire TutorialGuide into the game loop; show on New Game only
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-01 21:53:27 +02:00
28871b4ddc fix(game): null-safe moved() + idempotent begin(); cover multi-step + go_to_room hint
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-01 21:49:47 +02:00
546a7672bf feat(game): TutorialGuide interactive walkthrough engine
Alias-aware, stateful engine with verb/minArgs matching, go_direction/go_to_room
room-change detection, skip, and closing-tips on completion. 7 tests, all green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 21:42:30 +02:00
f47a3e7ca5 test(tutorial): cover malformed tutorial.yaml -> WorldLoadException 2026-06-01 21:40:06 +02:00
95098fbb09 feat(tutorial): Tutorial model + TutorialLoader + tutorial.yaml content
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-01 21:25:48 +02:00
5c9a8cb568 docs: implementation plan for interactive start tutorial
4 TDD tasks: Tutorial model+loader+tutorial.yaml, TutorialGuide engine,
Game+App wiring (New Game only), docs.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 21:23:06 +02:00
055a256b03 docs: spec for interactive start-of-game tutorial
Interactive walkthrough layered on the real game loop via a TutorialGuide
(optional in Game): core arc look->examine->take->inventory->use->go->go to,
verb-based alias-aware completion (movement steps require an actual room
change), closing tips, skippable via 'skip'. Data-driven tutorial.yaml +
separate TutorialLoader (not in World). Shown only on New Game.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 21:10:13 +02:00
ba63ce7b47 Merge go to <room> pathfinding into develop
BFS auto-walk over visited rooms (respecting locked exits + darkness), routed
through GoCommand (go to <room> vs go <direction>). Console: silent traversal +
a start/middle/destination summary line. GUI: the minimap animates the player
moving room-by-room via a new GameIO.travelStep hook (SwingIO override, ~300ms;
console no-op). New stateless Pathfinder BFS in the game package. Trie
autocomplete deferred.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 20:27:30 +02:00
930915da59 docs: sync spec unknown-target message with implemented wording 2026-06-01 20:27:19 +02:00
6d1b36f853 docs: mark go to <room> pathfinding implemented 2026-06-01 20:24:26 +02:00
c01c47a585 fix(command): combined unknown-target message; cover both go-to summary forms
- UX: unknown-target message now reads "don't know any direction or place
  called '...'" so direction typos (e.g. 'go nroth') make sense
- clarity: resolveVisited uses equalsIgnoreCase for id/name matching;
  add null-world guard comment pointing to CommandTestSupport
- Javadoc: note that 'to' filler word is stripped, making go-to forms equivalent
- tests: assert 'through the Hallway' in 3-node route; update unknown message
  assertion; add adjacentWalkUsesShortSummary covering the n==2 short-form path

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-01 20:23:41 +02:00
a30c153de1 feat(command): go to <room> auto-walk (BFS path, animated map, summary)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-01 20:13:25 +02:00
663a78ed9e feat(game): Pathfinder BFS over visited rooms (locks + light aware)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-01 20:08:11 +02:00
7c1cdc545e feat(io): travelStep hook for animated auto-walk (console no-op, Swing override)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-01 20:04:17 +02:00
7b60230f1e docs: implementation plan for go to <room> pathfinding
4 TDD tasks: GameIO.travelStep hook (+SwingIO animation), Pathfinder BFS over
visited rooms (locks+light), GoCommand routing/resolve/walk/summary, docs.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 20:02:11 +02:00
d10632b800 docs: spec for go to <room> pathfinding
BFS over visited rooms (respecting locks + light), routed via GoCommand
(go to <room> vs go <direction>); silent console traversal + summary naming
start/middle/destination; GUI minimap animates per step via a new
GameIO.travelStep hook (SwingIO override, ~300ms). Trie autocomplete deferred.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 19:03:26 +02:00
c10590028e Merge use X on Y item combinations into develop
Data-driven combination recipes (combinations.yaml: requires/consume/produce/
effects/response/failText), operands resolved from inventory or current room,
order-independent canonical pair key. UseCommand routes 2+ args to a new
Combinations engine; no parser change (on/with/to already fillers). Loader
validates ids + rejects self-combination/duplicate pairs at load. Demo:
matches + torch -> lit_torch (a working light source).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 17:48:20 +02:00
966762fff9 docs: mark use X on Y item combination implemented 2026-06-01 17:45:16 +02:00
1bdae4f295 test(loader): assert exact item set incl. demo items (restore completeness check) 2026-06-01 17:44:32 +02:00
8d88157573 feat(loader): load combinations.yaml; demo matches+torch -> lit_torch
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-01 17:38:17 +02:00
63af4c7641 docs(command): update UseCommand javadoc for the combination form 2026-06-01 17:32:58 +02:00
a89684807b feat(command): route use X on Y to the combination engine
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 17:31:07 +02:00
e7b9891771 fix(combinations): guard null response; reject self-combination at load
- Combinations.tryUse: only write response when non-null and non-blank,
  preventing NPE/IllegalArgument for effects-only recipes.
- CombinationFactory.fromDto: throw WorldLoadException when a == b,
  since item singletons make a self-combination impossible at runtime.
- Tests: +effectsOnlyRecipeAppliesEffectsWithoutProduceOrResponse
  (CombinationsTest 7→8), +rejectsSelfCombination
  (CombinationFactoryTest 4→5).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-01 17:28:32 +02:00
89962b4b60 feat(game): Combinations engine for use X on Y
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-01 17:22:16 +02:00
667991df26 test(loader): cover unknown consume id rejection 2026-06-01 17:19:41 +02:00
3d183ad162 feat(loader): CombinationDto + CombinationFactory with id validation 2026-06-01 17:17:25 +02:00
9f9b0c0808 feat(model): World.combinations field + backward-compatible constructors 2026-06-01 17:13:05 +02:00
bbc4fc37f8 style(test): drop unused import in CombinationTest 2026-06-01 17:11:37 +02:00
b7094bf152 feat(model): Combination recipe record + order-independent key 2026-06-01 17:08:14 +02:00
2e48ec7221 docs: implementation plan for use X on Y combinations
7 TDD tasks: Combination model+key, World.combinations, CombinationDto+Factory
(id validation), Combinations engine, UseCommand routing, loader wiring + demo
(matches+torch -> lit_torch), docs.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 17:06:35 +02:00
34cbb7f158 docs: spec for use X on Y item combinations
Data-driven combination recipes (requires/consume/produce/effects/response/
failText), operands from inventory or current room, order-independent lookup,
generic-or-failText feedback. No parser change (on/with already fillers).
Minimal matches+torch -> lit_torch demo.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 16:58:39 +02:00
6887102d19 feat(io): themed full-window menu overlay in the GUI
Replace the plain Swing JDialog menu with a glass-pane overlay that fills
the whole window: dark background matching the game palette, bundled font,
lavender heading, flat hover-highlighted buttons, Escape = last option. The
overlay hides the game chrome (HUD/map/quest panels) while in the menu and
rebuilds at the current frameScale*zoom on window resize, so it scales
correctly from startup.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 16:21:46 +02:00
3ae2dc2175 Merge main menu + save/load into develop
Adds a main-menu shell above the game loop (New Game / Load / Settings /
Quit) via a new GameIO.choose primitive (console numbered menu + Swing
buttons), JSON save/load as a delta over the YAML world (single active
slot; manual save, autosave on quest-completion + every 10 turns, and
quit-to-menu), and minimal runtime-togglable settings (colour + glyph).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 15:42:09 +02:00
9d3056d53e fix: EOF ends game loop (ConsoleIO returns null on EOF); settings.json excluded from save slots
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 15:39:08 +02:00
8a8700994c docs: mark main menu + save/load implemented; note minimal settings scope
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-01 15:30:51 +02:00
635ea8004c feat(io): SwingIO.choose renders the menu as buttons 2026-06-01 15:27:15 +02:00
163e2cc11b refactor(app): drop dead newSession param, unused import, restore main javadoc
- Remove unused `saves` parameter from `newSession(GameIO, SaveService)`
  → signature is now `newSession(GameIO)`, call site updated accordingly
- Remove unused `import thb.jeanluc.adventure.menu.MenuAction` (switch
  uses unqualified constants; import was dead, compile confirmed clean)
- Restore `/** Standard JVM entry point. @param args ignored */` Javadoc
  on `main` and restore full log message "Fatal error during game startup"

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-01 15:25:01 +02:00
1f15bfe330 feat(app): main-menu shell loop wiring; gitignore saves/
Replace App.run with a main-menu shell (New Game / Load / Settings /
Quit) above the game loop, wire SaveCommand + MenuCommand into the
registry with an autosave callback, and remove the now-unused
QuitCommand. Migrate GameTest from QuitCommand to MenuCommand.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 15:17:46 +02:00
9632d27b25 feat(game): autosave on quest completion 2026-06-01 15:12:18 +02:00
4b2357cd5b feat(command): save + menu commands (QuitCommand removal deferred to Task 11) 2026-06-01 15:10:10 +02:00
e564253f23 test(menu): cover pickSlot selection and Back sentinel
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-01 15:03:18 +02:00
6921af053a feat(menu): MainMenu, SettingsMenu, MenuAction (frontend-agnostic) 2026-06-01 14:58:41 +02:00
735a92ca6e test(settings): cover corrupt settings file falls back to defaults
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-01 14:56:14 +02:00
dfc50f0d9f feat(settings): Settings + SettingsStore; runtime-togglable ConsoleIO 2026-06-01 14:52:28 +02:00
404221525c test(save): cover version mismatch + slug; clean up tmp on move failure
Add loadRejectsIncompatibleVersion test asserting SaveException on schema
version 999, assert derived slug in listReturnsSlotMetadata, and harden
save() to delete the orphaned .tmp file when the non-atomic fallback move
also fails before re-throwing.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-01 14:50:10 +02:00
8eb433e02d feat(save): SaveService disk persistence (atomic write, list, load) 2026-06-01 14:44:32 +02:00
1172f7c3fd fix(save): restore visited set before setting current room; strengthen null-tolerance test
Reorder apply() so visitedRoomIds is restored before setCurrentRoom() is
called, ensuring the current room is never accidentally stripped from the
visited set by the subsequent clear(). Also adds a post-condition assertion
to applyToleratesMissingCollections that proves null roomItemIds leaves
room item tables empty after apply.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-01 14:42:15 +02:00
80a5112508 feat(save): SaveCodec capture/apply over a fresh world; tolerate null collections
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-01 14:34:48 +02:00
6d703c7caf feat(save): SaveData/SaveSlotInfo/SaveException model 2026-06-01 14:29:55 +02:00
b73d0aaa82 feat(io): GameIO.choose numbered-menu primitive (console default) 2026-06-01 14:26:43 +02:00
beba1c7e72 feat: restore hooks for flags, quest progress, switch state 2026-06-01 14:21:56 +02:00
c1826c7df8 test(game): strengthen legacy-constructor delegation assertions
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-01 14:19:54 +02:00
dd2b1b331f feat(game): GameSession bundles savable state; turn + autosave hook 2026-06-01 14:15:44 +02:00
aa0852cf10 docs: implementation plan for main menu + save/load
13 TDD tasks: GameSession + delegation, restore hooks, GameIO.choose,
SaveData/SaveCodec/SaveService (JSON), Settings + mutable ConsoleIO,
menu package, save/menu commands, quest autosave, App shell loop,
SwingIO buttons, docs.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 14:11:42 +02:00
2f96b7896b docs: spec for main menu + save/load (+ minimal settings)
Brainstormed design for backlog #6/#7: shell loop above the game loop,
GameIO.choose() primitive (console default + Swing buttons), GameSession
bundling savable state, JSON SaveData delta over the YAML world, single
active slot, autosave (quest-complete + quit + every 10 turns), and minimal
runtime-togglable settings (color/glyph mode).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 14:00:32 +02:00
6dcfbd62ef Merge light & darkness mechanic into develop
Room.dark + Item.light flags and a Light helper (isLit/carryingLight). Model B
gating: you can't enter a dark room without a lit light source; look/take/examine
are obscured in the dark; the HUD light field is wired. Entry-gate prevents
soft-locks. Demo: the dark dungeon needs the lit lamp to reach the generator.
127 tests green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 23:09:15 +02:00
9ea07470a9 feat(content): dark dungeon needs the lit lamp
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 23:08:42 +02:00
3cac6cb796 feat: darkness gating for go/look/take/examine + HUD light
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 23:07:36 +02:00
e65e3b600a feat: Room.dark + Item.light flags and Light helper
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 23:04:42 +02:00
acd797bf65 docs: implementation plan for light & darkness
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 23:02:01 +02:00
5d11d7730c docs: spec for light & darkness (model B, blocking)
Room.dark + Item.light flags, a Light helper (isLit/carryingLight), and command
gating: can't enter a dark room without a lit light source; look/take/examine
obscured in the dark; HUD light field wired. Entry-gate prevents soft-locks.
Demo: dark dungeon needs the lit lamp. Fuel/timed light out of scope.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 23:00:15 +02:00
122ff37a09 Merge win-condition & endings (sub-project 3.3) into develop
Priority-ordered, condition-driven endings (endings.yaml) checked each turn
after the quest tick; the first match prints an ending banner + run summary
(turns, quests X/Y, rank) and ends the game. EndingEngine mirrors the quest
engine; World gains 5/6/7-arg back-compat constructors. Two reachable demo
endings: victory (manor_secured) and flee (front door -> fled). Completes the
quest system (#3). 120 tests green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 22:51:15 +02:00
7295463805 feat(content): victory & flee endings; front-door flee switch
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 22:50:46 +02:00
3189b057a0 feat(game): EndingEngine + per-turn end check with summary
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 22:49:00 +02:00
f9bd2d9a67 feat: Ending model + endings.yaml loading (ordered, optional)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 22:47:38 +02:00
3ac9540a6a docs: implementation plan for win-condition & endings
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 22:45:12 +02:00
0f40ee637d docs: spec for win-condition & endings (sub-project 3.3)
Priority-ordered condition-driven endings + an end-of-game summary (turns,
quests X/Y, rank). EndingEngine mirrors the quest engine; Game ends the loop
when an ending fires. endings.yaml optional; World gains 5/6/7-arg back-compat
constructors. Two reachable demo endings (victory, flee) with no new mechanics.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 22:44:01 +02:00
eb78c7840e Merge quests & quest-log (sub-project 3.2) into develop
Condition-driven multi-stage quests on the foundation: Quest/QuestStage data,
QuestLog + QuestEngine (per-turn auto-advance with ✓/★ announcements and
cascade), START_QUEST effect, QuestView pushed/rendered like the map. Console
'quests' command and a GUI quest-box under the map. Demo quest restore_power
auto-advances as you restore power and earn the key. 116 tests green.
Win-condition/endings (3.3) deferred.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 22:35:11 +02:00
55944a0343 feat(content): demo quest 'restore_power'; document quests command
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 22:34:57 +02:00
e360a754a6 feat(io): GUI quest-box under the map
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 22:33:57 +02:00
c2e5789118 feat(io,command): quests command, QuestText, per-turn quest tick
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 22:31:33 +02:00
fe9ac65f25 feat(loader): quests.yaml loading + startQuest effect mapping
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 22:29:52 +02:00
423cf85c39 feat(game): QuestEngine (auto-advance + announcements) and QuestView
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 22:27:18 +02:00
4e0e951a8d feat(game): Quest model, QuestLog, START_QUEST effect
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 22:25:58 +02:00
84ab176b47 docs: implementation plan for quests & quest-log
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 22:24:20 +02:00
20d4f1470a docs: spec for quests & quest-log (sub-project 3.2)
Condition-driven multi-stage quests on top of the foundation: Quest/QuestStage
data, QuestLog + QuestEngine (per-turn auto-advance with announcements),
START_QUEST effect, QuestView display via a console 'quests' command and a GUI
quest-box under the map. quests.yaml optional; World gains a 5-arg back-compat
constructor. Win/endings deferred to 3.3.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 22:20:49 +02:00
59781e5cbd Merge quest foundation (world-state + conditions/effects) into develop
One typed condition/effect vocabulary (GameState + Conditions + Effects)
wired into five integration points: condition-locked exits, state-dependent
room descriptions, conditional NPC dialogue, reactions with requires/effects,
and switch effects. All new YAML fields optional (backward compatible). Demo:
a generator switch sets power_on, which unlocks and lights the cellar and
changes the old man's dialogue. 107 tests green. Light/dark, item-combination,
quest objects and endings deferred.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 22:02:34 +02:00
93a5da3af7 feat(content): demo the state engine (generator unlocks/lights the cellar)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 22:01:57 +02:00
acaae3c4f7 feat: switchable items apply effects when turned on
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 21:59:46 +02:00
ab008ee562 feat: NPC reactions gated by conditions, applying effects
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 21:57:23 +02:00
9d23cb01b7 feat: conditional NPC dialogue
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 21:55:15 +02:00
8cb2b360e6 feat: state-dependent room descriptions (LookCommand resolution)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 21:52:57 +02:00
e3b80736dd feat: condition-locked exits (+ room/dialogue state plumbing)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 21:52:06 +02:00
c339d13244 feat(loader): ConditionDto/EffectDto with toModel mapping
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 21:47:11 +02:00
bf10f11438 feat(game): world-state engine (GameState, Condition/Effect, evaluator/applier)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 21:46:29 +02:00
c4542165ab docs: implementation plan for quest foundation
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 21:44:37 +02:00
f79382c43a docs: spec for quest foundation (world-state + conditions/effects)
Approach A: one typed condition/effect vocabulary (GameState + Conditions +
Effects) reused by five integration points (locked exits, state-dependent
descriptions, conditional NPC dialogue, reactions with requires/effects,
switch effects). All new YAML fields optional. Light/dark, item-combination,
quest objects, endings deferred.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 21:39:59 +02:00
fdc4915431 docs: note quest-log GUI box (under map) for quest sub-project
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 21:37:37 +02:00
8b1232b2fc fix(io): auto-scale GUI text with window size, consistent with the map
Body text/HUD font now derives from a window-relative auto-scale (starting
at uiScale, growing as the window grows) and is re-applied on resize, so the
text tracks the window like the map does. Ctrl +/- zoom still multiplies on
top of both. Removes the resize hiccup where only the map reacted.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 21:26:09 +02:00
5fb2446373 fix(io): fit the GUI map to the side panel; re-fit on resize
MapPanel now scales the map to fit the side panel (min over width/height),
with the Ctrl +/- zoom as a multiplier on top, so it no longer overflows
and scrolls at normal zoom. The map re-fits whenever the window/side panel
is resized; side panel grows a little more (28%, max 460px) before capping.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 21:21:27 +02:00
aff1768808 fix(io): scale the GUI map with HiDPI factor and live zoom
MapPanel had fixed pixel sizes and an 11pt font, so it ignored uiScale and
the Ctrl +/- zoom. It now multiplies all dimensions, strokes and the label
font by a scale set from uiScale x zoom in SwingIO.applyFonts().

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 21:14:31 +02:00
e0f5821fbb Merge map / mini-map (sub-project 2) into develop
Fog-of-war map from the directional exit graph: MapLayout (BFS) builds a
frontend-agnostic MapView; SwingIO draws it (Graphics2D MapPanel, replacing
the exits text), ConsoleIO mirrors it as ASCII via the `map` command.
Visited tracking on Player; per-turn map push from Game. 90 tests green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 21:09:45 +02:00
b182b09070 feat(io): adaptive ASCII map width; document map command, mark sub-project done
AsciiMap sizes room boxes to the longest visible name (capped) so names
like 'Dark Hallway' are no longer truncated; connector centering follows.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 21:09:08 +02:00
8243d5dfc5 feat(io): GUI MapPanel (Graphics2D) in the side panel; drop exits text
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 21:06:48 +02:00
dd0b13fbd8 feat(game): push map to the IO each turn (GUI live update)
Game now derives the MapView from the world each turn; GameTest gains a
real World (the per-turn map push needs it).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 21:04:14 +02:00
7605b724b6 feat(io,command): map command + GameIO setMap/showMap, console ASCII
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 21:02:17 +02:00
a846297abe feat(io): AsciiMap console renderer for MapView
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 21:00:35 +02:00
cda18a75cd feat(map): MapLayout BFS grid layout with fog of war
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 20:59:52 +02:00
820c17aea9 feat(io): MapView model (CellState, RoomCell, Connection, MapView)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 20:58:58 +02:00
da2e90faec feat(model): track visited rooms on Player for the map
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 20:58:21 +02:00
7298427eaf docs: implementation plan for map / mini-map (sub-project 2)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 20:57:00 +02:00
025372b9b1 docs: spec for map / mini-map (sub-project 2)
Fog-of-war map from the directional exit graph. MapLayout (BFS) produces a
frontend-agnostic MapView; SwingIO draws it (Graphics2D MapPanel, replaces
exits text), ConsoleIO mirrors it as ASCII via the map command. Visited
tracking on Player. Item/NPC markers and locked-door styling deferred.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 20:51:09 +02:00
0e3f898927 Merge presentation/output layer (sub-project 1) into develop
Semantic styled-output model + console (ANSI/box-drawing) and Swing
(4-region, JTextPane, bundled font, HiDPI scaling + zoom, responsive
side panel) renderers, per-turn HUD, welcome banner. 79 tests green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 20:38:19 +02:00
2616c9bea5 fix: correct .gitignore for .superpowers/ (was glued to .DS_Store line)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 20:38:10 +02:00
f91d525fd0 fix(io): responsive side-panel width (ratio of window, clamped min/max)
Side panel (map slot) now sizes to ~25% of window width, clamped to
[160, 320] scaled px, recomputed on resize — instead of a fixed width.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 20:29:51 +02:00
5181622f7d docs: mark presentation layer (sub-project 1) as implemented
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 20:25:42 +02:00
94ce5ab2f7 fix(io): scale GUI fonts for HiDPI + live zoom (Ctrl +/-/0)
Java Swing does not reliably pick up desktop display scaling on every
Linux session (tiny text on HiDPI laptops). Detect the display scale from
the default GraphicsConfiguration transform (falling back to screen DPI)
and multiply all font sizes by it, plus the window size. Add live zoom via
Ctrl +/-/0 as a guaranteed manual override.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 20:25:04 +02:00
42330d9376 feat(io): welcome banner for game start
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 20:22:59 +02:00
d25f9f4504 feat(io): SwingIO 4-region layout, JTextPane styling, bundled font loader
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 20:21:23 +02:00
c27d719452 feat(game): publish HUD (location, gold, turn) each loop iteration
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 20:19:40 +02:00
d033a878db refactor(command): LookCommand emits semantic RoomView
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 20:18:50 +02:00
d31b583871 feat(io): ConsoleIO ANSI + box-drawing renderer with colour/glyph modes
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 20:18:18 +02:00
95ed54eb25 feat(io): make print(StyledText) the GameIO primitive; keep suite green
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 20:16:55 +02:00
f2b243afb5 feat(io): RoomView/Hud views and default Renderings
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 20:15:06 +02:00
32ece84a85 feat(io): semantic styled-text model (Style, Span, StyledText)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 20:14:22 +02:00
9e7961e903 docs: implementation plan for presentation/output layer
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 20:13:30 +02:00
705d5f9db1 docs: spec for presentation/output layer (sub-project 1)
Semantic output model (Style/Span/StyledText) + RoomView/Hud views,
GameIO extended additively (write(String) stays as default), ConsoleIO
(ANSI + box-drawing, tiered glyph modes) and SwingIO (region layout,
JTextPane, bundled font) renderers, command + TestIO migration. Map,
menu, save, music explicitly deferred to later sub-projects.

Also: capture "don't overwhelm / distribute info across regions" and
"B baseline + ASCII for big moments" decisions in the backlog.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 20:06:36 +02:00
197 changed files with 20242 additions and 214 deletions

View File

@@ -36,4 +36,13 @@ build/
.vscode/ .vscode/
### Mac OS ### ### Mac OS ###
.DS_Store.superpowers/ .DS_Store
### Brainstorming visual companion ###
.superpowers/
# Local save games and settings
saves/
# External background-music files (not bundled)
music/

View File

@@ -46,6 +46,8 @@ mvn exec:java@gui # Swing-GUI (thb.jeanluc.adventure.AppGui)
|---|---|---| |---|---|---|
| `go <richtung>` | `move`, `walk` | In eine Himmelsrichtung gehen | | `go <richtung>` | `move`, `walk` | In eine Himmelsrichtung gehen |
| `look` | `l` | Aktuellen Raum beschreiben | | `look` | `l` | Aktuellen Raum beschreiben |
| `map` | `m` | Karte der erkundeten Räume anzeigen |
| `quests` | `log`, `journal` | Aktive und erledigte Quests anzeigen |
| `inventory` | `inv`, `i` | Inventar anzeigen | | `inventory` | `inv`, `i` | Inventar anzeigen |
| `take <item>` | `pick`, `get` | Gegenstand aufnehmen | | `take <item>` | `pick`, `get` | Gegenstand aufnehmen |
| `drop <item>` | `put` | Gegenstand ablegen | | `drop <item>` | `put` | Gegenstand ablegen |

View File

@@ -17,7 +17,13 @@ alle Phasen 17 grün. Ziel: deutlich mehr Tiefe und Politur.
## Themenblöcke ## Themenblöcke
### 1. Reichere Präsentation / Ausgabe-Schicht ### 1. Reichere Präsentation / Ausgabe-Schicht ✅ umgesetzt (Branch `feature/presentation-layer`)
> Umgesetzt: semantisches Modell (`io.text`), `ConsoleIO` (ANSI + Box-Drawing,
> Color/Glyph-Modi), `SwingIO` (4 Regionen, `JTextPane`, gebündelter Font,
> HiDPI-Skalierung + Zoom `Strg +/-/0`), HUD pro Zug, Welcome-Banner. 79 Tests grün.
> Spec/Plan unter `docs/superpowers/`. Karte/Menü/Save/Musik weiterhin offen.
- Weg vom reinen `write(String)`: formatierte Ausgabe (Überschriften, Trenner, - Weg vom reinen `write(String)`: formatierte Ausgabe (Überschriften, Trenner,
Hervorhebungen für Items/NPCs/Richtungen). Hervorhebungen für Items/NPCs/Richtungen).
@@ -26,6 +32,19 @@ alle Phasen 17 grün. Ziel: deutlich mehr Tiefe und Politur.
- **GUI (Swing)**: mehr als ein `JTextArea` Farben, Stile, evtl. Panels, - **GUI (Swing)**: mehr als ein `JTextArea` Farben, Stile, evtl. Panels,
Statusanzeige, Bilder/Icons. Statusanzeige, Bilder/Icons.
**Festgelegt (Zielbild Konsole):** Stufe **B als Baseline** (Boxed Heading,
gelabelte Sektionen, persistente HUD-Zeile: Ort · Gold · Zug · Licht), **ASCII-Art
nur für große Momente** (Spielstart, Finale, Schlüsselräume). Semantische
Farb-Rollen: Items / NPCs / Exits / Heading / Gefahr.
**Designprinzipien:**
- **Nicht überladen / Progressive Disclosure** Raumeintritt zeigt nur das
Wesentliche (Ort, kurze Beschreibung, Auffälliges); Details auf Abruf
(`examine`, `look`).
- **Info auf Regionen verteilen statt Text stapeln** die **Karte ersetzt die
„Exits:"-Zeile**. GUI: persistentes Map-Panel macht die Exit-Zeile überflüssig.
Konsole: kompakte Mini-Map (bei `map` bzw. knapp beim Raumeintritt).
### 2. Nerd Fonts / Glyphen das Installationsproblem ### 2. Nerd Fonts / Glyphen das Installationsproblem
- Nerd-Font-Glyphen (Icons) sehen gut aus, müssen aber auf dem System des - Nerd-Font-Glyphen (Icons) sehen gut aus, müssen aber auf dem System des
@@ -37,7 +56,14 @@ alle Phasen 17 grün. Ziel: deutlich mehr Tiefe und Politur.
- **Konsole**: Font nicht kontrollierbar → gestufte Fallbacks - **Konsole**: Font nicht kontrollierbar → gestufte Fallbacks
(reines ASCII → Unicode-Box-Drawing → Nerd-Glyphen) statt harter Annahme. (reines ASCII → Unicode-Box-Drawing → Nerd-Glyphen) statt harter Annahme.
### 3. Karte / Map ### 3. Karte / Map ✅ umgesetzt (Branch `feature/map`)
> Umgesetzt: `MapLayout` (BFS-Gitter-Layout + Fog of War), `MapView`-Modell,
> GUI-`MapPanel` (Graphics2D, ersetzt die Exit-Liste), Konsolen-ASCII via
> `AsciiMap` + `map`-Befehl, Besuchte-Räume-Tracking auf `Player`, Map-Push pro Zug.
> Item/NPC-Marker und Türen-Styling offen (Quest-Teilprojekt). Spec/Plan unter
> `docs/superpowers/`.
- Übersichtskarte der Räume in **beiden** Modi. - Übersichtskarte der Räume in **beiden** Modi.
- Konsole: ASCII-Map. GUI: gezeichnete Map (Grid/Graph). - Konsole: ASCII-Map. GUI: gezeichnete Map (Grid/Graph).
@@ -52,10 +78,31 @@ alle Phasen 17 grün. Ziel: deutlich mehr Tiefe und Politur.
### 5. Quest-System (NPC-Ausbau) ### 5. Quest-System (NPC-Ausbau)
> ✅ Fundament umgesetzt (Branch `feature/quest-foundation`): World-Flags +
> Condition/Effect-Engine, verschlossene Exits, zustandsabhängige Beschreibungen,
> bedingte Dialoge, Reaktionen mit requires/effects, Schalter-Effekte. Demo:
> Generator schaltet Strom → Keller-Tür öffnet + Raum wird beleuchtet.
>
> ✅ #3.2 umgesetzt (Branch `feature/quests`): condition-driven Quests
> (Quest/QuestStage), QuestEngine mit Auto-Advance + Ansagen, START_QUEST-Effekt,
> Konsolen-Befehl `quests` und GUI-Quest-Box unter der Karte. Demo-Quest
> `restore_power`.
>
> ✅ #3.3 umgesetzt (Branch `feature/endings`): priorisierte, condition-driven
> Enden (`endings.yaml`) + End-Screen mit Summary (Züge, Quests X/Y, Rang).
> Demo: Sieg-Ende (`manor_secured`) und Flucht-Ende (Front Door → `fled`).
> **Damit ist das Quest-System (#3) komplett.** Offen: Licht/Dunkelheit &
> Item-Kombination sowie #4 Content-Ausbau.
- Aktuelles NPC-System: Begrüßung + Geschenk-Reaktion (Item → Item). - Aktuelles NPC-System: Begrüßung + Geschenk-Reaktion (Item → Item).
- Ausbau Richtung: mehrstufige Quests, Bedingungen, NPC-"Memory" (Zustand), - Ausbau Richtung: mehrstufige Quests, Bedingungen, NPC-"Memory" (Zustand),
Quest-Log, evtl. Win-Condition daran gekoppelt. Quest-Log, evtl. Win-Condition daran gekoppelt.
**GUI-Layout (festgelegt):** Quest-Log als **dauerhaft sichtbare Box unterhalb der
Karte** im rechten Seitenbereich (Map oben, Quests darunter). Gehört zu Quest-
Teilprojekt #2 (Quest-Log); das Fundament #1 reserviert/plant den Slot.
### 5b. Spielkern: zentraler Bogen + Mechanik-Spine ### 5b. Spielkern: zentraler Bogen + Mechanik-Spine
**Leitidee (bestätigt):** Bewusst *kondensiert* wenige Mechaniken, die sich **Leitidee (bestätigt):** Bewusst *kondensiert* wenige Mechaniken, die sich
@@ -75,9 +122,19 @@ des Bogens; einzelne Teilziele schalten je den nächsten Bereich frei.
Hebel/Ventile setzen Flags und öffnen Bereiche. Hebel/Ventile setzen Flags und öffnen Bereiche.
3. **Licht & Dunkelheit** dunkle Räume brauchen eine brennende Lichtquelle, 3. **Licht & Dunkelheit** dunkle Räume brauchen eine brennende Lichtquelle,
sonst keine Sicht auf Exits/Items (Atmosphäre, einfach umzusetzen). sonst keine Sicht auf Exits/Items (Atmosphäre, einfach umzusetzen).
> ✅ Umgesetzt (Branch `feature/light-darkness`): `Room.dark` + `Item.light`,
> `Light`-Helper, Gating in go/look/take/examine (Modell B: kein Eintritt ohne
> Licht), HUD-Licht verdrahtet. Demo: dunkler Dungeon braucht die brennende
> Lampe für den Generator.
4. **Item-Kombination** z.B. `match + candle → lit candle`. Achtung: v1.0 hat 4. **Item-Kombination** z.B. `match + candle → lit candle`. Achtung: v1.0 hat
bewusst argloses `use X` ohne Targets gewählt → Command-Grammatik muss erweitert bewusst argloses `use X` ohne Targets gewählt → Command-Grammatik muss erweitert
werden (jetzt erlaubt, da über MVP hinaus). werden (jetzt erlaubt, da über MVP hinaus).
> ✅ umgesetzt (Branch `feature/use-x-on-y`): `use X on Y` als datengetriebene
> Rezepte (`combinations.yaml`: requires/consume/produce/effects/response/failText),
> Operanden aus Inventar oder Raum, reihenfolge-unabhängiger Paar-Key. Kein
> Parser-Eingriff (`on`/`with`/`to` sind bereits Filler). Selbst-Kombination
> (a==b) wird beim Laden abgelehnt. Demo: `matches` + `torch` → `lit_torch`
> (dauerhafte Lichtquelle).
5. **Quest-Ketten + mehrere Enden** Teilziele referenzieren Flags; Kette 5. **Quest-Ketten + mehrere Enden** Teilziele referenzieren Flags; Kette
abschließen = Win; *welche* Flags gesetzt sind, entscheidet das Ende. abschließen = Win; *welche* Flags gesetzt sind, entscheidet das Ende.
@@ -93,6 +150,13 @@ vorerst puzzle-fokussiert ohne Survival-Druck.
### 6. Hauptmenü & Settings ### 6. Hauptmenü & Settings
> ✅ umgesetzt (Branch `feature/main-menu-save-load`). Hauptmenü-Shell oberhalb
> des Game-Loops (Neues Spiel / Laden / Einstellungen / Beenden) über neue
> `GameIO.choose`-Primitive (Konsole: nummeriertes Menü; GUI: Buttons).
> **Minimale Settings**: nur Farb-Modus + Glyphen-Modus (ASCII/Unicode), live
> umschaltbar, in `saves/settings.json` persistiert. Musik-/Typewriter-Toggles
> weiter zurückgestellt (Features existieren noch nicht).
- **Hauptmenü** vor der Spielschleife: *Neues Spiel*, *Spiel laden*, *Einstellungen*, *Beenden*. - **Hauptmenü** vor der Spielschleife: *Neues Spiel*, *Spiel laden*, *Einstellungen*, *Beenden*.
- **Spielstände-Liste**: gespeicherte Spiele mit Metadaten (Name, Raum, Zugzahl, - **Spielstände-Liste**: gespeicherte Spiele mit Metadaten (Name, Raum, Zugzahl,
Zeitstempel) anzeigen und hineinladen. Zeitstempel) anzeigen und hineinladen.
@@ -103,12 +167,27 @@ vorerst puzzle-fokussiert ohne Survival-Druck.
### 7. Speichern / Laden ### 7. Speichern / Laden
> ✅ umgesetzt (Branch `feature/main-menu-save-load`). JSON-Spielstand als
> Delta über die frisch aus YAML geladene Welt (`SaveData`/`SaveCodec`/
> `SaveService`, atomare Writes). **Ein aktiver Slot**: manuelles `save`,
> Autosave (Quest-Completion + alle 10 Züge) und `quit`/`menu` (speichern +
> zurück ins Menü) schreiben denselben Slot; Laden nur über das Menü.
> `saves/` ist gitignored.
- Spielstand serialisieren (Player-Zustand, Inventar, World-Flags, besuchte Räume, - Spielstand serialisieren (Player-Zustand, Inventar, World-Flags, besuchte Räume,
Quest-Fortschritt) Format z.B. YAML/JSON. Quest-Fortschritt) Format z.B. YAML/JSON.
- Mehrere Speicherstände, benennbar; Anbindung an die Spielstände-Liste im Menü. - Mehrere Speicherstände, benennbar; Anbindung an die Spielstände-Liste im Menü.
### 8. Musik (GUI) ### 8. Musik (GUI)
> ✅ umgesetzt (Branch `feature/music`): OGG Vorbis (vorbisspi/jorbis), pro Raum
> via `music:`-Feld in `rooms.yaml`, externer gitignored `music/`-Ordner (nicht im
> JAR). Idempotenter Track-Wechsel mit ~400 ms Fades; Räume ohne `music` blenden zu
> Stille; gleicher Track läuft nahtlos weiter. Music-Stufe Off/Low/Med/High in den
> Settings (Default Medium), persistiert. Konsole stumm. Testbarer `MusicController`
> über isoliertes `OggMusicBackend`; fehlende Datei → still, kein Absturz. Siehe
> `docs/music.md`.
- Hintergrundmusik **nur in der GUI**. - Hintergrundmusik **nur in der GUI**.
- **Pro Raum definierbar via YAML**: in `rooms.yaml` ein optionales Feld - **Pro Raum definierbar via YAML**: in `rooms.yaml` ein optionales Feld
(z.B. `music: <dateiname>`), Dateien in *einem* Ordner. (z.B. `music: <dateiname>`), Dateien in *einem* Ordner.
@@ -124,6 +203,19 @@ vorerst puzzle-fokussiert ohne Survival-Druck.
## Festgelegte Erweiterungs-Entscheidungen ## Festgelegte Erweiterungs-Entscheidungen
> ✅ **Pathfinding** umgesetzt (Branch `feature/pathfinding-go-to`): `go to <raum>`
> per BFS über besuchte Räume (Locks + Licht beachtet), via `GoCommand`
> (Richtung vs. Raumziel). Konsole still + Zusammenfassung (Start/Mitte/Ziel),
> GUI-Minikarte animiert pro Schritt (`GameIO.travelStep`, ~300 ms).
> **Trie-Autocomplete** weiterhin für eine spätere Runde zurückgestellt.
> ✅ **Tutorial** umgesetzt (Branch `feature/tutorial`): interaktiver Walkthrough
> beim Neuen Spiel (look→examine→take→inventory→use→go→go to), verb-/alias-basierte
> Abschluss-Erkennung (Bewegungs-Schritte verlangen echten Raumwechsel),
> Abschluss-Tipps, jederzeit per `skip` abbrechbar. Datengetrieben (`tutorial.yaml`,
> `TutorialLoader`), als `TutorialGuide` in den Game-Loop eingehängt; nur bei Neuem
> Spiel, nicht beim Laden.
| Entscheidung | Wert | | Entscheidung | Wert |
|---|---| |---|---|
| Eigene `uebung`-Datenstrukturen verwenden? | **Nein** Standard-Collections (vom Prof bestätigt) | | Eigene `uebung`-Datenstrukturen verwenden? | **Nein** Standard-Collections (vom Prof bestätigt) |

View File

@@ -0,0 +1,17 @@
# Background music (GUI)
Background music plays only in the Swing GUI. It is **data-driven and external**:
- Put `.ogg` (OGG Vorbis) files in a `music/` folder next to where you run the game.
This folder is git-ignored and never bundled in the JAR.
- A room plays a track via an optional `music:` field in `rooms.yaml`, e.g.
`music: manor-theme.ogg`. Entering a room with a different track fades the old one
out and the new one in (sequential, not an overlapping cross-fade);
a room with no `music:` field fades to silence; the same track keeps playing
seamlessly across rooms.
- Volume is the **Music** setting (Off / Low / Medium / High) in the Settings menu,
persisted to `saves/settings.json`. Off disables playback.
- Missing files are ignored (silent) — the game never crashes over absent audio.
- The console version is always silent.
Decoding uses the `vorbisspi` + `jorbis` libraries via the `javax.sound` SPI.

View File

@@ -0,0 +1,458 @@
# Win-Condition & Endings Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Priority-ordered, condition-driven endings with an end-of-game summary; the game ends when an ending fires.
**Architecture:** `Ending` is data (condition + text), loaded from optional `endings.yaml` into an ordered `World.endings`. `EndingEngine.triggered(ctx)` returns the first matching ending; `Game` checks it each turn after the quest tick, prints the ending + summary, and stops. Reuses the existing condition engine and quest log.
**Tech Stack:** Java 25, Maven, JUnit 5 + AssertJ, Lombok, Jackson/SnakeYAML.
Spec: [docs/superpowers/specs/2026-05-31-endings-design.md](../specs/2026-05-31-endings-design.md)
---
## Task 1: Ending model, DTO/factory, World.endings, loading
**Files:**
- Create: `model/Ending.java`, `loader/dto/EndingDto.java`, `loader/EndingFactory.java`
- Modify: `model/World.java`, `loader/WorldLoader.java`
- Test: `loader/EndingLoadingTest.java`
- [ ] **Step 1: Create `model/Ending.java`**
```java
package thb.jeanluc.adventure.model;
import java.util.List;
/** A game ending: shown (and ends the game) when its conditions hold. */
public record Ending(String id, String title, boolean victory, List<Condition> when, String text) {
public Ending {
when = when == null ? List.of() : List.copyOf(when);
}
}
```
- [ ] **Step 2: Add `endings` to `World.java` with back-compat constructors**
Add import:
```java
import java.util.List;
```
Add the field after `quests`:
```java
/** Ordered list of endings (first matching one wins). */
private final List<Ending> endings;
```
Replace the existing back-compat constructor with two that both delegate to the full constructor:
```java
/** Backward-compatible constructor for worlds without quests or endings. */
public World(Map<String, Room> rooms, Map<String, Item> items, Map<String, Npc> npcs,
String title, String welcomeMessage) {
this(rooms, items, npcs, title, welcomeMessage, Map.of(), List.of());
}
/** Backward-compatible constructor for worlds with quests but no endings. */
public World(Map<String, Room> rooms, Map<String, Item> items, Map<String, Npc> npcs,
String title, String welcomeMessage, Map<String, Quest> quests) {
this(rooms, items, npcs, title, welcomeMessage, quests, List.of());
}
```
(The 7-arg constructor is the Lombok-generated `@RequiredArgsConstructor` over all final fields in declaration order: rooms, items, npcs, title, welcomeMessage, quests, endings.)
- [ ] **Step 3: Create `loader/dto/EndingDto.java`**
```java
package thb.jeanluc.adventure.loader.dto;
import java.util.List;
/** YAML representation of a game ending. */
public record EndingDto(String id, String title, Boolean victory, List<ConditionDto> when, String text) {
}
```
- [ ] **Step 4: Create `loader/EndingFactory.java`**
```java
package thb.jeanluc.adventure.loader;
import thb.jeanluc.adventure.loader.dto.ConditionDto;
import thb.jeanluc.adventure.loader.dto.EndingDto;
import thb.jeanluc.adventure.model.Ending;
/** Builds {@link Ending} objects from {@link EndingDto}s. */
public final class EndingFactory {
private EndingFactory() {
}
public static Ending fromDto(EndingDto dto) {
return new Ending(
dto.id(),
dto.title(),
Boolean.TRUE.equals(dto.victory()),
ConditionDto.toModelList(dto.when()),
dto.text());
}
}
```
- [ ] **Step 5: Load `endings.yaml` in `WorldLoader.java`**
Add imports:
```java
import thb.jeanluc.adventure.loader.dto.EndingDto;
import thb.jeanluc.adventure.model.Ending;
```
After the `questDtos` read, add:
```java
List<EndingDto> endingDtos = readListOptional(basePath + "/endings.yaml", EndingDto.class);
```
After the quest `requireUniqueIds`, add:
```java
requireUniqueIds("ending", endingDtos.stream().map(EndingDto::id).toList());
```
Build an **ordered** ending list (preserve YAML order) next to the quest map build:
```java
List<Ending> endings = new ArrayList<>();
for (EndingDto dto : endingDtos) {
endings.add(EndingFactory.fromDto(dto));
}
```
Add the import for `ArrayList` if missing:
```java
import java.util.ArrayList;
```
Change the `World` construction to the 7-arg form:
```java
World world = new World(rooms, items, npcs,
gameDto.title(), gameDto.welcomeMessage(), quests, endings);
```
- [ ] **Step 6: Write the loading test**
`src/test/java/thb/jeanluc/adventure/loader/EndingLoadingTest.java`:
```java
package thb.jeanluc.adventure.loader;
import org.junit.jupiter.api.Test;
import thb.jeanluc.adventure.loader.dto.ConditionDto;
import thb.jeanluc.adventure.loader.dto.EndingDto;
import thb.jeanluc.adventure.model.Ending;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
class EndingLoadingTest {
@Test
void factoryMapsFields() {
Ending e = EndingFactory.fromDto(new EndingDto("victory", "Won", true,
List.of(new ConditionDto("manor_secured", null, null)), "You win."));
assertThat(e.id()).isEqualTo("victory");
assertThat(e.victory()).isTrue();
assertThat(e.when()).hasSize(1);
assertThat(e.text()).isEqualTo("You win.");
}
}
```
- [ ] **Step 7: Run tests**
Run: `mvn -q test -Dtest=EndingLoadingTest`
Expected: PASS.
Run: `mvn -q test`
Expected: PASS — the real `endings.yaml` does not exist yet, so `readListOptional` returns empty; existing `new World(...)` callers compile via the back-compat constructors.
- [ ] **Step 8: Commit**
```bash
git add src/main/java/thb/jeanluc/adventure/model/Ending.java \
src/main/java/thb/jeanluc/adventure/loader/dto/EndingDto.java \
src/main/java/thb/jeanluc/adventure/loader/EndingFactory.java \
src/main/java/thb/jeanluc/adventure/model/World.java \
src/main/java/thb/jeanluc/adventure/loader/WorldLoader.java \
src/test/java/thb/jeanluc/adventure/loader/EndingLoadingTest.java
git commit -m "feat: Ending model + endings.yaml loading (ordered, optional)"
```
---
## Task 2: EndingEngine + Game wiring
**Files:**
- Create: `game/EndingEngine.java`
- Modify: `game/Game.java`
- Test: `game/EndingEngineTest.java`
- [ ] **Step 1: Write failing test**
`src/test/java/thb/jeanluc/adventure/game/EndingEngineTest.java`:
```java
package thb.jeanluc.adventure.game;
import org.junit.jupiter.api.Test;
import thb.jeanluc.adventure.io.TestIO;
import thb.jeanluc.adventure.model.Condition;
import thb.jeanluc.adventure.model.Ending;
import thb.jeanluc.adventure.model.Player;
import thb.jeanluc.adventure.model.Room;
import thb.jeanluc.adventure.model.World;
import java.util.List;
import java.util.Map;
import static org.assertj.core.api.Assertions.assertThat;
class EndingEngineTest {
private GameContext ctx(List<Ending> endings) {
Player p = new Player(new Room("k", "K", "d"), 0);
World w = new World(Map.of(), Map.of(), Map.of(), "t", "w", Map.of(), endings);
return new GameContext(w, p, new TestIO());
}
private Ending ending(String id, boolean victory, String flag) {
return new Ending(id, id + "-title", victory,
List.of(new Condition(Condition.Type.FLAG, flag)), id + " text");
}
@Test
void firstMatchingEndingWinsByOrder() {
GameContext ctx = ctx(List.of(ending("victory", true, "won"), ending("fled", false, "fled")));
ctx.getState().set("won");
ctx.getState().set("fled");
assertThat(EndingEngine.triggered(ctx).id()).isEqualTo("victory");
}
@Test
void noEndingWhenNoConditionHolds() {
GameContext ctx = ctx(List.of(ending("victory", true, "won")));
assertThat(EndingEngine.triggered(ctx)).isNull();
}
@Test
void renderIncludesTitleTextAndSummary() {
GameContext ctx = ctx(List.of(ending("victory", true, "won")));
ctx.getState().set("won");
String out = EndingEngine.render(EndingEngine.triggered(ctx), ctx, 7).plainText();
assertThat(out).contains("victory-title").contains("victory text");
assertThat(out).contains("Turns: 7").contains("Quests completed: 0 / 0");
}
}
```
- [ ] **Step 2: Run to verify failure**
Run: `mvn -q test -Dtest=EndingEngineTest`
Expected: FAIL — `EndingEngine` missing.
- [ ] **Step 3: Create `game/EndingEngine.java`**
```java
package thb.jeanluc.adventure.game;
import thb.jeanluc.adventure.io.text.StyledText;
import thb.jeanluc.adventure.model.Ending;
/** Detects the first matching ending and renders the end-of-game screen. */
public final class EndingEngine {
private EndingEngine() {
}
/** First ending whose conditions hold, in list order; null if none. */
public static Ending triggered(GameContext ctx) {
for (Ending e : ctx.getWorld().getEndings()) {
if (Conditions.all(e.when(), ctx)) {
return e;
}
}
return null;
}
/** Builds the ending screen: title banner, text, and a run summary. */
public static StyledText render(Ending e, GameContext ctx, int turns) {
int total = ctx.getWorld().getQuests().size();
int done = ctx.getQuestLog().completed().size();
String bar = "".repeat(Math.max(12, e.title().length() + 6));
StyledText.Builder b = StyledText.builder();
b.heading(bar + "\n").heading(" " + e.title() + "\n").heading(bar + "\n");
b.plain(e.text().stripTrailing()).plain("\n\n");
b.dim("Turns: " + turns + "\n");
b.dim("Quests completed: " + done + " / " + total + "\n");
b.heading("Rank: " + rank(e, total, done));
return b.build();
}
private static String rank(Ending e, int total, int done) {
if (e.victory() && total > 0 && done == total) {
return "Master of the Manor";
}
if (e.victory()) {
return "Manor Reclaimed";
}
return "Escaped — the manor keeps its secrets";
}
}
```
- [ ] **Step 4: Wire endings into `Game.java`**
In `Game.java` add a `maybeEnd()` method and call it after each `publishHud()`. Add the import:
```java
import thb.jeanluc.adventure.model.Ending;
```
(`EndingEngine` is in the same `game` package — no import.) Update `run()` so it calls `maybeEnd()` after the initial publish and after the per-iteration publish:
```java
public void run() {
publishHud();
maybeEnd();
while (running) {
String input = ctx.getIo().readLine();
if (input == null) {
break;
}
ParsedCommand parsed = parser.parse(input);
if (parsed.verb().isEmpty()) {
continue;
}
Optional<Command> cmd = registry.find(parsed.verb());
if (cmd.isEmpty()) {
ctx.getIo().write("I don't understand '" + parsed.verb() + "'. Type 'help'.");
} else {
cmd.get().execute(ctx, parsed.args());
turn++;
}
publishHud();
maybeEnd();
}
}
private void maybeEnd() {
Ending e = EndingEngine.triggered(ctx);
if (e != null) {
ctx.getIo().print(EndingEngine.render(e, ctx, turn));
stop();
}
}
```
- [ ] **Step 5: Run tests**
Run: `mvn -q test -Dtest=EndingEngineTest`
Expected: PASS.
Run: `mvn -q test`
Expected: PASS — `GameTest`'s worlds have no endings (empty list), so `maybeEnd()` is a no-op there.
- [ ] **Step 6: Commit**
```bash
git add src/main/java/thb/jeanluc/adventure/game/EndingEngine.java \
src/main/java/thb/jeanluc/adventure/game/Game.java \
src/test/java/thb/jeanluc/adventure/game/EndingEngineTest.java
git commit -m "feat(game): EndingEngine + per-turn end check with summary"
```
---
## Task 3: Demo endings + flee door + docs + verification
**Files:**
- Create: `src/main/resources/world/endings.yaml`
- Modify: `src/main/resources/world/items.yaml`, `rooms.yaml`, `docs/enhancement-ideas.md`
- [ ] **Step 1: Create `src/main/resources/world/endings.yaml`**
```yaml
- id: victory
title: "The Manor Reclaimed"
victory: true
when: [{ flag: manor_secured }]
text: |
The lights hold steady and the whispers fade to nothing. Whatever held
this place has loosened its grip. The manor is yours now.
- id: fled
title: "Into the Night"
victory: false
when: [{ flag: fled }]
text: |
You wrench the front door open and bolt into the dark. Safe — but the
manor keeps its secrets, and they will keep you awake for years.
```
- [ ] **Step 2: Add a "front door" switch to `items.yaml`**
Append:
```yaml
- id: front_door
type: switchable
name: Front Door
description: The heavy front door. It would let you leave the manor for good.
state: false
onText: |
You haul the front door open and step out into the cold night.
offText: |
You ease the door shut again.
effects:
- { setFlag: fled }
```
- [ ] **Step 3: Place the door in the kitchen (`rooms.yaml`)**
Change the kitchen's items line from:
```yaml
items: [letter, lamp]
```
to:
```yaml
items: [letter, lamp, front_door]
```
- [ ] **Step 4: Mark 3.3 done in `docs/enhancement-ideas.md`**
Under the `### 5. Quest-System (NPC-Ausbau)` status block, append:
```markdown
> ✅ #3.3 umgesetzt (Branch `feature/endings`): priorisierte, condition-driven
> Enden (`endings.yaml`) + End-Screen mit Summary (Züge, Quests X/Y, Rang).
> Demo: Sieg-Ende (`manor_secured`) und Flucht-Ende (Front Door → `fled`).
> Damit ist das Quest-System (#3) komplett.
```
- [ ] **Step 5: Full suite + end-to-end console**
Run: `mvn -q test`
Expected: PASS.
Victory path:
```bash
printf 'go south\nuse generator\ngo north\ntake lamp\ngive lamp old_man\n' | mvn -q -DskipTests exec:java@run
```
Expected: completing the `restore_power` quest sets `manor_secured`; on the next turn the game prints the **"The Manor Reclaimed"** ending banner, the summary (`Turns: …`, `Quests completed: 1 / 1`, `Rank: Master of the Manor`), and exits — no `quit` needed.
Flee path:
```bash
printf 'use front_door\n' | mvn -q -DskipTests exec:java@run
```
Expected: the **"Into the Night"** ending + summary (`Rank: Escaped …`), then exit.
- [ ] **Step 6: Commit**
```bash
git add src/main/resources/world/endings.yaml \
src/main/resources/world/items.yaml \
src/main/resources/world/rooms.yaml \
docs/enhancement-ideas.md
git commit -m "feat(content): victory & flee endings; front-door flee switch"
```
---
## Self-Review notes
- **Spec coverage:** model+loader (T1), engine+Game wiring+summary (T2), demo+docs (T3). Scoreboards/turn-conditions deferred.
- **Backward compatibility:** `World` gains 5- and 6-arg back-compat constructors delegating to the 7-arg; existing `new World(...)` callers (tests, prior loader pattern) compile unchanged. `endings.yaml` optional. `GameTest` worlds have empty endings → `maybeEnd()` no-op.
- **Type consistency:** `EndingEngine.triggered(ctx)`/`render(Ending, ctx, int)`, `Ending.when/victory/title/text`, `World.getEndings`, `EndingFactory.fromDto`, `EndingDto` used consistently.
- **Layering:** `Ending` (model) carries no `game` dependency; `EndingEngine` (game) depends on model + io.text.

View File

@@ -0,0 +1,485 @@
# Light & Darkness Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Dark rooms you can't enter, see, or interact with unless you carry a lit light source (the lamp).
**Architecture:** A `Room.dark` flag and an `Item.light` flag, plus one `Light` helper (`isLit`/`carryingLight`). Four commands gate on it (`go` entry, `look`, `take`, `examine`); the HUD's `light` field is wired to it. Reuses the switchable lamp and is fully backward-compatible (optional flags).
**Tech Stack:** Java 25, Maven, JUnit 5 + AssertJ, Lombok.
Spec: [docs/superpowers/specs/2026-05-31-light-darkness-design.md](../specs/2026-05-31-light-darkness-design.md)
---
## Task 1: Data flags + `Light` helper
**Files:**
- Modify: `model/item/Item.java`, `loader/dto/ItemDto.java`, `loader/ItemFactory.java`, `model/Room.java`, `loader/dto/RoomDto.java`, `loader/RoomFactory.java`
- Create: `game/Light.java`
- Test: `game/LightTest.java`
- [ ] **Step 1: Write failing test**
`src/test/java/thb/jeanluc/adventure/game/LightTest.java`:
```java
package thb.jeanluc.adventure.game;
import org.junit.jupiter.api.Test;
import thb.jeanluc.adventure.io.TestIO;
import thb.jeanluc.adventure.model.Player;
import thb.jeanluc.adventure.model.Room;
import thb.jeanluc.adventure.model.item.PlainItem;
import thb.jeanluc.adventure.model.item.SwitchableItem;
import static org.assertj.core.api.Assertions.assertThat;
class LightTest {
private GameContext ctx(Player p) {
return new GameContext(null, p, new TestIO());
}
private SwitchableItem lamp(boolean on) {
return SwitchableItem.builder().id("lamp").name("Lamp").description("d")
.state(on).onText("on").offText("off").light(true).build();
}
@Test
void litRoomIsAlwaysLit() {
Room bright = new Room("r", "R", "d"); // dark defaults to false
assertThat(Light.isLit(ctx(new Player(bright, 0)), bright)).isTrue();
}
@Test
void darkRoomNeedsActiveLight() {
Room dark = new Room("r", "R", "d");
dark.setDark(true);
Player p = new Player(dark, 0);
GameContext ctx = ctx(p);
assertThat(Light.isLit(ctx, dark)).isFalse();
p.addItem(lamp(false));
assertThat(Light.isLit(ctx, dark)).isFalse(); // lamp off
p.removeItem("lamp");
p.addItem(lamp(true));
assertThat(Light.isLit(ctx, dark)).isTrue(); // lamp on, carried
assertThat(Light.carryingLight(ctx)).isTrue();
}
@Test
void litLampLyingInRoomLightsIt() {
Room dark = new Room("r", "R", "d");
dark.setDark(true);
dark.addItem(lamp(true));
Player p = new Player(dark, 0);
assertThat(Light.isLit(ctx(p), dark)).isTrue();
assertThat(Light.carryingLight(ctx(p))).isFalse(); // not carried
}
@Test
void nonLightItemDoesNotLight() {
Room dark = new Room("r", "R", "d");
dark.setDark(true);
Player p = new Player(dark, 0);
p.addItem(PlainItem.builder().id("rock").name("Rock").description("d").build());
assertThat(Light.isLit(ctx(p), dark)).isFalse();
}
}
```
- [ ] **Step 2: Run to verify failure**
Run: `mvn -q test -Dtest=LightTest`
Expected: FAIL — `Item.light` / `Room.setDark` / `Light` missing.
- [ ] **Step 3: Add `light` to `Item.java`**
Add the field after `description`:
```java
/** Whether this item can serve as a light source (when on, for switchables). */
protected final boolean light;
```
(`@SuperBuilder` exposes `.light(...)` on every subclass builder; `@Getter` generates `isLight()`. Unset builders default to `false`.)
- [ ] **Step 4: Add `light` to `ItemDto.java` and wire `ItemFactory.java`**
In `ItemDto.java` append a component (after `effects`) and update the back-compat constructor:
```java
List<EffectDto> effects,
Boolean light
```
```java
/** Backward-compatible constructor without effects/light. */
public ItemDto(String type, String id, String name, String description,
String readText, Boolean initialState, String onText, String offText) {
this(type, id, name, description, readText, initialState, onText, offText, null, null);
}
```
In `ItemFactory.java`, add `.light(Boolean.TRUE.equals(dto.light()))` to **each** of the three builder cases (plain, readable, switchable), e.g.:
```java
case "plain" -> PlainItem.builder()
.id(dto.id())
.name(dto.name())
.description(dto.description())
.light(Boolean.TRUE.equals(dto.light()))
.build();
```
(and the same `.light(...)` line before `.build()` in the `readable` and `switchable` cases.)
- [ ] **Step 5: Add `dark` to `Room.java`**
Add the import if missing:
```java
import lombok.Setter;
```
Add the field after `descriptionStates`:
```java
/** Whether this room is dark (needs a light source to enter/see). */
@Setter
private boolean dark;
```
(`@Getter` generates `isDark()`; not a constructor arg, so existing `new Room(id,name,desc)` calls are unaffected.)
- [ ] **Step 6: Add `dark` to `RoomDto.java` and wire `RoomFactory.java`**
In `RoomDto.java` append a component (after `descriptionStates`) and update the back-compat constructor:
```java
List<DescriptionStateDto> descriptionStates,
Boolean dark
```
```java
/** Backward-compatible constructor without the optional state fields. */
public RoomDto(String id, String name, String description,
Map<String, String> exits, List<String> items, List<String> npcs) {
this(id, name, description, exits, items, npcs, null, null, null);
}
```
In `RoomFactory.java`, set `dark` after building the shell:
```java
public static Room shellFromDto(RoomDto dto) {
Room room = new Room(dto.id(), dto.name(), dto.description());
room.setDark(Boolean.TRUE.equals(dto.dark()));
return room;
}
```
- [ ] **Step 7: Create `game/Light.java`**
```java
package thb.jeanluc.adventure.game;
import thb.jeanluc.adventure.model.Room;
import thb.jeanluc.adventure.model.item.Item;
import thb.jeanluc.adventure.model.item.SwitchableItem;
import java.util.Collection;
/** Decides whether a room is lit, given dark rooms and active light sources. */
public final class Light {
private Light() {
}
/** A room is lit if it is not dark, or an active light source is carried or present. */
public static boolean isLit(GameContext ctx, Room room) {
if (!room.isDark()) {
return true;
}
return hasActiveLight(ctx.getPlayer().getInventory().values())
|| hasActiveLight(room.getItems().values());
}
/** True if the player carries an active light source (for the HUD). */
public static boolean carryingLight(GameContext ctx) {
return hasActiveLight(ctx.getPlayer().getInventory().values());
}
private static boolean hasActiveLight(Collection<Item> items) {
for (Item it : items) {
if (isActiveLight(it)) {
return true;
}
}
return false;
}
private static boolean isActiveLight(Item it) {
if (!it.isLight()) {
return false;
}
return !(it instanceof SwitchableItem s) || s.isOn();
}
}
```
- [ ] **Step 8: Run tests**
Run: `mvn -q test -Dtest=LightTest`
Expected: PASS.
Run: `mvn -q test`
Expected: PASS — flags default off; existing item/room construction unchanged via back-compat constructors.
- [ ] **Step 9: Commit**
```bash
git add src/main/java/thb/jeanluc/adventure/model/item/Item.java \
src/main/java/thb/jeanluc/adventure/loader/dto/ItemDto.java \
src/main/java/thb/jeanluc/adventure/loader/ItemFactory.java \
src/main/java/thb/jeanluc/adventure/model/Room.java \
src/main/java/thb/jeanluc/adventure/loader/dto/RoomDto.java \
src/main/java/thb/jeanluc/adventure/loader/RoomFactory.java \
src/main/java/thb/jeanluc/adventure/game/Light.java \
src/test/java/thb/jeanluc/adventure/game/LightTest.java
git commit -m "feat: Room.dark + Item.light flags and Light helper"
```
---
## Task 2: Command gating + HUD
**Files:**
- Modify: `command/impl/GoCommand.java`, `command/impl/LookCommand.java`, `command/impl/TakeCommand.java`, `command/impl/ExamineCommand.java`, `game/Game.java`
- Test: `command/impl/DarknessTest.java`
- [ ] **Step 1: Write failing test**
`src/test/java/thb/jeanluc/adventure/command/impl/DarknessTest.java`:
```java
package thb.jeanluc.adventure.command.impl;
import org.junit.jupiter.api.Test;
import thb.jeanluc.adventure.game.GameContext;
import thb.jeanluc.adventure.io.TestIO;
import thb.jeanluc.adventure.model.Direction;
import thb.jeanluc.adventure.model.Player;
import thb.jeanluc.adventure.model.Room;
import thb.jeanluc.adventure.model.item.SwitchableItem;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
class DarknessTest {
private SwitchableItem lamp(boolean on) {
return SwitchableItem.builder().id("lamp").name("Lamp").description("d")
.state(on).onText("on").offText("off").light(true).build();
}
@Test
void cannotEnterDarkRoomWithoutLight() {
Room hall = new Room("hall", "Hall", "d");
Room cave = new Room("cave", "Cave", "d");
cave.setDark(true);
hall.addExit(Direction.NORTH, cave);
Player p = new Player(hall, 0);
GameContext ctx = new GameContext(null, p, new TestIO());
new GoCommand().execute(ctx, List.of("north"));
assertThat(p.getCurrentRoom()).isEqualTo(hall);
assertThat(((TestIO) ctx.getIo()).allOutput()).containsIgnoringCase("pitch black");
}
@Test
void canEnterDarkRoomWithLitLamp() {
Room hall = new Room("hall", "Hall", "d");
Room cave = new Room("cave", "Cave", "d");
cave.setDark(true);
hall.addExit(Direction.NORTH, cave);
Player p = new Player(hall, 0);
p.addItem(lamp(true));
GameContext ctx = new GameContext(null, p, new TestIO());
new GoCommand().execute(ctx, List.of("north"));
assertThat(p.getCurrentRoom()).isEqualTo(cave);
}
@Test
void lookAndTakeAreBlockedInTheDark() {
Room cave = new Room("cave", "Cave", "d");
cave.setDark(true);
Player p = new Player(cave, 0);
TestIO io = new TestIO();
GameContext ctx = new GameContext(null, p, io);
new LookCommand().execute(ctx, List.of());
assertThat(io.allOutput()).containsIgnoringCase("pitch black");
io.outputs().clear();
new TakeCommand().execute(ctx, List.of("anything"));
assertThat(io.allOutput()).containsIgnoringCase("too dark");
}
}
```
- [ ] **Step 2: Run to verify failure**
Run: `mvn -q test -Dtest=DarknessTest`
Expected: FAIL — no darkness gating yet (entering succeeds, look shows room).
- [ ] **Step 3: Gate `GoCommand`**
Add the import:
```java
import thb.jeanluc.adventure.game.Light;
```
After the exit-lock check and before `ctx.getPlayer().setCurrentRoom(next.get())`, insert:
```java
if (!Light.isLit(ctx, next.get())) {
ctx.getIo().write("It's pitch black beyond the doorway — you need a lit light source "
+ "(try lighting your lamp with 'use lamp').");
return;
}
```
- [ ] **Step 4: Gate `LookCommand`**
Add the import:
```java
import thb.jeanluc.adventure.game.Light;
```
At the very start of `execute`, after fetching the room:
```java
Room room = ctx.getPlayer().getCurrentRoom();
if (!Light.isLit(ctx, room)) {
ctx.getIo().write("It's pitch black; you can't make anything out. You need a light source.");
return;
}
```
(The existing `Room room = ...` line is already there — replace it with this guarded version, keeping the rest below unchanged.)
- [ ] **Step 5: Gate `TakeCommand`**
Add the import:
```java
import thb.jeanluc.adventure.game.Light;
```
After the `args.isEmpty()` check:
```java
if (!Light.isLit(ctx, ctx.getPlayer().getCurrentRoom())) {
ctx.getIo().write("It's too dark to see that.");
return;
}
```
- [ ] **Step 6: Gate `ExamineCommand`**
Add the import:
```java
import thb.jeanluc.adventure.game.Light;
```
After the `args.isEmpty()` check:
```java
if (!Light.isLit(ctx, ctx.getPlayer().getCurrentRoom())) {
ctx.getIo().write("It's too dark to see that.");
return;
}
```
- [ ] **Step 7: Wire the HUD `light` field in `Game.java`**
Add the import:
```java
import thb.jeanluc.adventure.game.Light; // same package 'game' — drop if redundant
```
(`Light` is in package `game`, same as `Game` — no import needed.) In `publishHud()`, change the `Hud` construction's last argument from `false` to `Light.carryingLight(ctx)`:
```java
ctx.getIo().setHud(new Hud(
ctx.getPlayer().getCurrentRoom().getName(),
ctx.getPlayer().getGold(),
turn,
Light.carryingLight(ctx)));
```
- [ ] **Step 8: Run tests**
Run: `mvn -q test -Dtest=DarknessTest`
Expected: PASS.
Run: `mvn -q test`
Expected: PASS (existing command tests use non-dark rooms → `isLit` true → no behaviour change).
- [ ] **Step 9: Commit**
```bash
git add src/main/java/thb/jeanluc/adventure/command/impl/GoCommand.java \
src/main/java/thb/jeanluc/adventure/command/impl/LookCommand.java \
src/main/java/thb/jeanluc/adventure/command/impl/TakeCommand.java \
src/main/java/thb/jeanluc/adventure/command/impl/ExamineCommand.java \
src/main/java/thb/jeanluc/adventure/game/Game.java \
src/test/java/thb/jeanluc/adventure/command/impl/DarknessTest.java
git commit -m "feat: darkness gating for go/look/take/examine + HUD light"
```
---
## Task 3: Demo (dark dungeon) + docs + verification
**Files:**
- Modify: `src/main/resources/world/items.yaml`, `rooms.yaml`, `docs/enhancement-ideas.md`
- [ ] **Step 1: Make the lamp a light source in `items.yaml`**
On the `lamp` item, add `light: true`:
```yaml
- type: switchable
id: lamp
name: Oil Lamp
description: An old oil lamp, heavy with fuel.
initialState: false
light: true
onText: The lamp flares to life, casting a warm glow.
offText: You snuff out the lamp.
```
- [ ] **Step 2: Make the dungeon dark in `rooms.yaml`**
On the `dungeon` room, add `dark: true` and tweak the description:
```yaml
- id: dungeon
name: Dungeon
description: |
A cramped stone room, black as pitch. A rusty generator squats in the corner.
dark: true
exits:
north: kitchen
items: [generator]
npcs: []
```
- [ ] **Step 3: Mark the mechanic done in `docs/enhancement-ideas.md`**
Under the mechanic-spine section (point 3, "Licht & Dunkelheit"), append a status note:
```markdown
> ✅ Umgesetzt (Branch `feature/light-darkness`): `Room.dark` + `Item.light`,
> `Light`-Helper, Gating in go/look/take/examine, HUD-Licht. Demo: dunkler
> Dungeon braucht die brennende Lampe für den Generator.
```
- [ ] **Step 4: Full suite + end-to-end console**
Run: `mvn -q test`
Expected: PASS.
Run:
```bash
printf 'go south\ntake lamp\nuse lamp\ngo south\nuse generator\nquit\n' | mvn -q -DskipTests exec:java@run
```
Expected: the first `go south` is blocked ("pitch black beyond the doorway"); after `take lamp` and `use lamp` (HUD flips to `light: on`), `go south` enters the dungeon and `use generator` restores the power. No exceptions.
- [ ] **Step 5: Commit**
```bash
git add src/main/resources/world/items.yaml \
src/main/resources/world/rooms.yaml \
docs/enhancement-ideas.md
git commit -m "feat(content): dark dungeon needs the lit lamp"
```
---
## Self-Review notes
- **Spec coverage:** flags + `Light` (T1), command gating + HUD (T2), demo + docs (T3). Fuel/timed light, cross-room radius deferred.
- **Backward compatibility:** `Item.light` and `Room.dark` default off; `ItemDto`/`RoomDto` keep back-compat constructors; `Room.dark` is not a constructor arg. Existing tests and YAML unaffected (non-dark rooms are always `isLit`).
- **Type consistency:** `Light.isLit(ctx, Room)` / `carryingLight(ctx)`, `Item.isLight()`, `Room.isDark()/setDark()`, `ItemDto.light`, `RoomDto.dark` used consistently.
- **Soft-lock safety:** entry-gate model means dark rooms are entered only with light; lit rooms always re-enterable.

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,860 @@
# GUI Background Music Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Add per-room background music in the GUI — OGG tracks streamed from an external `music/` folder, fading on room change, with an Off/Low/Med/High Settings control. Console stays silent.
**Architecture:** A testable `MusicController` holds the track/level decision logic (idempotent same-track, null→silence, OFF disables) and delegates raw operations to a `MusicBackend`. `OggMusicBackend` (the only audio/thread code, manually verified) decodes OGG via vorbisspi/jorbis and streams PCM through a `SourceDataLine` with gain fades. `GameIO.setMusic`/`setMusicLevel` are console no-ops; `SwingIO` owns the controller. `Game.publishHud` pushes the current room's track each turn; Settings carries a persisted `MusicLevel`.
**Tech Stack:** Java 25, Maven, vorbisspi+jorbis (OGG), Jackson (YAML), JUnit 5 + AssertJ, Lombok.
---
## File Structure
**Create:** `io/MusicLevel.java`, `io/MusicBackend.java`, `io/MusicController.java`, `io/OggMusicBackend.java`; tests `MusicLevelTest`, `MusicControllerTest`; `docs/music.md`.
**Modify:** `io/GameIO.java` (seams), `io/SwingIO.java` (wire controller), `io/ConsoleIO.java` (none — inherits no-op), `game/Game.java` (publishHud), `App.java` (setMusic(null) + settings apply), `model/Room.java` + `loader/dto/RoomDto.java` + `loader/RoomFactory.java` (music field), `save/Settings.java` + `save/SettingsStore`-tests + `menu/SettingsMenu.java` + `test/.../menu/MenuTest.java`, `src/main/resources/world/rooms.yaml`, `pom.xml`, `.gitignore`, `docs/enhancement-ideas.md`.
---
## Task 1: `MusicLevel` enum
**Files:** Create `src/main/java/thb/jeanluc/adventure/io/MusicLevel.java`; Test `src/test/java/thb/jeanluc/adventure/io/MusicLevelTest.java`
- [ ] **Step 1: Write the failing test**
`src/test/java/thb/jeanluc/adventure/io/MusicLevelTest.java`:
```java
package thb.jeanluc.adventure.io;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
class MusicLevelTest {
@Test
void gainIncreasesWithLevel() {
assertThat(MusicLevel.LOW.gainDb()).isLessThan(MusicLevel.MEDIUM.gainDb());
assertThat(MusicLevel.MEDIUM.gainDb()).isLessThan(MusicLevel.HIGH.gainDb());
}
@Test
void isOnOnlyWhenNotOff() {
assertThat(MusicLevel.OFF.isOn()).isFalse();
assertThat(MusicLevel.LOW.isOn()).isTrue();
assertThat(MusicLevel.HIGH.isOn()).isTrue();
}
@Test
void nextCyclesThroughAllAndWraps() {
assertThat(MusicLevel.OFF.next()).isEqualTo(MusicLevel.LOW);
assertThat(MusicLevel.LOW.next()).isEqualTo(MusicLevel.MEDIUM);
assertThat(MusicLevel.MEDIUM.next()).isEqualTo(MusicLevel.HIGH);
assertThat(MusicLevel.HIGH.next()).isEqualTo(MusicLevel.OFF);
}
}
```
- [ ] **Step 2: Run — expect FAIL** (`mvn -q test -Dtest=MusicLevelTest` → MusicLevel missing).
- [ ] **Step 3: Create `MusicLevel`**
`src/main/java/thb/jeanluc/adventure/io/MusicLevel.java`:
```java
package thb.jeanluc.adventure.io;
/** Background-music volume level, cycled in the Settings menu. */
public enum MusicLevel {
OFF, LOW, MEDIUM, HIGH;
/** Target MASTER_GAIN in decibels. Irrelevant for {@link #OFF} (playback disabled). */
public float gainDb() {
return switch (this) {
case OFF -> Float.NEGATIVE_INFINITY;
case LOW -> -20f;
case MEDIUM -> -10f;
case HIGH -> 0f;
};
}
/** @return true when music should play. */
public boolean isOn() {
return this != OFF;
}
/** Next level in the cycle (wraps HIGH → OFF). */
public MusicLevel next() {
return values()[(ordinal() + 1) % values().length];
}
}
```
- [ ] **Step 4: Run — PASS** (`mvn -q test -Dtest=MusicLevelTest`).
- [ ] **Step 5: Commit**`git add` the two files; `git commit -m "feat(io): MusicLevel enum (Off/Low/Med/High)"`.
---
## Task 2: `MusicBackend` + `MusicController`
**Files:** Create `io/MusicBackend.java`, `io/MusicController.java`; Test `io/MusicControllerTest.java`
- [ ] **Step 1: Write the failing test**
`src/test/java/thb/jeanluc/adventure/io/MusicControllerTest.java`:
```java
package thb.jeanluc.adventure.io;
import org.junit.jupiter.api.Test;
import java.util.ArrayList;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
class MusicControllerTest {
/** Records backend calls for assertions. */
private static final class FakeBackend implements MusicBackend {
final List<String> calls = new ArrayList<>();
public void fadeTo(String track, float gainDb) { calls.add("fadeTo:" + track); }
public void fadeOut() { calls.add("fadeOut"); }
public void setGainDb(float gainDb) { calls.add("setGain:" + gainDb); }
public void shutdown() { calls.add("shutdown"); }
}
@Test
void firstRoomWithTrackPlaysAtDefaultLevel() {
FakeBackend b = new FakeBackend();
new MusicController(b).room("a");
assertThat(b.calls).containsExactly("fadeTo:a");
}
@Test
void sameTrackDoesNotRestart() {
FakeBackend b = new FakeBackend();
MusicController c = new MusicController(b);
c.room("a");
b.calls.clear();
c.room("a");
assertThat(b.calls).isEmpty();
}
@Test
void differentTrackSwitches() {
FakeBackend b = new FakeBackend();
MusicController c = new MusicController(b);
c.room("a");
c.room("b");
assertThat(b.calls).containsExactly("fadeTo:a", "fadeTo:b");
}
@Test
void nullOrBlankRoomFadesToSilence() {
FakeBackend b = new FakeBackend();
MusicController c = new MusicController(b);
c.room("a");
c.room(null);
c.room(" ");
assertThat(b.calls).containsExactly("fadeTo:a", "fadeOut");
}
@Test
void offStopsAndIgnoresRooms() {
FakeBackend b = new FakeBackend();
MusicController c = new MusicController(b);
c.room("a");
c.level(MusicLevel.OFF);
b.calls.clear();
c.room("b");
assertThat(b.calls).isEmpty();
}
@Test
void levelChangeSetsGain() {
FakeBackend b = new FakeBackend();
MusicController c = new MusicController(b); // default MEDIUM
c.level(MusicLevel.HIGH);
assertThat(b.calls).containsExactly("setGain:" + MusicLevel.HIGH.gainDb());
}
@Test
void turningOffThenOnReplaysOnNextRoom() {
FakeBackend b = new FakeBackend();
MusicController c = new MusicController(b);
c.room("a");
c.level(MusicLevel.OFF); // fadeOut
c.level(MusicLevel.MEDIUM); // setGain
b.calls.clear();
c.room("a"); // current was reset → plays again
assertThat(b.calls).containsExactly("fadeTo:a");
}
}
```
- [ ] **Step 2: Run — expect FAIL.**
- [ ] **Step 3: Create `MusicBackend`**
`src/main/java/thb/jeanluc/adventure/io/MusicBackend.java`:
```java
package thb.jeanluc.adventure.io;
/** Raw audio operations — the only part that touches sound hardware. */
public interface MusicBackend {
/** Fade the current track out, then start {@code track} (looping) and fade in to {@code gainDb}. */
void fadeTo(String track, float gainDb);
/** Fade the current track out and stop. */
void fadeOut();
/** Set the playback volume immediately. */
void setGainDb(float gainDb);
/** Stop playback and release resources. */
void shutdown();
}
```
- [ ] **Step 4: Create `MusicController`**
`src/main/java/thb/jeanluc/adventure/io/MusicController.java`:
```java
package thb.jeanluc.adventure.io;
import java.util.Objects;
/**
* Frontend-agnostic music decision logic. Idempotent per track (never restarts
* the same track), fades to silence on a null/blank track, and honours the
* {@link MusicLevel} (OFF disables playback). Delegates raw operations to a
* {@link MusicBackend}.
*/
public final class MusicController {
private final MusicBackend backend;
private MusicLevel level = MusicLevel.MEDIUM;
private String current; // currently selected track, or null = silence
public MusicController(MusicBackend backend) {
this.backend = backend;
}
/** Called per turn with the current room's music field (may be null/blank). */
public void room(String track) {
if (!level.isOn()) {
return;
}
String next = (track == null || track.isBlank()) ? null : track;
if (Objects.equals(next, current)) {
return;
}
current = next;
if (next == null) {
backend.fadeOut();
} else {
backend.fadeTo(next, level.gainDb());
}
}
/** Applies a new music level (volume / off). */
public void level(MusicLevel newLevel) {
this.level = newLevel;
if (!newLevel.isOn()) {
backend.fadeOut();
current = null; // turning back on replays on the next room() call
} else {
backend.setGainDb(newLevel.gainDb());
}
}
public void shutdown() {
backend.shutdown();
}
}
```
- [ ] **Step 5: Run — PASS** (`mvn -q test -Dtest=MusicControllerTest`, 7 tests).
- [ ] **Step 6: Commit**`git commit -m "feat(io): MusicController + MusicBackend (testable music logic)"`.
---
## Task 3: `Room.music` field + loader
**Files:** Modify `model/Room.java`, `loader/dto/RoomDto.java`, `loader/RoomFactory.java`, `src/main/resources/world/rooms.yaml`; Test `loader/RoomMusicTest.java`
- [ ] **Step 1: Write the failing test**
`src/test/java/thb/jeanluc/adventure/loader/RoomMusicTest.java`:
```java
package thb.jeanluc.adventure.loader;
import org.junit.jupiter.api.Test;
import thb.jeanluc.adventure.loader.dto.RoomDto;
import thb.jeanluc.adventure.model.Room;
import java.util.List;
import java.util.Map;
import static org.assertj.core.api.Assertions.assertThat;
class RoomMusicTest {
@Test
void musicFieldIsCarriedOntoRoom() {
RoomDto dto = new RoomDto("r", "Room", "d",
Map.of(), List.of(), List.of(), null, null, null, "theme.ogg");
Room room = RoomFactory.shellFromDto(dto);
assertThat(room.getMusic()).isEqualTo("theme.ogg");
}
@Test
void absentMusicIsNull() {
RoomDto dto = new RoomDto("r", "Room", "d", Map.of(), List.of(), List.of());
Room room = RoomFactory.shellFromDto(dto);
assertThat(room.getMusic()).isNull();
}
}
```
- [ ] **Step 2: Run — expect FAIL** (no `getMusic`, RoomDto has no music arg).
- [ ] **Step 3: Add `music` to `Room`**
In `src/main/java/thb/jeanluc/adventure/model/Room.java`, after the `dark` field (which uses `@Setter`), add:
```java
/** Optional GUI background-music track filename for this room; null = none. */
@Setter
private String music;
```
(`@Getter` on the class provides `getMusic()`; `@Setter` provides `setMusic(String)`, mirroring `dark`.)
- [ ] **Step 4: Add `music` to `RoomDto`**
In `src/main/java/thb/jeanluc/adventure/loader/dto/RoomDto.java`, add `String music` as the final record component and update the backward-compatible constructor:
```java
public record RoomDto(
String id,
String name,
String description,
Map<String, String> exits,
List<String> items,
List<String> npcs,
List<ExitLockDto> exitLocks,
List<DescriptionStateDto> descriptionStates,
Boolean dark,
String music
) {
/** Backward-compatible constructor without the optional state fields. */
public RoomDto(String id, String name, String description,
Map<String, String> exits, List<String> items, List<String> npcs) {
this(id, name, description, exits, items, npcs, null, null, null, null);
}
}
```
- [ ] **Step 5: Carry it in `RoomFactory`**
In `src/main/java/thb/jeanluc/adventure/loader/RoomFactory.java`, in `shellFromDto`, after `room.setDark(...)`:
```java
room.setMusic(dto.music());
```
- [ ] **Step 6: Add demo `music:` fields**
In `src/main/resources/world/rooms.yaml`, add a `music:` line to two rooms (files supplied externally; absent → silent). For the `kitchen` room add ` music: manor-theme.ogg` and for the `dungeon` room add ` music: dungeon-drone.ogg` (place the line among the room's other top-level keys, e.g. after its `description`).
- [ ] **Step 7: Run — PASS** (`mvn -q test -Dtest=RoomMusicTest`), then `mvn -q clean test` (full suite green; WorldLoaderTest uses its own fixture base, unaffected by the production rooms.yaml `music:` additions).
- [ ] **Step 8: Commit**`git commit -m "feat(loader): per-room music field + demo rooms.yaml entries"`.
---
## Task 4: `GameIO` music seams + game-loop / app wiring
**Files:** Modify `io/GameIO.java`, `game/Game.java`, `App.java`; Test `io/MusicSeamDefaultTest.java`
- [ ] **Step 1: Write the failing test**
`src/test/java/thb/jeanluc/adventure/io/MusicSeamDefaultTest.java`:
```java
package thb.jeanluc.adventure.io;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
class MusicSeamDefaultTest {
@Test
void defaultMusicSeamsAreSilentNoOps() {
TestIO io = new TestIO();
io.setMusic("anything.ogg");
io.setMusic(null);
io.setMusicLevel(MusicLevel.HIGH);
assertThat(io.outputs()).isEmpty();
}
}
```
- [ ] **Step 2: Run — expect FAIL** (no `setMusic`/`setMusicLevel`).
- [ ] **Step 3: Add the default seams to `GameIO`**
In `src/main/java/thb/jeanluc/adventure/io/GameIO.java`, add (MusicLevel is in the same package — no import needed):
```java
/** Per-turn current-room music track; null/blank = silence. Console no-op; GUI plays. */
default void setMusic(String track) {
// no audio in text mode
}
/** Applies the music volume/level. Console no-op; GUI sets it. */
default void setMusicLevel(MusicLevel level) {
// no audio in text mode
}
```
- [ ] **Step 4: Push the current track each turn in `Game`**
In `src/main/java/thb/jeanluc/adventure/game/Game.java`, at the END of `publishHud()` (after `setQuests(...)`):
```java
ctx.getIo().setMusic(ctx.getPlayer().getCurrentRoom().getMusic());
```
- [ ] **Step 5: Silence music on return to menu in `App`**
In `src/main/java/thb/jeanluc/adventure/App.java`, in `play(...)`, immediately after `game.run();`:
```java
io.setMusic(null); // fade music out when returning to the menu
```
- [ ] **Step 6: Run — PASS** (`mvn -q test -Dtest=MusicSeamDefaultTest`), then `mvn -q clean test` (full suite green; console/loop tests unaffected — seams are no-ops).
- [ ] **Step 7: Commit**`git commit -m "feat(io): GameIO music seams; push room track per turn, silence at menu"`.
---
## Task 5: Settings `MusicLevel` + SettingsMenu refactor to `GameIO`
**Files:** Modify `save/Settings.java`, `menu/SettingsMenu.java`, `App.java`, `test/.../menu/MenuTest.java`, `test/.../save/SettingsStoreTest.java`
- [ ] **Step 1: Write the failing tests**
Add to `src/test/java/thb/jeanluc/adventure/save/SettingsStoreTest.java` (keep existing tests; add imports `thb.jeanluc.adventure.io.MusicLevel`):
```java
@Test
void musicLevelRoundTrips(@TempDir Path dir) {
SettingsStore store = new SettingsStore(dir.resolve("settings.json"));
store.save(new Settings(ConsoleIO.ColorMode.AUTO, ConsoleIO.GlyphMode.UNICODE, MusicLevel.HIGH));
assertThat(store.load().musicLevel()).isEqualTo(MusicLevel.HIGH);
}
@Test
void oldSettingsWithoutMusicDefaultToMedium(@TempDir Path dir) throws Exception {
Path file = dir.resolve("settings.json");
Files.writeString(file, "{\"colorMode\":\"ON\",\"glyphMode\":\"ASCII\"}");
assertThat(new SettingsStore(file).load().musicLevel()).isEqualTo(MusicLevel.MEDIUM);
}
```
(Add `import java.nio.file.Files;` / `Path` / `@TempDir` if not already present — the existing tests already use `@TempDir`.)
Update the music-toggle expectation in `src/test/java/thb/jeanluc/adventure/menu/MenuTest.java` — the existing `settingsMenuTogglesGlyphAndPersists` test calls `SettingsMenu.show(io, Settings.defaults(), store, new ConsoleIO())`. Change it to the new signature and account for the new "Music" row (options become Colour=1, Glyphs=2, Music=3, Back=4):
```java
@Test
void settingsMenuTogglesGlyphAndPersists(@TempDir Path dir) {
SettingsStore store = new SettingsStore(dir.resolve("settings.json"));
TestIO io = new TestIO().enqueue("2").enqueue("4"); // toggle Glyphs, then Back
Settings result = SettingsMenu.show(io, Settings.defaults(), store);
assertThat(result.glyphMode()).isEqualTo(ConsoleIO.GlyphMode.ASCII);
assertThat(store.load().glyphMode()).isEqualTo(ConsoleIO.GlyphMode.ASCII);
}
```
Add a music-cycle test:
```java
@Test
void settingsMenuCyclesMusicLevelAndPersists(@TempDir Path dir) {
SettingsStore store = new SettingsStore(dir.resolve("settings.json"));
TestIO io = new TestIO().enqueue("3").enqueue("4"); // cycle Music once, then Back
Settings result = SettingsMenu.show(io, Settings.defaults(), store);
assertThat(result.musicLevel()).isEqualTo(MusicLevel.MEDIUM.next()); // HIGH
assertThat(store.load().musicLevel()).isEqualTo(MusicLevel.MEDIUM.next());
}
```
(Add `import thb.jeanluc.adventure.io.MusicLevel;` to MenuTest.)
- [ ] **Step 2: Run — expect FAIL** (Settings has no musicLevel; show signature differs).
- [ ] **Step 3: Extend `Settings`**
Replace `src/main/java/thb/jeanluc/adventure/save/Settings.java`:
```java
package thb.jeanluc.adventure.save;
import thb.jeanluc.adventure.io.ConsoleIO;
import thb.jeanluc.adventure.io.MusicLevel;
/** Persisted user preferences (colour + glyph fidelity + music level). */
public record Settings(ConsoleIO.ColorMode colorMode, ConsoleIO.GlyphMode glyphMode, MusicLevel musicLevel) {
public Settings {
if (musicLevel == null) {
musicLevel = MusicLevel.MEDIUM; // backward-compat: old saves lack this field
}
}
/** Backward-compatible constructor for callers that don't set music. */
public Settings(ConsoleIO.ColorMode colorMode, ConsoleIO.GlyphMode glyphMode) {
this(colorMode, glyphMode, MusicLevel.MEDIUM);
}
public static Settings defaults() {
return new Settings(ConsoleIO.ColorMode.AUTO, ConsoleIO.GlyphMode.UNICODE, MusicLevel.MEDIUM);
}
}
```
- [ ] **Step 4: Refactor `SettingsMenu` to `GameIO` + add the Music row**
Replace `src/main/java/thb/jeanluc/adventure/menu/SettingsMenu.java`:
```java
package thb.jeanluc.adventure.menu;
import thb.jeanluc.adventure.io.ConsoleIO;
import thb.jeanluc.adventure.io.GameIO;
import thb.jeanluc.adventure.io.MusicLevel;
import thb.jeanluc.adventure.save.Settings;
import thb.jeanluc.adventure.save.SettingsStore;
import java.util.List;
/** Settings screen: cycle colour, glyph, and music level; persists + applies. */
public final class SettingsMenu {
private SettingsMenu() {
}
/** Loops the settings screen until "Back", persisting + applying each change. */
public static Settings show(GameIO io, Settings current, SettingsStore store) {
Settings s = current;
while (true) {
int i = io.choose("SETTINGS", List.of(
"Colour: " + s.colorMode(),
"Glyphs: " + s.glyphMode(),
"Music: " + s.musicLevel(),
"Back"));
if (i == 0) {
s = new Settings(nextColor(s.colorMode()), s.glyphMode(), s.musicLevel());
} else if (i == 1) {
s = new Settings(s.colorMode(), nextGlyph(s.glyphMode()), s.musicLevel());
} else if (i == 2) {
s = new Settings(s.colorMode(), s.glyphMode(), s.musicLevel().next());
} else {
return s;
}
store.save(s);
apply(s, io);
}
}
/** Applies settings to the IO: music level always; colour/glyph only on the console. */
public static void apply(Settings s, GameIO io) {
io.setMusicLevel(s.musicLevel());
if (io instanceof ConsoleIO c) {
c.setColorMode(s.colorMode());
c.setGlyphMode(s.glyphMode());
}
}
private static ConsoleIO.ColorMode nextColor(ConsoleIO.ColorMode m) {
return switch (m) {
case AUTO -> ConsoleIO.ColorMode.ON;
case ON -> ConsoleIO.ColorMode.OFF;
case OFF -> ConsoleIO.ColorMode.AUTO;
};
}
private static ConsoleIO.GlyphMode nextGlyph(ConsoleIO.GlyphMode m) {
return switch (m) {
case UNICODE -> ConsoleIO.GlyphMode.ASCII;
case ASCII -> ConsoleIO.GlyphMode.GLYPH;
case GLYPH -> ConsoleIO.GlyphMode.UNICODE;
};
}
}
```
- [ ] **Step 5: Update `App` call sites**
In `src/main/java/thb/jeanluc/adventure/App.java`, in `run()`:
- Remove the now-unused `ConsoleIO consoleIo = io instanceof ConsoleIO c ? c : null;` line (if it becomes unused after the changes below).
- Change the startup apply to `SettingsMenu.apply(settings, io);`.
- Change the SETTINGS case to `case SETTINGS -> settings = SettingsMenu.show(io, settings, settingsStore);`.
(If `ConsoleIO` import becomes unused in `App.java`, remove it. `io` is the `GameIO`.)
- [ ] **Step 6: Run — PASS** (`mvn -q test -Dtest=SettingsStoreTest,MenuTest,ConsoleIORenderTest`), then `mvn -q clean test` (full suite green).
- [ ] **Step 7: Commit**`git commit -m "feat(settings): persisted Music level; SettingsMenu applies via GameIO"`.
---
## Task 6: OGG dependency + `OggMusicBackend` + SwingIO wiring
**Files:** Modify `pom.xml`, `io/SwingIO.java`, `.gitignore`; Create `io/OggMusicBackend.java`, `docs/music.md`. (No unit test — audio is manually verified per the no-GUI-test convention.)
- [ ] **Step 1: Add the OGG dependencies to `pom.xml`**
In the `<dependencies>` block:
```xml
<!-- OGG Vorbis decoding (GUI background music) -->
<dependency>
<groupId>com.googlecode.soundlibs</groupId>
<artifactId>vorbisspi</artifactId>
<version>1.0.3.3</version>
</dependency>
<dependency>
<groupId>com.googlecode.soundlibs</groupId>
<artifactId>jorbis</artifactId>
<version>0.0.17.4</version>
</dependency>
```
Run `mvn -q -DskipTests compile` to confirm the dependencies resolve.
- [ ] **Step 2: Create `OggMusicBackend`**
`src/main/java/thb/jeanluc/adventure/io/OggMusicBackend.java`:
```java
package thb.jeanluc.adventure.io;
import lombok.extern.slf4j.Slf4j;
import javax.sound.sampled.AudioFormat;
import javax.sound.sampled.AudioInputStream;
import javax.sound.sampled.AudioSystem;
import javax.sound.sampled.FloatControl;
import javax.sound.sampled.SourceDataLine;
import java.nio.file.Files;
import java.nio.file.Path;
/**
* Streams OGG Vorbis tracks from the external {@code music/} folder through a
* {@link SourceDataLine}, looping, with ~400 ms gain fades. Decoding uses the
* vorbisspi/jorbis {@code javax.sound} SPI. All playback runs on a daemon
* thread; missing/undecodable files are logged and stay silent. GUI-only —
* verified manually.
*/
@Slf4j
public final class OggMusicBackend implements MusicBackend {
private static final String MUSIC_DIR = "music";
private static final int FADE_MS = 400;
private static final int FADE_STEPS = 20;
private static final int BUFFER = 4096;
private static final float FLOOR_DB = -40f; // near-silence for fades
private volatile Thread playThread;
private volatile boolean stopping;
private volatile SourceDataLine line;
private volatile float targetGainDb = MusicLevel.MEDIUM.gainDb();
@Override
public synchronized void fadeTo(String track, float gainDb) {
stopCurrent();
targetGainDb = gainDb;
Path file = Path.of(MUSIC_DIR, track);
if (!Files.isReadable(file)) {
log.warn("Music file not found, staying silent: {}", file);
return;
}
stopping = false;
playThread = new Thread(() -> playLoop(file, gainDb), "music");
playThread.setDaemon(true);
playThread.start();
}
@Override
public synchronized void fadeOut() {
stopCurrent();
}
@Override
public synchronized void setGainDb(float gainDb) {
targetGainDb = gainDb;
SourceDataLine l = line;
if (l != null) {
applyGain(l, gainDb);
}
}
@Override
public synchronized void shutdown() {
stopCurrent();
}
/** Signals the play thread to stop and waits for its fade-out + cleanup. */
private void stopCurrent() {
Thread t = playThread;
if (t == null) {
return;
}
stopping = true;
try {
t.join(FADE_MS + 600L);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
playThread = null;
}
private void playLoop(Path file, float gainDb) {
SourceDataLine l = null;
try {
while (!stopping) {
try (AudioInputStream in = AudioSystem.getAudioInputStream(file.toFile())) {
AudioFormat base = in.getFormat();
AudioFormat pcm = new AudioFormat(AudioFormat.Encoding.PCM_SIGNED,
base.getSampleRate(), 16, base.getChannels(),
base.getChannels() * 2, base.getSampleRate(), false);
try (AudioInputStream din = AudioSystem.getAudioInputStream(pcm, in)) {
if (l == null) {
l = AudioSystem.getSourceDataLine(pcm);
l.open(pcm);
l.start();
line = l;
ramp(l, FLOOR_DB, gainDb); // fade in once
}
byte[] buf = new byte[BUFFER];
int n;
while (!stopping && (n = din.read(buf, 0, buf.length)) != -1) {
l.write(buf, 0, n);
}
}
}
}
} catch (Exception e) {
log.warn("Music playback failed for {}: {}", file, e.getMessage());
} finally {
if (l != null) {
try {
ramp(l, currentGain(l), FLOOR_DB); // fade out
} catch (RuntimeException ignored) {
// best-effort fade
}
l.stop();
l.close();
}
line = null;
}
}
private void ramp(SourceDataLine l, float fromDb, float toDb) {
for (int i = 0; i <= FADE_STEPS; i++) {
applyGain(l, fromDb + (toDb - fromDb) * i / FADE_STEPS);
sleep(FADE_MS / FADE_STEPS);
}
}
private float currentGain(SourceDataLine l) {
try {
return ((FloatControl) l.getControl(FloatControl.Type.MASTER_GAIN)).getValue();
} catch (IllegalArgumentException e) {
return targetGainDb;
}
}
private void applyGain(SourceDataLine l, float db) {
try {
FloatControl gain = (FloatControl) l.getControl(FloatControl.Type.MASTER_GAIN);
gain.setValue(Math.max(gain.getMinimum(), Math.min(gain.getMaximum(), db)));
} catch (IllegalArgumentException ignored) {
// line has no master-gain control on this platform
}
}
private void sleep(long ms) {
try {
Thread.sleep(ms);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
}
```
If any call doesn't compile against the installed vorbisspi/`javax.sound` API, adjust minimally to compile while preserving the behaviour (decode OGG→PCM, stream with looping, MASTER_GAIN fades, graceful on missing file). This class is manually verified.
- [ ] **Step 3: Wire the controller into `SwingIO`**
In `src/main/java/thb/jeanluc/adventure/io/SwingIO.java`:
- Add a field (with the other final fields): `private final MusicController music = new MusicController(new OggMusicBackend());`
- Add the overrides (near `setMap`):
```java
@Override
public void setMusic(String track) {
music.room(track);
}
@Override
public void setMusicLevel(MusicLevel level) {
music.level(level);
}
```
(`MusicController`/`MusicLevel`/`OggMusicBackend` are in the same `io` package — no imports needed.)
- [ ] **Step 4: Ignore the music folder**
Append to `.gitignore`:
```
# External background-music files (not bundled)
music/
```
- [ ] **Step 5: Document the music folder**
Create `docs/music.md`:
```markdown
# Background music (GUI)
Background music plays only in the Swing GUI. It is **data-driven and external**:
- Put `.ogg` (OGG Vorbis) files in a `music/` folder next to where you run the game.
This folder is git-ignored and never bundled in the JAR.
- A room plays a track via an optional `music:` field in `rooms.yaml`, e.g.
`music: manor-theme.ogg`. Entering a room with a different track cross-fades to it;
a room with no `music:` field fades to silence; the same track keeps playing
seamlessly across rooms.
- Volume is the **Music** setting (Off / Low / Medium / High) in the Settings menu,
persisted to `saves/settings.json`. Off disables playback.
- Missing files are ignored (silent) — the game never crashes over absent audio.
- The console version is always silent.
Decoding uses the `vorbisspi` + `jorbis` libraries via the `javax.sound` SPI.
```
- [ ] **Step 6: Build + full suite + manual check**
Run: `mvn -q clean test` → BUILD SUCCESS (all prior + new tests; the audio backend has no unit test).
Manual (optional, needs audio): drop an `.ogg` as `music/manor-theme.ogg`, launch the GUI, New Game → the kitchen should play it; move to the dungeon → it cross-fades to `dungeon-drone.ogg` (or silence if absent); Settings → Music cycles volume/off.
- [ ] **Step 7: Commit**`git add` pom.xml, OggMusicBackend, SwingIO, .gitignore, docs/music.md; `git commit -m "feat(io): OggMusicBackend + SwingIO wiring; music/ folder + docs"`.
---
## Task 7: Backlog doc
**Files:** Modify `docs/enhancement-ideas.md`
- [ ] **Step 1:** Under section **8. Musik (GUI)**, add a `> ✅ umgesetzt (Branch feature/music)` note mirroring the existing ✅ style: OGG Vorbis (vorbisspi/jorbis), per-room `music:` in rooms.yaml, external gitignored `music/` folder, idempotent track switch with ~400 ms fades, fade-to-silence in unscored rooms, Music level Off/Low/Med/High in Settings (default Medium), console silent; testable `MusicController` over an isolated `OggMusicBackend`.
- [ ] **Step 2: Commit**`git commit -m "docs: mark GUI background music implemented"`.
---
## Final Verification
- [ ] `mvn -q clean test` — all tests pass (211 prior + MusicLevel/MusicController/RoomMusic/MusicSeam + extended Settings/Menu suites).
- [ ] OGG dependencies resolve; project compiles.
- [ ] Manual GUI smoke (optional, with an `.ogg`): room music plays/fades; Settings Music Off silences it; console run is silent.
- [ ] Load a saved game from before this feature → defaults to Medium, no crash.
- [ ] `git status` clean (no `music/` tracked).
```

View File

@@ -0,0 +1,609 @@
# `go to <room>` Pathfinding Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Add `go to <room>` — BFS auto-walk over visited rooms (respecting locked exits + darkness), routed through `GoCommand`, with a silent console summary and an animated GUI minimap.
**Architecture:** A new stateless `Pathfinder` (game package, next to `Conditions`/`Effects`/`Light`) does BFS over visited rooms using the same passability gates `GoCommand` enforces. `GoCommand` gains a branch: a directional arg moves one step (unchanged); a non-direction arg resolves a visited room by id/name and auto-walks the path. Per step it pushes a map frame via a new `GameIO.travelStep(MapView)` hook — a console no-op, overridden by `SwingIO` to repaint + briefly sleep so the minimap animates.
**Tech Stack:** Java 25, Maven, JUnit 5 + AssertJ, Lombok.
---
## File Structure
**Create:**
- `src/main/java/thb/jeanluc/adventure/game/Pathfinder.java` — BFS over visited rooms.
**Modify:**
- `io/GameIO.java` — add `travelStep(MapView)` default no-op.
- `io/SwingIO.java` — override `travelStep` (setMap + ~300ms sleep).
- `command/impl/GoCommand.java` — extract `moveDirection`; add `goToRoom` (resolve + path + walk + summary).
- `docs/enhancement-ideas.md` — mark pathfinding implemented.
---
## Task 1: `GameIO.travelStep` hook + `SwingIO` animation
**Files:**
- Modify: `src/main/java/thb/jeanluc/adventure/io/GameIO.java`
- Modify: `src/main/java/thb/jeanluc/adventure/io/SwingIO.java`
- Test: `src/test/java/thb/jeanluc/adventure/io/TravelStepDefaultTest.java`
- [ ] **Step 1: Write the failing test**
`src/test/java/thb/jeanluc/adventure/io/TravelStepDefaultTest.java`:
```java
package thb.jeanluc.adventure.io;
import org.junit.jupiter.api.Test;
import thb.jeanluc.adventure.io.text.MapView;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
class TravelStepDefaultTest {
@Test
void defaultTravelStepIsSilentNoOp() {
TestIO io = new TestIO();
// An empty map view is fine; the default must not print or throw.
io.travelStep(new MapView(List.of(), List.of()));
assertThat(io.outputs()).isEmpty();
}
}
```
- [ ] **Step 2: Run test to verify it fails**
Run: `mvn -q test -Dtest=TravelStepDefaultTest`
Expected: FAIL — `travelStep` is not defined on `GameIO`.
- [ ] **Step 3: Add the default to `GameIO`**
In `src/main/java/thb/jeanluc/adventure/io/GameIO.java`, add the import (if missing it is already there — `MapView` is used by `setMap`) and the method:
```java
/**
* One frame of an auto-walk (the {@code go to} command), as the player moves
* through a room en route. Default is a no-op: text mode travels silently.
* The GUI overrides this to repaint the map and pace the animation.
*
* @param view the map snapshot at this step
*/
default void travelStep(MapView view) {
// no animation in text mode
}
```
- [ ] **Step 4: Override in `SwingIO`**
In `src/main/java/thb/jeanluc/adventure/io/SwingIO.java`:
1. Add a constant near the other size constants (e.g. below `SMALL_SIZE`):
```java
/** Delay between rooms while animating a go-to auto-walk, in milliseconds. */
private static final long TRAVEL_STEP_MS = 300L;
```
2. Add the override (place it near `setMap`):
```java
@Override
public void travelStep(MapView view) {
setMap(view);
try {
Thread.sleep(TRAVEL_STEP_MS);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
```
(`setMap` already calls `map.show(view)` which repaints on the EDT; the sleep on the game-loop worker thread lets each frame render. `MapView` is already imported in `SwingIO`.)
- [ ] **Step 5: Run tests**
Run: `mvn -q test -Dtest=TravelStepDefaultTest`
Expected: PASS.
- [ ] **Step 6: Commit**
```bash
git add src/main/java/thb/jeanluc/adventure/io/GameIO.java \
src/main/java/thb/jeanluc/adventure/io/SwingIO.java \
src/test/java/thb/jeanluc/adventure/io/TravelStepDefaultTest.java
git commit -m "feat(io): travelStep hook for animated auto-walk (console no-op, Swing override)"
```
---
## Task 2: `Pathfinder` BFS
**Files:**
- Create: `src/main/java/thb/jeanluc/adventure/game/Pathfinder.java`
- Test: `src/test/java/thb/jeanluc/adventure/game/PathfinderTest.java`
- [ ] **Step 1: Write the failing test**
`src/test/java/thb/jeanluc/adventure/game/PathfinderTest.java`:
```java
package thb.jeanluc.adventure.game;
import org.junit.jupiter.api.Test;
import thb.jeanluc.adventure.io.TestIO;
import thb.jeanluc.adventure.model.Condition;
import thb.jeanluc.adventure.model.Direction;
import thb.jeanluc.adventure.model.ExitLock;
import thb.jeanluc.adventure.model.Player;
import thb.jeanluc.adventure.model.Room;
import thb.jeanluc.adventure.model.World;
import thb.jeanluc.adventure.model.item.Item;
import thb.jeanluc.adventure.model.item.PlainItem;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import static org.assertj.core.api.Assertions.assertThat;
class PathfinderTest {
private Room room(String id) {
return new Room(id, id, "d");
}
private GameContext ctx(Room start, List<Room> all, Set<String> visited) {
Map<String, Room> rooms = new LinkedHashMap<>();
for (Room r : all) {
rooms.put(r.getId(), r);
}
World w = new World(rooms, Map.of(), Map.of(), "t", "w");
Player p = new Player(start, 0);
p.getVisitedRoomIds().addAll(visited);
return new GameContext(w, p, new TestIO());
}
@Test
void findsShortestPathOverVisitedRooms() {
Room a = room("a"), b = room("b"), c = room("c");
a.addExit(Direction.NORTH, b);
b.addExit(Direction.NORTH, c);
GameContext ctx = ctx(a, List.of(a, b, c), Set.of("a", "b", "c"));
assertThat(Pathfinder.findPath(ctx, c)).containsExactly(b, c);
}
@Test
void willNotRouteThroughUnvisitedRoom() {
Room a = room("a"), b = room("b"), c = room("c");
a.addExit(Direction.NORTH, b);
b.addExit(Direction.NORTH, c);
GameContext ctx = ctx(a, List.of(a, b, c), Set.of("a", "c")); // b not visited
assertThat(Pathfinder.findPath(ctx, c)).isEmpty();
}
@Test
void respectsLockedExit() {
Room a = room("a"), b = room("b");
a.addExit(Direction.NORTH, b);
a.addExitLock(Direction.NORTH,
new ExitLock(List.of(new Condition(Condition.Type.FLAG, "open")), "locked"));
GameContext ctx = ctx(a, List.of(a, b), Set.of("a", "b"));
assertThat(Pathfinder.findPath(ctx, b)).isEmpty();
ctx.getState().set("open");
assertThat(Pathfinder.findPath(ctx, b)).containsExactly(b);
}
@Test
void respectsDarkness() {
Room a = room("a");
Room dark = room("dark");
dark.setDark(true);
a.addExit(Direction.NORTH, dark);
GameContext ctx = ctx(a, List.of(a, dark), Set.of("a", "dark"));
assertThat(Pathfinder.findPath(ctx, dark)).isEmpty();
Item torch = PlainItem.builder().id("lit").name("Lit").description("d").light(true).build();
ctx.getPlayer().addItem(torch);
assertThat(Pathfinder.findPath(ctx, dark)).containsExactly(dark);
}
@Test
void unreachableTargetReturnsEmpty() {
Room a = room("a");
Room island = room("island"); // no connecting exits
GameContext ctx = ctx(a, List.of(a, island), Set.of("a", "island"));
assertThat(Pathfinder.findPath(ctx, island)).isEmpty();
}
}
```
- [ ] **Step 2: Run test to verify it fails**
Run: `mvn -q test -Dtest=PathfinderTest`
Expected: FAIL — `Pathfinder` does not exist.
- [ ] **Step 3: Create `Pathfinder`**
`src/main/java/thb/jeanluc/adventure/game/Pathfinder.java`:
```java
package thb.jeanluc.adventure.game;
import thb.jeanluc.adventure.model.Direction;
import thb.jeanluc.adventure.model.ExitLock;
import thb.jeanluc.adventure.model.Room;
import java.util.ArrayDeque;
import java.util.HashMap;
import java.util.LinkedList;
import java.util.List;
import java.util.Map;
import java.util.Queue;
import java.util.Set;
/**
* Breadth-first shortest-path search over the rooms the player has already
* visited, honouring the same gates as a manual move: locked exits and
* darkness. Stateless utility, alongside {@link Conditions}/{@link Effects}/
* {@link Light}.
*/
public final class Pathfinder {
private Pathfinder() {
}
/**
* Finds the shortest path from the player's current room to {@code target}.
*
* @param ctx active game context
* @param target destination room (expected to be a visited room)
* @return the rooms to step through, excluding the start and ending at
* {@code target}; empty if there is no currently-passable path
* (or {@code target} is the current room)
*/
public static List<Room> findPath(GameContext ctx, Room target) {
Room start = ctx.getPlayer().getCurrentRoom();
Set<String> visited = ctx.getPlayer().getVisitedRoomIds();
Queue<Room> queue = new ArrayDeque<>();
Map<Room, Room> cameFrom = new HashMap<>();
queue.add(start);
cameFrom.put(start, null);
while (!queue.isEmpty()) {
Room current = queue.poll();
if (current == target) {
return reconstruct(cameFrom, target);
}
for (Map.Entry<Direction, Room> exit : current.getExits().entrySet()) {
Room next = exit.getValue();
if (cameFrom.containsKey(next) || !visited.contains(next.getId())) {
continue;
}
ExitLock lock = current.getExitLocks().get(exit.getKey());
if (lock != null && !Conditions.all(lock.requires(), ctx)) {
continue;
}
if (!Light.isLit(ctx, next)) {
continue;
}
cameFrom.put(next, current);
queue.add(next);
}
}
return List.of();
}
/** Walks predecessors back from target to start; returns steps after start. */
private static List<Room> reconstruct(Map<Room, Room> cameFrom, Room target) {
LinkedList<Room> path = new LinkedList<>();
for (Room r = target; r != null && cameFrom.get(r) != null; r = cameFrom.get(r)) {
path.addFirst(r);
}
return path;
}
}
```
- [ ] **Step 4: Run tests to verify they pass**
Run: `mvn -q test -Dtest=PathfinderTest`
Expected: PASS (5 tests).
- [ ] **Step 5: Commit**
```bash
git add src/main/java/thb/jeanluc/adventure/game/Pathfinder.java \
src/test/java/thb/jeanluc/adventure/game/PathfinderTest.java
git commit -m "feat(game): Pathfinder BFS over visited rooms (locks + light aware)"
```
---
## Task 3: `GoCommand` — route, resolve, walk, summarize
**Files:**
- Modify: `src/main/java/thb/jeanluc/adventure/command/impl/GoCommand.java`
- Test: `src/test/java/thb/jeanluc/adventure/command/impl/GoCommandPathTest.java`
- [ ] **Step 1: Write the failing test**
`src/test/java/thb/jeanluc/adventure/command/impl/GoCommandPathTest.java`:
```java
package thb.jeanluc.adventure.command.impl;
import org.junit.jupiter.api.Test;
import thb.jeanluc.adventure.game.GameContext;
import thb.jeanluc.adventure.io.TestIO;
import thb.jeanluc.adventure.model.Condition;
import thb.jeanluc.adventure.model.Direction;
import thb.jeanluc.adventure.model.ExitLock;
import thb.jeanluc.adventure.model.Player;
import thb.jeanluc.adventure.model.Room;
import thb.jeanluc.adventure.model.World;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import static org.assertj.core.api.Assertions.assertThat;
class GoCommandPathTest {
/** kitchen --N--> hallway --N--> library (all lit). Optional lock on kitchen->hallway. */
private GameContext world(boolean lockHallway, String libraryName) {
Room kitchen = new Room("kitchen", "Kitchen", "d");
Room hallway = new Room("hallway", "Hallway", "d");
Room library = new Room("library", libraryName, "d");
kitchen.addExit(Direction.NORTH, hallway);
hallway.addExit(Direction.SOUTH, kitchen);
hallway.addExit(Direction.NORTH, library);
library.addExit(Direction.SOUTH, hallway);
if (lockHallway) {
kitchen.addExitLock(Direction.NORTH,
new ExitLock(List.of(new Condition(Condition.Type.FLAG, "open")), "It's locked."));
}
Map<String, Room> rooms = new LinkedHashMap<>();
rooms.put("kitchen", kitchen);
rooms.put("hallway", hallway);
rooms.put("library", library);
World w = new World(rooms, Map.of(), Map.of(), "t", "w");
Player p = new Player(kitchen, 0);
p.getVisitedRoomIds().addAll(List.of("kitchen", "hallway", "library"));
return new GameContext(w, p, new TestIO());
}
private String out(GameContext ctx) {
return ((TestIO) ctx.getIo()).allOutput();
}
@Test
void goToWalksToVisitedRoomById() {
GameContext ctx = world(false, "Library");
new GoCommand().execute(ctx, List.of("library"));
assertThat(ctx.getPlayer().getCurrentRoom().getId()).isEqualTo("library");
assertThat(out(ctx)).contains("make your way");
}
@Test
void resolvesByRoomName() {
// id "library" but type the display name (lower-cased) — still resolves
GameContext ctx = world(false, "Grand Library");
new GoCommand().execute(ctx, List.of("grand", "library"));
assertThat(ctx.getPlayer().getCurrentRoom().getId()).isEqualTo("library");
}
@Test
void unknownPlaceReportsSo() {
GameContext ctx = world(false, "Library");
new GoCommand().execute(ctx, List.of("dungeon"));
assertThat(ctx.getPlayer().getCurrentRoom().getId()).isEqualTo("kitchen");
assertThat(out(ctx)).contains("don't know any place called 'dungeon'");
}
@Test
void alreadyThere() {
GameContext ctx = world(false, "Library");
new GoCommand().execute(ctx, List.of("kitchen"));
assertThat(out(ctx)).contains("already in the Kitchen");
}
@Test
void noPassablePathReportsSo() {
GameContext ctx = world(true, "Library"); // kitchen->hallway locked, flag unset
new GoCommand().execute(ctx, List.of("library"));
assertThat(ctx.getPlayer().getCurrentRoom().getId()).isEqualTo("kitchen");
assertThat(out(ctx)).contains("can't find a way there");
}
@Test
void directionalMoveStillWorks() {
GameContext ctx = world(false, "Library");
new GoCommand().execute(ctx, List.of("north"));
assertThat(ctx.getPlayer().getCurrentRoom().getId()).isEqualTo("hallway");
}
}
```
- [ ] **Step 2: Run test to verify it fails**
Run: `mvn -q test -Dtest=GoCommandPathTest`
Expected: FAIL — `go to` routing not implemented (e.g. `goToWalksToVisitedRoomById` fails: "library" treated as an unknown direction).
- [ ] **Step 3: Rewrite `GoCommand`**
Replace `src/main/java/thb/jeanluc/adventure/command/impl/GoCommand.java`:
```java
package thb.jeanluc.adventure.command.impl;
import thb.jeanluc.adventure.command.Command;
import thb.jeanluc.adventure.game.Conditions;
import thb.jeanluc.adventure.game.GameContext;
import thb.jeanluc.adventure.game.Light;
import thb.jeanluc.adventure.game.Pathfinder;
import thb.jeanluc.adventure.map.MapLayout;
import thb.jeanluc.adventure.model.Direction;
import thb.jeanluc.adventure.model.ExitLock;
import thb.jeanluc.adventure.model.Room;
import java.util.ArrayList;
import java.util.List;
import java.util.Optional;
/**
* Moves the player. {@code go <direction>} steps once to a connected room.
* {@code go to <room>} auto-walks the shortest path (BFS) over already-visited
* rooms, honouring locked exits and darkness. Usage: {@code go <direction>} or
* {@code go to <room>}.
*/
public class GoCommand implements Command {
@Override
public void execute(GameContext ctx, List<String> args) {
if (args.isEmpty()) {
ctx.getIo().write("Go where? Try 'go north' or 'go to <room>'.");
return;
}
try {
Direction dir = Direction.fromString(args.getFirst());
moveDirection(ctx, dir);
} catch (IllegalArgumentException notADirection) {
goToRoom(ctx, args);
}
}
/** Single-step move in a compass direction (the original behaviour). */
private void moveDirection(GameContext ctx, Direction dir) {
Room here = ctx.getPlayer().getCurrentRoom();
Optional<Room> next = here.getExit(dir);
if (next.isEmpty()) {
ctx.getIo().write("You can't go " + dir.getLabel() + " from here.");
return;
}
ExitLock lock = here.getExitLocks().get(dir);
if (lock != null && !Conditions.all(lock.requires(), ctx)) {
ctx.getIo().write(lock.blocked());
return;
}
if (!Light.isLit(ctx, next.get())) {
ctx.getIo().write("It's pitch black beyond the doorway — you need a lit light source "
+ "(try lighting your lamp with 'use lamp').");
return;
}
ctx.getPlayer().setCurrentRoom(next.get());
new LookCommand().execute(ctx, List.of());
}
/** Resolve a visited room by id/name and auto-walk the BFS path to it. */
private void goToRoom(GameContext ctx, List<String> args) {
String name = String.join(" ", args).toLowerCase();
Room target = resolveVisited(ctx, name);
if (target == null) {
ctx.getIo().write("You don't know any place called '" + name + "'.");
return;
}
Room start = ctx.getPlayer().getCurrentRoom();
if (target == start) {
ctx.getIo().write("You're already in the " + target.getName() + ".");
return;
}
List<Room> path = Pathfinder.findPath(ctx, target);
if (path.isEmpty()) {
ctx.getIo().write("You can't find a way there right now.");
return;
}
List<Room> route = new ArrayList<>();
route.add(start);
route.addAll(path);
for (Room step : path) {
ctx.getPlayer().setCurrentRoom(step);
ctx.getIo().travelStep(MapLayout.compute(
ctx.getWorld(), ctx.getPlayer().getVisitedRoomIds(), step));
}
ctx.getIo().write(summary(route));
new LookCommand().execute(ctx, List.of());
}
/** Finds a visited room whose id or display name matches {@code name}. */
private Room resolveVisited(GameContext ctx, String name) {
for (String id : ctx.getPlayer().getVisitedRoomIds()) {
Room r = ctx.getWorld().getRooms().get(id);
if (r == null) {
continue;
}
if (id.equals(name) || r.getName().toLowerCase().equals(name)) {
return r;
}
}
return null;
}
/** Names start, destination, and at most one middle waypoint. */
private String summary(List<Room> route) {
int n = route.size();
String start = route.get(0).getName();
String dest = route.get(n - 1).getName();
if (n >= 3) {
String middle = route.get(n / 2).getName();
return "You make your way from the " + start + ", through the " + middle
+ ", to the " + dest + ".";
}
return "You make your way from the " + start + " to the " + dest + ".";
}
@Override
public String help() {
return "go <direction> - move one room (north/south/east/west); "
+ "or go to <room> to travel to a known room";
}
}
```
- [ ] **Step 4: Run tests to verify they pass**
Run: `mvn -q test -Dtest=GoCommandPathTest`
Expected: PASS (6 tests).
- [ ] **Step 5: Full suite (no regressions to existing go/movement tests)**
Run: `mvn -q clean test`
Expected: BUILD SUCCESS, all green (existing `GameTest`/command tests that use `go north` still pass).
- [ ] **Step 6: Commit**
```bash
git add src/main/java/thb/jeanluc/adventure/command/impl/GoCommand.java \
src/test/java/thb/jeanluc/adventure/command/impl/GoCommandPathTest.java
git commit -m "feat(command): go to <room> auto-walk (BFS path, animated map, summary)"
```
---
## Task 4: Docs
**Files:**
- Modify: `docs/enhancement-ideas.md`
- [ ] **Step 1: Mark pathfinding implemented**
In `docs/enhancement-ideas.md`, find the "Festgelegte Erweiterungs-Entscheidungen"
area where the blockquote notes Pathfinding is *in scope for a later round* (added
when main-menu/save-load shipped). Update that note to mark it done, in the existing
`> ✅` style:
> ✅ umgesetzt (Branch `feature/pathfinding-go-to`): `go to <raum>` per BFS über
> besuchte Räume (Locks + Licht beachtet), via `GoCommand` (Richtung vs. Raumziel).
> Konsole still + Zusammenfassung (Start/Mitte/Ziel), GUI-Minikarte animiert pro
> Schritt (`GameIO.travelStep`, ~300 ms). Trie-Autocomplete weiter zurückgestellt.
- [ ] **Step 2: Commit**
```bash
git add docs/enhancement-ideas.md
git commit -m "docs: mark go to <room> pathfinding implemented"
```
---
## Final Verification
- [ ] `mvn -q clean test` — all tests pass (184 prior + new Pathfinder/GoCommand/travelStep suites).
- [ ] Manual GUI smoke (optional): start a game, walk kitchen→hallway→library manually (so they're visited), return to kitchen, then `go to library` — the minimap dot should step through the hallway to the library (~300ms each), then the library room renders with a summary line.
- [ ] Manual console smoke (optional): same, confirming silent traversal + the "You make your way ..." line + destination look.
- [ ] `git status` clean.

View File

@@ -0,0 +1,822 @@
# Interactive Start-of-Game Tutorial Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Add an interactive, skippable start-of-game walkthrough that makes the player perform the core commands (look→examine→take→inventory→use→go→go-to) in the real starting room, then lists the rest, shown only on New Game.
**Architecture:** A stateful `TutorialGuide` (game package) is layered onto the real `Game` loop (optional; null in normal/loaded play). It prints an instruction, and after each executed command checks — via alias-aware registry lookup, with movement steps requiring an actual room change — whether the step is satisfied (confirm + advance) or not (gentle hint). Steps come from an optional `tutorial.yaml` loaded by a standalone `TutorialLoader` into a `Tutorial`/`TutorialStep` model. `skip` ends it. `App` wires the guide only for New Game.
**Tech Stack:** Java 25, Maven, Jackson (YAML), JUnit 5 + AssertJ, Lombok.
---
## File Structure
**Create:**
- `model/Tutorial.java`, `model/TutorialStep.java` — domain records.
- `loader/dto/TutorialDto.java`, `loader/dto/TutorialStepDto.java` — YAML DTOs.
- `loader/TutorialLoader.java` — reads optional `/world/tutorial.yaml``Tutorial`.
- `game/TutorialGuide.java` — the guide.
- `src/main/resources/world/tutorial.yaml` — intro + 7 steps + closing tips.
- `src/test/resources/world/tutorial.yaml` — small 2-step loader fixture.
**Modify:**
- `game/Game.java` — optional guide field + `setTutorialGuide` + loop hooks.
- `App.java``play(..., withTutorial)`, build guide on New Game.
- `docs/enhancement-ideas.md` — note the tutorial.
---
## Task 1: Tutorial model, DTO, loader, content
**Files:**
- Create: `src/main/java/thb/jeanluc/adventure/model/Tutorial.java`
- Create: `src/main/java/thb/jeanluc/adventure/model/TutorialStep.java`
- Create: `src/main/java/thb/jeanluc/adventure/loader/dto/TutorialDto.java`
- Create: `src/main/java/thb/jeanluc/adventure/loader/dto/TutorialStepDto.java`
- Create: `src/main/java/thb/jeanluc/adventure/loader/TutorialLoader.java`
- Create: `src/main/resources/world/tutorial.yaml`
- Create: `src/test/resources/world/tutorial.yaml`
- Test: `src/test/java/thb/jeanluc/adventure/loader/TutorialLoaderTest.java`
- [ ] **Step 1: Write the failing test**
`src/test/java/thb/jeanluc/adventure/loader/TutorialLoaderTest.java`:
```java
package thb.jeanluc.adventure.loader;
import org.junit.jupiter.api.Test;
import thb.jeanluc.adventure.model.Tutorial;
import static org.assertj.core.api.Assertions.assertThat;
class TutorialLoaderTest {
@Test
void loadsTheTestFixture() {
Tutorial t = new TutorialLoader().load(); // test-classpath /world/tutorial.yaml shadows prod
assertThat(t.isEmpty()).isFalse();
assertThat(t.intro()).isNotBlank();
assertThat(t.closingTips()).isNotBlank();
assertThat(t.steps()).hasSize(2);
assertThat(t.steps().getFirst().expect()).isEqualTo("look");
assertThat(t.steps().getFirst().minArgs()).isZero();
}
@Test
void missingFileReturnsEmptyTutorial() {
Tutorial t = new TutorialLoader("/no-such-base").load();
assertThat(t.isEmpty()).isTrue();
assertThat(t.steps()).isEmpty();
}
}
```
- [ ] **Step 2: Run test to verify it fails**
Run: `mvn -q test -Dtest=TutorialLoaderTest`
Expected: FAIL — `Tutorial`/`TutorialLoader` do not exist.
- [ ] **Step 3: Create the model records**
`src/main/java/thb/jeanluc/adventure/model/TutorialStep.java`:
```java
package thb.jeanluc.adventure.model;
/**
* One interactive tutorial step: an instruction, the command that satisfies it
* ({@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
* wrong command.
*/
public record TutorialStep(
String instruction,
String expect,
int minArgs,
String confirm,
String hint
) {
}
```
`src/main/java/thb/jeanluc/adventure/model/Tutorial.java`:
```java
package thb.jeanluc.adventure.model;
import java.util.List;
/** Onboarding walkthrough: an intro, ordered steps, and closing tips. */
public record Tutorial(String intro, List<TutorialStep> steps, String closingTips) {
public Tutorial {
steps = steps == null ? List.of() : List.copyOf(steps);
}
public boolean isEmpty() {
return steps.isEmpty();
}
/** An absent tutorial (no steps). */
public static Tutorial none() {
return new Tutorial(null, List.of(), null);
}
}
```
- [ ] **Step 4: Create the DTOs**
`src/main/java/thb/jeanluc/adventure/loader/dto/TutorialStepDto.java`:
```java
package thb.jeanluc.adventure.loader.dto;
/** YAML representation of a tutorial step. */
public record TutorialStepDto(
String instruction,
String expect,
Integer minArgs,
String confirm,
String hint
) {
}
```
`src/main/java/thb/jeanluc/adventure/loader/dto/TutorialDto.java`:
```java
package thb.jeanluc.adventure.loader.dto;
import java.util.List;
/** YAML representation of the tutorial document. */
public record TutorialDto(String intro, List<TutorialStepDto> steps, String closingTips) {
}
```
- [ ] **Step 5: Create `TutorialLoader`**
`src/main/java/thb/jeanluc/adventure/loader/TutorialLoader.java`:
```java
package thb.jeanluc.adventure.loader;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.dataformat.yaml.YAMLFactory;
import thb.jeanluc.adventure.loader.dto.TutorialDto;
import thb.jeanluc.adventure.loader.dto.TutorialStepDto;
import thb.jeanluc.adventure.model.Tutorial;
import thb.jeanluc.adventure.model.TutorialStep;
import java.io.IOException;
import java.io.InputStream;
import java.util.ArrayList;
import java.util.List;
/**
* Loads the optional onboarding tutorial from {@code <base>/tutorial.yaml}.
* Kept separate from {@link WorldLoader}: the tutorial is onboarding, not world
* content. Returns {@link Tutorial#none()} when the file is absent.
*/
public class TutorialLoader {
/** Default classpath base directory. */
public static final String DEFAULT_BASE = "/world";
private final ObjectMapper yaml = new ObjectMapper(new YAMLFactory());
private final String basePath;
public TutorialLoader() {
this(DEFAULT_BASE);
}
public TutorialLoader(String basePath) {
this.basePath = basePath;
}
/** Reads the tutorial; absent file → {@link Tutorial#none()}. */
public Tutorial load() {
String resource = basePath + "/tutorial.yaml";
try (InputStream in = getClass().getResourceAsStream(resource)) {
if (in == null) {
return Tutorial.none();
}
TutorialDto dto = yaml.readValue(in, TutorialDto.class);
if (dto == null) {
return Tutorial.none();
}
List<TutorialStep> steps = new ArrayList<>();
if (dto.steps() != null) {
for (TutorialStepDto s : dto.steps()) {
steps.add(new TutorialStep(
s.instruction(),
s.expect(),
s.minArgs() == null ? 0 : s.minArgs(),
s.confirm(),
s.hint()));
}
}
return new Tutorial(dto.intro(), steps, dto.closingTips());
} catch (IOException e) {
throw new WorldLoadException("Failed to parse " + resource, e);
}
}
}
```
- [ ] **Step 6: Create the production `tutorial.yaml`**
`src/main/resources/world/tutorial.yaml`:
```yaml
intro: |
Welcome to the manor. Let me show you the ropes — just type the commands as
I describe them. (Type 'skip' at any time to jump straight into the game.)
steps:
- instruction: "First, look around the room: type 'look'."
expect: look
confirm: "Good — 'look' (or 'l') re-describes wherever you are."
hint: "When you're ready, just type: look"
- instruction: "Examine something you noticed: 'examine <thing>' (or 'x <thing>')."
expect: examine
minArgs: 1
confirm: "That's how you inspect items, exits, and people up close."
hint: "Try examining something, e.g.: examine lamp"
- instruction: "Pick something up: 'take <item>'."
expect: take
minArgs: 1
confirm: "Taken. Things you carry go into your inventory."
hint: "Pick something up, e.g.: take lamp"
- instruction: "Check what you're carrying: 'inventory' (or 'i')."
expect: inventory
confirm: "That's your inventory."
hint: "Type: inventory"
- instruction: "Use an item: 'use <item>' — try lighting the lamp."
expect: use
minArgs: 1
confirm: "Some items toggle, read, or react when you use them."
hint: "Use something, e.g.: use lamp"
- instruction: "Now move to another room: 'go <direction>' (try 'go north')."
expect: go_direction
confirm: "You can move north / south / east / west between connected rooms."
hint: "Head to a connected room, e.g.: go north"
- instruction: "Travel back to a room you've already seen: 'go to <room>'."
expect: go_to_room
confirm: "Nice — 'go to <room>' auto-walks to any room you've already visited."
hint: "Try: go to <a room you've already visited>"
closingTips: |
That's the core. A few more you'll want:
read · drop · talk to <npc> · give <item> to <npc> ·
use <item> on <item> (combine things) · map · quests · save · menu.
Type 'help' anytime. Good luck.
```
- [ ] **Step 7: Create the test fixture `tutorial.yaml`**
`src/test/resources/world/tutorial.yaml` (small, deterministic for the loader test):
```yaml
intro: |
Test intro.
steps:
- instruction: "Look: type 'look'."
expect: look
confirm: "Looked."
hint: "Type look."
- instruction: "Take: type 'take <item>'."
expect: take
minArgs: 1
confirm: "Took it."
hint: "Take something."
closingTips: |
Test tips.
```
- [ ] **Step 8: Run tests to verify they pass**
Run: `mvn -q test -Dtest=TutorialLoaderTest`
Expected: PASS (2 tests).
- [ ] **Step 9: Commit**
```bash
git add src/main/java/thb/jeanluc/adventure/model/Tutorial.java \
src/main/java/thb/jeanluc/adventure/model/TutorialStep.java \
src/main/java/thb/jeanluc/adventure/loader/dto/TutorialDto.java \
src/main/java/thb/jeanluc/adventure/loader/dto/TutorialStepDto.java \
src/main/java/thb/jeanluc/adventure/loader/TutorialLoader.java \
src/main/resources/world/tutorial.yaml \
src/test/resources/world/tutorial.yaml \
src/test/java/thb/jeanluc/adventure/loader/TutorialLoaderTest.java
git commit -m "feat(tutorial): Tutorial model + TutorialLoader + tutorial.yaml content"
```
---
## Task 2: `TutorialGuide`
**Files:**
- Create: `src/main/java/thb/jeanluc/adventure/game/TutorialGuide.java`
- Test: `src/test/java/thb/jeanluc/adventure/game/TutorialGuideTest.java`
- [ ] **Step 1: Write the failing test**
`src/test/java/thb/jeanluc/adventure/game/TutorialGuideTest.java`:
```java
package thb.jeanluc.adventure.game;
import org.junit.jupiter.api.Test;
import thb.jeanluc.adventure.command.CommandParser;
import thb.jeanluc.adventure.command.CommandRegistry;
import thb.jeanluc.adventure.command.ParsedCommand;
import thb.jeanluc.adventure.command.impl.ExamineCommand;
import thb.jeanluc.adventure.command.impl.GoCommand;
import thb.jeanluc.adventure.command.impl.InventoryCommand;
import thb.jeanluc.adventure.command.impl.LookCommand;
import thb.jeanluc.adventure.command.impl.TakeCommand;
import thb.jeanluc.adventure.command.impl.UseCommand;
import thb.jeanluc.adventure.io.TestIO;
import thb.jeanluc.adventure.model.Direction;
import thb.jeanluc.adventure.model.Player;
import thb.jeanluc.adventure.model.Room;
import thb.jeanluc.adventure.model.Tutorial;
import thb.jeanluc.adventure.model.TutorialStep;
import thb.jeanluc.adventure.model.World;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import static org.assertj.core.api.Assertions.assertThat;
class TutorialGuideTest {
private CommandRegistry registry() {
CommandRegistry r = new CommandRegistry();
r.register(new LookCommand(), "look", "l");
r.register(new ExamineCommand(), "examine", "x");
r.register(new TakeCommand(), "take", "get");
r.register(new InventoryCommand(), "inventory", "i");
r.register(new UseCommand(), "use");
r.register(new GoCommand(), "go", "move", "walk");
return r;
}
private final Room kitchen = new Room("kitchen", "Kitchen", "d");
private final Room hallway = new Room("hallway", "Hallway", "d");
private GameContext ctx() {
kitchen.addExit(Direction.NORTH, hallway);
Map<String, Room> rooms = new LinkedHashMap<>();
rooms.put("kitchen", kitchen);
rooms.put("hallway", hallway);
World w = new World(rooms, Map.of(), Map.of(), "t", "w");
Player p = new Player(kitchen, 0);
return new GameContext(w, p, new TestIO());
}
private ParsedCommand parse(String s) {
return new CommandParser().parse(s);
}
private String out(GameContext ctx) {
return ((TestIO) ctx.getIo()).allOutput();
}
private TutorialStep step(String instr, String expect, int minArgs) {
return new TutorialStep(instr, expect, minArgs, "OK:" + expect, "HINT:" + expect);
}
@Test
void beginPrintsIntroAndFirstInstruction() {
GameContext ctx = ctx();
Tutorial t = new Tutorial("INTRO", List.of(step("do look", "look", 0)), "TIPS");
TutorialGuide g = new TutorialGuide(t, registry());
g.begin(ctx);
assertThat(out(ctx)).contains("INTRO").contains("do look");
assertThat(g.isActive()).isTrue();
}
@Test
void aliasSatisfiesVerbStepAndAdvancesToTips() {
GameContext ctx = ctx();
Tutorial t = new Tutorial("INTRO", List.of(step("do look", "look", 0)), "TIPS");
TutorialGuide g = new TutorialGuide(t, registry());
g.begin(ctx);
g.onCommand(ctx, parse("l")); // alias of look
assertThat(out(ctx)).contains("OK:look").contains("TIPS");
assertThat(g.isActive()).isFalse();
}
@Test
void minArgsGatesTakeStep() {
GameContext ctx = ctx();
Tutorial t = new Tutorial("I", List.of(step("take it", "take", 1)), "TIPS");
TutorialGuide g = new TutorialGuide(t, registry());
g.begin(ctx);
g.onCommand(ctx, parse("take")); // no arg -> hint, not satisfied
assertThat(out(ctx)).contains("HINT:take");
assertThat(g.isActive()).isTrue();
g.onCommand(ctx, parse("take lamp")); // satisfied
assertThat(out(ctx)).contains("OK:take");
}
@Test
void goDirectionRequiresAnActualMove() {
GameContext ctx = ctx();
Tutorial t = new Tutorial("I", List.of(step("move", "go_direction", 0)), "TIPS");
TutorialGuide g = new TutorialGuide(t, registry());
g.begin(ctx);
// typed a direction but did NOT move -> hint
g.onCommand(ctx, parse("go north"));
assertThat(out(ctx)).contains("HINT:go_direction");
assertThat(g.isActive()).isTrue();
// now actually move, then the same command satisfies
ctx.getPlayer().setCurrentRoom(hallway);
g.onCommand(ctx, parse("go north"));
assertThat(out(ctx)).contains("OK:go_direction");
assertThat(g.isActive()).isFalse();
}
@Test
void goToRoomNeedsNonDirectionArgAndMove() {
GameContext ctx = ctx();
Tutorial t = new Tutorial("I", List.of(step("travel", "go_to_room", 0)), "TIPS");
TutorialGuide g = new TutorialGuide(t, registry());
g.begin(ctx);
ctx.getPlayer().setCurrentRoom(hallway); // moved
g.onCommand(ctx, parse("go kitchen")); // "kitchen" is not a direction
assertThat(out(ctx)).contains("OK:go_to_room");
}
@Test
void skipDeactivates() {
GameContext ctx = ctx();
Tutorial t = new Tutorial("I", List.of(step("do look", "look", 0)), "TIPS");
TutorialGuide g = new TutorialGuide(t, registry());
g.begin(ctx);
g.skip(ctx);
assertThat(out(ctx)).contains("Tutorial skipped");
assertThat(g.isActive()).isFalse();
}
@Test
void emptyTutorialIsInactive() {
TutorialGuide g = new TutorialGuide(Tutorial.none(), registry());
assertThat(g.isActive()).isFalse();
}
}
```
- [ ] **Step 2: Run test to verify it fails**
Run: `mvn -q test -Dtest=TutorialGuideTest`
Expected: FAIL — `TutorialGuide` does not exist.
- [ ] **Step 3: Create `TutorialGuide`**
`src/main/java/thb/jeanluc/adventure/game/TutorialGuide.java`:
```java
package thb.jeanluc.adventure.game;
import thb.jeanluc.adventure.command.Command;
import thb.jeanluc.adventure.command.CommandRegistry;
import thb.jeanluc.adventure.command.ParsedCommand;
import thb.jeanluc.adventure.model.Direction;
import thb.jeanluc.adventure.model.Room;
import thb.jeanluc.adventure.model.Tutorial;
import thb.jeanluc.adventure.model.TutorialStep;
import java.util.Optional;
/**
* Drives the interactive start-of-game walkthrough, layered onto the real game
* loop. Prints an instruction, then on each executed command decides whether the
* current step is satisfied (confirm + advance) or not (hint). Movement steps
* require an actual room change. Alias-aware via the {@link CommandRegistry}.
*/
public final class TutorialGuide {
private final Tutorial tutorial;
private final CommandRegistry registry;
private int index;
private boolean active;
private Room roomAtStepStart;
public TutorialGuide(Tutorial tutorial, CommandRegistry registry) {
this.tutorial = tutorial;
this.registry = registry;
this.active = !tutorial.isEmpty();
}
public boolean isActive() {
return active;
}
/** Prints the intro and the first instruction (call once at loop start). */
public void begin(GameContext ctx) {
if (!active) {
return;
}
if (tutorial.intro() != null && !tutorial.intro().isBlank()) {
ctx.getIo().write(tutorial.intro());
}
enterStep(ctx);
}
/** Evaluates the current step against the command that was just executed. */
public void onCommand(GameContext ctx, ParsedCommand parsed) {
if (!active) {
return;
}
TutorialStep step = tutorial.steps().get(index);
if (satisfies(step, ctx, parsed)) {
ctx.getIo().write(step.confirm());
index++;
if (index >= tutorial.steps().size()) {
if (tutorial.closingTips() != null && !tutorial.closingTips().isBlank()) {
ctx.getIo().write(tutorial.closingTips());
}
active = false;
} else {
enterStep(ctx);
}
} else {
ctx.getIo().write(step.hint());
}
}
/** Ends the tutorial early (the 'skip' command). */
public void skip(GameContext ctx) {
if (active) {
ctx.getIo().write("Tutorial skipped.");
active = false;
}
}
private void enterStep(GameContext ctx) {
roomAtStepStart = ctx.getPlayer().getCurrentRoom();
ctx.getIo().write(tutorial.steps().get(index).instruction());
}
private boolean satisfies(TutorialStep step, GameContext ctx, ParsedCommand parsed) {
String expect = step.expect();
if ("go_direction".equals(expect)) {
return isGo(parsed) && firstIsDirection(parsed) && moved(ctx);
}
if ("go_to_room".equals(expect)) {
return isGo(parsed) && !firstIsDirection(parsed) && moved(ctx);
}
return sameCommand(parsed.verb(), expect) && parsed.args().size() >= step.minArgs();
}
private boolean isGo(ParsedCommand p) {
return sameCommand(p.verb(), "go") && !p.args().isEmpty();
}
private boolean firstIsDirection(ParsedCommand p) {
if (p.args().isEmpty()) {
return false;
}
try {
Direction.fromString(p.args().get(0));
return true;
} catch (IllegalArgumentException e) {
return false;
}
}
private boolean moved(GameContext ctx) {
return ctx.getPlayer().getCurrentRoom() != roomAtStepStart;
}
private boolean sameCommand(String a, String b) {
Optional<Command> ca = registry.find(a);
Optional<Command> cb = registry.find(b);
return ca.isPresent() && cb.isPresent() && ca.get() == cb.get();
}
}
```
- [ ] **Step 4: Run tests to verify they pass**
Run: `mvn -q test -Dtest=TutorialGuideTest`
Expected: PASS (7 tests).
- [ ] **Step 5: Commit**
```bash
git add src/main/java/thb/jeanluc/adventure/game/TutorialGuide.java \
src/test/java/thb/jeanluc/adventure/game/TutorialGuideTest.java
git commit -m "feat(game): TutorialGuide interactive walkthrough engine"
```
---
## Task 3: Wire the guide into `Game` and `App`
**Files:**
- Modify: `src/main/java/thb/jeanluc/adventure/game/Game.java`
- Modify: `src/main/java/thb/jeanluc/adventure/App.java`
- Test: `src/test/java/thb/jeanluc/adventure/game/GameTutorialTest.java`
- [ ] **Step 1: Write the failing test**
`src/test/java/thb/jeanluc/adventure/game/GameTutorialTest.java`:
```java
package thb.jeanluc.adventure.game;
import org.junit.jupiter.api.Test;
import thb.jeanluc.adventure.command.CommandParser;
import thb.jeanluc.adventure.command.CommandRegistry;
import thb.jeanluc.adventure.command.impl.LookCommand;
import thb.jeanluc.adventure.io.TestIO;
import thb.jeanluc.adventure.model.Player;
import thb.jeanluc.adventure.model.Room;
import thb.jeanluc.adventure.model.Tutorial;
import thb.jeanluc.adventure.model.TutorialStep;
import thb.jeanluc.adventure.model.World;
import java.util.List;
import java.util.Map;
import static org.assertj.core.api.Assertions.assertThat;
class GameTutorialTest {
@Test
void guideBeginsAndAdvancesThenSkipEndsViaLoop() {
Room kitchen = new Room("kitchen", "Kitchen", "d");
World w = new World(Map.of("kitchen", kitchen), Map.of(), Map.of(), "t", "w");
TestIO io = new TestIO();
// scripted player input: a matching 'look', then 'skip'; then EOF (null) ends run()
io.enqueue("look").enqueue("skip");
GameContext ctx = new GameContext(new GameSession(w, new Player(kitchen, 0), "s"), io);
CommandRegistry registry = new CommandRegistry();
registry.register(new LookCommand(), "look", "l");
Tutorial t = new Tutorial("INTRO",
List.of(new TutorialStep("do look", "look", 0, "STEP1-OK", "hint"),
new TutorialStep("do look again", "look", 0, "STEP2-OK", "hint")),
"TIPS");
Game game = new Game(ctx, registry, new CommandParser());
game.setTutorialGuide(new TutorialGuide(t, registry));
game.run();
String out = io.allOutput();
assertThat(out).contains("INTRO"); // begin printed
assertThat(out).contains("STEP1-OK"); // first 'look' advanced step 1
assertThat(out).contains("Tutorial skipped"); // 'skip' ended it before step 2 finished
}
}
```
- [ ] **Step 2: Run test to verify it fails**
Run: `mvn -q test -Dtest=GameTutorialTest`
Expected: FAIL — `Game.setTutorialGuide` does not exist.
- [ ] **Step 3: Add the guide hooks to `Game`**
In `src/main/java/thb/jeanluc/adventure/game/Game.java`:
1. Add the import:
```java
import thb.jeanluc.adventure.game.TutorialGuide;
```
(Same package — skip the import; reference `TutorialGuide` directly.)
2. Add a field after `private boolean running = true;`:
```java
/** Optional onboarding guide; null in normal/loaded play. */
private TutorialGuide tutorialGuide;
/** Wires the interactive tutorial (New Game only). */
public void setTutorialGuide(TutorialGuide tutorialGuide) {
this.tutorialGuide = tutorialGuide;
}
```
3. In `run()`, after the initial `publishHud(); maybeEnd();` and before `while (running) {`:
```java
if (tutorialGuide != null) {
tutorialGuide.begin(ctx);
}
```
4. Inside the loop, replace the body from the `parsed.verb().isEmpty()` check through the dispatch with this (adds the skip interception and the post-command hook):
```java
ParsedCommand parsed = parser.parse(input);
if (parsed.verb().isEmpty()) {
continue;
}
if (tutorialGuide != null && tutorialGuide.isActive()
&& parsed.verb().equals("skip")) {
tutorialGuide.skip(ctx);
publishHud();
continue;
}
Optional<Command> cmd = registry.find(parsed.verb());
if (cmd.isEmpty()) {
ctx.getIo().write("I don't understand '" + parsed.verb() + "'. Type 'help'.");
} else {
cmd.get().execute(ctx, parsed.args());
ctx.getSession().incrementTurn();
if (ctx.getSession().getTurn() % 10 == 0) {
ctx.save();
}
if (tutorialGuide != null) {
tutorialGuide.onCommand(ctx, parsed);
}
}
publishHud();
maybeEnd();
```
- [ ] **Step 4: Wire `App`**
In `src/main/java/thb/jeanluc/adventure/App.java`:
1. Add imports:
```java
import thb.jeanluc.adventure.game.TutorialGuide;
import thb.jeanluc.adventure.loader.TutorialLoader;
import thb.jeanluc.adventure.model.Tutorial;
```
2. Change the two `play(...)` call sites in `run()`:
```java
case NEW_GAME -> play(io, saves, newSession(io), true);
```
and inside the LOAD branch:
```java
play(io, saves, saves.load(slot.slug()), false);
```
3. Change the `play` signature and add the guide wiring (after the `HelpCommand` is registered, before the banner):
```java
private static void play(GameIO io, SaveService saves, GameSession session, boolean withTutorial) {
```
and, immediately after `registry.register(new HelpCommand(registry), "help", "?");`:
```java
if (withTutorial) {
Tutorial tutorial = new TutorialLoader().load();
if (!tutorial.isEmpty()) {
game.setTutorialGuide(new TutorialGuide(tutorial, registry));
}
}
```
- [ ] **Step 5: Run tests + full suite**
Run: `mvn -q test -Dtest=GameTutorialTest`
Expected: PASS.
Run: `mvn -q clean test`
Expected: BUILD SUCCESS, all green (existing GameTest / loop tests unaffected — guide is null there).
- [ ] **Step 6: Manual console smoke (optional)**
Run: `mvn -q -DskipTests compile dependency:build-classpath -Dmdep.outputFile=/tmp/cp.txt >/dev/null 2>&1` then
`printf '1\nHero\nlook\nskip\nquit\n4\n' | java -cp "target/classes:$(cat /tmp/cp.txt)" thb.jeanluc.adventure.App`
Expected: New Game → name → room shown → tutorial intro + "look" instruction → after `look`, confirmation + next instruction → `skip` → "Tutorial skipped." → normal play → `quit` → menu → Quit.
- [ ] **Step 7: Commit**
```bash
git add src/main/java/thb/jeanluc/adventure/game/Game.java \
src/main/java/thb/jeanluc/adventure/App.java \
src/test/java/thb/jeanluc/adventure/game/GameTutorialTest.java
git commit -m "feat: wire TutorialGuide into the game loop; show on New Game only"
```
---
## Task 4: Docs
**Files:**
- Modify: `docs/enhancement-ideas.md`
- [ ] **Step 1: Add a tutorial note**
In `docs/enhancement-ideas.md`, under the "Festgelegte Erweiterungs-Entscheidungen"
area (near the pathfinding ✅ note), add:
> ✅ **Tutorial** umgesetzt (Branch `feature/tutorial`): interaktiver Walkthrough
> beim Neuen Spiel (look→examine→take→inventory→use→go→go to), verb-/alias-basierte
> Abschluss-Erkennung (Bewegungs-Schritte verlangen echten Raumwechsel), Abschluss-
> Tipps, jederzeit per `skip` abbrechbar. Datengetrieben (`tutorial.yaml`,
> `TutorialLoader`), als `TutorialGuide` in den Game-Loop eingehängt; nur bei Neuem
> Spiel, nicht beim Laden.
- [ ] **Step 2: Commit**
```bash
git add docs/enhancement-ideas.md
git commit -m "docs: note interactive start-of-game tutorial"
```
---
## Final Verification
- [ ] `mvn -q clean test` — all tests pass (197 prior + TutorialLoader/TutorialGuide/GameTutorial suites).
- [ ] Manual console smoke (Task 3 Step 6) behaves as described.
- [ ] Manual: Load a saved game → NO tutorial appears.
- [ ] `git status` clean.
```

View File

@@ -0,0 +1,957 @@
# `use X on Y` Item Combinations Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Add a data-driven `use X on Y` item-combination mechanic: recipes that optionally require conditions, consume items, produce an item, apply effects, and print a response — operands resolved from inventory or the current room.
**Architecture:** A new `Combination` domain record + optional `combinations.yaml` loaded like quests/endings into `World.combinations` (a `Map` keyed by an order-independent pair key). A stateless `Combinations` engine (alongside `Conditions`/`Effects`/`Light`) performs resolution, lookup, and application. `UseCommand` routes 2+ args to the engine; 1 arg keeps the existing single-item behaviour. No parser change — `on`/`with`/`to` are already filler words.
**Tech Stack:** Java 25, Maven, Jackson (YAML loader), JUnit 5 + AssertJ, Lombok.
---
## File Structure
**Create:**
- `src/main/java/thb/jeanluc/adventure/model/Combination.java` — domain record + canonical pair key.
- `src/main/java/thb/jeanluc/adventure/loader/dto/CombinationDto.java` — YAML DTO.
- `src/main/java/thb/jeanluc/adventure/loader/CombinationFactory.java` — DTO → domain, validates item ids exist.
- `src/main/java/thb/jeanluc/adventure/game/Combinations.java` — combination engine.
- `src/main/resources/world/combinations.yaml` — demo recipe.
**Modify:**
- `model/World.java` — add `combinations` field + backward-compatible constructors + getter.
- `loader/WorldLoader.java` — read `combinations.yaml`, build the map (dup-key check), pass to `World`.
- `command/impl/UseCommand.java` — route 2+ args to `Combinations.tryUse`.
- `src/main/resources/world/items.yaml` — add `matches`, `torch`, `lit_torch`.
- `src/main/resources/world/rooms.yaml` — place `matches` (library) and `torch` (hallway).
---
## Task 1: `Combination` domain model + canonical key
**Files:**
- Create: `src/main/java/thb/jeanluc/adventure/model/Combination.java`
- Test: `src/test/java/thb/jeanluc/adventure/model/CombinationTest.java`
- [ ] **Step 1: Write the failing test**
`src/test/java/thb/jeanluc/adventure/model/CombinationTest.java`:
```java
package thb.jeanluc.adventure.model;
import org.junit.jupiter.api.Test;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
class CombinationTest {
@Test
void keyIsOrderIndependentAndSorted() {
assertThat(Combination.key("matches", "torch"))
.isEqualTo(Combination.key("torch", "matches"))
.isEqualTo("matches|torch");
assertThat(Combination.key("b", "a")).isEqualTo("a|b");
}
@Test
void nullCollectionComponentsBecomeEmpty() {
Combination c = new Combination("a", "b", null, null, null, null, "ok", null);
assertThat(c.requires()).isEmpty();
assertThat(c.consume()).isEmpty();
assertThat(c.effects()).isEmpty();
assertThat(c.produce()).isNull();
assertThat(c.key()).isEqualTo("a|b");
}
}
```
- [ ] **Step 2: Run test to verify it fails**
Run: `mvn -q test -Dtest=CombinationTest`
Expected: FAIL — `Combination` does not exist.
- [ ] **Step 3: Create `Combination`**
`src/main/java/thb/jeanluc/adventure/model/Combination.java`:
```java
package thb.jeanluc.adventure.model;
import java.util.List;
/**
* A data-driven item-combination recipe. Identified by an unordered pair of
* item ids ({@code a}, {@code b}). When applied it may require conditions,
* consume items, produce an item, apply effects, and print a response.
*/
public record Combination(
String a,
String b,
List<Condition> requires,
List<String> consume,
String produce,
List<Effect> effects,
String response,
String failText
) {
public Combination {
requires = requires == null ? List.of() : List.copyOf(requires);
consume = consume == null ? List.of() : List.copyOf(consume);
effects = effects == null ? List.of() : List.copyOf(effects);
}
/** Order-independent key for an item pair (sorted, '|'-joined). */
public static String key(String x, String y) {
return x.compareTo(y) <= 0 ? x + "|" + y : y + "|" + x;
}
/** This recipe's canonical pair key. */
public String key() {
return key(a, b);
}
}
```
- [ ] **Step 4: Run test to verify it passes**
Run: `mvn -q test -Dtest=CombinationTest`
Expected: PASS.
- [ ] **Step 5: Commit**
```bash
git add src/main/java/thb/jeanluc/adventure/model/Combination.java \
src/test/java/thb/jeanluc/adventure/model/CombinationTest.java
git commit -m "feat(model): Combination recipe record + order-independent key"
```
---
## Task 2: `World.combinations` field + constructors
**Files:**
- Modify: `src/main/java/thb/jeanluc/adventure/model/World.java`
- Test: `src/test/java/thb/jeanluc/adventure/model/WorldCombinationsTest.java`
- [ ] **Step 1: Write the failing test**
`src/test/java/thb/jeanluc/adventure/model/WorldCombinationsTest.java`:
```java
package thb.jeanluc.adventure.model;
import org.junit.jupiter.api.Test;
import java.util.List;
import java.util.Map;
import static org.assertj.core.api.Assertions.assertThat;
class WorldCombinationsTest {
@Test
void legacyConstructorsDefaultToEmptyCombinations() {
World w5 = new World(Map.of(), Map.of(), Map.of(), "t", "w");
World w6 = new World(Map.of(), Map.of(), Map.of(), "t", "w", Map.of());
World w7 = new World(Map.of(), Map.of(), Map.of(), "t", "w", Map.of(), List.of());
assertThat(w5.getCombinations()).isEmpty();
assertThat(w6.getCombinations()).isEmpty();
assertThat(w7.getCombinations()).isEmpty();
}
@Test
void fullConstructorStoresCombinations() {
Combination c = new Combination("a", "b", List.of(), List.of(), null, List.of(), "r", null);
World w = new World(Map.of(), Map.of(), Map.of(), "t", "w",
Map.of(), List.of(), Map.of(c.key(), c));
assertThat(w.getCombinations()).containsKey("a|b");
}
}
```
- [ ] **Step 2: Run test to verify it fails**
Run: `mvn -q test -Dtest=WorldCombinationsTest`
Expected: FAIL — no `getCombinations()` / no 8-arg constructor.
- [ ] **Step 3: Add the field, constructors, and import**
In `src/main/java/thb/jeanluc/adventure/model/World.java`:
Add the import near the other model imports:
```java
import thb.jeanluc.adventure.model.Combination;
```
(If the file is in package `thb.jeanluc.adventure.model`, `Combination` is same-package — skip the import; reference it directly.)
Add the field immediately after the `endings` field:
```java
/** Global lookup of combination recipes by canonical pair key. */
private final Map<String, Combination> combinations;
```
Update the existing 5-arg and 6-arg convenience constructors and add a 7-arg one, so all delegate to the Lombok-generated 8-arg constructor:
```java
/** Backward-compatible constructor for worlds without quests, endings, or combinations. */
public World(Map<String, Room> rooms, Map<String, Item> items, Map<String, Npc> npcs,
String title, String welcomeMessage) {
this(rooms, items, npcs, title, welcomeMessage, Map.of(), List.of(), Map.of());
}
/** Backward-compatible constructor for worlds with quests but no endings/combinations. */
public World(Map<String, Room> rooms, Map<String, Item> items, Map<String, Npc> npcs,
String title, String welcomeMessage, Map<String, Quest> quests) {
this(rooms, items, npcs, title, welcomeMessage, quests, List.of(), Map.of());
}
/** Backward-compatible constructor for worlds with quests + endings but no combinations. */
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) {
this(rooms, items, npcs, title, welcomeMessage, quests, endings, Map.of());
}
```
(The `@RequiredArgsConstructor` now generates the canonical 8-arg constructor `(rooms, items, npcs, title, welcomeMessage, quests, endings, combinations)`, and `@Getter` provides `getCombinations()`.)
- [ ] **Step 4: Run tests to verify they pass**
Run: `mvn -q test -Dtest=WorldCombinationsTest,QuestEngineTest`
Expected: PASS (QuestEngineTest uses the 6-arg constructor — still compiles).
- [ ] **Step 5: Full compile check (all World callers still build)**
Run: `mvn -q -DskipTests test-compile`
Expected: BUILD SUCCESS (5/6/7-arg call sites in tests + WorldLoader still resolve).
- [ ] **Step 6: Commit**
```bash
git add src/main/java/thb/jeanluc/adventure/model/World.java \
src/test/java/thb/jeanluc/adventure/model/WorldCombinationsTest.java
git commit -m "feat(model): World.combinations field + backward-compatible constructors"
```
---
## Task 3: `CombinationDto` + `CombinationFactory` (with id validation)
**Files:**
- Create: `src/main/java/thb/jeanluc/adventure/loader/dto/CombinationDto.java`
- Create: `src/main/java/thb/jeanluc/adventure/loader/CombinationFactory.java`
- Test: `src/test/java/thb/jeanluc/adventure/loader/CombinationFactoryTest.java`
Validation lives in the factory (build-time, with the item map at hand) rather than `WorldValidator`, to avoid changing `WorldValidator`'s constructor (used directly by `WorldValidatorTest`).
- [ ] **Step 1: Write the failing test**
`src/test/java/thb/jeanluc/adventure/loader/CombinationFactoryTest.java`:
```java
package thb.jeanluc.adventure.loader;
import org.junit.jupiter.api.Test;
import thb.jeanluc.adventure.loader.dto.CombinationDto;
import thb.jeanluc.adventure.model.Combination;
import thb.jeanluc.adventure.model.item.Item;
import thb.jeanluc.adventure.model.item.PlainItem;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
class CombinationFactoryTest {
private Map<String, Item> items() {
Map<String, Item> m = new HashMap<>();
for (String id : List.of("matches", "torch", "lit_torch")) {
m.put(id, PlainItem.builder().id(id).name(id).description("d").light(false).build());
}
return m;
}
@Test
void buildsCombinationFromDto() {
CombinationDto dto = new CombinationDto("matches", "torch", null,
List.of("matches", "torch"), "lit_torch", null, "You light it.", null);
Combination c = CombinationFactory.fromDto(dto, items());
assertThat(c.key()).isEqualTo("matches|torch");
assertThat(c.consume()).containsExactly("matches", "torch");
assertThat(c.produce()).isEqualTo("lit_torch");
assertThat(c.response()).isEqualTo("You light it.");
}
@Test
void rejectsUnknownOperandId() {
CombinationDto dto = new CombinationDto("matches", "ghost", null, null, null, null, "x", null);
assertThatThrownBy(() -> CombinationFactory.fromDto(dto, items()))
.isInstanceOf(WorldLoadException.class)
.hasMessageContaining("ghost");
}
@Test
void rejectsUnknownProduceId() {
CombinationDto dto = new CombinationDto("matches", "torch", null, null, "phantom", null, "x", null);
assertThatThrownBy(() -> CombinationFactory.fromDto(dto, items()))
.isInstanceOf(WorldLoadException.class)
.hasMessageContaining("phantom");
}
}
```
- [ ] **Step 2: Run test to verify it fails**
Run: `mvn -q test -Dtest=CombinationFactoryTest`
Expected: FAIL — `CombinationDto` / `CombinationFactory` do not exist.
- [ ] **Step 3: Create `CombinationDto`**
`src/main/java/thb/jeanluc/adventure/loader/dto/CombinationDto.java`:
```java
package thb.jeanluc.adventure.loader.dto;
import java.util.List;
/** YAML representation of an item-combination recipe. */
public record CombinationDto(
String a,
String b,
List<ConditionDto> requires,
List<String> consume,
String produce,
List<EffectDto> effects,
String response,
String failText
) {
}
```
- [ ] **Step 4: Create `CombinationFactory`**
`src/main/java/thb/jeanluc/adventure/loader/CombinationFactory.java`:
```java
package thb.jeanluc.adventure.loader;
import thb.jeanluc.adventure.loader.dto.CombinationDto;
import thb.jeanluc.adventure.loader.dto.ConditionDto;
import thb.jeanluc.adventure.loader.dto.EffectDto;
import thb.jeanluc.adventure.model.Combination;
import thb.jeanluc.adventure.model.item.Item;
import java.util.List;
import java.util.Map;
/** Builds {@link Combination}s from {@link CombinationDto}s, validating item ids. */
public final class CombinationFactory {
private CombinationFactory() {
}
public static Combination fromDto(CombinationDto dto, Map<String, Item> items) {
requireItem(items, dto.a(), "a");
requireItem(items, dto.b(), "b");
if (dto.consume() != null) {
for (String id : dto.consume()) {
requireItem(items, id, "consume");
}
}
if (dto.produce() != null) {
requireItem(items, dto.produce(), "produce");
}
return new Combination(
dto.a(),
dto.b(),
ConditionDto.toModelList(dto.requires()),
dto.consume() == null ? List.of() : List.copyOf(dto.consume()),
dto.produce(),
EffectDto.toModelList(dto.effects()),
dto.response(),
dto.failText());
}
private static void requireItem(Map<String, Item> items, String id, String field) {
if (id == null) {
throw new WorldLoadException("Combination '" + field + "' is missing an item id");
}
if (!items.containsKey(id)) {
throw new WorldLoadException(
"Combination references unknown item '" + id + "' (field '" + field + "')");
}
}
}
```
- [ ] **Step 5: Run tests to verify they pass**
Run: `mvn -q test -Dtest=CombinationFactoryTest`
Expected: PASS (3 tests).
- [ ] **Step 6: Commit**
```bash
git add src/main/java/thb/jeanluc/adventure/loader/dto/CombinationDto.java \
src/main/java/thb/jeanluc/adventure/loader/CombinationFactory.java \
src/test/java/thb/jeanluc/adventure/loader/CombinationFactoryTest.java
git commit -m "feat(loader): CombinationDto + CombinationFactory with id validation"
```
---
## Task 4: `Combinations` engine
**Files:**
- Create: `src/main/java/thb/jeanluc/adventure/game/Combinations.java`
- Test: `src/test/java/thb/jeanluc/adventure/game/CombinationsTest.java`
- [ ] **Step 1: Write the failing test**
`src/test/java/thb/jeanluc/adventure/game/CombinationsTest.java`:
```java
package thb.jeanluc.adventure.game;
import org.junit.jupiter.api.Test;
import thb.jeanluc.adventure.io.TestIO;
import thb.jeanluc.adventure.model.Combination;
import thb.jeanluc.adventure.model.Condition;
import thb.jeanluc.adventure.model.Effect;
import thb.jeanluc.adventure.model.Player;
import thb.jeanluc.adventure.model.Room;
import thb.jeanluc.adventure.model.World;
import thb.jeanluc.adventure.model.item.Item;
import thb.jeanluc.adventure.model.item.PlainItem;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import static org.assertj.core.api.Assertions.assertThat;
class CombinationsTest {
private Item plain(String id, boolean light) {
return PlainItem.builder().id(id).name(id).description("d").light(light).build();
}
/** World with matches/torch/lit_torch/shovel and the given recipes; player in an empty room. */
private GameContext ctx(Combination... recipes) {
Map<String, Item> items = new LinkedHashMap<>();
items.put("matches", plain("matches", false));
items.put("torch", plain("torch", false));
items.put("lit_torch", plain("lit_torch", true));
items.put("shovel", plain("shovel", false));
Map<String, Combination> combos = new HashMap<>();
for (Combination c : recipes) {
combos.put(c.key(), c);
}
Room start = new Room("start", "Start", "d");
World w = new World(Map.of("start", start), items, Map.of(), "t", "w",
Map.of(), List.of(), combos);
return new GameContext(w, new Player(start, 0), new TestIO());
}
private void give(GameContext ctx, String id) {
ctx.getPlayer().addItem(ctx.getWorld().getItems().get(id));
}
private void putInRoom(GameContext ctx, String id) {
ctx.getPlayer().getCurrentRoom().addItem(ctx.getWorld().getItems().get(id));
}
private String out(GameContext ctx) {
return ((TestIO) ctx.getIo()).allOutput();
}
private Combination torchRecipe() {
return new Combination("matches", "torch", List.of(),
List.of("matches", "torch"), "lit_torch", List.of(),
"You light the torch.", null);
}
@Test
void combinesFromInventory() {
GameContext ctx = ctx(torchRecipe());
give(ctx, "matches");
give(ctx, "torch");
Combinations.tryUse(ctx, "matches", "torch");
assertThat(ctx.getPlayer().hasItem("lit_torch")).isTrue();
assertThat(ctx.getPlayer().hasItem("matches")).isFalse();
assertThat(ctx.getPlayer().hasItem("torch")).isFalse();
assertThat(out(ctx)).contains("light the torch");
}
@Test
void matchIsOrderIndependent() {
GameContext ctx = ctx(torchRecipe());
give(ctx, "matches");
give(ctx, "torch");
Combinations.tryUse(ctx, "torch", "matches");
assertThat(ctx.getPlayer().hasItem("lit_torch")).isTrue();
}
@Test
void consumesOperandFromCurrentRoom() {
GameContext ctx = ctx(torchRecipe());
give(ctx, "matches");
putInRoom(ctx, "torch");
Combinations.tryUse(ctx, "matches", "torch");
assertThat(ctx.getPlayer().hasItem("lit_torch")).isTrue();
assertThat(ctx.getPlayer().getCurrentRoom().findItem("torch")).isEmpty();
}
@Test
void missingOperandReportsNoSuch() {
GameContext ctx = ctx(torchRecipe());
give(ctx, "matches"); // torch absent
Combinations.tryUse(ctx, "matches", "torch");
assertThat(out(ctx)).contains("no 'torch'");
assertThat(ctx.getPlayer().hasItem("lit_torch")).isFalse();
}
@Test
void noRecipeForPairSaysNothingHappens() {
GameContext ctx = ctx(torchRecipe());
give(ctx, "shovel");
give(ctx, "torch");
Combinations.tryUse(ctx, "shovel", "torch");
assertThat(out(ctx)).contains("Nothing happens.");
}
@Test
void unmetRequiresShowsFailTextAndDoesNotProduce() {
Combination gated = new Combination("matches", "torch",
List.of(new Condition(Condition.Type.FLAG, "dry")),
List.of("matches", "torch"), "lit_torch", List.of(),
"It catches.", "The torch is too damp to light.");
GameContext ctx = ctx(gated);
give(ctx, "matches");
give(ctx, "torch");
Combinations.tryUse(ctx, "matches", "torch");
assertThat(out(ctx)).contains("too damp");
assertThat(ctx.getPlayer().hasItem("lit_torch")).isFalse();
assertThat(ctx.getPlayer().hasItem("matches")).isTrue(); // not consumed
}
@Test
void metRequiresAppliesEffectsAndProduces() {
Combination gated = new Combination("matches", "torch",
List.of(new Condition(Condition.Type.FLAG, "dry")),
List.of("matches", "torch"), "lit_torch",
List.of(new Effect(Effect.Type.SET_FLAG, "torch_lit")),
"It catches.", "too damp");
GameContext ctx = ctx(gated);
ctx.getState().set("dry");
give(ctx, "matches");
give(ctx, "torch");
Combinations.tryUse(ctx, "matches", "torch");
assertThat(ctx.getPlayer().hasItem("lit_torch")).isTrue();
assertThat(ctx.getState().isSet("torch_lit")).isTrue();
}
}
```
- [ ] **Step 2: Run test to verify it fails**
Run: `mvn -q test -Dtest=CombinationsTest`
Expected: FAIL — `Combinations` does not exist.
- [ ] **Step 3: Create `Combinations`**
`src/main/java/thb/jeanluc/adventure/game/Combinations.java`:
```java
package thb.jeanluc.adventure.game;
import thb.jeanluc.adventure.model.Combination;
import thb.jeanluc.adventure.model.item.Item;
/**
* Applies {@code use X on Y} item combinations. Stateless utility, mirroring
* {@link Conditions}/{@link Effects}/{@link Light}. Operands resolve from the
* player's inventory or the current room.
*/
public final class Combinations {
private Combinations() {
}
/**
* Attempts to combine the two named items. Prints a resolution error if an
* operand is absent, the recipe's {@code failText} (or a generic line) if a
* recipe's requirements are unmet, "Nothing happens." if there is no recipe,
* or applies consume/produce/effects + the response on success.
*/
public static void tryUse(GameContext ctx, String idX, String idY) {
if (!present(ctx, idX)) {
ctx.getIo().write(noSuch(idX));
return;
}
if (!present(ctx, idY)) {
ctx.getIo().write(noSuch(idY));
return;
}
Combination c = ctx.getWorld().getCombinations().get(Combination.key(idX, idY));
if (c == null) {
ctx.getIo().write("Nothing happens.");
return;
}
if (!Conditions.all(c.requires(), ctx)) {
ctx.getIo().write(c.failText() != null ? c.failText() : "Nothing happens.");
return;
}
for (String id : c.consume()) {
removeFromAnywhere(ctx, id);
}
if (c.produce() != null) {
Item produced = ctx.getWorld().getItems().get(c.produce());
if (produced != null) {
ctx.getPlayer().addItem(produced);
}
}
Effects.applyAll(c.effects(), ctx);
ctx.getIo().write(c.response());
}
private static boolean present(GameContext ctx, String id) {
return ctx.getPlayer().hasItem(id)
|| ctx.getPlayer().getCurrentRoom().findItem(id).isPresent();
}
private static void removeFromAnywhere(GameContext ctx, String id) {
if (ctx.getPlayer().removeItem(id).isEmpty()) {
ctx.getPlayer().getCurrentRoom().removeItem(id);
}
}
private static String noSuch(String id) {
return "There is no '" + id + "' here or in your inventory.";
}
}
```
- [ ] **Step 4: Run tests to verify they pass**
Run: `mvn -q test -Dtest=CombinationsTest`
Expected: PASS (7 tests).
- [ ] **Step 5: Commit**
```bash
git add src/main/java/thb/jeanluc/adventure/game/Combinations.java \
src/test/java/thb/jeanluc/adventure/game/CombinationsTest.java
git commit -m "feat(game): Combinations engine for use X on Y"
```
---
## Task 5: Route `UseCommand` to combinations
**Files:**
- Modify: `src/main/java/thb/jeanluc/adventure/command/impl/UseCommand.java`
- Test: `src/test/java/thb/jeanluc/adventure/command/impl/UseCommandComboTest.java`
- [ ] **Step 1: Write the failing test**
`src/test/java/thb/jeanluc/adventure/command/impl/UseCommandComboTest.java`:
```java
package thb.jeanluc.adventure.command.impl;
import org.junit.jupiter.api.Test;
import thb.jeanluc.adventure.game.GameContext;
import thb.jeanluc.adventure.io.TestIO;
import thb.jeanluc.adventure.model.Combination;
import thb.jeanluc.adventure.model.Player;
import thb.jeanluc.adventure.model.Room;
import thb.jeanluc.adventure.model.World;
import thb.jeanluc.adventure.model.item.Item;
import thb.jeanluc.adventure.model.item.PlainItem;
import thb.jeanluc.adventure.model.item.SwitchableItem;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import static org.assertj.core.api.Assertions.assertThat;
class UseCommandComboTest {
private GameContext ctx() {
Map<String, Item> items = new LinkedHashMap<>();
items.put("matches", PlainItem.builder().id("matches").name("Matches").description("d").light(false).build());
items.put("torch", PlainItem.builder().id("torch").name("Torch").description("d").light(false).build());
items.put("lit_torch", PlainItem.builder().id("lit_torch").name("Lit Torch").description("d").light(true).build());
SwitchableItem lamp = SwitchableItem.builder()
.id("lamp").name("Lamp").description("d").light(true)
.onText("The lamp glows.").offText("off").state(false).effects(List.of()).build();
items.put("lamp", lamp);
Combination recipe = new Combination("matches", "torch", List.of(),
List.of("matches", "torch"), "lit_torch", List.of(), "You light the torch.", null);
Room start = new Room("start", "Start", "d");
World w = new World(Map.of("start", start), items, Map.of(), "t", "w",
Map.of(), List.of(), Map.of(recipe.key(), recipe));
GameContext ctx = new GameContext(w, new Player(start, 0), new TestIO());
ctx.getPlayer().addItem(items.get("matches"));
ctx.getPlayer().addItem(items.get("torch"));
ctx.getPlayer().addItem(lamp);
return ctx;
}
@Test
void emptyArgsAsksWhat() {
GameContext ctx = ctx();
new UseCommand().execute(ctx, List.of());
assertThat(((TestIO) ctx.getIo()).allOutput()).contains("Use what?");
}
@Test
void twoArgsRoutesToCombination() {
GameContext ctx = ctx();
new UseCommand().execute(ctx, List.of("matches", "torch"));
assertThat(ctx.getPlayer().hasItem("lit_torch")).isTrue();
assertThat(((TestIO) ctx.getIo()).allOutput()).contains("light the torch");
}
@Test
void oneArgUsesItemDirectly() {
GameContext ctx = ctx();
new UseCommand().execute(ctx, List.of("lamp"));
assertThat(((SwitchableItem) ctx.getWorld().getItems().get("lamp")).isOn()).isTrue();
assertThat(((TestIO) ctx.getIo()).allOutput()).contains("lamp glows");
}
}
```
- [ ] **Step 2: Run test to verify it fails**
Run: `mvn -q test -Dtest=UseCommandComboTest`
Expected: FAIL — `twoArgsRoutesToCombination` fails (current `UseCommand` treats only the first arg, no combination).
- [ ] **Step 3: Add the combination branch to `UseCommand`**
In `src/main/java/thb/jeanluc/adventure/command/impl/UseCommand.java`:
Add the import:
```java
import thb.jeanluc.adventure.game.Combinations;
```
Insert the two-arg branch in `execute`, right after the empty-args check and before `String itemId = args.getFirst();`:
```java
if (args.size() >= 2) {
Combinations.tryUse(ctx, args.get(0), args.get(1));
return;
}
```
Also update the `help()` text:
```java
@Override
public String help() {
return "use <item> - use an item; or use <item> on <item> to combine them";
}
```
- [ ] **Step 4: Run tests to verify they pass**
Run: `mvn -q test -Dtest=UseCommandComboTest`
Expected: PASS (3 tests).
- [ ] **Step 5: Commit**
```bash
git add src/main/java/thb/jeanluc/adventure/command/impl/UseCommand.java \
src/test/java/thb/jeanluc/adventure/command/impl/UseCommandComboTest.java
git commit -m "feat(command): route use X on Y to the combination engine"
```
---
## Task 6: Loader wiring + demo content
**Files:**
- Modify: `src/main/java/thb/jeanluc/adventure/loader/WorldLoader.java`
- Modify: `src/main/resources/world/items.yaml`
- Modify: `src/main/resources/world/rooms.yaml`
- Create: `src/main/resources/world/combinations.yaml`
- Test: `src/test/java/thb/jeanluc/adventure/loader/CombinationLoadingTest.java`
- [ ] **Step 1: Write the failing test**
`src/test/java/thb/jeanluc/adventure/loader/CombinationLoadingTest.java`:
```java
package thb.jeanluc.adventure.loader;
import org.junit.jupiter.api.Test;
import thb.jeanluc.adventure.game.Combinations;
import thb.jeanluc.adventure.game.GameContext;
import thb.jeanluc.adventure.game.GameSession;
import thb.jeanluc.adventure.io.TestIO;
import thb.jeanluc.adventure.model.Combination;
import static org.assertj.core.api.Assertions.assertThat;
class CombinationLoadingTest {
@Test
void realWorldLoadsTheTorchRecipe() {
WorldLoader.LoadResult loaded = new WorldLoader().load();
assertThat(loaded.world().getCombinations())
.containsKey(Combination.key("matches", "torch"));
assertThat(loaded.world().getItems()).containsKeys("matches", "torch", "lit_torch");
}
@Test
void torchRecipeWorksEndToEnd() {
WorldLoader.LoadResult loaded = new WorldLoader().load();
GameContext ctx = new GameContext(
new GameSession(loaded.world(), loaded.player(), "test"), new TestIO());
// grab the demo items straight from the registry, regardless of room
ctx.getPlayer().addItem(ctx.getWorld().getItems().get("matches"));
ctx.getPlayer().addItem(ctx.getWorld().getItems().get("torch"));
Combinations.tryUse(ctx, "matches", "torch");
assertThat(ctx.getPlayer().hasItem("lit_torch")).isTrue();
}
}
```
- [ ] **Step 2: Run test to verify it fails**
Run: `mvn -q test -Dtest=CombinationLoadingTest`
Expected: FAIL — no `combinations.yaml`/`matches` items yet; `getCombinations()` is empty.
- [ ] **Step 3: Wire `WorldLoader`**
In `src/main/java/thb/jeanluc/adventure/loader/WorldLoader.java`:
Add imports:
```java
import thb.jeanluc.adventure.loader.dto.CombinationDto;
import thb.jeanluc.adventure.model.Combination;
```
In `load()`, add the read alongside the other optional reads (after the `endingDtos` line):
```java
List<CombinationDto> combinationDtos =
readListOptional(basePath + "/combinations.yaml", CombinationDto.class);
```
After the `endings` list is built (and after `items` is populated), build the combinations map:
```java
Map<String, Combination> combinations = new HashMap<>();
for (CombinationDto dto : combinationDtos) {
Combination c = CombinationFactory.fromDto(dto, items);
String key = c.key();
if (combinations.containsKey(key)) {
throw new WorldLoadException("Duplicate combination for pair '" + key + "'");
}
combinations.put(key, c);
}
```
Change the `World` construction to the 8-arg form:
```java
World world = new World(rooms, items, npcs,
gameDto.title(), gameDto.welcomeMessage(), quests, endings, combinations);
```
- [ ] **Step 4: Add demo items to `items.yaml`**
Append to `src/main/resources/world/items.yaml`:
```yaml
- type: plain
id: matches
name: Box of Matches
description: A small box of dry matches. They rattle when you shake it.
- type: plain
id: torch
name: Unlit Torch
description: A pitch-soaked torch, waiting for a flame.
- type: plain
id: lit_torch
name: Lit Torch
description: A burning torch, throwing back the dark.
light: true
```
- [ ] **Step 5: Place demo items in rooms**
In `src/main/resources/world/rooms.yaml`:
- Change the `library` room's items line from `items: [shovel]` to:
```yaml
items: [shovel, matches]
```
- Change the `hallway` room's items line from `items: []` to:
```yaml
items: [torch]
```
- [ ] **Step 6: Create `combinations.yaml`**
`src/main/resources/world/combinations.yaml`:
```yaml
- a: matches
b: torch
consume: [matches, torch]
produce: lit_torch
response: |
You strike a match and touch it to the torch; it catches with a low hiss
and burns steady. You now hold a lit torch.
```
- [ ] **Step 7: Run tests to verify they pass**
Run: `mvn -q test -Dtest=CombinationLoadingTest`
Expected: PASS (2 tests).
- [ ] **Step 8: Full suite (no regressions from new items)**
Run: `mvn -q clean test`
Expected: BUILD SUCCESS, all tests green. (WorldLoaderTest uses its own test fixture base, not these production resources, so it is unaffected.)
- [ ] **Step 9: Commit**
```bash
git add src/main/java/thb/jeanluc/adventure/loader/WorldLoader.java \
src/main/resources/world/items.yaml \
src/main/resources/world/rooms.yaml \
src/main/resources/world/combinations.yaml \
src/test/java/thb/jeanluc/adventure/loader/CombinationLoadingTest.java
git commit -m "feat(loader): load combinations.yaml; demo matches+torch -> lit_torch"
```
---
## Task 7: Docs
**Files:**
- Modify: `docs/enhancement-ideas.md`
- [ ] **Step 1: Mark the combination mechanic implemented**
In `docs/enhancement-ideas.md`, under section **5b** (Mechanik-Spine), add a `> ✅`
blockquote note after the bullet for mechanic **4. Item-Kombination**, mirroring
the existing `> ✅ umgesetzt (Branch ...)` style used for light/darkness etc.:
> ✅ umgesetzt (Branch `feature/use-x-on-y`): `use X on Y` als datengetriebene
> Rezepte (`combinations.yaml`: requires/consume/produce/effects/response/failText),
> Operanden aus Inventar oder Raum, reihenfolge-unabhängig. Kein Parser-Eingriff
> (`on`/`with`/`to` sind bereits Filler). Demo: `matches` + `torch` → `lit_torch`
> (dauerhafte Lichtquelle).
- [ ] **Step 2: Commit**
```bash
git add docs/enhancement-ideas.md
git commit -m "docs: mark use X on Y item combination implemented"
```
---
## Final Verification
- [ ] `mvn -q clean test` — all tests pass (162 prior + the new combination suites).
- [ ] Manual console smoke (optional): start a game, navigate to the hallway (`take torch`) and library (`take matches`), then `use matches on torch` → "You ... lit torch"; `inventory` shows `lit_torch`, `matches`/`torch` gone.
- [ ] `git status` clean.

View File

@@ -0,0 +1,116 @@
# Spec: Win-Condition & Enden (Teilprojekt 3.3)
Stand: 2026-05-31. Capstone des Quest-Systems. Baut auf Fundament (3.1) + Quests
(3.2) auf: Conditions/Effects/Flags + QuestLog sind vorhanden.
## 1. Kontext & Ziel
Das Spiel endet bisher nur per `quit`. Ziel: **mehrere, priorisierte Enden**,
condition-driven (Ansatz wie Quests), plus ein **End-Screen mit Zusammenfassung**
(Züge, abgeschlossene Quests, Rang). Bestätigt: **A mit Summary**.
## 2. Scope
**In Scope:**
- `Ending`-Datenmodell + optionale `endings.yaml` (Reihenfolge = Priorität).
- `EndingEngine` (erstes passendes Ende ermitteln + End-Screen rendern).
- `Game`-Anbindung: pro Zug nach dem Quest-Tick prüfen; bei Treffer Ende +
Summary ausgeben und `stop()`.
- End-Summary: Züge, Quests X/Y, Rang.
- Loader: `EndingDto`, `EndingFactory`, `World.endings`.
- Demo: zwei spielbar erreichbare Enden ohne neue Mechanik.
**Out of Scope:** Scoreboard/Persistenz; zugbasierte Conditions; Spielstand-Reset.
## 3. Datenmodell (model)
```java
public record Ending(String id, String title, boolean victory,
List<Condition> when, String text) { /* when: List.copyOf */ }
```
`endings.yaml` (geordnete Liste, erstes passendes gewinnt):
```yaml
- id: victory
title: "The Manor Reclaimed"
victory: true
when: [{ flag: manor_secured }]
text: |
The lights hold steady, the whispers fade. You've made the manor yours.
- id: fled
title: "Into the Night"
victory: false
when: [{ flag: fled }]
text: |
You bolt through the front door. Safe — but the manor keeps its secrets.
```
## 4. Engine (game)
- `EndingEngine.triggered(GameContext ctx)` → erstes `Ending`, dessen `when` via
`Conditions.all` gilt, sonst `null`. Rein, testbar (Priorität/erster Treffer).
- `EndingEngine.render(Ending e, GameContext ctx, int turns)``StyledText`:
Titel-Banner + `text` + Summary.
- **Summary**: `Turns: N`, `Quests completed: X / Y`
(`X = questLog.completed().size()`, `Y = world.getQuests().size()`), plus
**Rang**:
- victory & alle Quests → „Master of the Manor"
- victory → „Manor Reclaimed"
- sonst → „Escaped — the manor keeps its secrets"
## 5. Game-Anbindung
In `Game.run` nach `publishHud()` (Initial **und** pro Schleifendurchlauf) ein
`maybeEnd()`:
```java
private void maybeEnd() {
Ending e = EndingEngine.triggered(ctx);
if (e != null) {
ctx.getIo().print(EndingEngine.render(e, ctx, turn));
stop();
}
}
```
`stop()` setzt `running=false`; die Schleife endet sauber, kein zusätzlicher Prompt.
Konsole: Programm endet. GUI: Worker-Thread endet, Fenster bleibt mit End-Screen.
## 6. Loading
- `EndingDto(id, title, Boolean victory, List<ConditionDto> when, String text)`.
- `EndingFactory.fromDto` baut `Ending` (`when` via `ConditionDto.toModelList`).
- `WorldLoader` liest `endings.yaml` **optional** (`readListOptional`), baut eine
**geordnete** `List<Ending>` (Reihenfolge der YAML-Liste), `requireUniqueIds("ending", …)`.
- `World` bekommt Feld `endings` (List) + **rückwärtskompatible 5- und
6-Arg-Konstruktoren** (leere Quests/Endings), damit bestehende `new World(...)`-
Aufrufe (Tests, bisheriger Loader-Pfad) unverändert kompilieren. Der Loader nutzt
den vollen 7-Arg-Konstruktor.
## 7. Fehlerbehandlung
| Fall | Verhalten |
|---|---|
| `endings.yaml` fehlt | leere Liste → Spiel endet nur per `quit` (wie bisher) |
| mehrere Conditions treffen | erstes Ende in Listenreihenfolge gewinnt |
| Ending ohne `when` | trifft sofort (leere Condition-Liste = true) Autoren sollten Reihenfolge beachten |
| keine Quests definiert | Summary zeigt `0 / 0`, Rang nach victory-Flag |
## 8. Demonstration (zwei erreichbare Enden, keine neue Mechanik)
- **victory**: `manor_secured` wird gesetzt, wenn die `restore_power`-Quest endet
(bereits vorhanden) → Sieg-Ende.
- **fled**: neuer schaltbarer Gegenstand **Front Door** (`effects: setFlag fled`)
in der Küche → Flucht-Ende. `victory` steht vor `fled`, also schlägt das
Sichern der Villa die Flucht.
## 9. Testing
- `EndingEngine.triggered`: Priorität/erster Treffer; kein Treffer → `null`.
- `EndingEngine.render`: enthält Titel, Text, Züge, Quests-Zähler, Rang.
- `EndingFactory.fromDto`: Mapping inkl. victory/when/text.
- End-to-End (Konsole): Villa sichern → Sieg-Ende + Summary, Spiel endet;
alternativ Front Door benutzen → Flucht-Ende.
- `Game`-Integration: nicht separat unit-getestet (Loop/IO); über Konsolen-Smoke.
## 10. Offene Detailfragen (in Implementierung)
- Genaue Banner-Optik des End-Screens (HEADING-Rahmen).
- Rang-Schwellen (zunächst nur victory + alle-Quests-Abfrage).

View File

@@ -0,0 +1,87 @@
# Spec: Licht & Dunkelheit (Mechanik-Erweiterung)
Stand: 2026-05-31. Kleine Mechanik aus dem Spine (#3 der Mechanik-Liste), die das
Fundament (Flags/Items) nutzt. Strenge: **Modell B** (Dunkelheit blockiert).
## 1. Kontext & Ziel
Dunkle Räume sollen den schaltbaren `lamp` sinnvoll machen: Ohne aktive
Lichtquelle kann man einen dunklen Raum **nicht betreten**, nichts sehen
(`look`), und nichts aufnehmen/untersuchen. Die HUD-Zeile `light: on/off` wird
endlich verdrahtet.
## 2. Scope
**In Scope:**
- `Room.dark` (Bool, optional), `Item.light` (Bool, optional).
- `Light`-Helper: `isLit`, `carryingLight`.
- Command-Gating: `go` (Eintritt), `look`, `take`, `examine`; HUD-`lightOn`.
- Demo: Dungeon dunkel → Lampe nötig.
**Out of Scope:** Lampen-Brennstoff/zeitlich begrenztes Licht; Lichtradius über
Räume hinweg; Zufall.
## 3. Daten (rückwärtskompatibel)
- **`Room.dark`**: `@Setter`-Feld (default `false`), **kein** Konstruktor-Argument →
keine Test-Brüche. Vom `RoomFactory` aus `RoomDto.dark` gesetzt. YAML: `dark: true`.
- **`Item.light`**: `boolean` am abstrakten `Item` (via `@SuperBuilder`), default
`false`. Vom `ItemFactory` aus `ItemDto.light` gesetzt. YAML: `light: true`.
- **Aktive Lichtquelle** = `item.light` **und** (kein Switchable **oder**
`SwitchableItem.isOn()`). Die `lamp` leuchtet also nur eingeschaltet.
## 4. Helper `Light` (game)
```java
static boolean isLit(GameContext ctx, Room room) {
if (!room.isDark()) return true;
return hasActiveLight(player-inventory) || hasActiveLight(room-items);
}
static boolean carryingLight(GameContext ctx); // aktive Lichtquelle im Inventar
```
`hasActiveLight(items)` iteriert; `isActiveLight(item)` = `item.isLight()` &&
(`!(item instanceof SwitchableItem s)` || `s.isOn()`).
Räume gelten als beleuchtet, wenn sie nicht dunkel sind **oder** eine aktive
Lichtquelle getragen wird **oder** im Raum liegt.
## 5. Command-Gating (Modell B)
| Command | Verhalten bei dunklem, unbeleuchtetem Zielraum/aktuellem Raum |
|---|---|
| `GoCommand` | **Eintritt blockiert**, wenn `!isLit(ctx, target)`: Hinweis-Text, kein Wechsel. (Lichträume bleiben immer betretbar → **kein Soft-Lock**, Rückzug stets möglich.) |
| `LookCommand` | `!isLit(ctx, current)` → „It's pitch black; you can't make anything out." statt `RoomView` (Fall: Lampe ging im Raum aus). |
| `TakeCommand` | `!isLit(ctx, current)` → „It's too dark to see that." |
| `ExamineCommand` | `!isLit(ctx, current)` → „It's too dark to see that." |
| HUD | `lightOn` = `Light.carryingLight(ctx)` (in `Game.publishHud`). |
Erlaubt im Dunkeln bleiben: `inventory`, `drop`, **`use`** (z. B. Lampe anzünden),
`quests`, `map`, `help`. So kann man eine getragene Lampe im Dunkeln anzünden.
## 6. Fehlerbehandlung / Soft-Lock
Eintrittssperre statt Innen-Sperre: Man betritt einen dunklen Raum nur **mit**
Licht. Geht das Licht drinnen aus, sind `look`/`take`/`examine` blockiert, aber der
Rückweg in einen hellen Raum bleibt (Zielraum hell → `isLit` true). Kein Soft-Lock.
## 7. Demo
- `lamp`: `light: true` (bereits schaltbar; `use lamp` → an).
- `dungeon`: `dark: true`; der Generator steht dort. Ablauf:
`take lamp``use lamp` (an) → `go south` (jetzt betretbar) → `use generator`.
HUD zeigt `light: on`, sobald die Lampe brennt. Gibt man die Lampe später dem
alten Mann, verliert man das Licht (thematisch).
## 8. Testing
- `Light.isLit`: nicht dunkel → hell; dunkel ohne Licht → dunkel; dunkel + getragene
brennende Lampe → hell; dunkel + Lampe **aus** → dunkel; dunkel + brennende Lampe
**im Raum** → hell. `carryingLight`.
- Command-Gates: `go` in dunklen Raum ohne Licht blockt, mit brennender Lampe ok;
`look`/`take` im dunklen Raum blockt.
- End-to-End (Konsole): `go south` ohne Licht blockt; `take lamp`/`use lamp`/`go south`
betritt; HUD `light: on`.
## 9. Offene Detailfragen (in Implementierung)
- Genauer Hinweis-Text der Eintrittssperre (nennt `use lamp`).

View File

@@ -0,0 +1,151 @@
# Spec: Karte / Mini-Map (Teilprojekt 2)
Stand: 2026-05-31. Zweites Teilprojekt aus [enhancement-ideas.md](../../enhancement-ideas.md).
Baut auf der Präsentations-Schicht (Teilprojekt 1) auf, insbesondere dem
reservierten GUI-Seitenpanel und dem semantischen Modell-+-Renderer-Muster.
## 1. Kontext & Ziel
Räume bilden bereits einen gerichteten N/S/O/W-Graphen über `Room.exits`
(`EnumMap<Direction, Room>`). Ziel: eine **Karte** in beiden Frontends, die diesen
Graphen sichtbar macht **Fog of War** (nur Entdecktes), Stil **„umrandete Räume +
Korridore"** (im Brainstorming bestätigt). Die GUI zeichnet grafisch (Graphics2D),
die Konsole spiegelt es als ASCII.
Bestätigte Entscheidungen:
- **Fog of War**: nur besuchte Räume + deren direkte Nachbarn als `?`-Stub.
- **Stil**: umrandete Räume, durch Korridore verbunden; aktueller Raum hervorgehoben.
- **Layout aus dem Graphen abgeleitet** (keine Koordinaten in YAML).
- GUI: persistentes Panel ersetzt die Exit-Zeile. Konsole: `map`-Befehl on demand,
Exit-Zeile im Raumtext bleibt.
## 2. Scope
**In Scope:**
- `MapView`-Modell (platzierte Zellen + Verbindungen, Zustände).
- `MapLayout`-Service: BFS-Layout aus dem Exit-Graphen + Fog-of-War-Filter.
- **Besuchte-Räume-Tracking** auf `Player`.
- `GameIO`: `setMap` (Push pro Zug, GUI) + `showMap` (on demand, Konsole).
- `MapCommand` (`map`, `m`).
- `Game`-Loop ruft `setMap` pro Zug (neben `setHud`).
- GUI: `MapPanel` (Graphics2D) im Seitenpanel, scrollbar, auf aktuellen Raum zentriert.
- Konsole: ASCII-Karte (`AsciiMap`), respektiert `GlyphMode`.
**Out of Scope (spätere Teilprojekte):**
- Marker für Items/NPCs auf der Karte.
- Styling für verschlossene/blockierte Exits (Quest-Teilprojekt; Karte bekommt
später einen Hook dafür).
- Zoom über das Scrollen hinaus.
## 3. Architektur (Modell + Renderer pro Frontend)
`MapLayout` erzeugt aus `World` (Exit-Graph), der Menge besuchter Räume und dem
aktuellen Raum eine frontend-agnostische `MapView`. Layout und Fog-of-War-Logik
liegen ausschließlich hier; die Renderer kennen nur das fertige `MapView`.
```mermaid
flowchart LR
W["World (Exit-Graph)"] --> L["MapLayout.compute()"]
V["Player.visitedRoomIds"] --> L
C["aktueller Raum"] --> L
L --> MV["MapView (Zellen + Verbindungen)"]
MV -- "setMap()" --> GUI["SwingIO → MapPanel (Graphics2D)"]
MV -- "showMap()" --> CON["ConsoleIO → AsciiMap (StyledText)"]
```
## 4. Komponenten & Datenmodell
Views in `io.text`, Layout-Logik in neuem Package `map`, GUI-Panel in `io`.
```java
// io.text
public enum CellState { CURRENT, VISITED, KNOWN }
public record RoomCell(String id, String name, int x, int y, CellState state) {}
public record Connection(RoomCell from, RoomCell to) {} // Stil via Zellzustand
public record MapView(java.util.List<RoomCell> cells,
java.util.List<Connection> connections) {
public MapView { cells = List.copyOf(cells); connections = List.copyOf(connections); }
}
```
- `map.MapLayout` `static MapView compute(World world, String startRoomId, Set<String> visited, Room current)`.
- `io.text.AsciiMap` `static StyledText render(MapView, GlyphMode)`.
- `io.MapPanel` `JComponent`, `void show(MapView)`; `paintComponent` zeichnet.
- `command.impl.MapCommand` baut `MapView`, ruft `io.showMap(view)`.
- `model.Player` `Set<String> visitedRoomIds` (LinkedHashSet, Reihenfolge stabil).
### GameIO-Erweiterung (additiv)
```java
/** Push pro Zug: GUI aktualisiert ihr Panel; Konsole ignoriert (kein Spam). */
default void setMap(MapView view) { /* no-op */ }
/** On demand (map-Befehl): rendert die Karte. Default = ASCII in UNICODE. */
default void showMap(MapView view) { print(AsciiMap.render(view, GlyphMode.UNICODE)); }
```
- **`ConsoleIO`** überschreibt `showMap``print(AsciiMap.render(view, this.glyphs))`
(nutzt also seinen konfigurierten Glyph-Modus). `setMap` bleibt Default (no-op) →
kein Karten-Spam pro Zug in der Konsole.
- **`SwingIO`** überschreibt `setMap``MapPanel` neu zeichnen; `showMap` → no-op
(das Panel ist ohnehin sichtbar).
- **`TestIO`** nutzt beide Defaults: `setMap` = no-op, `showMap` druckt UNICODE-ASCII
in `outputs``MapCommand`-Test kann darauf assertieren. Kein Override nötig.
So liegt der Glyph-Modus dort, wo er hingehört (am Konsolen-Renderer), ohne die
`GameIO`-Schnittstelle um eine Glyph-Methode aufzublähen.
## 5. Fog-of-War-Regeln
| Zustand | Bedeutung | Darstellung |
|---|---|---|
| `CURRENT` | besuchter Raum, in dem der Spieler steht | Goldener Rahmen |
| `VISITED` | bereits betretener Raum | heller Rahmen + Name |
| `KNOWN` | direkter Exit-Nachbar eines besuchten Raums, noch nicht betreten | gestrichelter `?`-Stub |
| (hidden) | alles andere | nicht platziert |
Beim Betreten eines `?`-Raums wird er `VISITED`; seine Nachbarn werden zu neuen
`KNOWN`-Stubs. Die Karte wächst eine Ringschicht voraus.
## 6. Layout-Algorithmus & Konfliktbehandlung
BFS vom Startraum über den **vollständigen** Exit-Graphen; jede Richtung verschiebt
die Gitterkoordinate (N→y1, S→y+1, O→x+1, W→x1). Hand-gebaute Welten sind
geometrisch konsistent; falls BFS zwei Räume auf dieselbe Zelle legt (nicht-planarer
Graph), **gewinnt der erste**, die Kollision wird geloggt, das Rendern läuft weiter
(kein Crash). Die Fog-of-War-Filterung läuft nach dem Layout; danach werden die
Koordinaten normalisiert (Verschiebung, sodass min = 0,0).
## 7. Konsolen-Verhalten
`map` / `m` druckt die ASCII-Karte on demand (keine persistente Region in der
Konsole). Die Box-Raumansicht behält ihre Inline-`Exits:`-Zeile Konsolenspieler
müssen also nicht `map` tippen, um zu navigieren; die Karte ist Überblick-Bonus. In
der GUI ersetzt das Dauerpanel die Exit-Zeile.
## 8. Fehlerbehandlung
| Fall | Verhalten |
|---|---|
| Koordinaten-Kollision (nicht-planar) | erste Zuordnung gewinnt, Warnung geloggt, Rendern fährt fort |
| Startraum hat keine Exits | Karte zeigt nur die aktuelle Zelle |
| Sehr große Karte (viele Räume) | GUI-Panel scrollbar, auf aktuelle Zelle zentriert; ASCII so groß wie nötig |
## 9. Testing
- `MapLayout` (reine Logik): Koordinaten aus bekanntem Graphen; Fog-Zustände für eine
gegebene Visited-Menge; Kollisionsbehandlung; Normalisierung.
- `MapView`/Records: Immutabilität.
- `AsciiMap`: strukturelle Assertions auf bekanntem `MapView` (Namen vorhanden,
aktueller markiert, `?` für `KNOWN`), Farbe aus.
- `MapCommand`: über `TestIO` erzeugt Karten-Ausgabe.
- `Player`: Besuchte-Räume-Tracking (Start + nach Bewegung).
- `SwingIO`/`MapPanel`: nicht unit-getestet (Graphics2D/EDT); manueller Smoke-Test.
## 10. Offene Detailfragen (in Implementierung entscheidbar)
- ASCII-Zellbreite/-höhe und Verbindungslänge (Lesbarkeit vs. Kompaktheit).
- Genaue Farben/Strichstärken im `MapPanel` (an Präsentations-Palette angelehnt).

View File

@@ -0,0 +1,222 @@
# Spec: Präsentations-/Ausgabe-Schicht (Teilprojekt 1)
Stand: 2026-05-31. Erstes Teilprojekt aus [enhancement-ideas.md](../../enhancement-ideas.md).
Baut auf der v1.0-Baseline (Tag `v1.0`) auf.
## 1. Kontext & Ziel
`GameIO` bietet aktuell nur `readLine()` + `write(String)`; beide Frontends
(`ConsoleIO`, `SwingIO`) schieben rohen Text durch. Ziel: **reichere, strukturierte
Ausgabe** in beiden Modi, ohne den für beide Frontends **identischen Game-Loop**
aufzugeben (Kernwert der Architektur).
Zielbild (bestätigt im Brainstorming):
- Konsole: **Stufe B** Boxed Heading, gelabelte Sektionen, persistente HUD-Zeile;
ASCII-Art nur für große Momente.
- GUI ist der **Fokus** (mehr Freiheit), Konsole wird weiter unterstützt.
- Semantische Farb-Rollen für Items / NPCs / Exits / Headings / Gefahr.
- **Progressive Disclosure** (nicht überladen) und **Info auf Regionen verteilen**
(Karte ersetzt später die Exit-Zeile).
## 2. Scope
**In Scope:**
- Semantisches Ausgabe-Modell (`Style`, `Span`, `StyledText` + Builder).
- High-Level-Views: `RoomView`, `Hud`.
- Erweiterung der `GameIO`-Schnittstelle (additiv, `write(String)` bleibt als Default).
- `ConsoleIO` als Renderer: ANSI-Farben + Box-Drawing (Stufe B), HUD-Zeile,
ASCII-Art-Banner-Mechanismus, gestufte Glyph-Fähigkeit.
- `SwingIO` als Renderer: Region-Layout (HUD oben, Output mittig, Seitenpanel
rechts reserviert, Input unten), `JTextPane`/`StyledDocument`, **gebündelter Font**.
- Migration der bestehenden Commands auf die neue API; `TestIO` angepasst.
**Out of Scope (jeweils eigene spätere Specs):**
- Tatsächliches Zeichnen der **Karte** (Teilprojekt 2) Seitenpanel ist nur Slot,
zeigt bis dahin die Exit-Liste.
- **Hauptmenü/Settings** (6), **Speichern/Laden** (7), **Musik** (8).
- Neue **Inhalte/Mechaniken** (Quests, Licht, Item-Kombination).
Die Schnittstellen lassen bewusst Slots (Seitenpanel, HUD-Felder) für diese
späteren Teile.
## 3. Architektur (Approach 2)
**Commands erzeugen Bedeutung, kein Format.** Ein Command baut ein semantisches
Objekt (`StyledText` oder eine View) und übergibt es an `GameIO`. Jedes Frontend
**rendert** es selbst: `ConsoleIO` → ANSI/Box-Drawing, `SwingIO` → Swing-Komponenten
und **eigenes Layout**. Der Game-Loop bleibt unverändert.
```mermaid
flowchart LR
CMD["Command<br/>(z.B. LookCommand)"] -- "baut" --> VIEW["RoomView / StyledText / Hud<br/>(semantisch, kein Format)"]
VIEW -- "GameIO.showRoom() / print() / setHud()" --> IO{{"GameIO"}}
IO --> CON["ConsoleIO<br/>ANSI + Box-Drawing"]
IO --> GUI["SwingIO<br/>JTextPane + Panels"]
```
## 4. Komponenten & API
Neues Subpackage `io.text` für das Modell **und** die Views (`Style`, `Span`,
`StyledText`, `RoomView`, `Hud`); die Renderer (`ConsoleIO`, `SwingIO`) bleiben in `io`.
### 4.1 Style (enum)
```java
public enum Style { PLAIN, HEADING, ITEM, NPC, EXIT, DANGER, DIM }
```
Kleines, bewusst begrenztes Rollen-Set. Mapping auf konkrete Farben/Attribute
liegt im jeweiligen Renderer, nicht am Style.
### 4.2 Span & StyledText
```java
public record Span(String text, Style style) {}
public final class StyledText {
private final List<Span> spans; // immutable
public List<Span> spans() { ... }
public String plainText() { ... } // Spans konkateniert, ohne Format
public static StyledText of(String s) { ... } // ein PLAIN-Span
public static Builder builder() { ... }
public static final class Builder {
Builder plain(String s); Builder heading(String s);
Builder item(String s); Builder npc(String s);
Builder exit(String s); Builder danger(String s);
Builder dim(String s); StyledText build();
}
}
```
Beispiel:
```java
io.print(StyledText.builder()
.plain("You pick up ").item("the brass lamp").plain(".").build());
```
Zeilenumbrüche stehen als `\n` im Text der Spans.
### 4.3 Views
```java
public record RoomView(String name, String description,
List<String> items, List<String> npcs,
List<String> exits) {}
public record Hud(String location, int gold, int turn, boolean lightOn) {}
```
Views tragen bereits **anzeigefertige Strings** (Namen aufgelöst), aber **kein
Layout**. `RoomView.exits` versorgt vorerst das GUI-Seitenpanel; sobald Teilprojekt 2
die Karte zeichnet, blendet der GUI-Renderer die Exit-Sektion aus.
### 4.4 GameIO (erweitert, additiv)
```java
public interface GameIO {
String readLine();
void print(StyledText text); // gestylter Ausgabe-Block
void showRoom(RoomView room); // High-Level-Raumansicht
void setHud(Hud hud); // HUD-Region aktualisieren
default void write(String s) { // Komfort + Rückwärtskompatibilität
print(StyledText.of(s));
}
}
```
`write(String)` bleibt als Default → bestehende Aufrufstellen kompilieren weiter;
Migration kann schrittweise erfolgen.
### 4.5 ConsoleIO (Renderer)
- `print`: Spans → ANSI-Sequenzen (Rollen→Farbe), dann `println`.
- `showRoom`: Stufe-B-Layout mit Box-Drawing-Rahmen (Heading + Sektionen
„You see / Here is / Exits"), Rollen-Farben.
- `setHud`: dim gefärbte Statuszeile (`[ Kitchen · 0 gold · turn 7 · light: off ]`).
- **ASCII-Art-Banner**: Methode/Helfer für große Momente (Start, Finale); Kunst aus
Ressourcen geladen. Mechanismus in Scope, konkrete Kunstwerke folgen mit Inhalten.
- **Konfiguration** (Defaults sicher, Verdrahtung mit Settings-Menü später):
- `ColorMode`: an/aus; Auto-Erkennung (`System.console() == null` → keine ANSI).
- `GlyphMode`: `ASCII | UNICODE | GLYPH`, Default `UNICODE` (Box-Drawing breit
unterstützt; Nerd-Glyphen nur bei explizitem Opt-in). Bei `ASCII` degradieren
die Rahmen auf `+`, `-`, `|` statt der Unicode-Box-Drawing-Zeichen.
### 4.6 SwingIO (Renderer) + Layout
Region-Layout (bestätigt, Variante A):
- **NORTH**: HUD-Leiste (`JPanel` mit `JLabel`s) Ort · Gold · Zug · Licht.
- **CENTER**: `JTextPane` mit `StyledDocument` (Farben/Stile via `AttributeSet` pro
`Style`), scrollbar.
- **EAST**: Seitenpanel, reserviert für Karte; zeigt bis dahin `RoomView.exits` als Liste.
- **SOUTH**: Eingabefeld (bestehende `LinkedBlockingQueue`-Brücke bleibt).
**Gebündelter Font:** `.ttf` unter `src/main/resources/fonts/`, via
`Font.createFont(Font.TRUETYPE_FONT, stream)` geladen und registriert → Farben und
**Glyphen funktionieren ohne Installation auf dem Spieler-System**. Fallback auf
`Font.MONOSPACED` bei Ladefehler (geloggt).
`print`/`showRoom` schreiben gestylte Spans in das `JTextPane`; `setHud` aktualisiert
die Labels. Alle Swing-Mutationen via `SwingUtilities.invokeLater` (wie bisher).
## 5. Datenfluss
```
Command → baut RoomView/StyledText/Hud → GameIO.{showRoom|print|setHud}
→ Frontend-Renderer → Konsole (ANSI) bzw. GUI (Swing)
```
Game-Loop, Parser, Registry, Domain: **unverändert**.
## 6. Nerd-Font / Glyph-Strategie (zusammengefasst)
- **GUI**: Font mitliefern (bundled `.ttf`) → Problem gelöst, keine Installation nötig.
- **Konsole**: Font nicht kontrollierbar → **gestufte Fallbacks** (`ASCII`
`UNICODE`-Box-Drawing → `GLYPH`). Default `UNICODE`. Keine harte Abhängigkeit von
installierten Nerd Fonts.
## 7. Migration der Commands & Tests
- `LookCommand`: statt `StringBuilder` eine `RoomView` bauen → `io.showRoom(view)`.
- Übrige Commands (`Take`, `Drop`, `Use`, `Read`, `Examine`, `Talk`, `Give`,
`Inventory`, `Help`): `io.print(StyledText…)` mit Rollen, wo es Mehrwert bringt;
sonst genügt das Default-`write(String)`. Schrittweise, nicht Big-Bang.
- `TestIO`: implementiert `print`/`showRoom`/`setHud` durch **Flattening auf
Klartext** (`StyledText.plainText()` bzw. RoomView→Textblock), abgelegt in
`outputs`. `lastOutput()`/`allOutput()` liefern weiterhin Klartext → die
bestehenden `.contains(...)`-Assertions der 67 Tests bleiben gültig.
## 8. Fehlerbehandlung
| Fall | Verhalten |
|---|---|
| Gebündelter Font nicht ladbar | Log-Warnung, Fallback `Font.MONOSPACED` |
| Terminal ohne ANSI / Umleitung | Auto-Erkennung → Farben aus, reiner Text |
| `GlyphMode=GLYPH` ohne passende Terminal-Font | Nutzer-Opt-in; kein Auto-Fallback nötig, Default bleibt `UNICODE` |
| `null`-Text an `StyledText.of` | `IllegalArgumentException` (wie bestehende Konventionen) |
## 9. Testing-Strategie
- **Unit**: `StyledText`-Builder & `plainText()`, `Span`, Immutabilität von
`RoomView`/`Hud`.
- **ConsoleIO**: Rendern einer bekannten `RoomView`/`Hud` mit Farbe AUS gegen einen
`StringWriter`/`PrintStream` (Test-Konstruktor existiert bereits) → strukturelle
Assertions (Heading, Labels, Exit-Namen).
- **Command-Tests**: laufen unverändert über das angepasste `TestIO` (Klartext).
- **SwingIO**: nicht unit-getestet (EDT/Swing); Logik dünn halten, manueller
Smoke-Test. Verifikation per `mvn exec:java@gui`.
## 10. Handoffs an spätere Teilprojekte
- **Karte (2)**: GUI-Seitenpanel + künftige `showMap(MapView)`-Methode;
`RoomView.exits` ist bis dahin Platzhalter-Quelle.
- **Menü/Settings (6)**: verdrahtet später `ColorMode`/`GlyphMode`/Lautstärke.
- **Save (7)**, **Musik (8)**: nutzen die HUD/Region-Slots, hier nicht implementiert.
## 11. Offene Detailfragen (in Implementierung entscheidbar)
- Konkrete Wahl des gebündelten Fonts (freier Monospace bzw. Nerd-Font-Variante,
Lizenz prüfen).
- Exakte ANSI-Farbpalette pro Rolle.
- Ob `Hud` schon ein `lightOn` führt, obwohl die Licht-Mechanik erst mit den Inhalten
kommt → Feld vorsehen, vorerst konstant `false`/ausgeblendet.

View File

@@ -0,0 +1,185 @@
# Spec: Quest-Fundament World-State & Bedingungen/Effekte (Teilprojekt 3.1)
Stand: 2026-05-31. Erstes Stück des Quest-Systems (Teilprojekt 3) aus
[enhancement-ideas.md](../../enhancement-ideas.md). Liefert die Zustands- und
Bedingungs-/Effekt-Engine, auf der Quests (#3.2) und Enden (#3.3) aufbauen.
## 1. Kontext & Ziel
Das NPC-System ist heute statisch: fester `greeting`, Reaktionen
(`consumes`/`gives`/`response`) ohne Bedingungen, kein Weltzustand. Ziel:
**gated progression** datengetrieben ermöglichen verschlossene Türen,
zustandsabhängige Beschreibungen, reaktive NPCs, Schalter, die Weltzustand setzen
über **eine** kleine, wiederverwendbare Bedingungs-/Effekt-Sprache (Ansatz A).
Scope **core-only** (bestätigt): Licht/Dunkelheit und Item-Kombination werden
bewusst auf spätere kleine Folge-Specs verschoben.
## 2. Scope
**In Scope:**
- **`GameState`** Flag-Speicher (benannte Booleans).
- **Condition/Effect-Modell** + Auswerter (`Conditions`) + Anwender (`Effects`).
- Fünf Integrationspunkte: verschlossene Exits, zustandsabhängige
Raumbeschreibungen, bedingte NPC-Dialoge, Reaktionen mit `requires`/`effects`,
Schalter mit `effects`.
- Loader/DTO/Factory/Resolver-Erweiterungen, alle neuen YAML-Felder **optional**
(Rückwärtskompatibilität).
**Out of Scope (eigene spätere Specs):**
- Licht & Dunkelheit; Item-Kombination (`use X on Y`).
- Quest-Objekte, Quest-Log, GUI-Quest-Box (Teilprojekt #3.2).
- Win-Condition / mehrere Enden (#3.3).
- `startFlags`-Seeding (YAGNI Flags starten leer = alle false, was der
Ausgangslage „Strom aus, Keller geflutet" entspricht).
## 3. Kern-Engine (Ansatz A)
### 3.1 GameState
`GameState` (Package `game`) hält die gesetzten Flags als `Set<String>`
(vorhanden = true). API: `isSet(name)`, `set(name)`, `clear(name)`, `all()`.
`GameContext` bekommt ein **selbst-initialisiertes** Feld
`private final GameState state = new GameState();` (per `@Getter` lesbar). Da das
Feld initialisiert ist, bleibt der `@RequiredArgsConstructor` (world, player, io)
**unverändert** keine Anpassung der bestehenden Konstruktionsstellen nötig.
### 3.2 Condition (Daten in `model`, Auswerter in `game`)
```java
public record Condition(Type type, String arg) {
public enum Type { FLAG, NOT_FLAG, HAS_ITEM }
}
```
`Conditions.all(List<Condition>, GameContext)` → true, wenn alle gelten (leere
Liste = true):
- `FLAG``state.isSet(arg)`
- `NOT_FLAG``!state.isSet(arg)`
- `HAS_ITEM``player.hasItem(arg)`
### 3.3 Effect (Daten in `model`, Anwender in `game`)
```java
public record Effect(Type type, String arg) {
public enum Type { SET_FLAG, CLEAR_FLAG, GIVE_ITEM, REMOVE_ITEM, SAY }
}
```
`Effects.applyAll(List<Effect>, GameContext)` wendet jeden an:
- `SET_FLAG`/`CLEAR_FLAG``state.set/clear(arg)`
- `GIVE_ITEM` → Item aus `world.getItems().get(arg)` ins Inventar (unbekannt →
Warnung geloggt, übersprungen)
- `REMOVE_ITEM``player.removeItem(arg)`
- `SAY``io.write(arg)`
## 4. Integrationspunkte
Jeder trägt nur `requires` (Conditions) und/oder `effects` und ruft die Helfer.
| # | Träger (model) | YAML-Feld | Konsument | Verhalten |
|---|---|---|---|---|
| 1 | `Room.exitLocks: EnumMap<Direction, ExitLock>` | `exitLocks` | `GoCommand` | Vor Bewegung: gilt `requires` nicht → `blocked` ausgeben, nicht bewegen |
| 2 | `Room.descriptionStates: List<DescriptionState>` | `descriptionStates` | `LookCommand` | Erste passende Variante als Beschreibung, sonst `description` |
| 3 | `Npc.dialogue: List<DialogueLine>` | `dialogue` | `TalkCommand` | Erste passende Zeile, sonst `greeting` |
| 4 | `NpcReaction.requires/effects` | `requires`, `effects` | `GiveCommand` | `requires` prüfen; bei Erfolg consume/give wie bisher + `effects` |
| 5 | `SwitchableItem.effects: List<Effect>` | `effects` | `use` | Beim Schalten **auf „on"**: `effects` anwenden |
Hilfstypen (records in `model`): `ExitLock(List<Condition> requires, String blocked)`,
`DescriptionState(List<Condition> when, String text)`,
`DialogueLine(List<Condition> when, String text)`.
### YAML-Beispiele
```yaml
# rooms.yaml
exits: { east: cellar }
exitLocks:
- direction: east
requires: [{ flag: power_on }]
blocked: "The cellar door won't budge without power."
descriptionStates:
- when: [{ flag: power_on }]
text: "The cellar, now lit, reveals a workbench."
# npcs.yaml
dialogue:
- when: [{ flag: power_on }]
text: '"You did it — the lights are back!"'
reactions:
- onReceive: fuse
requires: [{ flag: cellar_drained }]
response: "That'll hold now."
effects:
- { removeItem: fuse }
- { setFlag: fuse_installed }
# items.yaml
- id: breaker
type: switchable
name: Breaker
onText: "You flip the breaker. The manor hums to life."
offText: "You switch the breaker off."
state: false
effects:
- { setFlag: power_on }
```
## 5. Loading / DTO / Mapping
- Neue DTOs (`loader.dto`): `ConditionDto(flag, notFlag, hasItem)`,
`EffectDto(setFlag, clearFlag, giveItem, removeItem, say)`, `ExitLockDto`,
`DescriptionStateDto`, `DialogueLineDto`. Jeweils `toModel()`.
- Erweiterte DTOs: `RoomDto` (+`exitLocks`, +`descriptionStates`), `NpcDto`
(+`dialogue`), `ReactionDto` (+`requires`, +`effects`), `ItemDto` (+`effects`).
- `ConditionDto.toModel()`: genau ein Feld gesetzt → entsprechende `Condition`,
sonst `WorldLoadException`. Analog `EffectDto`.
- Verdrahtung: `ReferenceResolver.resolveRooms` baut `exitLocks` (Direction via
`Direction.fromString`) und `descriptionStates`; `resolveNpcs` baut `dialogue`
und erweitert die `NpcReaction` um `requires`/`effects`; `ItemFactory` setzt
`SwitchableItem.effects`.
- **Item-Referenzen in Conditions/Effects** (`hasItem`/`giveItem`/`removeItem`)
werden **nicht** beim Laden aufgelöst, sondern zur Laufzeit (lazy) kein
Eingriff in die strikte Resolver-Validierung; `GIVE_ITEM` unbekannt ist
laufzeit-tolerant (Warnung).
## 6. Fehlerbehandlung
| Fall | Verhalten |
|---|---|
| Condition/Effect-DTO ohne gesetztes Feld | `WorldLoadException` beim Laden |
| `exitLocks` mit unbekannter Richtung | `WorldLoadException` (`Direction.fromString`) |
| `GIVE_ITEM` mit unbekannter Item-ID | Warnung geloggt, Effekt übersprungen |
| Neue Felder fehlen (alte YAMLs) | null → als leere Liste behandelt, lädt normal |
| Schalter `off``on` mehrfach | `effects` werden bei jedem Übergang auf „on" angewendet (idempotent bei `setFlag`) |
## 7. Rückwärtskompatibilität
Alle neuen Felder sind optional (nullable). Bestehende `rooms.yaml`, `npcs.yaml`,
`items.yaml` und alle Test-Fixtures laden unverändert; die 90 bestehenden Tests
bleiben grün. `NpcReaction` behält `consumes`/`gives`/`response`.
## 8. Testing
- **Unit:** `GameState` (set/clear/isSet); `Conditions.all` (FLAG/NOT_FLAG/HAS_ITEM,
AND, leer=true); `Effects.applyAll` (alle fünf Typen, unbekanntes Item tolerant);
`ConditionDto.toModel`/`EffectDto.toModel` (inkl. Fehlerfall).
- **Integration (in-Code-Welt wie bestehende Command-Tests):**
- `GoCommand`: gesperrter Exit blockt ohne Flag, passiert mit Flag.
- `LookCommand`: Beschreibung wechselt mit Flag.
- `TalkCommand`: Dialogzeile wechselt mit Flag, sonst `greeting`.
- `GiveCommand`: Reaktion mit `requires` blockt/erlaubt; `effects` setzen Flag.
- `SwitchableItem.use`: setzt Flag beim Einschalten.
- **Loader:** ein Round-Trip-Test, der ein YAML mit allen neuen Feldern lädt;
ein Test, dass alte YAMLs ohne neue Felder weiterhin laden.
## 9. Demonstration (am Ende, minimal)
Eine kleine, sichere Erweiterung der echten Welt-YAMLs, um die Kette
end-to-end spielbar zu zeigen (z. B. Schalter setzt ein Flag, das eine Tür
öffnet und eine Beschreibung ändert) ohne bestehende Tests zu berühren.
## 10. Offene Detailfragen (in Implementierung entscheidbar)
- `SAY`-Effekt: schlicht `io.write` oder gestylt (`StyledText`) zunächst `write`.
- Ob `ExitLock`/`DescriptionState`/`DialogueLine` als records in `model` oder
`model.state` liegen im Plan festgelegt (`model`).

View File

@@ -0,0 +1,163 @@
# Spec: Quests & Quest-Log (Teilprojekt 3.2)
Stand: 2026-05-31. Baut auf dem Quest-Fundament (3.1) auf: Flags, Conditions,
Effects sind vorhanden. Liefert echte Quest-Objekte, eine Fortschritts-Engine und
die Anzeige (Konsolen-Befehl + GUI-Quest-Box unter der Karte).
## 1. Kontext & Ziel
Das Fundament erlaubt gated progression über Flags. Jetzt: **strukturierte Quests**
mit mehreren Stufen, deren Fortschritt **automatisch aus dem Weltzustand** erkannt
wird (bestätigt: Ansatz A, condition-driven), plus sichtbares **Quest-Log**.
## 2. Scope
**In Scope:**
- Quest-Datenmodell (`Quest`, `QuestStage`) + `quests.yaml`.
- Runtime: `QuestLog` (Fortschritt), `QuestEngine` (Tick pro Zug, Auto-Advance,
Ansagen), `START_QUEST`-Effekt.
- Anzeige: `QuestView`-Modell, `GameIO.setQuests/showQuests`, Konsolen-Befehl
`quests`, GUI-Quest-Box **unter der Karte**.
- Loader: `QuestDto`/`QuestStageDto`, `QuestFactory`, `quests.yaml` (optional),
`World.quests`.
- Demo-Quest in der echten Welt.
**Out of Scope (3.3 / später):**
- Win-Condition / mehrere Enden (3.3) eine Quest kann aber per `onComplete`-Effekt
ein Flag setzen, das 3.3 nutzt.
- Quest-Verzweigung, Fehlschlag-Zustände, Voraussetzungen (per `startQuest`-Effekt
ausdrückbar).
## 3. Datenmodell (model)
```java
public record Quest(String id, String title, boolean autoStart,
List<QuestStage> stages, List<Effect> onComplete) { /* List.copyOf */ }
public record QuestStage(String objective, List<Condition> completion,
List<Effect> onComplete) { /* List.copyOf */ }
```
`quests.yaml`:
```yaml
- id: restore_power
title: "Bring the Manor to Life"
autoStart: true
stages:
- objective: "Get the power running."
completion: [{ flag: power_on }]
- objective: "Earn the Old Man's brass key."
completion: [{ hasItem: key }]
onComplete:
- { setFlag: manor_secured }
```
## 4. Runtime (game)
### 4.1 QuestLog (in GameContext)
`QuestLog` (selbst-initialisiertes Feld in `GameContext`, wie `GameState` → kein
Konstruktor-Eingriff). Hält nur Laufzeit-Fortschritt:
- `Map<String, Integer> stageIndex` aktuelle Stufe je *aktiver* Quest.
- `Set<String> completed` abgeschlossene Quest-IDs.
- API: `start(id)`, `isActive(id)`, `isCompleted(id)`, `stageIndex(id)`,
`advance(id)`, `complete(id)`.
### 4.2 START_QUEST-Effekt
`Effect.Type.START_QUEST` (arg = Quest-ID). `Effects.apply` ruft
`ctx.getQuestLog().start(arg)`. `EffectDto` bekommt Feld `startQuest`. Damit können
Dialoge/Schalter/Reaktionen Quests starten.
### 4.3 QuestEngine.tick(ctx)
Pro Zug aus der `Game`-Schleife (neben `setHud`/`setMap`):
1. Auto-Start: jede `autoStart`-Quest, die weder aktiv noch abgeschlossen ist, starten.
2. Für jede **aktive** Quest die `completion` der **aktuellen** Stufe via
`Conditions.all` prüfen. Erfüllt →
- `onComplete`-Effekte der Stufe anwenden,
- Ansage (`io.print`, z. B. „✓ Objective complete: …"),
- `advance`; war es die letzte Stufe → Quest-`onComplete` anwenden, `complete`,
Ansage „Quest complete: …".
3. **Kaskade**: Schleife wiederholen, solange sich etwas geändert hat (begrenzt durch
Gesamt-Stufenzahl), damit ein Zug mehrere Stufen auslösen kann.
`tick` ist idempotent (abgeschlossene Stufen feuern nicht erneut). `QuestEngine.viewOf(ctx)`
baut die `QuestView` aus `World.getQuests()` + `QuestLog`.
## 5. Anzeige
### 5.1 Modell (io.text)
```java
public record QuestEntry(String title, String objective) {}
public record QuestView(List<QuestEntry> active, List<String> completed) {}
```
### 5.2 GameIO (additiv, wie die Karte)
```java
default void setQuests(QuestView view) { /* no-op */ } // Push pro Zug (GUI)
default void showQuests(QuestView view) { print(QuestText.render(view)); } // on demand (Konsole)
```
- **Konsole**: nutzt beide Defaults. `setQuests` = no-op; `showQuests` druckt via
`QuestText.render` (io.text), ausgelöst durch `QuestsCommand`.
- **GUI**: überschreibt `setQuests` (Quest-Box neu zeichnen) und `showQuests` (no-op,
Box ist immer sichtbar).
- `TestIO`: Defaults; `showQuests` druckt in `outputs` → testbar.
### 5.3 Konsolen-Befehl
`QuestsCommand` (`quests`, `log`, `journal`) → `io.showQuests(QuestEngine.viewOf(ctx))`.
Ausgabe: aktive Quests (Titel + aktuelles Ziel), darunter abgeschlossene (dim, mit ✓).
### 5.4 GUI-Quest-Box
Rechter Seitenbereich wird zu einem Panel: **Karte oben, Quest-Box unten**
(`BorderLayout`: Map-Scroll CENTER, Quest-Scroll SOUTH mit fester/anteiliger Höhe).
Die Quest-Box ist ein gestyltes, schreibgeschütztes Textfeld (`JTextPane`): aktive
Quests fett (Titel) + Ziel; abgeschlossene dim mit ✓. Font folgt der Auto-Skalierung
(`applyFonts`). `setQuests` aktualisiert sie pro Zug.
## 6. Loading
- `QuestDto(id, title, Boolean autoStart, List<QuestStageDto> stages, List<EffectDto> onComplete)`,
`QuestStageDto(objective, List<ConditionDto> completion, List<EffectDto> onComplete)`.
- `QuestFactory.fromDto` baut `Quest` (Conditions/Effects via vorhandene
`toModelList`); keine Resolver-Phase nötig (Flags/Items lazy, Quest-IDs sind Strings).
- `WorldLoader` liest `quests.yaml` **optional** (fehlt die Datei → leere Liste),
baut `Map<String, Quest>` und übergibt sie an `World`.
- `World` bekommt Feld `quests` + **rückwärtskompatiblen 5-Arg-Konstruktor**
(delegiert mit leerer Quest-Map), damit bestehende `new World(...)`-Aufrufe in Tests
unverändert kompilieren.
## 7. Fehlerbehandlung
| Fall | Verhalten |
|---|---|
| `quests.yaml` fehlt | leere Quest-Map, Spiel läuft ohne Quests |
| `START_QUEST` mit unbekannter Quest-ID | `QuestLog.start` legt Eintrag an; `viewOf` ignoriert IDs ohne Definition (Warnung geloggt) |
| Quest ohne Stufen | gilt sofort als abgeschlossen beim ersten Tick |
| Doppelte Quest-IDs | `WorldLoader.requireUniqueIds` erweitert um Quests |
## 8. Testing
- **QuestLog**: start/advance/complete, isActive/isCompleted.
- **QuestEngine**: Auto-Start; Stufe rückt vor, wenn Condition gilt; Ansage; Quest
schließt nach letzter Stufe ab; Kaskade (eine Stufe löst die nächste); `viewOf`
liefert aktive + abgeschlossene korrekt.
- **Effects**: `START_QUEST` startet Quest.
- **EffectDto/QuestDto**: `toModel`/`fromDto`-Mapping.
- **QuestsCommand**: über `TestIO` Ausgabe enthält aktives Ziel.
- **WorldLoader**: lädt `quests.yaml`; fehlende Datei → leer.
- **GUI**: nicht unit-getestet; manueller Smoke (`exec:java@gui`).
## 9. Demonstration
`quests.yaml` mit `restore_power` (autoStart, 2 Stufen): Strom anschalten →
Schlüssel vom alten Mann holen. Sichtbar im Konsolen-`quests`-Befehl und in der
GUI-Box; Ansagen beim Erfüllen.
## 10. Offene Detailfragen (in Implementierung)
- Höhe/Anteil der GUI-Quest-Box (ca. 30 % der Seitenleiste, scrollbar).
- Stil der Ansagen (zunächst `HEADING`).

View File

@@ -0,0 +1,281 @@
# Spec: Hauptmenü + Speichern/Laden (+ minimale Settings)
Stand: 2026-06-01. Erstes Teilprojekt der Erweiterungsrunde nach Abschluss von
Phasen 17 (Backlog #6 + #7, Teil von #6-Settings). Baut auf vorhandenem
`GameIO`-Abstraktions-Modell, `GameContext` (World/Player/GameState/QuestLog) und
dem `App.run(io)`-Einstieg auf, der von Konsole und GUI geteilt wird.
## 1. Kontext & Ziel
Das Spiel startet bisher direkt in die Spielschleife und endet per `quit` /
Ending / EOF mit Prozess-Ende; es gibt keine Persistenz. Ziel:
- **Hauptmenü oberhalb der Spielschleife** (Neues Spiel · Laden · Einstellungen ·
Beenden), identisch in Konsole und GUI über die geteilte `GameIO`-Abstraktion.
- **Speichern/Laden** des veränderlichen Spielzustands als JSON, als **Delta über
die frisch aus YAML geladene Welt** (Welt-Definition bleibt datengetrieben; der
Spielstand enthält nur den mutierten Zustand).
- **Ein aktiver Slot pro laufendem Spiel** (beim Neues-Spiel benannt). Manuelles
`save`, Autosave und Speichern-beim-Verlassen schreiben alle in diesen Slot.
- **Minimale Settings**: Farb-Modus + Glyphen-Modus (ASCII/Unicode) umschalten,
persistiert.
Bestätigte Entscheidungen (Brainstorming 2026-06-01):
- Menü-Integration: **eine neue `GameIO`-Primitive `choose(...)`** (Konsole-Default
= nummeriertes Textmenü, `SwingIO` überschreibt mit Buttons). Menü-Logik
einmalig geschrieben, Konsolen-Parität gratis.
- Save-Format: **JSON** (maschinengenerierter Zustand; klar getrennt vom
YAML-Content). Jackson ist bereits Abhängigkeit.
- Slot-Modell: **ein aktiver Slot für alles** (save + autosave + quit-save
überschreiben denselben Slot; Menü-Laden listet alle benannten Spielstände).
- `quit`/`exit`/`menu` in-game = **speichern + zurück ins Hauptmenü**; Prozess-Ende
nur über das Hauptmenü-„Beenden".
- Neues Spiel: **Slot-Name abfragen, mit Default** (Enter akzeptiert Default).
- Autosave: **Events (Quest abgeschlossen, quit-to-menu) + Intervall alle 10 Züge**.
- Settings-Datei: **`saves/settings.json`**.
## 2. Scope
**In Scope:**
- Shell-Schleife in `App.run(io)` (Menü-Schicht über dem Game-Loop).
- `menu`-Paket: `MainMenu`, `SettingsMenu`, `MenuAction`.
- `GameIO.choose(String title, List<String> options) → int` + Konsole-Default
(nummeriert, Re-Prompt) + `SwingIO`-Override (Buttons).
- `GameSession` (Bündel des speicherbaren Zustands + Slot-Name + `turn`).
- `SaveData`-DTO (JSON) + `SaveService` (save/list/load, atomar) + `SaveSlotInfo`.
- In-Game-Befehle: `save`, `menu` (Aliase `quit`/`exit`). Kein `load`-Befehl.
- Autosave: Quest-Completion-Hook, quit-to-menu, Intervall alle 10 Züge.
- `Settings` + `SettingsStore` (`saves/settings.json`); `ConsoleIO`-Farb-/Glyphen-
Modus zur Laufzeit umschaltbar machen (Felder non-final + Setter).
- Tests: Round-Trip, Overlay, Failure-Fälle, Menü-Logik, Konsolen-`choose`.
**Out of Scope:**
- Mehrere Slots pro Spiel / getrennter Autosave-Slot (bewusst „ein aktiver Slot").
- Settings für Musik/Typewriter (Features existieren noch nicht).
- GUI-Hintergrundbilder/„echte" Menü-Screens (Buttons genügen).
- Pathfinding / `go to <raum>` (eigene spätere Runde, in Scope gehalten).
- Migration alter Save-Versionen (nur Versions-Check + graceful refuse).
## 3. Architektur: Shell-Schleife über dem Loop
`App.run(io)` wird zur **Shell-Schleife**, die den Lebenszyklus besitzt. Der
Prozess endet nur über Hauptmenü-„Beenden"; Spielende/`menu` kehren ins Menü
zurück.
```
App.run(io):
Settings s = SettingsStore.load(); // → auf io anwenden (Farbe/Glyphen)
loop:
switch (MainMenu.show(io)) {
NEW_GAME -> { GameSession g = newSession(promptName(io)); play(io, g); }
LOAD -> { SaveSlotInfo slot = pickSlot(io); // oder „zurück"
if (slot != null) {
try { play(io, SaveService.load(slot.slug())); }
catch (SaveException e) { io.write(e.getUserMessage()); }
} }
SETTINGS -> SettingsMenu.show(io, s); // toggelt + persistiert + wendet an
QUIT -> return;
}
play(io, session):
GameContext ctx = new GameContext(session); // s.u.
CommandRegistry registry = buildRegistry(...); // inkl. save/menu
Game game = new Game(ctx, registry, parser);
Banner + Welcome + erstes look;
game.run(); // kehrt zurück bei menu/quit/Ending/EOF
```
`newSession(name)` lädt die Welt frisch aus YAML (`WorldLoader`) und baut eine
neue `GameSession`. `SaveService.load` lädt die Welt ebenso frisch und legt das
Save-Delta darüber.
## 4. `GameSession` (game)
Bündelt den **speicherbaren** Zustand an einem Ort (heute über `GameContext` +
privates `turn` in `Game` verstreut). `turn` wandert hierher, damit es gespeichert
und in der Slot-Metadata gezeigt werden kann.
```java
public class GameSession {
private final World world; // aus YAML geladen (statisch)
private final Player player;
private final GameState state;
private final QuestLog questLog;
private int turn;
private final String slotName; // gebundener aktiver Slot
// getter; getTurn/setTurn/incrementTurn
}
```
`GameContext` bekommt einen Konstruktor `GameContext(GameSession, GameIO)` (oder
hält eine `GameSession`-Referenz statt der vier Einzelfelder). Bestehende
Command-Signaturen (`execute(ctx, args)`) bleiben unverändert; `ctx.getWorld()`
etc. delegieren an die Session. `Game` liest/erhöht `turn` über die Session statt
über ein eigenes Feld.
## 5. Menü-Schicht (menu)
```java
public enum MenuAction { NEW_GAME, LOAD, SETTINGS, QUIT }
public final class MainMenu { // frontend-agnostisch
public static MenuAction show(GameIO io) {
int i = io.choose("Haunted Manor", List.of(
"New Game", "Load Game", "Settings", "Quit"));
return MenuAction.values()[i]; // Reihenfolge = Indizes
}
}
```
`SettingsMenu.show(GameIO, Settings)` toggelt Farbe/Glyphen über wiederholte
`choose`-Aufrufe (mit „zurück"-Option), persistiert via `SettingsStore` und wendet
live auf das `io` an. `pickSlot`/`promptName` nutzen `choose` bzw. `readLine`.
### `GameIO.choose` (io)
Neue Primitive auf `GameIO`:
```java
/** Zeigt eine Auswahl; gibt den 0-basierten Index der Wahl zurück. */
default int choose(String title, List<String> options) {
print(/* Titel + nummerierte Liste 1..n als StyledText */);
while (true) {
String line = readLine();
Integer n = parseInRange(line, 1, options.size());
if (n != null) return n - 1;
write("Please enter a number between 1 and " + options.size() + ".");
}
}
```
- **Konsole** nutzt den Default (nummeriert + Re-Prompt). **EOF/`null` → letzte
Option** (Konvention: Menüs ordnen die sichere/abbrechende Wahl — „Quit" bzw.
„zurück" — stets als letzte Option, sodass EOF nie in einen Zustand führt).
- **`SwingIO` überschreibt** `choose`: rendert Buttons (ein Button je Option),
blockiert den Worker-Thread bis zum Klick — dasselbe Blocking-Handoff-Muster wie
das vorhandene `readLine` (EDT ↔ Worker). Kein Re-Prompt nötig (Buttons sind
immer gültig).
## 6. In-Game-Befehle (command/impl)
- **`SaveCommand`** (`save`): `SaveService.save(session)` → „Game saved." / bei
Fehler graceful Meldung, Loop läuft weiter.
- **`MenuCommand`** (`menu`, Aliase `quit`, `exit`): `SaveService.save(session)`
dann `game.stop()` → zurück ins Hauptmenü. Ersetzt den bisherigen `QuitCommand`
(Bindung an `Game` wie gehabt via `bind(game)`).
- **Kein `load`-Befehl** — Laden ausschließlich über das Hauptmenü.
## 7. Autosave
Ein aktiver Slot; alle Autosaves überschreiben ihn. Trigger:
- **Quest abgeschlossen** — Hook in `QuestEngine` (nach `questLog.complete(id)`),
ruft eine `Autosaver`/`SaveService.save(session)`-Callback auf. Der Engine darf
dafür Zugriff auf einen Save-Callback bekommen (z.B. via `GameContext`), um die
Schichtung (game → loader) nicht zu verletzen.
- **quit-to-menu** — `MenuCommand` speichert ohnehin.
- **Intervall** — in `Game.run()` nach jedem erfolgreichen Befehl: wenn
`turn % 10 == 0`, `SaveService.save(session)`.
Autosave-Fehler werden geloggt und schlucken (kein Loop-Abbruch); optional eine
dezente Meldung.
## 8. Save-Datenmodell: `SaveData` (loader/save)
JSON-DTO, reines Delta über die YAML-Welt:
```java
public record SaveData(
int schemaVersion, // aktuell 1; Mismatch → refuse
String worldTitle, // Sanity-Check gegen geladene Welt
String slotName,
long savedAtEpochMillis, // System.currentTimeMillis()
int turn,
String currentRoomId,
List<String> visitedRoomIds, // geordnet
int gold,
List<String> inventoryItemIds, // geordnet
List<String> flags, // gesetzte World-Flags
Map<String,Integer> questStages, // aktive Quest → Stage-Index
List<String> questCompleted,
Map<String,List<String>> roomItemIds, // roomId → Item-Ids im Raum
Map<String,Boolean> switchStates // SwitchableItem-Id → on/off
) {}
```
`inventoryItemIds` + `roomItemIds` erfassen die Item-Platzierung vollständig;
Items in keinem von beiden gelten als verbraucht/entfernt. Switch-Zustände decken
Lampe/Generator/Tür ab.
## 9. Persistenz: `SaveService` (loader/save)
- Verzeichnis `saves/` (relativ zum Arbeitsverzeichnis, on-demand angelegt, in
`.gitignore`). Ein Slot = `saves/<slug>.json`, `slug` = sanitisierter Name
(`[a-z0-9_-]`, Rest → `_`).
- **`save(GameSession)`**: `SaveData` aus Session bauen → Jackson → **atomar**
schreiben (Temp-Datei + `Files.move` ATOMIC_MOVE/REPLACE_EXISTING).
- **`list() → List<SaveSlotInfo>`**: `saves/*.json` scannen, Header-Felder lesen
(`slotName`, `currentRoomId`, `turn`, `savedAtEpochMillis`) → für die
Laden-Liste. Beschädigte Dateien überspringen (geloggt).
- **`load(slug) → GameSession`**: `SaveData` lesen → Welt frisch via `WorldLoader`
`SaveApplier` legt das Delta darüber → `GameSession`.
- **`SaveApplier`**: löst Ids gegen die frische Welt auf (Räume, Items). Setzt
Player-Raum/Gold/Inventar/Visited, World-Flags, QuestLog (Stages + completed),
verteilt Items in Räume/Inventar, setzt Switch-Zustände, `turn`.
```java
public record SaveSlotInfo(String slug, String slotName, String roomId,
int turn, long savedAtEpochMillis) {}
```
## 10. Minimale Settings (game/io)
```java
public record Settings(ConsoleIO.ColorMode colorMode, ConsoleIO.GlyphMode glyphMode) {
public static Settings defaults() { return new Settings(ColorMode.AUTO, GlyphMode.UNICODE); }
}
```
- `SettingsStore.load()/save(Settings)``saves/settings.json` (Jackson). Fehlend/
beschädigt → `Settings.defaults()`.
- **`ConsoleIO`**: `useColor`/`glyphs` von `final` auf veränderlich umstellen +
`setColorMode`/`setGlyphMode`, damit ein Settings-Toggle **live** wirkt (heute
konstruktor-fix). `App.main` lädt Settings vor dem Menü und konstruiert/füttert
`ConsoleIO` damit.
- `SwingIO`: Glyphen/Farbe für die GUI ggf. No-Op bzw. minimal (GUI rendert eigene
Stile); Settings-Wirkung primär Konsole. (Bestätigen im Plan.)
## 11. Fehlerbehandlung
- **`SaveException`** (eigene, mit `getUserMessage()`): beschädigtes/unlesbares
JSON, Schreibrechte fehlen, Schema-Mismatch, unbekannte Ids → gefangen, geloggt,
benutzerfreundliche Meldung; **nie** Absturz. Load-Fehler → zurück ins Menü.
- Konsolen-`choose`: ungültige Eingabe → Re-Prompt; `null`/EOF → sichere Vorgabe.
- Leere Slot-Liste beim Laden → „No saved games." → zurück ins Menü.
- GUI-Buttons können keine ungültige Wahl liefern.
## 12. Tests
- **`SaveServiceTest`** (Temp-Dir): Session bauen → `save``load` → volle
Zustandsgleichheit (Raum, Inventar, Item-Platzierung, Flags, Quest-Stages +
completed, Switch-Zustände, Gold, `turn`).
- **`SaveDataTest`**: Jackson (De-)Serialisierung stabil; Overlay auf frische Welt
(verschobene Items, Quest mitten in Stage).
- **Failure**: beschädigtes JSON, unbekannte Raum-Id, Schema-Mismatch → `SaveException`
mit sinnvoller Meldung; `list()` überspringt kaputte Dateien.
- **`MainMenu`/`SettingsMenu`**: Fake-`GameIO` mit skriptbaren `choose`-Ergebnissen;
prüft Aktions-Mapping + Settings-Persistenz.
- **Konsolen-`choose`-Default**: nummeriertes Rendering + Bad-Input-Re-Prompt über
gemocktes `readLine`.
- **Konvention**: `SwingIO.choose`/GUI von Unit-Tests ausgenommen (manuelle
Verifikation), wie etabliert keine YAML-/GUI-abhängigen Domain-Tests.
## 13. Architektur-Werte / Risiken
- **Geteilter Loop bleibt zentral**: nur *eine* neue IO-Primitive; Menü-*Logik* ist
frontend-agnostisch, nur das Rendering divergiert (Default vs. Override).
- **Datengetrieben**: Welt weiter aus YAML; Save ist reines Delta — Content-Updates
brechen alte Saves höchstens bei entfernten Ids (→ graceful refuse).
- **Schichtung**: `SaveService` lebt unter `loader` (kennt `WorldLoader`); der
Quest-Autosave-Hook braucht einen Save-Callback im `GameContext`, um `game` nicht
direkt von `loader` abhängig zu machen — im Plan sauber verdrahten.
- **GUI-Threading**: `SwingIO.choose` muss dasselbe EDT↔Worker-Blocking-Muster wie
`readLine` nutzen; sonst Deadlock-Gefahr.

View File

@@ -0,0 +1,226 @@
# Spec: Hintergrundmusik (GUI)
Stand: 2026-06-01. Letzte der geplanten Mechaniken (nach `use X on Y`,
Pathfinding, Tutorial). Setzt Backlog #8 um: **Hintergrundmusik nur in der GUI**,
pro Raum via YAML, externer `music/`-Ordner, gestreamtes OGG. Baut auf der
`GameIO`-Abstraktion, dem Per-Zug-Push (`Game.publishHud`), dem datengetriebenen
Loader und der minimalen Settings auf.
## 1. Kontext & Ziel
Bisher gibt es keinen Ton. Ziel: **Hintergrundmusik in der GUI**, pro Raum über
ein optionales `music:`-Feld in `rooms.yaml`. Beim Raumwechsel sanfter Übergang;
Lautstärke/Aus über die Settings. Konsole bleibt stumm (No-Op).
Bestätigte Entscheidungen (Brainstorming 2026-06-01):
- **Format/Lib**: **OGG Vorbis** via `vorbisspi` + `jorbis` (javax.sound-SPI),
gestreamt über eine `SourceDataLine`.
- **Wechsel**: gleicher Track → unverändert weiter (kein Neustart); anderer Track →
**kurzes Fade-out, dann Fade-in** (~400 ms, sequenziell, eine Line);
Raum **ohne** `music`**Fade to silence**.
- **Settings**: eine zyklische **Music**-Stufe `OFF/LOW/MEDIUM/HIGH`, persistiert;
`OFF` deaktiviert Wiedergabe. Default **MEDIUM**.
- **Dateien**: externer `music/`-Ordner (Arbeitsverzeichnis), **gitignored**, nicht
im JAR; fehlende Datei → still + Log, kein Absturz.
- **Demo**: einige Räume mit Beispiel-`music:`-Feldern + Doku zum `music/`-Ordner.
## 2. Scope
**In Scope:**
- `MusicLevel`-Enum (OFF/LOW/MEDIUM/HIGH + `gainDb()`).
- `MusicController` (frontend-agnostisch, **testbar**): Track-/Level-Logik,
delegiert an ein `MusicBackend`.
- `MusicBackend`-Interface (Roh-Operationen) + `OggMusicBackend` (Audio, **manuell**).
- `GameIO.setMusic(String)` + `setMusicLevel(MusicLevel)` (Default No-Op);
`SwingIO`-Override mit `MusicController(new OggMusicBackend())`.
- `Game.publishHud` ruft `setMusic(currentRoom.getMusic())`; nach `game.run()`
zurück ins Menü → `setMusic(null)`.
- `Room.music` + `RoomDto.music` + `RoomFactory`; optionales `music:` in `rooms.yaml`.
- `Settings.musicLevel` + `SettingsStore` (fehlt → MEDIUM); `SettingsMenu`-Zeile
„Music"; Anwendung via `io.setMusicLevel(...)` (Konsole No-Op).
- `pom.xml`: `vorbisspi` + `jorbis`.
- `music/` in `.gitignore` + kurze Doku.
- Tests: `MusicController` (Fake-Backend), `MusicLevel`, Settings-Roundtrip,
Room-`music`-Laden.
**Out of Scope:**
- Konsolen-Audio (bleibt stumm).
- True Cross-Fade (überlappende Lines) — nur sequenzielles Fade.
- Sound-Effekte / mehrere gleichzeitige Tracks.
- Mitliefern von Audiodateien im Repo.
- Automatisierte Tests der echten OGG-Dekodierung/-Wiedergabe (manuell, GUI-only).
## 3. `MusicLevel` (model oder io)
```java
public enum MusicLevel {
OFF, LOW, MEDIUM, HIGH;
/** MASTER_GAIN in dB; für OFF irrelevant (Wiedergabe deaktiviert). */
public float gainDb() {
return switch (this) {
case OFF -> Float.NEGATIVE_INFINITY;
case LOW -> -20f;
case MEDIUM -> -10f;
case HIGH -> 0f;
};
}
public boolean isOn() { return this != OFF; }
/** Nächste Stufe im Zyklus (für den Settings-Toggle). */
public MusicLevel next() {
return values()[(ordinal() + 1) % values().length];
}
}
```
Platzierung: `io`-Paket (UI-nah, wie `ConsoleIO.ColorMode`). Settings referenziert es.
## 4. `MusicBackend` + `MusicController` (io)
```java
/** Raw audio operations; the only part that touches sound hardware. */
public interface MusicBackend {
void fadeTo(String track, float gainDb); // fade current out, start track (loop), fade in
void fadeOut(); // fade current out + stop
void setGainDb(float gainDb); // immediate volume change
void shutdown(); // stop + release resources
}
```
```java
/** Frontend-agnostic music decision logic. Idempotent per track; honours level. */
public final class MusicController {
private final MusicBackend backend;
private MusicLevel level = MusicLevel.MEDIUM;
private String current; // currently selected track id, or null = silence
public MusicController(MusicBackend backend) { this.backend = backend; }
/** Called per turn with the current room's music field (may be null). */
public void room(String track) {
if (!level.isOn()) { return; } // OFF: stay silent, ignore
String t = (track == null || track.isBlank()) ? null : track;
if (java.util.Objects.equals(t, current)) { return; } // same → keep playing
current = t;
if (t == null) { backend.fadeOut(); } else { backend.fadeTo(t, level.gainDb()); }
}
/** Settings change. */
public void level(MusicLevel newLevel) {
this.level = newLevel;
if (!newLevel.isOn()) { backend.fadeOut(); current = null; }
else { backend.setGainDb(newLevel.gainDb()); }
// turning ON again starts on the next room() call (current was reset to null)
}
public void shutdown() { backend.shutdown(); }
}
```
Hinweis: Bei Wechsel OFF→ON beginnt die Musik beim nächsten `room(...)` (pro Zug
ohnehin aufgerufen), da `current` auf null gesetzt wurde. Das vermeidet, dass der
Controller den aktuellen Raum kennen muss.
## 5. `OggMusicBackend` (io, Audio, manuell)
- Hält den aktuellen Wiedergabe-Thread (Daemon) + `SourceDataLine`.
- `fadeTo(track, gainDb)`: laufenden Track per `MASTER_GAIN`-Rampe (~400 ms)
ausblenden, Thread stoppen (`volatile boolean stopping`), `music/<track>` öffnen
(`AudioSystem.getAudioInputStream` → vorbisspi liefert dekodiertes PCM), in einer
Schleife (Loop) in die Line schreiben, auf `gainDb` einblenden.
- `fadeOut()`: Rampe runter, Thread stoppen, Line schließen.
- `setGainDb(db)`: `MASTER_GAIN`-FloatControl sofort setzen.
- `shutdown()`: stoppen + Ressourcen freigeben.
- **Fehlende Datei / Decode-Fehler**: `log.warn`, still (kein Throw). `music/` fehlt
ganz → ebenso still.
- Threading: Wiedergabe auf Daemon-Thread; Fades blockierend auf dem aufrufenden
Thread (Worker), kurz (~400 ms) — analog zur bestehenden `travelStep`-Pause.
`music/<track>` = `Path.of("music", track)` relativ zum Arbeitsverzeichnis.
## 6. `GameIO`-Anbindung
```java
/** Per-turn current-room track; null = silence. Console no-op; GUI plays. */
default void setMusic(String track) { /* no-op */ }
/** Apply the music volume/level. Console no-op; GUI sets it. */
default void setMusicLevel(MusicLevel level) { /* no-op */ }
```
- `SwingIO`: Feld `MusicController music = new MusicController(new OggMusicBackend());`
`setMusic(t) → music.room(t)`; `setMusicLevel(l) → music.level(l)`.
- `ConsoleIO`: erbt die No-Op-Defaults (kein Audio).
- `Game.publishHud()`: zusätzlich `ctx.getIo().setMusic(ctx.getPlayer().getCurrentRoom().getMusic());`
(idempotent → Wechsel nur bei echtem Raumwechsel).
- `App.play(...)`: nach `game.run()``io.setMusic(null);` (Musik beim Rückkehr ins
Menü ausblenden).
## 7. Room-Feld + Loader
- `Room`: optionales `private final String music;` (+ Getter). Konstruktor/Factory
erweitern; null = kein Track.
- `RoomDto`: optionales `music`-Feld (nullable).
- `RoomFactory.shellFromDto`: `music` durchreichen.
- `rooms.yaml`: optionales `music: <datei>.ogg` pro Raum.
- Demo: z.B. `kitchen: music: manor-theme.ogg`, `dungeon: music: dungeon-drone.ogg`
(Dateien liefert der Nutzer; fehlend → still).
## 8. Settings-Erweiterung
- `Settings` (record) bekommt `MusicLevel musicLevel`; `defaults()` → MEDIUM.
- `SettingsStore`: Jackson serialisiert `MusicLevel` als Namen; alter Spielstand
ohne Feld → `null` → auf MEDIUM normalisieren (im `load()` oder via Defaults).
- `SettingsMenu`: dritte Zeile „Music: <level>"; Auswahl zyklt `level.next()`;
nach jeder Änderung speichern + `applySettings`.
- **Refactor**: `SettingsMenu.apply/show` nehmen statt `ConsoleIO` das `GameIO`
entgegen. `apply(Settings, GameIO io)`:
`io.setMusicLevel(s.musicLevel());` **und** `if (io instanceof ConsoleIO c) {
c.setColorMode(...); c.setGlyphMode(...); }`. Aufrufer (App, `MenuTest`) anpassen.
- `App.run`: beim Start `SettingsMenu.apply(settings, io)` (setzt auch Music-Level).
## 9. Abhängigkeit + Dateien
- `pom.xml` (`<dependencies>`):
- `com.googlecode.soundlibs:vorbisspi:1.0.3.3`
- `com.googlecode.soundlibs:jorbis:0.0.17.4`
(vorbisspi zieht `tritonus-share` transitiv; bei Bedarf explizit ergänzen.)
- `.gitignore`: `music/`.
- Kurze Doku (z.B. in `docs/` oder README-Abschnitt): „Lege `.ogg`-Dateien in
`music/` ab; Räume referenzieren sie über `music:` in `rooms.yaml`. Ohne Dateien
bleibt die GUI stumm."
## 10. Tests
- **`MusicControllerTest`** (Fake-`MusicBackend`, das Aufrufe protokolliert):
- `room("a")``fadeTo("a", MEDIUM.gainDb())`; erneut `room("a")` → kein Aufruf.
- `room("b")``fadeTo("b", ...)`; `room(null)``fadeOut()`.
- `level(OFF)``fadeOut()` + danach ignoriert `room("a")` (still).
- `level(HIGH)` (von MEDIUM) → `setGainDb(HIGH)`; danach `room("a")` spielt.
- leerer/blank Track == null.
- **`MusicLevelTest`**: `gainDb()`-Mapping; `next()`-Zyklus; `isOn()`.
- **`SettingsStoreTest`** (erweitern): musicLevel round-trip; fehlendes Feld → MEDIUM.
- **`SettingsMenu`/`MenuTest`** (anpassen an `GameIO`-Signatur): Music-Zeile zyklt
und persistiert.
- **Room-`music`-Laden**: Loader-/Factory-Test, dass `music:` auf `Room.getMusic()`
landet (und Abwesenheit → null).
- `OggMusicBackend` + echte Wiedergabe: **manuell** (eine `.ogg` + Audio-Hardware),
keine Unit-Tests (Konvention: kein GUI-/Hardware-abhängiger Test).
## 11. Architektur-Werte / Risiken
- **Geteilter Loop bleibt**: nur zwei neue `GameIO`-No-Op-Defaults; Musik-Logik im
testbaren `MusicController`, Audio isoliert im `OggMusicBackend`. Konsole stumm.
- **Testbarkeit**: die wertvolle Entscheidungslogik (Idempotenz, null→Stille, OFF)
ist gegen ein Fake-Backend voll getestet; Hardware-Audio bleibt manuell.
- **Datengetrieben**: `music:` pro Raum in YAML; keine Hardcodierung.
- **Externe Dateien**: `music/` gitignored, nicht im JAR — Repo bleibt klein; fehlende
Dateien sind ein No-Op (graceful), kein Spielabbruch.
- **Risiko Lib**: vorbisspi/jorbis sind alt, funktionieren aber mit `javax.sound`.
Falls eine Datei nicht dekodierbar ist → graceful silence. Lizenz: LGPL/BSD-artig,
für eine Erweiterung erlaubt (Backlog: Bibliotheken erlaubt).
- **Threading**: Fades blockieren kurz den Worker-Thread (~400 ms, wie `travelStep`);
Wiedergabe auf Daemon-Thread. Beim Spielende `setMusic(null)` → Stille im Menü.
- **Settings-Refactor**: `SettingsMenu` von `ConsoleIO` auf `GameIO` umstellen ist die
einzige Änderung an bestehendem, bereits reviewtem Code — minimal und nötig, damit
der Music-Level beide Frontends erreicht (Konsole No-Op).

View File

@@ -0,0 +1,194 @@
# Spec: Pathfinding `go to <room>`
Stand: 2026-06-01. Zweite der verbleibenden Mechaniken (nach `use X on Y`).
Setzt den in `enhancement-ideas.md` als Algorithmus-Showcase notierten Punkt um:
`go to <raum>` per BFS. Baut auf `GoCommand`, `MapLayout` (BFS-Gitter + Fog of
War), `Light`, `Conditions`/`ExitLock` und der `GameIO`-Abstraktion auf.
## 1. Kontext & Ziel
Bewegung erfolgt bisher Schritt für Schritt per `go <richtung>`. Ziel: **`go to
<raum>`** läuft automatisch per **BFS** über bereits **besuchte** Räume zum Ziel,
unter Beachtung derselben Gates wie ein manueller Zug (verschlossene Exits, Licht).
In der **GUI** soll die Minikarte die Bewegung **Raum für Raum animiert** zeigen;
in der Konsole läuft die Reise still mit einer knappen Zusammenfassung.
Bestätigte Entscheidungen (Brainstorming 2026-06-01):
- **Erreichbarkeit**: nur **besuchte** Räume sind Wegpunkte/Ziele (Fog of War bleibt
erhalten; kein Teleport in Unentdecktes).
- **Darstellung**: Konsole still + Zusammenfassung + Ziel-`look`; GUI animiert die
Minikarte (Map-Push pro Schritt).
- **Zusammenfassung**: nennt **Start**, **Ziel** und **höchstens einen** mittleren
Wegpunkt (Mittelknoten). Bei 9 Knoten: 1., 5., 9.
- **Schrittverzögerung GUI**: ~**300 ms** pro Raum.
- **Trie-Autocomplete**: **zurückgestellt** (eigenes späteres Teilprojekt).
## 2. Scope
**In Scope:**
- `GoCommand`-Erweiterung: Argument ist Richtung → bestehender Einzelzug; sonst →
Raum-Ziel-Pathfinding. Keine neue Verb-Registrierung (Parser entfernt `to`).
- `Pathfinder` (`game`): BFS über besuchte Räume mit Passierbarkeits-Prädikat
(Exit-Lock + Licht), liefert den Pfad oder leer.
- Auto-Walk: Schritt für Schritt `setCurrentRoom` + `GameIO.travelStep(MapView)`;
danach Zusammenfassung + `LookCommand`.
- Neue `GameIO.travelStep(MapView)`-Default-Methode (No-Op Konsole) + `SwingIO`-
Override (Map-Push + ~300 ms Sleep).
- Ziel-Auflösung nur unter besuchten Räumen (id, dann name; case-insensitive).
- Zusammenfassung mit Start/Mitte/Ziel-Logik.
- Tests: `Pathfinder` (BFS, Locks, Licht, besucht-nur, unerreichbar), `GoCommand`
(Raum-Ziel, Meldungen, Richtung weiterhin).
**Out of Scope:**
- Trie-/Tab-Autocomplete (eigenes Teilprojekt).
- Routing durch **unbesuchte** Räume.
- Pfad-Neuplanung mitten im Lauf (Zustand ändert sich während eines Zuges nicht).
- Gewichtete Kanten / Kosten (alle Kanten gleich; BFS = kürzeste Raumzahl).
- Mehr-Wort-Item-/Richtungs-Sonderfälle über die beschriebene Auflösung hinaus.
## 3. Routing in `GoCommand`
Der Parser entfernt `to`/`with`/… als Filler, daher liefern `go to library` und
`go library` beide `args=[library]`. `execute` verzweigt:
```java
if (args.isEmpty()) { io.write("Go where? Try 'go north' or 'go to <room>'."); return; }
String first = args.getFirst();
try {
Direction dir = Direction.fromString(first);
moveDirection(ctx, dir); // bestehender Einzelzug (unverändert ausgelagert)
return;
} catch (IllegalArgumentException notADirection) {
goToRoom(ctx, args); // neuer Raum-Ziel-Pfad
}
```
`moveDirection` = der bisherige Rumpf (Exit prüfen, Lock, Licht, setCurrentRoom,
look). `move`/`walk`-Aliase erben beides.
## 4. Ziel-Auflösung (`goToRoom`)
- Zielname = `String.join(" ", args).toLowerCase()` (`go to old kitchen` → "old kitchen").
- Suche **nur unter besuchten Räumen** (`player.getVisitedRoomIds()``world.getRooms().get(id)`):
Treffer, wenn `id.equals(name)` **oder** `room.getName().toLowerCase().equals(name)`.
- Kein Treffer → `"You don't know any direction or place called '<name>'."` (verrät keine
unentdeckten Räume).
- Ziel == aktueller Raum → `"You're already in the <Name>."`
- sonst → `Pathfinder.findPath`; leer → `"You can't find a way there right now."`;
sonst Auto-Walk (§6).
## 5. `Pathfinder` (game/Pathfinder.java)
```java
/** Shortest path (BFS) over visited rooms to target, honouring locks + light.
* Returns the steps AFTER the current room, ending at target; empty if none. */
public static List<Room> findPath(GameContext ctx, Room target) { ... }
```
- BFS ab `player.getCurrentRoom()`; Queue + `Map<Room,Room> cameFrom`.
- Kante `from --dir--> to` passierbar gdw.:
1. `to` ist in `player.getVisitedRoomIds()`,
2. Lock: `from.getExitLocks().get(dir)` ist null **oder** `Conditions.all(lock.requires(), ctx)`,
3. Licht: `Light.isLit(ctx, to)`.
- Bei Erreichen von `target` Pfad via `cameFrom` rekonstruieren (ohne Startraum,
inkl. `target`). Unerreichbar → `List.of()`.
- Rein über die Domäne + `ctx`; keine IO. Testbar wie `MapLayoutTest`/`QuestEngineTest`.
Hinweis: Da nur besuchte Räume Kanten liefern und Start immer „besucht" ist, ist
die Suche auf die bekannte Karte beschränkt. `target == current` behandelt
`goToRoom` vorab (Pathfinder müsste sonst leeren Pfad liefern).
## 6. Auto-Walk + animierte Minikarte
```java
List<Room> path = Pathfinder.findPath(ctx, target);
if (path.isEmpty()) { io.write("You can't find a way there right now."); return; }
for (Room step : path) {
ctx.getPlayer().setCurrentRoom(step);
ctx.getIo().travelStep(MapLayout.compute(
ctx.getWorld(), ctx.getPlayer().getVisitedRoomIds(), step));
}
ctx.getIo().write(summary(routeIncludingStart)); // §7
new LookCommand().execute(ctx, List.of());
```
- `setCurrentRoom` markiert Räume als besucht (bereits besucht → idempotent).
- `go to` ist **ein** Zug (ein Command) — der Game-Loop erhöht `turn` einmal.
### `GameIO.travelStep(MapView)`
```java
/** Per-step travel frame during auto-walk. Console: no-op (silent). */
default void travelStep(MapView view) {
// no animation in text mode
}
```
`SwingIO`-Override:
```java
@Override
public void travelStep(MapView view) {
setMap(view); // Map-Panel neu zeichnen (invokeLater)
try { Thread.sleep(TRAVEL_STEP_MS); } // ~300 ms, läuft auf dem Worker-Thread
catch (InterruptedException e) { Thread.currentThread().interrupt(); }
}
```
`TRAVEL_STEP_MS = 300`. Konsole bleibt still und ohne Pause (Default No-Op).
Der reguläre `publishHud` am Zugende setzt den finalen Map-/HUD-Stand.
## 7. Zusammenfassungs-Zeile (§ Wegpunkte)
Über die volle Route `route = [current] + path` (n = `route.size()`, Index 0..n-1):
- **Start** = `route[0]`, **Ziel** = `route[n-1]` immer genannt.
- **Mitte** = `route[n/2]` nur wenn `n >= 3` und der Index weder 0 noch n-1 ist
(also höchstens **ein** Zwischenpunkt). Bei n=9 → Index 4 (der 5. Knoten).
- Rendering:
- n == 2: `"You make your way from the <Start> to the <Ziel>."`
- n >= 3: `"You make your way from the <Start>, through the <Mitte>, to the <Ziel>."`
(Artikel „the" bewusst einfach gehalten; reine Flavor-Zeile, leicht anpassbar.)
## 8. Fehler-/Randfälle (Meldungen)
| Fall | Meldung |
|---|---|
| `go` ohne Argument | "Go where? Try 'go north' or 'go to <room>'." |
| Richtung ohne Exit | (bestehend) "You can't go <dir> from here." |
| Richtung gesperrt / dunkel | (bestehend) Lock-Text / Dunkelheits-Hinweis |
| Zielname unbekannt (unter besuchten) | "You don't know any direction or place called '<name>'." |
| Ziel == aktueller Raum | "You're already in the <Name>." |
| Ziel besucht, aber kein passierbarer Pfad | "You can't find a way there right now." |
## 9. Tests
- **`PathfinderTest`** (Welt inline, wie `MapLayoutTest`):
- kürzester Pfad über besuchte Räume (Länge + Knoten korrekt).
- unbesuchter Zwischenraum wird **nicht** als Wegpunkt genutzt (kein Pfad bzw.
Umweg nur über Besuchtes).
- kein Pfad durch einen Exit mit nicht erfüllter `ExitLock`-Bedingung; Pfad
erscheint, wenn die Flag gesetzt ist.
- kein Pfad in einen dunklen Raum ohne Licht; mit getragenem Licht erreichbar.
- unerreichbares Ziel → leere Liste.
- **`GoCommand`-Tests** (Raum-Ziel):
- `go to <name>` läuft zum Ziel (currentRoom aktualisiert, Ziel-`look` erfolgt).
- Auflösung per id **und** per name (case-insensitive).
- unbekannter Name / Ziel==aktuell / kein Pfad → jeweilige Meldung.
- `go north` (Richtung) funktioniert weiterhin (Regressionsschutz).
- **`travelStep`**: Default ist No-Op (Tests nutzen `TestIO`; keine Sleeps, keine
GUI). GUI-Animation wird manuell verifiziert (Konvention: keine GUI-Unit-Tests).
## 10. Architektur-Werte / Risiken
- **Gemeinsamer Loop bleibt**: nur eine neue `GameIO`-Default-Methode; Pacing/
Animation lebt im Renderer (`SwingIO`), Konsole No-Op. Keine Divergenz der Logik.
- **Konsistenz mit manuellem Zug**: Pathfinder nutzt dieselben Gates (Lock, Licht)
wie `GoCommand` — kein Auto-Walk durch etwas, das man manuell nicht passieren darf.
- **Fog of War**: nur besuchte Räume → kein Informationsleck über Unentdecktes.
- **Algorithmus-Showcase**: klassisches BFS (Queue + `cameFrom`) mit
Standard-Collections (entspricht der Projekt-Entscheidung „keine eigenen
`uebung`-Strukturen"). `MapLayout` macht bereits BFS — `Pathfinder` ist die
fokussierte Wegfindungs-Variante (eigene Verantwortlichkeit, eigene Tests).
- **GUI-Threading**: `travelStep` schläft auf dem Worker-Thread (wie `readLine`
blockiert), `setMap` zeichnet via EDT — kein Deadlock; EDT bleibt responsiv.
- Risiko: lange Pfade × 300 ms könnten sich zäh anfühlen; bewusst akzeptiert (kurze
Karte). Später ggf. Settings-gesteuert.

View File

@@ -0,0 +1,254 @@
# Spec: Interaktives Start-Tutorial
Stand: 2026-06-01. Dritte der verbleibenden Mechaniken (nach `use X on Y` und
Pathfinding). Setzt das vom Nutzer gewünschte **interaktive Tutorial beim
Spielstart, abbrechbar per `skip`** um. Baut auf dem geteilten Game-Loop,
`CommandRegistry`/`CommandParser`, `GameIO` und dem datengetriebenen Loader auf.
## 1. Kontext & Ziel
Neue Spieler kennen die Befehle nicht. Ziel: ein **interaktiver Walkthrough** zu
Beginn eines **Neuen Spiels**, der den Spieler im **echten Startraum** Schritt für
Schritt die Kernbefehle ausführen lässt (er wartet, bis der jeweilige Befehl kam),
mit Bestätigung/Hinweisen, und am Ende die übrigen Befehle als Tipps nennt.
Jederzeit per `skip` überspringbar. Geladene Spielstände zeigen kein Tutorial.
Bestätigte Entscheidungen (Brainstorming 2026-06-01):
- **Stil**: interaktiv (wait-for-it) im echten Spiel, nicht in einer Sandbox.
- **Kern-Bogen** (interaktiv, in Reihenfolge): `look``examine``take`
`inventory``use``go` (Richtung) → `go to` (Pathfinding). Danach
**Abschluss-Tipps** für den Rest (read, drop, talk/give, use X on Y, map,
quests, save, menu).
- **Abschluss-Erkennung verb-basiert** (alias-fähig): jede gültige Nutzung des
erwarteten Verbs zählt; optional `minArgs`.
- **Datengetrieben**: `tutorial.yaml`; Logik (Verb-/Richtungs-Matching) im Code.
- **Häufigkeit**: bei jedem Neuen Spiel; `skip` überspringt; keine Persistenz.
- **Falscher (aber gültiger) Befehl**: wird **ausgeführt** (echtes Spiel) + sanfter
Hinweis, Schritt rückt **nicht** vor.
- **Platzierung**: **nach** Banner + Willkommen + erstem Raum-`look`.
## 2. Scope
**In Scope:**
- `TutorialGuide` (game), in den Game-Loop eingehängt (optional; null = kein Tutorial).
- `Tutorial`/`TutorialStep` (model) + `TutorialDto`/`TutorialStepDto` (loader/dto)
+ `TutorialLoader` (optionales `/world/tutorial.yaml`, eigener Loader — nicht in `World`).
- `Game`: optionaler Guide (`setTutorialGuide`) + `begin`/`onCommand`/`skip`-Anbindung.
- `App.play(..., withTutorial)`: Guide nur beim Neuen Spiel bauen.
- `tutorial.yaml`: Intro, 7 Kern-Schritte, Abschluss-Tipps.
- Tests: Guide-Logik, Loader, Game-Integration.
**Out of Scope:**
- Persistenz „Tutorial gesehen" / Settings-Toggle (eigene spätere Option).
- Sandbox-Welt / Zurücksetzen von Tutorial-Aktionen (es ist das echte Spiel).
- Erzwungenes `use X on Y` / `talk` etc. (nur in Abschluss-Tipps genannt).
- GUI-Sonderdarstellung (Tutorial nutzt die normale styled Ausgabe; `skip` per Eingabe).
## 3. Datenmodell (model)
```java
public record TutorialStep(
String instruction, // an den Spieler: was tun
String expect, // Verb-Name ("look"/"examine"/...) ODER "go_direction" / "go_to_room"
int minArgs, // Mindest-Argumentzahl (z.B. 1 für take/examine/use); 0 default
String confirm, // Bestätigung bei Erfolg
String hint // sanfter Hinweis bei gültigem-aber-falschem Befehl
) {}
public record Tutorial(String intro, List<TutorialStep> steps, String closingTips) {
public Tutorial {
steps = steps == null ? List.of() : List.copyOf(steps);
}
public boolean isEmpty() { return steps.isEmpty(); }
public static Tutorial none() { return new Tutorial(null, List.of(), null); }
}
```
`tutorial.yaml` (ein Mapping, optional):
```yaml
intro: |
Welcome. Let me show you the ropes — type the commands as I describe them.
(Type 'skip' at any time to jump straight into the game.)
steps:
- instruction: "Look around the room: type 'look'."
expect: look
confirm: "Good — 'look' (or 'l') always re-describes where you are."
hint: "When you're ready, just type: look"
- instruction: "Examine something you see: 'examine <thing>' (or 'x <thing>')."
expect: examine
minArgs: 1
confirm: "That's how you inspect items, exits and people up close."
hint: "Try examining something, e.g.: examine lamp"
- instruction: "Pick something up: 'take <item>'."
expect: take
minArgs: 1
confirm: "Taken. Items you carry go into your inventory."
hint: "Pick something up, e.g.: take lamp"
- instruction: "Check what you're carrying: 'inventory' (or 'i')."
expect: inventory
confirm: "That's your inventory."
hint: "Type: inventory"
- instruction: "Use an item: 'use <item>' (e.g. light the lamp)."
expect: use
minArgs: 1
confirm: "Some items toggle, read or react when used."
hint: "Use something, e.g.: use lamp"
- instruction: "Move to another room: 'go <direction>' (try 'go north')."
expect: go_direction
confirm: "You can move north/south/east/west between connected rooms."
hint: "Head somewhere, e.g.: go north"
- instruction: "Travel back to a room you've seen: 'go to <room>'."
expect: go_to_room
confirm: "Nice — 'go to <room>' auto-walks to any room you've already visited."
hint: "Try: go to <a room you've already visited>"
closingTips: |
That's the core. A few more you'll want:
read · drop · talk to <npc> · give <item> to <npc> ·
use <item> on <item> (combine things) · map · quests · save · menu.
Type 'help' anytime. Good luck.
```
## 4. `TutorialLoader` (loader)
- `loader/dto/TutorialDto` (`intro`, `List<TutorialStepDto> steps`, `closingTips`),
`loader/dto/TutorialStepDto` (`instruction`, `expect`, `Integer minArgs`, `confirm`, `hint`).
- `TutorialLoader.load(basePath)``Tutorial`: liest `basePath + "/tutorial.yaml"`
optional (fehlt → `Tutorial.none()`), mappt DTO→Modell (`minArgs` null → 0).
Nutzt einen eigenen Jackson-`YAMLFactory`-Mapper wie `WorldLoader`. Default-Base
`/world` (überschreibbar für Tests). Parse-Fehler → `WorldLoadException`.
- Bewusst **getrennt von `World`**: Tutorial ist Onboarding, kein Welt-Inhalt.
## 5. `TutorialGuide` (game)
Zustandsbehaftet (ein laufendes Tutorial), hält die Schritte + den Index + die
`CommandRegistry` (für alias-fähiges Verb-Matching).
```java
public final class TutorialGuide {
private final Tutorial tutorial;
private final CommandRegistry registry;
private int index = 0;
private boolean active;
private Room roomAtStepStart; // zum Erkennen echter Bewegung
public TutorialGuide(Tutorial tutorial, CommandRegistry registry) {
this.tutorial = tutorial; this.registry = registry;
this.active = !tutorial.isEmpty();
}
public boolean isActive() { return active; }
/** Druckt Intro + erste Anweisung (am Loop-Start, nach dem ersten look). */
public void begin(GameContext ctx) { if (active) { print intro; enterStep(ctx); } }
/** Nach jedem ausgeführten Befehl: prüft den aktuellen Schritt. */
public void onCommand(GameContext ctx, ParsedCommand parsed) {
if (!active) return;
TutorialStep step = tutorial.steps().get(index);
if (satisfies(step, ctx, parsed)) {
ctx.getIo().write(step.confirm());
index++;
if (index >= tutorial.steps().size()) { ctx.getIo().print(closingTips); active = false; }
else enterStep(ctx);
} else {
ctx.getIo().write(step.hint()); // gültiger-aber-falscher Befehl → Nudge
}
}
/** 'skip' während des Tutorials: Abbruch ohne Zug. */
public void skip(GameContext ctx) { if (active) { ctx.getIo().write("Tutorial skipped."); active = false; } }
/** Merkt den aktuellen Raum (für Bewegungs-Erkennung) und druckt die Anweisung. */
private void enterStep(GameContext ctx) {
roomAtStepStart = ctx.getPlayer().getCurrentRoom();
ctx.getIo().write(tutorial.steps().get(index).instruction());
}
}
```
**`satisfies(step, ctx, parsed)`**:
- `expect == "go_direction"``sameCommand(parsed.verb(), "go")` **und** `args` nicht leer
**und** erstes Arg ist eine Richtung (`Direction.fromString` wirft nicht) **und**
der Spieler ist **tatsächlich umgezogen** (`ctx.getPlayer().getCurrentRoom() != roomAtStepStart`).
- `expect == "go_to_room"``sameCommand(parsed.verb(), "go")` **und** `args` nicht leer
**und** erstes Arg ist **keine** Richtung **und** der Spieler ist tatsächlich umgezogen.
- sonst (Verb-Name) → `sameCommand(parsed.verb(), step.expect())` **und**
`args.size() >= step.minArgs()`.
- `sameCommand(a, b)` = `registry.find(a)` und `registry.find(b)` beide präsent und
**dieselbe** Command-Instanz (alias-fähig: `l`==`look`, `x`==`examine`, `get`==`take`,
`i`==`inventory`).
Die Bewegungs-Bedingung verhindert, dass ein **blockierter** Zug (z.B. `go south`
in den dunklen Dungeon ohne Licht → kein Raumwechsel) den Schritt vorrückt — wichtig,
damit für `go_to_room` danach wirklich zwei Räume besucht sind.
Hinweis: `onCommand` wird nur nach einem **erfolgreich aufgelösten** Befehl gerufen;
unbekannte Eingaben behandelt der Loop bereits („I don't understand …").
## 6. Game-Loop-Anbindung (game/Game.java)
- Neues optionales Feld `private TutorialGuide tutorialGuide;` + `setTutorialGuide(...)`
(Zwei-Phasen-Wiring wie `QuitCommand`/`MenuCommand`).
- In `run()` **vor** der `while`-Schleife (nach dem initialen `publishHud`/`maybeEnd`):
`if (tutorialGuide != null) tutorialGuide.begin(ctx);`
- In der Schleife, **vor** dem Dispatch: wenn `tutorialGuide != null &&
tutorialGuide.isActive() && parsed.verb().equals("skip")` → `tutorialGuide.skip(ctx);
publishHud(); continue;` (kein `turn++`).
- **Nach** `cmd.get().execute(...); turn++;`:
`if (tutorialGuide != null) tutorialGuide.onCommand(ctx, parsed);`
Der Guide ist in normalem/geladenem Spiel `null` → kein Overhead, kein Verhalten.
## 7. App-Anbindung (App.java)
- `App.run` lädt das Tutorial einmal beim Start: `Tutorial tutorial =
new TutorialLoader().load();` (wie Settings/SaveService).
- `play(io, saves, session, boolean withTutorial)`: nach dem Registry-Aufbau, wenn
`withTutorial && !tutorial.isEmpty()` → `game.setTutorialGuide(new TutorialGuide(
tutorial, registry))`.
- `case NEW_GAME -> play(io, saves, newSession(io), true);`
`LOAD -> play(io, saves, saves.load(slot.slug()), false);`
- Reihenfolge in `play`: Banner → Willkommen → erstes `look` → `game.run()` (darin
`guide.begin`). Damit erscheint das Tutorial **nach** dem ersten Raumbild.
## 8. Flow & Meldungen
- Neues Spiel → Name → Banner/Willkommen → Raum-`look` → Intro + 1. Anweisung.
- Spieler tippt Befehle (echte Ausführung); passender Befehl → `confirm` + nächste
Anweisung; falscher (gültiger) → `hint`. Nach dem letzten Schritt → `closingTips`,
Guide inaktiv, normales Spiel läuft weiter (Spieler steht, wo das Tutorial endete).
- `skip` jederzeit → „Tutorial skipped." → normales Spiel.
- Während des Tutorials laufen HUD/Karte/Quests/Autosave normal (es ist der echte Loop).
## 9. Tests
- **`TutorialGuideTest`** (Fake-`CommandRegistry` mit echten Command-Instanzen oder
reale Registry; `TestIO`):
- Alias-Match: `l` erfüllt `look`-Schritt; `x` erfüllt `examine`.
- `minArgs`: `take` ohne Arg erfüllt **nicht** (minArgs 1), `take lamp` schon.
- `go_direction` (Arg = Richtung) vs `go_to_room` (Arg = keine Richtung).
- Falscher gültiger Befehl → `hint`, kein Vorrücken.
- Vorrücken + `confirm`; nach letztem Schritt → `closingTips` + `isActive()==false`.
- `skip` → inaktiv, „Tutorial skipped.".
- **`TutorialLoaderTest`**: lädt Fixture-`tutorial.yaml` → Intro/Steps/ClosingTips;
fehlende Datei → `Tutorial.none()` (`isEmpty()`).
- **`GameTutorialTest`** (Integration): kleine Welt + reale Registry + `TestIO` mit
skript-Eingaben durch `game.run()`; prüft, dass `begin` druckt, Schritte bei
passenden Befehlen vorrücken, und `skip` beendet. (App-/GUI-Wiring manuell.)
- Konvention: keine GUI-/YAML-abhängigen Domain-Tests außer dem Loader-Fixture.
## 10. Architektur-Werte / Risiken
- **Geteilter Loop bleibt zentral**: der Guide hängt sich minimal in `Game` ein;
Konsole und GUI verhalten sich identisch (normale styled Ausgabe, `skip` per Eingabe).
- **Datengetrieben**: Schritte/Texte in `tutorial.yaml`; nur das Matching ist Code.
Verb-Matching nutzt die Registry (alias-fähig, keine hartkodierten Alias-Listen).
- **Geringe Kopplung an Content**: Schritte sind verb-/richtungs-basiert (kein fixer
Item-Name nötig); ändert sich der Start-Content, bleibt das Tutorial gültig.
`go_to_room` funktioniert, weil nach dem `go`-Schritt zwei Räume besucht sind.
- **Echtes Spiel, kein Sandbox**: Tutorial-Aktionen sind reale Züge (Turn/Quests/
Autosave laufen). Risiko: der Spieler „verbraucht" den ersten Raum — bewusst
akzeptiert (es ist die echte Welt; nichts geht verloren).
- **`skip`-Sonderfall**: nur während aktivem Tutorial abgefangen; außerhalb bleibt
`skip` ein unbekanntes Verb (harmlos).
- Risiko: `expect`-Tokens (`go_direction`/`go_to_room` + Verbnamen) sind ein kleines
bespoke Schema; dokumentiert in `tutorial.yaml`-Kommentar und im Loader.

View File

@@ -0,0 +1,206 @@
# Spec: Item-Kombination `use X on Y`
Stand: 2026-06-01. Erste der verbleibenden Mechaniken nach Hauptmenü +
Speichern/Laden. Setzt das in `enhancement-ideas.md` #5b (Mechanik-Spine §4)
festgelegte Item-Kombinations-Feature um. Baut auf der bestehenden
Condition/Effect-Engine, dem datengetriebenen Loader (wie quests/endings) und
dem vorhandenen `UseCommand` auf.
## 1. Kontext & Ziel
v1.0 hat bewusst argloses `use X` ohne Ziel gewählt. Ziel jetzt: **`use X on Y`**
als datengetriebene **Kombinations-Mechanik** — ein Rezept kann Bedingungen
prüfen, Items verbrauchen, ein Item erzeugen, Flags setzen und einen Text
ausgeben. Deckt sowohl Transform (`matches + torch → lit_torch`) als auch
Anwenden-auf-Ziel (`use key on door → effekt`) mit *einem* Modell ab.
Bestätigte Entscheidungen (Brainstorming 2026-06-01):
- **Allgemeine, datengetriebene Rezepte** (requires / consume / produce / effects
/ response / failText) — Superset, nutzt die vorhandene Engine.
- **Operanden** lösen aus **Inventar oder aktuellem Raum** auf (`use key on door`,
`use matches on torch`).
- **Fehlschlag**: kein Rezept → generische Zeile; Rezept vorhanden aber `requires`
nicht erfüllt → optionales `failText` (Hinweis), sonst generische Zeile.
- **Demo**: ein minimales Showcase-Rezept (`matches + torch → lit_torch`) plus die
wenigen dafür nötigen Items, in bestehende Räume gestellt.
## 2. Scope
**In Scope:**
- `UseCommand`-Routing: 0 Args → "Use what?"; 1 Arg → bestehendes Einzel-`use`;
≥2 Args → Kombination der ersten beiden Operanden.
- `Combination`-Datenmodell + optionale `combinations.yaml` (geladen wie
quests/endings über `readListOptional`).
- `CombinationDto` + Factory + `World.combinations` (Map mit kanonischem Paar-Key).
- `Combinations`-Engine (`game`) — Auflösung, Lookup, requires/consume/produce/
effects/response/failText.
- `WorldValidator`-Regeln: `a`/`b`/`consume`/`produce` referenzieren existierende
Item-Ids; keine doppelten Paare.
- Minimales Demo-Rezept + Items.
- Tests: Engine, UseCommand-Routing, Loader, Validator.
**Out of Scope:**
- Parser-Änderung — `on`/`with`/`to` sind **bereits** Filler (s. §3); keine neue
Grammatik nötig.
- Mehrteilige (>2) Rezepte / Kombination aus 3+ Items.
- Mehrwort-Item-Namen-Auflösung (Operanden lösen per Item-Id auf, wie heute).
- Größerer Content-/Rätsel-Ausbau (eigenes Teilprojekt).
## 3. Grammatik & Parsing (keine Änderung nötig)
`CommandParser.FILLERS` enthält bereits `to`, `with`, `on`, `at`, `the`, `a`,
`an`. Daher liefern alle drei Eingaben dieselbe Args-Liste:
```
use matches on torch → verb=use, args=[matches, torch]
use matches with torch → verb=use, args=[matches, torch]
use matches torch → verb=use, args=[matches, torch]
```
`UseCommand` verzweigt nur über die Arg-Anzahl:
```java
if (args.isEmpty()) { io.write("Use what?"); return; }
if (args.size() >= 2) { Combinations.tryUse(ctx, args.get(0), args.get(1)); return; }
// 1 Arg: bestehende Einzel-use-Logik (Inventar dann Raum, item.use(ctx))
```
Bei ≥2 Args zählen die **ersten beiden** Tokens als Operandenpaar.
## 4. Datenmodell (model)
```java
public record Combination(
String a, String b, // Operanden-Item-Ids (ungeordnet)
List<Condition> requires, // optional; leer = immer erfüllt
List<String> consume, // Item-Ids, die entfernt werden
String produce, // optional; Item-Id, die ins Inventar kommt (null = keins)
List<Effect> effects, // optional; Flag-Mutationen
String response, // Erfolgstext
String failText // optional; Hinweis bei nicht erfüllten requires
) {
public Combination {
requires = requires == null ? List.of() : List.copyOf(requires);
consume = consume == null ? List.of() : List.copyOf(consume);
effects = effects == null ? List.of() : List.copyOf(effects);
}
/** Kanonischer, reihenfolge-unabhängiger Schlüssel für ein Item-Paar. */
public static String key(String x, String y) {
return x.compareTo(y) <= 0 ? x + "|" + y : y + "|" + x;
}
public String key() { return key(a, b); }
}
```
`combinations.yaml` (Demo):
```yaml
- a: matches
b: torch
consume: [matches]
produce: lit_torch
response: |
You strike the matches; the torch catches with a low hiss and steady glow.
# requires/effects/failText optional, hier nicht nötig
```
`World` erhält `Map<String, Combination> combinations` (Key = `Combination.key()`).
Backward-kompatible Konstruktor-Überladung wie bei quests/endings.
## 5. Kombinations-Engine (game/Combinations.java)
Statische Utility wie `Conditions`/`Effects`/`Light`:
```java
public static void tryUse(GameContext ctx, String idX, String idY) {
Player p = ctx.getPlayer();
// 1. Beide Operanden auflösen (Inventar zuerst, dann aktueller Raum)
if (!present(ctx, idX)) { ctx.getIo().write(noSuch(idX)); return; }
if (!present(ctx, idY)) { ctx.getIo().write(noSuch(idY)); return; }
// 2. Rezept-Lookup über kanonischen Key
Combination c = ctx.getWorld().getCombinations().get(Combination.key(idX, idY));
if (c == null) { ctx.getIo().write("Nothing happens."); return; }
// 3. requires prüfen
if (!Conditions.all(c.requires(), ctx)) {
ctx.getIo().write(c.failText() != null ? c.failText() : "Nothing happens.");
return;
}
// 4. consume → produce → effects → response
for (String id : c.consume()) { removeFromAnywhere(ctx, id); }
if (c.produce() != null) {
Item produced = ctx.getWorld().getItems().get(c.produce());
p.addItem(produced);
}
Effects.applyAll(c.effects(), ctx);
ctx.getIo().print(/* response als StyledText */);
}
```
Hilfsmethoden:
- `present(ctx, id)` → Inventar **oder** aktueller Raum enthält `id`.
- `removeFromAnywhere(ctx, id)``player.removeItem(id)`, sonst
`currentRoom.removeItem(id)`.
- `noSuch(id)` → "There is no '<id>' here or in your inventory." (wie UseCommand).
- Erzeugtes Item ist in `items.yaml` definiert, aber in **keinem** Raum platziert
(existiert in `world.getItems()`, „nirgends"), bis es gefertigt wird → dann ins
Inventar. Save/Load erfasst Item-Platzierung + Switch-Zustände bereits korrekt;
ungefertigte Items bleiben unplatziert.
## 6. Loader-Anbindung
- `CombinationDto` (Jackson-Record): `a, b, requires, consume, produce, effects,
response, failText`. `requires`/`effects` via `ConditionDto.toModelList` /
`EffectDto.toModelList` (vorhanden).
- `WorldLoader`: `readListOptional(basePath + "/combinations.yaml", CombinationDto.class)`.
- `CombinationFactory`: DTO → `Combination`; sammelt in `Map<String, Combination>`
per `Combination.key()`. Doppelter Key → `WorldLoadException`.
- `World.combinations` + Getter; backward-kompatible Konstruktoren.
## 7. Validierung (WorldValidator)
- `a`, `b`, jede `consume`-Id und (falls gesetzt) `produce` müssen in der
Item-Registry existieren → sonst `WorldLoadException` mit klarer Meldung.
- Doppelte Paar-Keys → Fehler (im Factory bereits abgefangen; Validator
dokumentiert die Regel).
- `requires`/`effects` nutzen die bestehende Condition/Effect-Validierung, falls
vorhanden; sonst rein strukturell.
## 8. Demo-Content (minimal)
- `items.yaml`: `matches` (plain), `torch` (plain, `light: false`), `lit_torch`
(**plain mit `light: true`**). `Light.isActiveLight` wertet ein Nicht-Switchable
mit `light: true` als **immer aktive** Lichtquelle (nur SwitchableItems müssen
„on" sein) — also ist `lit_torch` eine dauerhaft brennende Lichtquelle, ohne
Toggle. `lit_torch` wird **nicht** in einem Raum platziert (entsteht nur durchs
Rezept).
- `rooms.yaml`: `matches` und `torch` in erreichbare Räume legen.
- `combinations.yaml`: das eine Rezept aus §4.
- Spielbar nachweisbar: `take matches`, `take torch`, `use matches on torch` →
`lit_torch` im Inventar (Lichtquelle), `matches` verbraucht.
## 9. Tests
- **`CombinationsTest`**: ungeordneter Match (`X on Y` == `Y on X`); `requires`
erfüllt/nicht erfüllt (failText vs. generisch); consume aus Inventar **und** aus
Raum; produce landet im Inventar; effects gesetzt; kein Rezept → "Nothing
happens."; fehlender Operand → noSuch-Meldung.
- **`UseCommand`-Routing**: 0 Args → "Use what?"; 1 Arg → `item.use` (bestehend);
2 Args → Combinations-Pfad (über Fake-/TestIO + kleine Welt).
- **Loader**: `combinations.yaml` → `World.combinations` korrekt; Key-Kanonisierung;
doppeltes Paar → Exception.
- **Validator**: unbekannte `a`/`b`/`consume`/`produce`-Id → `WorldLoadException`.
- Konvention: keine GUI-/YAML-abhängigen Domain-Tests; Engine-Tests bauen Welt
inline wie `QuestEngineTest`.
## 10. Architektur-Werte / Risiken
- **Datengetrieben**: Rezepte in YAML, kein Hardcoding; gleiche DTO/Domain-Trennung
wie quests/endings.
- **Geringe Kopplung**: neue `Combinations`-Utility neben `Conditions`/`Effects`;
`UseCommand` bekommt nur eine Verzweigung. Kein Parser-Eingriff.
- **Reihenfolge-Unabhängigkeit** über kanonischen Key vermeidet doppelte Rezepte.
- **Erzeugte Items** „aus dem Nichts": müssen in `items.yaml` definiert, aber
unplatziert sein — Validator stellt Existenz sicher; Save/Load erfasst sie nach
dem Fertigen automatisch.
- Risiko: `produce` eines bereits irgendwo platzierten Items würde dieselbe Instanz
ins Inventar verschieben (Items sind Singletons). Demo vermeidet das (unplatziert).
Validator/Doku weist darauf hin.

View File

@@ -49,6 +49,18 @@
<version>${logback.version}</version> <version>${logback.version}</version>
</dependency> </dependency>
<!-- OGG Vorbis decoding (GUI background music) -->
<dependency>
<groupId>com.googlecode.soundlibs</groupId>
<artifactId>vorbisspi</artifactId>
<version>1.0.3.3</version>
</dependency>
<dependency>
<groupId>com.googlecode.soundlibs</groupId>
<artifactId>jorbis</artifactId>
<version>0.0.17.4</version>
</dependency>
<!-- Tests --> <!-- Tests -->
<dependency> <dependency>
<groupId>org.junit.jupiter</groupId> <groupId>org.junit.jupiter</groupId>

View File

@@ -10,21 +10,39 @@ import thb.jeanluc.adventure.command.impl.GoCommand;
import thb.jeanluc.adventure.command.impl.HelpCommand; import thb.jeanluc.adventure.command.impl.HelpCommand;
import thb.jeanluc.adventure.command.impl.InventoryCommand; import thb.jeanluc.adventure.command.impl.InventoryCommand;
import thb.jeanluc.adventure.command.impl.LookCommand; import thb.jeanluc.adventure.command.impl.LookCommand;
import thb.jeanluc.adventure.command.impl.QuitCommand; import thb.jeanluc.adventure.command.impl.MapCommand;
import thb.jeanluc.adventure.command.impl.MenuCommand;
import thb.jeanluc.adventure.command.impl.QuestsCommand;
import thb.jeanluc.adventure.command.impl.ReadCommand; import thb.jeanluc.adventure.command.impl.ReadCommand;
import thb.jeanluc.adventure.command.impl.SaveCommand;
import thb.jeanluc.adventure.command.impl.TakeCommand; import thb.jeanluc.adventure.command.impl.TakeCommand;
import thb.jeanluc.adventure.command.impl.TalkCommand; import thb.jeanluc.adventure.command.impl.TalkCommand;
import thb.jeanluc.adventure.command.impl.UseCommand; import thb.jeanluc.adventure.command.impl.UseCommand;
import thb.jeanluc.adventure.game.Game; import thb.jeanluc.adventure.game.Game;
import thb.jeanluc.adventure.game.GameContext; import thb.jeanluc.adventure.game.GameContext;
import thb.jeanluc.adventure.game.GameSession;
import thb.jeanluc.adventure.game.TutorialGuide;
import thb.jeanluc.adventure.io.ConsoleIO; import thb.jeanluc.adventure.io.ConsoleIO;
import thb.jeanluc.adventure.io.GameIO; import thb.jeanluc.adventure.io.GameIO;
import thb.jeanluc.adventure.io.text.Banner;
import thb.jeanluc.adventure.loader.TutorialLoader;
import thb.jeanluc.adventure.loader.WorldLoader; import thb.jeanluc.adventure.loader.WorldLoader;
import thb.jeanluc.adventure.menu.MainMenu;
import thb.jeanluc.adventure.menu.SettingsMenu;
import thb.jeanluc.adventure.model.Tutorial;
import thb.jeanluc.adventure.save.SaveException;
import thb.jeanluc.adventure.save.SaveService;
import thb.jeanluc.adventure.save.SaveSlotInfo;
import thb.jeanluc.adventure.save.Settings;
import thb.jeanluc.adventure.save.SettingsStore;
import java.util.List;
/** /**
* Entry point for the console version of the game. Loads the world from * Console entry point. Loads settings, then runs the main-menu shell loop:
* YAML, wires up the command registry, and hands control to the * New Game / Load / Settings / Quit. Each game runs the shared {@link Game}
* {@link Game} loop. * loop and returns here when the player saves-and-exits, reaches an ending,
* or input ends. Shared by console and GUI via {@link #run(GameIO)}.
*/ */
@Slf4j @Slf4j
public final class App { public final class App {
@@ -32,11 +50,7 @@ public final class App {
private App() { private App() {
} }
/** /** Standard JVM entry point. @param args ignored */
* Standard JVM entry point.
*
* @param args ignored
*/
public static void main(String[] args) { public static void main(String[] args) {
GameIO io = new ConsoleIO(); GameIO io = new ConsoleIO();
try { try {
@@ -48,15 +62,51 @@ public final class App {
} }
} }
/** /** Runs the menu shell on the given IO until the player quits. */
* Boots the game on the given IO channel. Reusable by both
* {@link App} (console) and {@code AppGui} (Swing worker thread).
*
* @param io the IO channel to use
*/
public static void run(GameIO io) { public static void run(GameIO io) {
SaveService saves = new SaveService();
SettingsStore settingsStore = new SettingsStore();
Settings settings = settingsStore.load();
SettingsMenu.apply(settings, io);
boolean running = true;
while (running) {
switch (MainMenu.show(io)) {
case NEW_GAME -> play(io, saves, newSession(io), true);
case LOAD -> {
SaveSlotInfo slot = MainMenu.pickSlot(io, saves);
if (slot != null) {
try {
play(io, saves, saves.load(slot.slug()), false);
} catch (SaveException e) {
io.write("Could not load: " + e.getUserMessage());
}
}
}
case SETTINGS -> settings = SettingsMenu.show(io, settings, settingsStore);
case QUIT -> running = false;
}
}
io.write("Farewell.");
}
/** Loads a fresh world and binds it to a newly named session. */
private static GameSession newSession(GameIO io) {
WorldLoader.LoadResult loaded = new WorldLoader().load(); WorldLoader.LoadResult loaded = new WorldLoader().load();
GameContext ctx = new GameContext(loaded.world(), loaded.player(), io); String name = MainMenu.promptName(io, "Manor");
return new GameSession(loaded.world(), loaded.player(), name);
}
/** Builds the registry, runs one game to completion, autosaving to its slot. */
private static void play(GameIO io, SaveService saves, GameSession session, boolean withTutorial) {
GameContext ctx = new GameContext(session, io);
ctx.setSaveCallback(() -> {
try {
saves.save(session);
} catch (SaveException e) {
log.warn("Autosave failed", e);
}
});
CommandRegistry registry = new CommandRegistry(); CommandRegistry registry = new CommandRegistry();
registry.register(new GoCommand(), "go", "move", "walk"); registry.register(new GoCommand(), "go", "move", "walk");
@@ -67,20 +117,31 @@ public final class App {
registry.register(new UseCommand(), "use"); registry.register(new UseCommand(), "use");
registry.register(new ReadCommand(), "read"); registry.register(new ReadCommand(), "read");
registry.register(new ExamineCommand(), "examine", "x", "inspect"); registry.register(new ExamineCommand(), "examine", "x", "inspect");
registry.register(new MapCommand(), "map", "m");
registry.register(new QuestsCommand(), "quests", "log", "journal");
registry.register(new TalkCommand(), "talk", "speak"); registry.register(new TalkCommand(), "talk", "speak");
registry.register(new GiveCommand(), "give"); registry.register(new GiveCommand(), "give");
registry.register(new HelpCommand(registry), "help", "?"); registry.register(new SaveCommand(saves), "save");
QuitCommand quit = new QuitCommand();
registry.register(quit, "quit", "exit");
Game game = new Game(ctx, registry, new CommandParser()); Game game = new Game(ctx, registry, new CommandParser());
quit.bind(game);
io.write(loaded.world().getTitle()); MenuCommand menu = new MenuCommand(saves);
io.write(loaded.world().getWelcomeMessage()); menu.bind(game);
new LookCommand().execute(ctx, java.util.List.of()); registry.register(menu, "menu", "quit", "exit");
registry.register(new HelpCommand(registry), "help", "?");
if (withTutorial) {
Tutorial tutorial = new TutorialLoader().load();
if (!tutorial.isEmpty()) {
game.setTutorialGuide(new TutorialGuide(tutorial, registry));
}
}
io.print(Banner.welcome(session.getWorld().getTitle()));
io.write(session.getWorld().getWelcomeMessage());
new LookCommand().execute(ctx, List.of());
game.run(); game.run();
io.setMusic(null); // fade music out when returning to the menu
} }
} }

View File

@@ -2,6 +2,7 @@ package thb.jeanluc.adventure.command.impl;
import thb.jeanluc.adventure.command.Command; import thb.jeanluc.adventure.command.Command;
import thb.jeanluc.adventure.game.GameContext; import thb.jeanluc.adventure.game.GameContext;
import thb.jeanluc.adventure.game.Light;
import thb.jeanluc.adventure.model.Npc; import thb.jeanluc.adventure.model.Npc;
import thb.jeanluc.adventure.model.item.Item; import thb.jeanluc.adventure.model.item.Item;
@@ -20,6 +21,10 @@ public class ExamineCommand implements Command {
ctx.getIo().write("Examine what?"); ctx.getIo().write("Examine what?");
return; return;
} }
if (!Light.isLit(ctx, ctx.getPlayer().getCurrentRoom())) {
ctx.getIo().write("It's too dark to see that.");
return;
}
String id = args.getFirst(); String id = args.getFirst();
Optional<Item> item = ctx.getPlayer().findItem(id); Optional<Item> item = ctx.getPlayer().findItem(id);
if (item.isEmpty()) { if (item.isEmpty()) {

View File

@@ -1,6 +1,8 @@
package thb.jeanluc.adventure.command.impl; package thb.jeanluc.adventure.command.impl;
import thb.jeanluc.adventure.command.Command; import thb.jeanluc.adventure.command.Command;
import thb.jeanluc.adventure.game.Conditions;
import thb.jeanluc.adventure.game.Effects;
import thb.jeanluc.adventure.game.GameContext; import thb.jeanluc.adventure.game.GameContext;
import thb.jeanluc.adventure.model.Npc; import thb.jeanluc.adventure.model.Npc;
import thb.jeanluc.adventure.model.NpcReaction; import thb.jeanluc.adventure.model.NpcReaction;
@@ -44,12 +46,17 @@ public class GiveCommand implements Command {
} }
NpcReaction reaction = reactionOpt.get(); NpcReaction reaction = reactionOpt.get();
if (!Conditions.all(reaction.getRequires(), ctx)) {
ctx.getIo().write(npc.getName() + " isn't ready for that yet.");
return;
}
if (reaction.getConsumes() != null) { if (reaction.getConsumes() != null) {
ctx.getPlayer().removeItem(reaction.getConsumes().getId()); ctx.getPlayer().removeItem(reaction.getConsumes().getId());
} }
if (reaction.getGives() != null) { if (reaction.getGives() != null) {
ctx.getPlayer().addItem(reaction.getGives()); ctx.getPlayer().addItem(reaction.getGives());
} }
Effects.applyAll(reaction.getEffects(), ctx);
ctx.getIo().write(npc.getName() + ": " + reaction.getResponse()); ctx.getIo().write(npc.getName() + ": " + reaction.getResponse());
} }

View File

@@ -1,43 +1,128 @@
package thb.jeanluc.adventure.command.impl; package thb.jeanluc.adventure.command.impl;
import thb.jeanluc.adventure.command.Command; import thb.jeanluc.adventure.command.Command;
import thb.jeanluc.adventure.game.Conditions;
import thb.jeanluc.adventure.game.GameContext; import thb.jeanluc.adventure.game.GameContext;
import thb.jeanluc.adventure.game.Light;
import thb.jeanluc.adventure.game.Pathfinder;
import thb.jeanluc.adventure.map.MapLayout;
import thb.jeanluc.adventure.model.Direction; import thb.jeanluc.adventure.model.Direction;
import thb.jeanluc.adventure.model.ExitLock;
import thb.jeanluc.adventure.model.Room; import thb.jeanluc.adventure.model.Room;
import java.util.ArrayList;
import java.util.List; import java.util.List;
import java.util.Optional; import java.util.Optional;
/** /**
* Moves the player into the room reachable in a given direction. * Moves the player. {@code go <direction>} steps once to a connected room.
* Usage: {@code go <direction>}. * {@code go to <room>} auto-walks the shortest path (BFS) over already-visited
* rooms, honouring locked exits and darkness. Usage: {@code go <direction>} or
* {@code go to <room>}. The parser strips the filler word "to", so "go to
* &lt;room&gt;" and "go &lt;room&gt;" are equivalent.
*/ */
public class GoCommand implements Command { public class GoCommand implements Command {
@Override @Override
public void execute(GameContext ctx, List<String> args) { public void execute(GameContext ctx, List<String> args) {
if (args.isEmpty()) { if (args.isEmpty()) {
ctx.getIo().write("Go where? Try 'go north'."); ctx.getIo().write("Go where? Try 'go north' or 'go to <room>'.");
return; return;
} }
Direction dir;
try { try {
dir = Direction.fromString(args.getFirst()); Direction dir = Direction.fromString(args.getFirst());
} catch (IllegalArgumentException e) { moveDirection(ctx, dir);
ctx.getIo().write("I don't know which way '" + args.getFirst() + "' is."); } catch (IllegalArgumentException notADirection) {
return; goToRoom(ctx, args);
} }
Optional<Room> next = ctx.getPlayer().getCurrentRoom().getExit(dir); }
/** Single-step move in a compass direction (the original behaviour). */
private void moveDirection(GameContext ctx, Direction dir) {
Room here = ctx.getPlayer().getCurrentRoom();
Optional<Room> next = here.getExit(dir);
if (next.isEmpty()) { if (next.isEmpty()) {
ctx.getIo().write("You can't go " + dir.getLabel() + " from here."); ctx.getIo().write("You can't go " + dir.getLabel() + " from here.");
return; return;
} }
ExitLock lock = here.getExitLocks().get(dir);
if (lock != null && !Conditions.all(lock.requires(), ctx)) {
ctx.getIo().write(lock.blocked());
return;
}
if (!Light.isLit(ctx, next.get())) {
ctx.getIo().write("It's pitch black beyond the doorway — you need a lit light source "
+ "(try lighting your lamp with 'use lamp').");
return;
}
ctx.getPlayer().setCurrentRoom(next.get()); ctx.getPlayer().setCurrentRoom(next.get());
new LookCommand().execute(ctx, List.of()); new LookCommand().execute(ctx, List.of());
} }
/** Resolve a visited room by id/name and auto-walk the BFS path to it. */
private void goToRoom(GameContext ctx, List<String> args) {
String name = String.join(" ", args).toLowerCase();
Room target = resolveVisited(ctx, name);
if (target == null) {
ctx.getIo().write("You don't know any direction or place called '" + name + "'.");
return;
}
Room start = ctx.getPlayer().getCurrentRoom();
if (target == start) {
ctx.getIo().write("You're already in the " + target.getName() + ".");
return;
}
List<Room> path = Pathfinder.findPath(ctx, target);
if (path.isEmpty()) {
ctx.getIo().write("You can't find a way there right now.");
return;
}
List<Room> route = new ArrayList<>();
route.add(start);
route.addAll(path);
for (Room step : path) {
ctx.getPlayer().setCurrentRoom(step);
ctx.getIo().travelStep(MapLayout.compute(
ctx.getWorld(), ctx.getPlayer().getVisitedRoomIds(), step));
}
ctx.getIo().write(summary(route));
new LookCommand().execute(ctx, List.of());
}
/** Finds a visited room whose id or display name matches {@code name}. */
private Room resolveVisited(GameContext ctx, String name) {
// the command test harness (CommandTestSupport) uses a null world; production always has one
if (ctx.getWorld() == null) {
return null;
}
for (String id : ctx.getPlayer().getVisitedRoomIds()) {
Room r = ctx.getWorld().getRooms().get(id);
if (r == null) {
continue;
}
if (id.equalsIgnoreCase(name) || r.getName().equalsIgnoreCase(name)) {
return r;
}
}
return null;
}
/** Names start, destination, and at most one middle waypoint. */
private String summary(List<Room> route) {
int n = route.size();
String start = route.get(0).getName();
String dest = route.get(n - 1).getName();
if (n >= 3) {
String middle = route.get(n / 2).getName();
return "You make your way from the " + start + ", through the " + middle
+ ", to the " + dest + ".";
}
return "You make your way from the " + start + " to the " + dest + ".";
}
@Override @Override
public String help() { public String help() {
return "go <direction> - move to the connected room (north/south/east/west)"; return "go <direction> - move one room (north/south/east/west); "
+ "or go to <room> to travel to a known room";
} }
} }

View File

@@ -1,50 +1,42 @@
package thb.jeanluc.adventure.command.impl; package thb.jeanluc.adventure.command.impl;
import thb.jeanluc.adventure.command.Command; import thb.jeanluc.adventure.command.Command;
import thb.jeanluc.adventure.game.Conditions;
import thb.jeanluc.adventure.game.GameContext; import thb.jeanluc.adventure.game.GameContext;
import thb.jeanluc.adventure.game.Light;
import thb.jeanluc.adventure.io.text.RoomView;
import thb.jeanluc.adventure.model.DescriptionState;
import thb.jeanluc.adventure.model.Direction; import thb.jeanluc.adventure.model.Direction;
import thb.jeanluc.adventure.model.Npc; import thb.jeanluc.adventure.model.Npc;
import thb.jeanluc.adventure.model.Room; import thb.jeanluc.adventure.model.Room;
import thb.jeanluc.adventure.model.item.Item; import thb.jeanluc.adventure.model.item.Item;
import java.util.List; import java.util.List;
import java.util.stream.Collectors;
/** /**
* Prints a full description of the room the player is currently in: * Describes the current room by emitting a {@link RoomView}; the active
* name, description, visible items, visible NPCs, and available exits. * frontend decides how to render it. Usage: {@code look}.
* Usage: {@code look}.
*/ */
public class LookCommand implements Command { public class LookCommand implements Command {
@Override @Override
public void execute(GameContext ctx, List<String> args) { public void execute(GameContext ctx, List<String> args) {
Room room = ctx.getPlayer().getCurrentRoom(); Room room = ctx.getPlayer().getCurrentRoom();
StringBuilder sb = new StringBuilder(); if (!Light.isLit(ctx, room)) {
sb.append("== ").append(room.getName()).append(" ==\n"); ctx.getIo().write("It's pitch black; you can't make anything out. You need a light source.");
sb.append(room.getDescription().stripTrailing()); return;
if (!room.getItems().isEmpty()) {
String items = room.getItems().values().stream()
.map(Item::getName)
.collect(Collectors.joining(", "));
sb.append("\nYou see: ").append(items).append('.');
} }
if (!room.getNpcs().isEmpty()) { List<String> items = room.getItems().values().stream().map(Item::getName).toList();
String npcs = room.getNpcs().values().stream() List<String> npcs = room.getNpcs().values().stream().map(Npc::getName).toList();
.map(Npc::getName) List<String> exits = room.getExits().keySet().stream().map(Direction::getLabel).toList();
.collect(Collectors.joining(", ")); String description = room.getDescription();
sb.append("\nHere is: ").append(npcs).append('.'); for (DescriptionState ds : room.getDescriptionStates()) {
if (Conditions.all(ds.when(), ctx)) {
description = ds.text();
break;
} }
if (room.getExits().isEmpty()) {
sb.append("\nThere are no obvious exits.");
} else {
String exits = room.getExits().keySet().stream()
.map(Direction::getLabel)
.collect(Collectors.joining(", "));
sb.append("\nExits: ").append(exits).append('.');
} }
ctx.getIo().write(sb.toString()); ctx.getIo().showRoom(new RoomView(room.getName(), description, items, npcs, exits));
} }
@Override @Override

View File

@@ -0,0 +1,26 @@
package thb.jeanluc.adventure.command.impl;
import thb.jeanluc.adventure.command.Command;
import thb.jeanluc.adventure.game.GameContext;
import thb.jeanluc.adventure.io.text.MapView;
import thb.jeanluc.adventure.map.MapLayout;
import java.util.List;
/** Shows the fog-of-war map of explored rooms. Usage: {@code map}. */
public class MapCommand implements Command {
@Override
public void execute(GameContext ctx, List<String> args) {
MapView view = MapLayout.compute(
ctx.getWorld(),
ctx.getPlayer().getVisitedRoomIds(),
ctx.getPlayer().getCurrentRoom());
ctx.getIo().showMap(view);
}
@Override
public String help() {
return "map - show the map of explored rooms";
}
}

View File

@@ -0,0 +1,44 @@
package thb.jeanluc.adventure.command.impl;
import lombok.RequiredArgsConstructor;
import thb.jeanluc.adventure.command.Command;
import thb.jeanluc.adventure.game.Game;
import thb.jeanluc.adventure.game.GameContext;
import thb.jeanluc.adventure.save.SaveException;
import thb.jeanluc.adventure.save.SaveService;
import java.util.List;
/**
* Saves the active slot and returns to the main menu by stopping the loop.
* Usage: {@code menu} (aliases {@code quit}, {@code exit}).
*/
@RequiredArgsConstructor
public class MenuCommand implements Command {
private final SaveService saves;
private Game game;
/** Binds the running game (two-phase wiring, as the registry is built first). */
public void bind(Game game) {
this.game = game;
}
@Override
public void execute(GameContext ctx, List<String> args) {
try {
saves.save(ctx.getSession());
ctx.getIo().write("Game saved. Returning to the main menu.");
} catch (SaveException e) {
ctx.getIo().write("Could not save: " + e.getUserMessage());
}
if (game != null) {
game.stop();
}
}
@Override
public String help() {
return "menu - save and return to the main menu (aliases: quit, exit)";
}
}

View File

@@ -0,0 +1,21 @@
package thb.jeanluc.adventure.command.impl;
import thb.jeanluc.adventure.command.Command;
import thb.jeanluc.adventure.game.GameContext;
import thb.jeanluc.adventure.game.QuestEngine;
import java.util.List;
/** Shows the active and completed quests. Usage: {@code quests}. */
public class QuestsCommand implements Command {
@Override
public void execute(GameContext ctx, List<String> args) {
ctx.getIo().showQuests(QuestEngine.viewOf(ctx));
}
@Override
public String help() {
return "quests - show your active and completed quests (alias: log, journal)";
}
}

View File

@@ -1,41 +0,0 @@
package thb.jeanluc.adventure.command.impl;
import thb.jeanluc.adventure.command.Command;
import thb.jeanluc.adventure.game.Game;
import thb.jeanluc.adventure.game.GameContext;
import java.util.List;
/**
* Ends the game loop. Holds a reference to the {@link Game} so it can
* flip the loop flag. Usage: {@code quit}.
*/
public class QuitCommand implements Command {
/** The game whose loop is to be stopped. Set via {@link #bind(Game)}. */
private Game game;
/**
* Binds the game instance after construction. Two-phase wiring is
* necessary because the registry is typically built before the game
* starts running.
*
* @param game the running game; must not be null
*/
public void bind(Game game) {
this.game = game;
}
@Override
public void execute(GameContext ctx, List<String> args) {
ctx.getIo().write("Goodbye.");
if (game != null) {
game.stop();
}
}
@Override
public String help() {
return "quit - exit the game (alias: exit)";
}
}

View File

@@ -0,0 +1,31 @@
package thb.jeanluc.adventure.command.impl;
import lombok.RequiredArgsConstructor;
import thb.jeanluc.adventure.command.Command;
import thb.jeanluc.adventure.game.GameContext;
import thb.jeanluc.adventure.save.SaveException;
import thb.jeanluc.adventure.save.SaveService;
import java.util.List;
/** Manually saves the active slot. Usage: {@code save}. */
@RequiredArgsConstructor
public class SaveCommand implements Command {
private final SaveService saves;
@Override
public void execute(GameContext ctx, List<String> args) {
try {
saves.save(ctx.getSession());
ctx.getIo().write("Game saved.");
} catch (SaveException e) {
ctx.getIo().write(e.getUserMessage());
}
}
@Override
public String help() {
return "save - save your game to the current slot";
}
}

View File

@@ -2,6 +2,7 @@ package thb.jeanluc.adventure.command.impl;
import thb.jeanluc.adventure.command.Command; import thb.jeanluc.adventure.command.Command;
import thb.jeanluc.adventure.game.GameContext; import thb.jeanluc.adventure.game.GameContext;
import thb.jeanluc.adventure.game.Light;
import thb.jeanluc.adventure.model.Room; import thb.jeanluc.adventure.model.Room;
import thb.jeanluc.adventure.model.item.Item; import thb.jeanluc.adventure.model.item.Item;
@@ -20,6 +21,10 @@ public class TakeCommand implements Command {
ctx.getIo().write("Take what?"); ctx.getIo().write("Take what?");
return; return;
} }
if (!Light.isLit(ctx, ctx.getPlayer().getCurrentRoom())) {
ctx.getIo().write("It's too dark to see that.");
return;
}
String itemId = args.getFirst(); String itemId = args.getFirst();
Room room = ctx.getPlayer().getCurrentRoom(); Room room = ctx.getPlayer().getCurrentRoom();
Optional<Item> taken = room.removeItem(itemId); Optional<Item> taken = room.removeItem(itemId);

View File

@@ -1,7 +1,9 @@
package thb.jeanluc.adventure.command.impl; package thb.jeanluc.adventure.command.impl;
import thb.jeanluc.adventure.command.Command; import thb.jeanluc.adventure.command.Command;
import thb.jeanluc.adventure.game.Conditions;
import thb.jeanluc.adventure.game.GameContext; import thb.jeanluc.adventure.game.GameContext;
import thb.jeanluc.adventure.model.DialogueLine;
import thb.jeanluc.adventure.model.Npc; import thb.jeanluc.adventure.model.Npc;
import java.util.List; import java.util.List;
@@ -24,7 +26,15 @@ public class TalkCommand implements Command {
ctx.getIo().write("There is no '" + npcId + "' here to talk to."); ctx.getIo().write("There is no '" + npcId + "' here to talk to.");
return; return;
} }
ctx.getIo().write(npc.get().getName() + " says: " + npc.get().getGreeting()); Npc found = npc.get();
String line = found.getGreeting();
for (DialogueLine dl : found.getDialogue()) {
if (Conditions.all(dl.when(), ctx)) {
line = dl.text();
break;
}
}
ctx.getIo().write(found.getName() + " says: " + line);
} }
@Override @Override

View File

@@ -1,6 +1,7 @@
package thb.jeanluc.adventure.command.impl; package thb.jeanluc.adventure.command.impl;
import thb.jeanluc.adventure.command.Command; import thb.jeanluc.adventure.command.Command;
import thb.jeanluc.adventure.game.Combinations;
import thb.jeanluc.adventure.game.GameContext; import thb.jeanluc.adventure.game.GameContext;
import thb.jeanluc.adventure.model.item.Item; import thb.jeanluc.adventure.model.item.Item;
@@ -8,9 +9,10 @@ import java.util.List;
import java.util.Optional; import java.util.Optional;
/** /**
* Invokes the item-specific {@link Item#use(GameContext)} action. The * With one argument, invokes the item-specific {@link Item#use(GameContext)}
* item must either be in the player's inventory or in the current room. * action; the item must be in the player's inventory or the current room.
* Usage: {@code use <item-id>}. * With two arguments ({@code use <item> on <item>}), delegates to the
* combination engine. Usage: {@code use <item>} or {@code use <item> on <item>}.
*/ */
public class UseCommand implements Command { public class UseCommand implements Command {
@@ -20,6 +22,10 @@ public class UseCommand implements Command {
ctx.getIo().write("Use what?"); ctx.getIo().write("Use what?");
return; return;
} }
if (args.size() >= 2) {
Combinations.tryUse(ctx, args.get(0), args.get(1));
return;
}
String itemId = args.getFirst(); String itemId = args.getFirst();
Optional<Item> item = ctx.getPlayer().findItem(itemId); Optional<Item> item = ctx.getPlayer().findItem(itemId);
if (item.isEmpty()) { if (item.isEmpty()) {
@@ -34,6 +40,6 @@ public class UseCommand implements Command {
@Override @Override
public String help() { public String help() {
return "use <item> - use an item (read it, switch it on/off, ...)"; return "use <item> - use an item; or use <item> on <item> to combine them";
} }
} }

View File

@@ -0,0 +1,69 @@
package thb.jeanluc.adventure.game;
import thb.jeanluc.adventure.model.Combination;
import thb.jeanluc.adventure.model.item.Item;
/**
* Applies {@code use X on Y} item combinations. Stateless utility, mirroring
* {@link Conditions}/{@link Effects}/{@link Light}. Operands resolve from the
* player's inventory or the current room.
*/
public final class Combinations {
private Combinations() {
}
/**
* Attempts to combine the two named items. Prints a resolution error if an
* operand is absent, the recipe's {@code failText} (or a generic line) if a
* recipe's requirements are unmet, "Nothing happens." if there is no recipe,
* or applies consume/produce/effects + the response on success.
*/
public static void tryUse(GameContext ctx, String idX, String idY) {
if (!present(ctx, idX)) {
ctx.getIo().write(noSuch(idX));
return;
}
if (!present(ctx, idY)) {
ctx.getIo().write(noSuch(idY));
return;
}
Combination c = ctx.getWorld().getCombinations().get(Combination.key(idX, idY));
if (c == null) {
ctx.getIo().write("Nothing happens.");
return;
}
if (!Conditions.all(c.requires(), ctx)) {
ctx.getIo().write(c.failText() != null ? c.failText() : "Nothing happens.");
return;
}
for (String id : c.consume()) {
removeFromAnywhere(ctx, id);
}
if (c.produce() != null) {
Item produced = ctx.getWorld().getItems().get(c.produce());
if (produced != null) {
ctx.getPlayer().addItem(produced);
}
}
Effects.applyAll(c.effects(), ctx);
if (c.response() != null && !c.response().isBlank()) {
ctx.getIo().write(c.response());
}
}
private static boolean present(GameContext ctx, String id) {
return ctx.getPlayer().hasItem(id)
|| ctx.getPlayer().getCurrentRoom().findItem(id).isPresent();
}
private static void removeFromAnywhere(GameContext ctx, String id) {
if (ctx.getPlayer().removeItem(id).isEmpty()) {
ctx.getPlayer().getCurrentRoom().removeItem(id);
}
}
private static String noSuch(String id) {
return "There is no '" + id + "' here or in your inventory.";
}
}

View File

@@ -0,0 +1,33 @@
package thb.jeanluc.adventure.game;
import thb.jeanluc.adventure.model.Condition;
import java.util.List;
/** Evaluates {@link Condition}s against a {@link GameContext}. */
public final class Conditions {
private Conditions() {
}
public static boolean holds(Condition c, GameContext ctx) {
return switch (c.type()) {
case FLAG -> ctx.getState().isSet(c.arg());
case NOT_FLAG -> !ctx.getState().isSet(c.arg());
case HAS_ITEM -> ctx.getPlayer().hasItem(c.arg());
};
}
/** True iff every condition holds. A null or empty list is vacuously true. */
public static boolean all(List<Condition> conditions, GameContext ctx) {
if (conditions == null) {
return true;
}
for (Condition c : conditions) {
if (!holds(c, ctx)) {
return false;
}
}
return true;
}
}

View File

@@ -0,0 +1,43 @@
package thb.jeanluc.adventure.game;
import lombok.extern.slf4j.Slf4j;
import thb.jeanluc.adventure.model.Effect;
import thb.jeanluc.adventure.model.item.Item;
import java.util.List;
/** Applies {@link Effect}s to a {@link GameContext}. */
@Slf4j
public final class Effects {
private Effects() {
}
public static void apply(Effect e, GameContext ctx) {
switch (e.type()) {
case SET_FLAG -> ctx.getState().set(e.arg());
case CLEAR_FLAG -> ctx.getState().clear(e.arg());
case GIVE_ITEM -> {
Item it = ctx.getWorld().getItems().get(e.arg());
if (it == null) {
log.warn("Effect GIVE_ITEM references unknown item '{}'", e.arg());
} else {
ctx.getPlayer().addItem(it);
}
}
case REMOVE_ITEM -> ctx.getPlayer().removeItem(e.arg());
case SAY -> ctx.getIo().write(e.arg());
case START_QUEST -> ctx.getQuestLog().start(e.arg());
}
}
/** Applies each effect in order. A null list is a no-op. */
public static void applyAll(List<Effect> effects, GameContext ctx) {
if (effects == null) {
return;
}
for (Effect e : effects) {
apply(e, ctx);
}
}
}

View File

@@ -0,0 +1,46 @@
package thb.jeanluc.adventure.game;
import thb.jeanluc.adventure.io.text.StyledText;
import thb.jeanluc.adventure.model.Ending;
/** Detects the first matching ending and renders the end-of-game screen. */
public final class EndingEngine {
private EndingEngine() {
}
/** First ending whose conditions hold, in list order; null if none. */
public static Ending triggered(GameContext ctx) {
for (Ending e : ctx.getWorld().getEndings()) {
if (Conditions.all(e.when(), ctx)) {
return e;
}
}
return null;
}
/** Builds the ending screen: title banner, text, and a run summary. */
public static StyledText render(Ending e, GameContext ctx, int turns) {
int total = ctx.getWorld().getQuests().size();
int done = ctx.getQuestLog().completed().size();
String bar = "".repeat(Math.max(12, e.title().length() + 6));
StyledText.Builder b = StyledText.builder();
b.heading(bar + "\n").heading(" " + e.title() + "\n").heading(bar + "\n");
b.plain(e.text().stripTrailing()).plain("\n\n");
b.dim("Turns: " + turns + "\n");
b.dim("Quests completed: " + done + " / " + total + "\n");
b.heading("Rank: " + rank(e, total, done));
return b.build();
}
private static String rank(Ending e, int total, int done) {
if (e.victory() && total > 0 && done == total) {
return "Master of the Manor";
}
if (e.victory()) {
return "Manor Reclaimed";
}
return "Escaped — the manor keeps its secrets";
}
}

View File

@@ -5,6 +5,10 @@ import thb.jeanluc.adventure.command.Command;
import thb.jeanluc.adventure.command.CommandParser; import thb.jeanluc.adventure.command.CommandParser;
import thb.jeanluc.adventure.command.CommandRegistry; import thb.jeanluc.adventure.command.CommandRegistry;
import thb.jeanluc.adventure.command.ParsedCommand; import thb.jeanluc.adventure.command.ParsedCommand;
import thb.jeanluc.adventure.io.text.Hud;
import thb.jeanluc.adventure.io.text.MapView;
import thb.jeanluc.adventure.map.MapLayout;
import thb.jeanluc.adventure.model.Ending;
import java.util.Optional; import java.util.Optional;
@@ -28,10 +32,23 @@ public class Game {
/** Loop flag. Flipped to false by {@link #stop()}. */ /** Loop flag. Flipped to false by {@link #stop()}. */
private boolean running = true; private boolean running = true;
/** Optional onboarding guide; null in normal/loaded play. */
private TutorialGuide tutorialGuide;
/** Wires the interactive tutorial (New Game only). */
public void setTutorialGuide(TutorialGuide tutorialGuide) {
this.tutorialGuide = tutorialGuide;
}
/** /**
* Runs the loop until {@link #stop()} is called or input is exhausted. * Runs the loop until {@link #stop()} is called or input is exhausted.
*/ */
public void run() { public void run() {
publishHud();
maybeEnd();
if (tutorialGuide != null) {
tutorialGuide.begin(ctx);
}
while (running) { while (running) {
String input = ctx.getIo().readLine(); String input = ctx.getIo().readLine();
if (input == null) { if (input == null) {
@@ -41,13 +58,58 @@ public class Game {
if (parsed.verb().isEmpty()) { if (parsed.verb().isEmpty()) {
continue; continue;
} }
if (tutorialGuide != null && tutorialGuide.isActive()
&& parsed.verb().equals("skip")) {
tutorialGuide.skip(ctx);
publishHud();
continue;
}
Optional<Command> cmd = registry.find(parsed.verb()); Optional<Command> cmd = registry.find(parsed.verb());
if (cmd.isEmpty()) { if (cmd.isEmpty()) {
ctx.getIo().write("I don't understand '" + parsed.verb() + "'. Type 'help'."); ctx.getIo().write("I don't understand '" + parsed.verb() + "'. Type 'help'.");
} else { } else {
cmd.get().execute(ctx, parsed.args()); cmd.get().execute(ctx, parsed.args());
ctx.getSession().incrementTurn();
if (ctx.getSession().getTurn() % 10 == 0) {
ctx.save();
}
if (tutorialGuide != null && tutorialGuide.isActive()) {
tutorialGuide.onCommand(ctx, parsed);
} }
} }
publishHud();
maybeEnd();
}
}
/** Ends the game if any ending's conditions are met, printing its screen. */
private void maybeEnd() {
// Don't end the game (win or lose) while the player is still in the tutorial —
// a pending ending fires once the walkthrough finishes or is skipped.
if (tutorialGuide != null && tutorialGuide.isActive()) {
return;
}
Ending e = EndingEngine.triggered(ctx);
if (e != null) {
ctx.getIo().print(EndingEngine.render(e, ctx, ctx.getSession().getTurn()));
stop();
}
}
private void publishHud() {
QuestEngine.tick(ctx);
ctx.getIo().setHud(new Hud(
ctx.getPlayer().getCurrentRoom().getName(),
ctx.getPlayer().getGold(),
ctx.getSession().getTurn(),
Light.carryingLight(ctx)));
MapView map = MapLayout.compute(
ctx.getWorld(),
ctx.getPlayer().getVisitedRoomIds(),
ctx.getPlayer().getCurrentRoom());
ctx.getIo().setMap(map);
ctx.getIo().setQuests(QuestEngine.viewOf(ctx));
ctx.getIo().setMusic(ctx.getPlayer().getCurrentRoom().getMusic());
} }
/** /**

View File

@@ -1,26 +1,63 @@
package thb.jeanluc.adventure.game; package thb.jeanluc.adventure.game;
import lombok.Getter;
import lombok.RequiredArgsConstructor;
import thb.jeanluc.adventure.io.GameIO; import thb.jeanluc.adventure.io.GameIO;
import thb.jeanluc.adventure.model.Player; import thb.jeanluc.adventure.model.Player;
import thb.jeanluc.adventure.model.World; import thb.jeanluc.adventure.model.World;
/** /**
* Bundle of everything a command needs to do its work: the world, the * Bundle of everything a command needs: the active {@link GameSession}
* player, and the IO channel. Passing this single object keeps command * (world, player, flags, quests, turn) and the IO channel. Getters delegate
* signatures small and consistent. * to the session so existing command code keeps working unchanged.
*/ */
@Getter
@RequiredArgsConstructor
public class GameContext { public class GameContext {
/** Loaded world (rooms, items, npcs). */ private final GameSession session;
private final World world;
/** The player avatar. */
private final Player player;
/** IO channel for reading input and writing output. */
private final GameIO io; private final GameIO io;
/** Silent autosave hook, wired by the app; no-op by default (tests). */
private Runnable saveCallback = () -> { };
public GameContext(GameSession session, GameIO io) {
this.session = session;
this.io = io;
}
/** Convenience for tests/legacy callers: wraps a fresh single-use session. */
public GameContext(World world, Player player, GameIO io) {
this(new GameSession(world, player, "session"), io);
}
public GameSession getSession() {
return session;
}
public World getWorld() {
return session.getWorld();
}
public Player getPlayer() {
return session.getPlayer();
}
public GameState getState() {
return session.getState();
}
public QuestLog getQuestLog() {
return session.getQuestLog();
}
public GameIO getIo() {
return io;
}
/** Sets the silent autosave hook (called by interval/quest autosave). */
public void setSaveCallback(Runnable saveCallback) {
this.saveCallback = saveCallback;
}
/** Runs the silent autosave hook. */
public void save() {
saveCallback.run();
}
} }

View File

@@ -0,0 +1,36 @@
package thb.jeanluc.adventure.game;
import lombok.Getter;
import thb.jeanluc.adventure.model.Player;
import thb.jeanluc.adventure.model.World;
/**
* Bundle of the mutable, savable game state for one playthrough: the
* loaded {@link World}, the {@link Player}, world {@link GameState} flags,
* {@link QuestLog} progress, the turn counter, and the bound save-slot name.
* Save/load read and write exactly this object.
*/
@Getter
public class GameSession {
private final World world;
private final Player player;
private final GameState state = new GameState();
private final QuestLog questLog = new QuestLog();
private final String slotName;
private int turn;
public GameSession(World world, Player player, String slotName) {
this.world = world;
this.player = player;
this.slotName = slotName;
}
public void setTurn(int turn) {
this.turn = turn;
}
public void incrementTurn() {
this.turn++;
}
}

View File

@@ -0,0 +1,34 @@
package thb.jeanluc.adventure.game;
import java.util.Collection;
import java.util.Collections;
import java.util.LinkedHashSet;
import java.util.Set;
/** Named world flags. A flag is "set" (true) when present in the set. */
public class GameState {
private final Set<String> flags = new LinkedHashSet<>();
public boolean isSet(String flag) {
return flags.contains(flag);
}
public void set(String flag) {
flags.add(flag);
}
public void clear(String flag) {
flags.remove(flag);
}
public Set<String> all() {
return Collections.unmodifiableSet(flags);
}
/** Replaces all flags with the given collection (used by load). */
public void restore(Collection<String> newFlags) {
flags.clear();
flags.addAll(newFlags);
}
}

View File

@@ -0,0 +1,44 @@
package thb.jeanluc.adventure.game;
import thb.jeanluc.adventure.model.Room;
import thb.jeanluc.adventure.model.item.Item;
import thb.jeanluc.adventure.model.item.SwitchableItem;
import java.util.Collection;
/** Decides whether a room is lit, given dark rooms and active light sources. */
public final class Light {
private Light() {
}
/** A room is lit if it is not dark, or an active light source is carried or present. */
public static boolean isLit(GameContext ctx, Room room) {
if (!room.isDark()) {
return true;
}
return hasActiveLight(ctx.getPlayer().getInventory().values())
|| hasActiveLight(room.getItems().values());
}
/** True if the player carries an active light source (for the HUD). */
public static boolean carryingLight(GameContext ctx) {
return hasActiveLight(ctx.getPlayer().getInventory().values());
}
private static boolean hasActiveLight(Collection<Item> items) {
for (Item it : items) {
if (isActiveLight(it)) {
return true;
}
}
return false;
}
private static boolean isActiveLight(Item it) {
if (!it.isLight()) {
return false;
}
return !(it instanceof SwitchableItem s) || s.isOn();
}
}

View File

@@ -0,0 +1,76 @@
package thb.jeanluc.adventure.game;
import thb.jeanluc.adventure.model.Direction;
import thb.jeanluc.adventure.model.ExitLock;
import thb.jeanluc.adventure.model.Room;
import java.util.ArrayDeque;
import java.util.HashMap;
import java.util.LinkedList;
import java.util.List;
import java.util.Map;
import java.util.Queue;
import java.util.Set;
/**
* Breadth-first shortest-path search over the rooms the player has already
* visited, honouring the same gates as a manual move: locked exits and
* darkness. Stateless utility, alongside {@link Conditions}/{@link Effects}/
* {@link Light}.
*/
public final class Pathfinder {
private Pathfinder() {
}
/**
* Finds the shortest path from the player's current room to {@code target}.
*
* @param ctx active game context
* @param target destination room (expected to be a visited room)
* @return the rooms to step through, excluding the start and ending at
* {@code target}; empty if there is no currently-passable path
* (or {@code target} is the current room)
*/
public static List<Room> findPath(GameContext ctx, Room target) {
Room start = ctx.getPlayer().getCurrentRoom();
Set<String> visited = ctx.getPlayer().getVisitedRoomIds();
Queue<Room> queue = new ArrayDeque<>();
Map<Room, Room> cameFrom = new HashMap<>();
queue.add(start);
cameFrom.put(start, null);
while (!queue.isEmpty()) {
Room current = queue.poll();
if (current == target) {
return reconstruct(cameFrom, target);
}
for (Map.Entry<Direction, Room> exit : current.getExits().entrySet()) {
Room next = exit.getValue();
if (cameFrom.containsKey(next) || !visited.contains(next.getId())) {
continue;
}
ExitLock lock = current.getExitLocks().get(exit.getKey());
if (lock != null && !Conditions.all(lock.requires(), ctx)) {
continue;
}
if (!Light.isLit(ctx, next)) {
continue;
}
cameFrom.put(next, current);
queue.add(next);
}
}
return List.of();
}
/** Walks predecessors back from target to start; returns steps after start. */
private static List<Room> reconstruct(Map<Room, Room> cameFrom, Room target) {
LinkedList<Room> path = new LinkedList<>();
for (Room r = target; r != null && cameFrom.get(r) != null; r = cameFrom.get(r)) {
path.addFirst(r);
}
return path;
}
}

View File

@@ -0,0 +1,95 @@
package thb.jeanluc.adventure.game;
import thb.jeanluc.adventure.io.text.QuestEntry;
import thb.jeanluc.adventure.io.text.QuestView;
import thb.jeanluc.adventure.io.text.StyledText;
import thb.jeanluc.adventure.model.Quest;
import thb.jeanluc.adventure.model.QuestStage;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
/** Per-turn quest progression and view building, driven by world conditions. */
public final class QuestEngine {
private QuestEngine() {
}
public static void tick(GameContext ctx) {
Map<String, Quest> quests = ctx.getWorld().getQuests();
for (Quest q : quests.values()) {
if (q.autoStart() && !ctx.getQuestLog().isActive(q.id()) && !ctx.getQuestLog().isCompleted(q.id())) {
ctx.getQuestLog().start(q.id());
}
}
int guard = 0;
int max = totalStages(quests) + quests.size() + 1;
boolean changed = true;
while (changed && guard++ < max) {
changed = false;
for (String id : new ArrayList<>(ctx.getQuestLog().active())) {
Quest q = quests.get(id);
if (q == null) {
continue;
}
int idx = ctx.getQuestLog().stageIndex(id);
if (idx >= q.stages().size()) {
finish(ctx, q);
changed = true;
continue;
}
QuestStage stage = q.stages().get(idx);
if (Conditions.all(stage.completion(), ctx)) {
Effects.applyAll(stage.onComplete(), ctx);
ctx.getIo().print(StyledText.builder()
.heading("✓ Objective complete: ").plain(stage.objective()).build());
ctx.getQuestLog().advance(id);
if (ctx.getQuestLog().stageIndex(id) >= q.stages().size()) {
finish(ctx, q);
}
changed = true;
}
}
}
}
private static void finish(GameContext ctx, Quest q) {
if (ctx.getQuestLog().isCompleted(q.id())) {
return;
}
Effects.applyAll(q.onComplete(), ctx);
ctx.getQuestLog().complete(q.id());
ctx.getIo().print(StyledText.builder()
.heading("★ Quest complete: ").plain(q.title()).build());
ctx.save();
}
public static QuestView viewOf(GameContext ctx) {
Map<String, Quest> quests = ctx.getWorld().getQuests();
List<QuestEntry> active = new ArrayList<>();
for (String id : ctx.getQuestLog().active()) {
Quest q = quests.get(id);
if (q == null) {
continue;
}
int idx = ctx.getQuestLog().stageIndex(id);
String objective = idx < q.stages().size() ? q.stages().get(idx).objective() : "";
active.add(new QuestEntry(q.title(), objective));
}
List<String> completed = new ArrayList<>();
for (String id : ctx.getQuestLog().completed()) {
Quest q = quests.get(id);
completed.add(q == null ? id : q.title());
}
return new QuestView(active, completed);
}
private static int totalStages(Map<String, Quest> quests) {
int total = 0;
for (Quest q : quests.values()) {
total += q.stages().size();
}
return total;
}
}

View File

@@ -0,0 +1,58 @@
package thb.jeanluc.adventure.game;
import java.util.Collection;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.Map;
import java.util.Set;
/** Runtime quest progress: current stage per active quest, plus completed ids. */
public class QuestLog {
private final Map<String, Integer> stageIndex = new LinkedHashMap<>();
private final Set<String> completed = new LinkedHashSet<>();
public void start(String id) {
if (!completed.contains(id)) {
stageIndex.putIfAbsent(id, 0);
}
}
public boolean isActive(String id) {
return stageIndex.containsKey(id);
}
public boolean isCompleted(String id) {
return completed.contains(id);
}
public int stageIndex(String id) {
return stageIndex.getOrDefault(id, 0);
}
public void advance(String id) {
stageIndex.merge(id, 1, Integer::sum);
}
public void complete(String id) {
stageIndex.remove(id);
completed.add(id);
}
public Set<String> active() {
return Collections.unmodifiableSet(stageIndex.keySet());
}
public Set<String> completed() {
return Collections.unmodifiableSet(completed);
}
/** Replaces all progress with the given stage map and completed set (load). */
public void restore(Map<String, Integer> stages, Collection<String> completedIds) {
stageIndex.clear();
stageIndex.putAll(stages);
completed.clear();
completed.addAll(completedIds);
}
}

View File

@@ -0,0 +1,123 @@
package thb.jeanluc.adventure.game;
import thb.jeanluc.adventure.command.Command;
import thb.jeanluc.adventure.command.CommandRegistry;
import thb.jeanluc.adventure.command.ParsedCommand;
import thb.jeanluc.adventure.model.Direction;
import thb.jeanluc.adventure.model.Room;
import thb.jeanluc.adventure.model.Tutorial;
import thb.jeanluc.adventure.model.TutorialStep;
import java.util.Optional;
/**
* Drives the interactive start-of-game walkthrough, layered onto the real game
* loop. Prints an instruction, then on each executed command decides whether the
* current step is satisfied (confirm + advance) or not (hint). Movement steps
* require an actual room change. Alias-aware via the {@link CommandRegistry}.
*/
public final class TutorialGuide {
private final Tutorial tutorial;
private final CommandRegistry registry;
private int index;
private boolean active;
private boolean begun;
private Room roomAtStepStart;
public TutorialGuide(Tutorial tutorial, CommandRegistry registry) {
this.tutorial = tutorial;
this.registry = registry;
this.active = !tutorial.isEmpty();
}
/** @return true while the walkthrough is still running. */
public boolean isActive() {
return active;
}
/** Prints the intro and the first instruction (call once at loop start). */
public void begin(GameContext ctx) {
if (!active || begun) {
return;
}
begun = true;
if (tutorial.intro() != null && !tutorial.intro().isBlank()) {
ctx.getIo().write(tutorial.intro());
}
enterStep(ctx);
}
/** Evaluates the current step against the command that was just executed. */
public void onCommand(GameContext ctx, ParsedCommand parsed) {
if (!active) {
return;
}
TutorialStep step = tutorial.steps().get(index);
if (satisfies(step, ctx, parsed)) {
ctx.getIo().write(step.confirm());
index++;
if (index >= tutorial.steps().size()) {
if (tutorial.closingTips() != null && !tutorial.closingTips().isBlank()) {
ctx.getIo().write(tutorial.closingTips());
}
active = false;
} else {
enterStep(ctx);
}
} else {
ctx.getIo().write(step.hint());
}
}
/** Ends the tutorial early (the 'skip' command). */
public void skip(GameContext ctx) {
if (active) {
ctx.getIo().write("Tutorial skipped.");
active = false;
}
}
private void enterStep(GameContext ctx) {
roomAtStepStart = ctx.getPlayer().getCurrentRoom();
ctx.getIo().write(tutorial.steps().get(index).instruction());
}
private boolean satisfies(TutorialStep step, GameContext ctx, ParsedCommand parsed) {
String expect = step.expect();
if ("go_direction".equals(expect)) {
return isGo(parsed) && firstIsDirection(parsed) && moved(ctx);
}
if ("go_to_room".equals(expect)) {
return isGo(parsed) && !firstIsDirection(parsed) && moved(ctx);
}
return sameCommand(parsed.verb(), expect) && parsed.args().size() >= step.minArgs();
}
private boolean isGo(ParsedCommand p) {
return sameCommand(p.verb(), "go") && !p.args().isEmpty();
}
private boolean firstIsDirection(ParsedCommand p) {
if (p.args().isEmpty()) {
return false;
}
try {
Direction.fromString(p.args().get(0));
return true;
} catch (IllegalArgumentException e) {
return false;
}
}
private boolean moved(GameContext ctx) {
return roomAtStepStart != null
&& ctx.getPlayer().getCurrentRoom() != roomAtStepStart;
}
private boolean sameCommand(String a, String b) {
Optional<Command> ca = registry.find(a);
Optional<Command> cb = registry.find(b);
return ca.isPresent() && cb.isPresent() && ca.get() == cb.get();
}
}

View File

@@ -1,41 +1,69 @@
package thb.jeanluc.adventure.io; package thb.jeanluc.adventure.io;
import thb.jeanluc.adventure.io.text.AsciiMap;
import thb.jeanluc.adventure.io.text.Hud;
import thb.jeanluc.adventure.io.text.MapView;
import thb.jeanluc.adventure.io.text.RoomView;
import thb.jeanluc.adventure.io.text.Span;
import thb.jeanluc.adventure.io.text.Style;
import thb.jeanluc.adventure.io.text.StyledText;
import java.io.BufferedReader; import java.io.BufferedReader;
import java.io.IOException; import java.io.IOException;
import java.io.InputStreamReader; import java.io.InputStreamReader;
import java.io.PrintStream; import java.io.PrintStream;
import java.nio.charset.StandardCharsets; import java.nio.charset.StandardCharsets;
import java.util.List;
/** /**
* {@link GameIO} backed by {@code System.in} and {@code System.out}. * {@link GameIO} for the terminal: ANSI colours, box-drawing room frames
* Each {@code write} appends a newline so output blocks are visually * (level "B"), and a dim HUD line. Colour and glyph fidelity are configurable
* separated. {@code readLine} prints a {@code &gt; } prompt before * and degrade safely (no colours when output is piped; ASCII frames on request).
* blocking.
*/ */
public class ConsoleIO implements GameIO { public class ConsoleIO implements GameIO {
/** Input source. */ /** Whether ANSI colour is emitted. */
public enum ColorMode { ON, OFF, AUTO }
/** Frame/glyph fidelity tier. */
public enum GlyphMode { ASCII, UNICODE, GLYPH }
private static final int FRAME_WIDTH = 44;
private final BufferedReader in; private final BufferedReader in;
/** Output sink. */
private final PrintStream out; private final PrintStream out;
private boolean useColor;
private GlyphMode glyphs;
/**
* Creates a console IO using standard input and output.
*/
public ConsoleIO() { public ConsoleIO() {
this(new BufferedReader(new InputStreamReader(System.in, StandardCharsets.UTF_8)), System.out); this(new BufferedReader(new InputStreamReader(System.in, StandardCharsets.UTF_8)),
System.out, ColorMode.AUTO, GlyphMode.UNICODE);
} }
/** /** Backward-compatible stream constructor (colour off, unicode frames). */
* Test-friendly constructor allowing custom streams.
*
* @param in reader to consume player input
* @param out stream to write player output
*/
public ConsoleIO(BufferedReader in, PrintStream out) { public ConsoleIO(BufferedReader in, PrintStream out) {
this(in, out, ColorMode.OFF, GlyphMode.UNICODE);
}
public ConsoleIO(BufferedReader in, PrintStream out, ColorMode colorMode, GlyphMode glyphs) {
this.in = in; this.in = in;
this.out = out; this.out = out;
this.glyphs = glyphs;
setColorMode(colorMode);
}
/** Re-resolves colour emission from the given mode (AUTO checks the console). */
public void setColorMode(ColorMode mode) {
this.useColor = switch (mode) {
case ON -> true;
case OFF -> false;
case AUTO -> System.console() != null;
};
}
/** Switches the frame/glyph fidelity tier live. */
public void setGlyphMode(GlyphMode mode) {
this.glyphs = mode;
} }
@Override @Override
@@ -43,15 +71,100 @@ public class ConsoleIO implements GameIO {
out.print("> "); out.print("> ");
out.flush(); out.flush();
try { try {
String line = in.readLine(); // null propagates as true EOF (ends the game loop); "" stays "" (blank input re-prompts).
return line == null ? "" : line; return in.readLine();
} catch (IOException e) { } catch (IOException e) {
return ""; // Treat a stream failure as EOF so input ends cleanly rather than spinning.
return null;
} }
} }
@Override @Override
public void write(String s) { public void print(StyledText text) {
out.println(s); StringBuilder sb = new StringBuilder();
for (Span s : text.spans()) {
sb.append(paint(s.style(), s.text()));
}
out.println(sb);
}
@Override
public void showRoom(RoomView room) {
out.println(paint(Style.HEADING, frameTop(room.name())));
out.println(room.description().stripTrailing());
if (!room.items().isEmpty()) {
out.println(section("Items", room.items(), Style.ITEM));
}
if (!room.npcs().isEmpty()) {
out.println(section("People", room.npcs(), Style.NPC));
}
if (!room.exits().isEmpty()) {
out.println(section("Exits", room.exits(), Style.EXIT));
} else {
out.println(" " + paint(Style.DIM, "no obvious exits"));
}
out.println(paint(Style.HEADING, frameBottom()));
}
@Override
public void setHud(Hud h) {
String line = "[ " + h.location() + " · " + h.gold() + " gold · turn " + h.turn()
+ " · light: " + (h.lightOn() ? "on" : "off") + " ]";
out.println(paint(Style.DIM, line));
}
@Override
public void showMap(MapView view) {
print(AsciiMap.render(view, glyphs != GlyphMode.ASCII));
}
private String section(String label, List<String> xs, Style st) {
StringBuilder sb = new StringBuilder(" ").append(paint(Style.DIM, label)).append(" ");
for (int i = 0; i < xs.size(); i++) {
if (i > 0) {
sb.append(" · ");
}
sb.append(paint(st, xs.get(i)));
}
return sb.toString();
}
private String frameTop(String title) {
String tl = glyphs == GlyphMode.ASCII ? "+" : "";
String tr = glyphs == GlyphMode.ASCII ? "+" : "";
String h = glyphs == GlyphMode.ASCII ? "-" : "";
StringBuilder sb = new StringBuilder(tl).append(h).append(' ')
.append(title.toUpperCase()).append(' ');
while (sb.length() < FRAME_WIDTH - 1) {
sb.append(h);
}
return sb.append(tr).toString();
}
private String frameBottom() {
String bl = glyphs == GlyphMode.ASCII ? "+" : "";
String br = glyphs == GlyphMode.ASCII ? "+" : "";
String h = glyphs == GlyphMode.ASCII ? "-" : "";
StringBuilder sb = new StringBuilder(bl);
while (sb.length() < FRAME_WIDTH - 1) {
sb.append(h);
}
return sb.append(br).toString();
}
private String paint(Style st, String s) {
if (!useColor) {
return s;
}
String code = switch (st) {
case HEADING -> "";
case ITEM -> "";
case NPC -> "";
case EXIT -> "";
case DANGER -> "";
case DIM -> "";
case PLAIN -> "";
};
return code.isEmpty() ? s : code + s + "";
} }
} }

View File

@@ -1,24 +1,116 @@
package thb.jeanluc.adventure.io; package thb.jeanluc.adventure.io;
import thb.jeanluc.adventure.io.text.AsciiMap;
import thb.jeanluc.adventure.io.text.Hud;
import thb.jeanluc.adventure.io.text.MapView;
import thb.jeanluc.adventure.io.text.QuestText;
import thb.jeanluc.adventure.io.text.QuestView;
import thb.jeanluc.adventure.io.text.Renderings;
import thb.jeanluc.adventure.io.text.RoomView;
import thb.jeanluc.adventure.io.text.StyledText;
import java.util.List;
/** /**
* Bidirectional text channel between the engine and the player. Implemented * Bidirectional channel between the engine and the player. Commands emit
* by both {@code ConsoleIO} and {@code SwingIO} so the game loop is agnostic * semantic, role-styled output via {@link #print(StyledText)} (the primitive)
* to the actual interface. * and the {@link #showRoom}/{@link #setHud} views; each frontend renders them.
*/ */
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.
*
* @return the next user input line, or an empty string at end of input
*/
String readLine(); String readLine();
/** /** Renders one styled output block. The only method implementors must provide. */
* Writes a message to the player. Each call corresponds to one logical void print(StyledText text);
* output block; the implementation handles line breaks at the boundary.
* /** Convenience: a plain, unstyled line. */
* @param s message text; must not be null default void write(String s) {
*/ print(StyledText.of(s));
void write(String s); }
/** Renders a room. Default flattens via {@link Renderings}; renderers may override. */
default void showRoom(RoomView room) {
print(Renderings.roomToStyledText(room));
}
/** Updates the status/HUD region. No-op by default; renderers may override. */
default void setHud(Hud hud) {
// no-op
}
/** Per-turn push of the current map. GUI repaints its panel; console ignores. */
default void setMap(MapView view) {
// no-op
}
/**
* One frame of an auto-walk (the {@code go to} command), as the player moves
* through a room en route. Default is a no-op: text mode travels silently.
* The GUI overrides this to repaint the map and pace the animation.
*
* @param view the map snapshot at this step
*/
default void travelStep(MapView view) {
// no animation in text mode
}
/** On-demand map (the 'map' command). Default renders ASCII (Unicode frames). */
default void showMap(MapView view) {
print(AsciiMap.render(view, true));
}
/** Per-turn push of the quest log. GUI repaints its box; console ignores. */
default void setQuests(QuestView view) {
// no-op
}
/** Per-turn current-room music track; null/blank = silence. Console no-op; GUI plays. */
default void setMusic(String track) {
// no audio in text mode
}
/** Applies the music volume/level. Console no-op; GUI sets it. */
default void setMusicLevel(MusicLevel level) {
// no audio in text mode
}
/** On-demand quest log (the 'quests' command). Default renders styled text. */
default void showQuests(QuestView view) {
print(QuestText.render(view));
}
/**
* Presents a titled list of options and returns the 0-based index of the
* player's choice. Default (console) prints a numbered list and reads a
* number, re-prompting on invalid input. EOF / empty input returns the
* LAST option (menus place the safe/cancel option last). Frontends with
* native widgets (e.g. buttons) may override.
*
* @param title heading shown above the options
* @param options non-empty list of option labels
* @return 0-based index into {@code options}
*/
default int choose(String title, List<String> options) {
StyledText.Builder b = StyledText.builder().heading(title).plain("\n");
for (int i = 0; i < options.size(); i++) {
b.plain(" " + (i + 1) + ") " + options.get(i) + "\n");
}
print(b.build());
while (true) {
String line = readLine();
if (line == null || line.isBlank()) {
return options.size() - 1;
}
try {
int n = Integer.parseInt(line.trim());
if (n >= 1 && n <= options.size()) {
return n - 1;
}
} catch (NumberFormatException ignored) {
// fall through to re-prompt
}
write("Please enter a number between 1 and " + options.size() + ".");
}
}
} }

View File

@@ -0,0 +1,204 @@
package thb.jeanluc.adventure.io;
import thb.jeanluc.adventure.io.text.CellState;
import thb.jeanluc.adventure.io.text.Connection;
import thb.jeanluc.adventure.io.text.MapView;
import thb.jeanluc.adventure.io.text.RoomCell;
import javax.swing.JComponent;
import javax.swing.JScrollPane;
import javax.swing.JViewport;
import javax.swing.SwingUtilities;
import java.awt.BasicStroke;
import java.awt.Color;
import java.awt.Container;
import java.awt.Dimension;
import java.awt.Font;
import java.awt.Graphics;
import java.awt.Graphics2D;
import java.awt.Insets;
import java.awt.RenderingHints;
import java.awt.Stroke;
import java.util.List;
/**
* Draws a {@link MapView} as outlined rooms linked by corridors (Graphics2D).
*
* <p>The map is scaled to <em>fit</em> the side panel, so it never needs
* scrollbars at normal zoom and re-fits whenever the window (and thus the
* panel) is resized. The {@link #setZoom(float) zoom} from {@code Ctrl +/-}
* multiplies on top of the fit scale (zoom &gt; 1 enlarges and may scroll).</p>
*/
public class MapPanel extends JComponent {
private static final int BASE_CELL_W = 96;
private static final int BASE_CELL_H = 46;
private static final int BASE_GAP_X = 34;
private static final int BASE_GAP_Y = 30;
private static final int BASE_PAD = 16;
private static final float BASE_FONT = 11f;
private static final Color BG = new Color(0x0e, 0x12, 0x18);
private static final Color CORRIDOR = new Color(0x46, 0x70, 0x8a);
private static final Color VISITED = new Color(0xcf, 0xd6, 0xe0);
private static final Color CURRENT = new Color(0xe6, 0xc3, 0x4a);
private static final Color KNOWN = new Color(0x3a, 0x46, 0x55);
private final Font baseFont;
private float zoom = 1f;
private MapView view = new MapView(List.of(), List.of());
public MapPanel(Font font) {
this.baseFont = font;
setBackground(BG);
setOpaque(true);
}
/** Sets the user zoom multiplier (applied on top of the fit-to-panel scale). */
public void setZoom(float zoom) {
SwingUtilities.invokeLater(() -> {
this.zoom = Math.max(0.4f, zoom);
revalidate();
repaint();
});
}
/** Replaces the rendered map. */
public void show(MapView v) {
SwingUtilities.invokeLater(() -> {
this.view = v;
revalidate();
repaint();
});
}
/** The space the map should fit into: the enclosing scroll pane's viewport area. */
private Dimension available() {
Container p = getParent();
if (p instanceof JViewport && p.getParent() instanceof JScrollPane sp) {
Insets in = sp.getInsets();
int w = sp.getWidth() - in.left - in.right;
int h = sp.getHeight() - in.top - in.bottom;
if (w > 0 && h > 0) {
return new Dimension(w, h);
}
}
Dimension s = getSize();
return (s.width > 0 && s.height > 0) ? s : new Dimension(220, 320);
}
private int[] gridBounds() {
int maxX = 0;
int maxY = 0;
for (RoomCell c : view.cells()) {
maxX = Math.max(maxX, c.x());
maxY = Math.max(maxY, c.y());
}
return new int[]{maxX, maxY};
}
/** Fit-to-panel scale times the user zoom. */
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 natH = 2.0 * BASE_PAD + (b[1] + 1) * BASE_CELL_H + b[1] * BASE_GAP_Y;
if (natW <= 0 || natH <= 0) {
return zoom;
}
double fit = Math.min(avail.width / natW, avail.height / natH);
fit = Math.max(0.3, Math.min(3.0, fit));
return (float) (fit * zoom);
}
private Dimension content(float eff, int[] b) {
int pad = Math.round(BASE_PAD * eff);
int cw = Math.round(BASE_CELL_W * eff);
int ch = Math.round(BASE_CELL_H * eff);
int gx = Math.round(BASE_GAP_X * eff);
int gy = Math.round(BASE_GAP_Y * eff);
return new Dimension(pad * 2 + (b[0] + 1) * cw + b[0] * gx,
pad * 2 + (b[1] + 1) * ch + b[1] * gy);
}
@Override
public Dimension getPreferredSize() {
if (view.cells().isEmpty()) {
return available();
}
Dimension avail = available();
int[] b = gridBounds();
Dimension c = content(effScale(avail, b), b);
// Fill the viewport when the map is smaller; allow growth (scroll) when zoomed in.
return new Dimension(Math.max(c.width, avail.width), Math.max(c.height, avail.height));
}
@Override
protected void paintComponent(Graphics g0) {
Graphics2D g = (Graphics2D) g0.create();
g.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);
g.setColor(BG);
g.fillRect(0, 0, getWidth(), getHeight());
if (view.cells().isEmpty()) {
g.dispose();
return;
}
int[] b = gridBounds();
float eff = effScale(available(), b);
g.setFont(baseFont.deriveFont(BASE_FONT * eff));
int cellW = Math.round(BASE_CELL_W * eff);
int cellH = Math.round(BASE_CELL_H * eff);
int gapX = Math.round(BASE_GAP_X * eff);
int gapY = Math.round(BASE_GAP_Y * eff);
int pad = Math.round(BASE_PAD * eff);
int arc = Math.round(12 * eff);
Dimension c = content(eff, b);
int offX = Math.max(0, (getWidth() - c.width) / 2);
int offY = Math.max(0, (getHeight() - c.height) / 2);
Stroke solid = new BasicStroke(2f * eff);
Stroke dashed = new BasicStroke(1.5f * eff, BasicStroke.CAP_BUTT, BasicStroke.JOIN_MITER,
10f, new float[]{4f * eff, 3f * eff}, 0f);
// corridors first
for (Connection cn : view.connections()) {
boolean known = cn.from().state() == CellState.KNOWN || cn.to().state() == CellState.KNOWN;
g.setStroke(known ? dashed : solid);
g.setColor(known ? KNOWN : CORRIDOR);
int x1 = offX + pad + cn.from().x() * (cellW + gapX) + cellW / 2;
int y1 = offY + pad + cn.from().y() * (cellH + gapY) + cellH / 2;
int x2 = offX + pad + cn.to().x() * (cellW + gapX) + cellW / 2;
int y2 = offY + pad + cn.to().y() * (cellH + gapY) + cellH / 2;
g.drawLine(x1, y1, x2, y2);
}
// rooms on top
for (RoomCell cell : view.cells()) {
int x = offX + pad + cell.x() * (cellW + gapX);
int y = offY + pad + cell.y() * (cellH + gapY);
Color stroke = switch (cell.state()) {
case CURRENT -> CURRENT;
case VISITED -> VISITED;
case KNOWN -> KNOWN;
};
g.setColor(cell.state() == CellState.CURRENT
? new Color(0x27, 0x21, 0x10)
: new Color(0x11, 0x16, 0x1f));
g.fillRoundRect(x, y, cellW, cellH, arc, arc);
g.setStroke(cell.state() == CellState.KNOWN
? dashed
: new BasicStroke((cell.state() == CellState.CURRENT ? 2.5f : 1.5f) * eff));
g.setColor(stroke);
g.drawRoundRect(x, y, cellW, cellH, arc, arc);
String label = cell.state() == CellState.KNOWN ? "?" : cell.name();
int tw = g.getFontMetrics().stringWidth(label);
int ascent = g.getFontMetrics().getAscent();
g.setColor(cell.state() == CellState.KNOWN ? KNOWN.brighter() : stroke);
g.drawString(label, x + (cellW - tw) / 2, y + (cellH + ascent) / 2 - 2);
}
g.dispose();
}
}

View File

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

View File

@@ -0,0 +1,55 @@
package thb.jeanluc.adventure.io;
import java.util.Objects;
/**
* Frontend-agnostic music decision logic. Idempotent per track (never restarts
* the same track), fades to silence on a null/blank track, and honours the
* {@link MusicLevel} (OFF disables playback). Delegates raw operations to a
* {@link MusicBackend}.
*/
public final class MusicController {
private final MusicBackend backend;
private MusicLevel level = MusicLevel.MEDIUM;
private String current; // currently selected track, or null = silence
public MusicController(MusicBackend backend) {
this.backend = backend;
}
/** Called per turn with the current room's music field (may be null/blank). */
public void room(String track) {
if (!level.isOn()) {
return;
}
String next = (track == null || track.isBlank()) ? null : track;
if (Objects.equals(next, current)) {
return;
}
current = next;
if (next == null) {
backend.fadeOut();
} else {
backend.fadeTo(next, level.gainDb());
}
}
/** Applies a new music level (volume / off). */
public void level(MusicLevel newLevel) {
if (newLevel == this.level) {
return;
}
this.level = newLevel;
if (!newLevel.isOn()) {
backend.fadeOut();
current = null; // turning back on replays on the next room() call
} else {
backend.setGainDb(newLevel.gainDb());
}
}
public void shutdown() {
backend.shutdown();
}
}

View File

@@ -0,0 +1,26 @@
package thb.jeanluc.adventure.io;
/** Background-music volume level, cycled in the Settings menu. */
public enum MusicLevel {
OFF, LOW, MEDIUM, HIGH;
/** Target MASTER_GAIN in decibels. Irrelevant for {@link #OFF} (playback disabled). */
public float gainDb() {
return switch (this) {
case OFF -> Float.NEGATIVE_INFINITY;
case LOW -> -20f;
case MEDIUM -> -10f;
case HIGH -> 0f;
};
}
/** @return true when music should play. */
public boolean isOn() {
return this != OFF;
}
/** Next level in the cycle (wraps HIGH → OFF). */
public MusicLevel next() {
return values()[(ordinal() + 1) % values().length];
}
}

View File

@@ -0,0 +1,155 @@
package thb.jeanluc.adventure.io;
import lombok.extern.slf4j.Slf4j;
import javax.sound.sampled.AudioFormat;
import javax.sound.sampled.AudioInputStream;
import javax.sound.sampled.AudioSystem;
import javax.sound.sampled.FloatControl;
import javax.sound.sampled.SourceDataLine;
import java.nio.file.Files;
import java.nio.file.Path;
/**
* Streams OGG Vorbis tracks from the external {@code music/} folder through a
* {@link SourceDataLine}, looping, with ~400 ms gain fades. Decoding uses the
* vorbisspi/jorbis {@code javax.sound} SPI. All playback runs on a daemon
* thread; missing/undecodable files are logged and stay silent. GUI-only —
* verified manually.
*/
@Slf4j
public final class OggMusicBackend implements MusicBackend {
private static final String MUSIC_DIR = "music";
private static final int FADE_MS = 400;
private static final int FADE_STEPS = 20;
private static final int BUFFER = 4096;
private static final float FLOOR_DB = -40f; // near-silence for fades
private volatile Thread playThread;
private volatile boolean stopping;
private volatile SourceDataLine line;
private volatile float targetGainDb = MusicLevel.MEDIUM.gainDb();
@Override
public synchronized void fadeTo(String track, float gainDb) {
stopCurrent();
targetGainDb = gainDb;
Path file = Path.of(MUSIC_DIR, track);
if (!Files.isReadable(file)) {
log.warn("Music file not found, staying silent: {}", file);
return;
}
stopping = false;
playThread = new Thread(() -> playLoop(file, gainDb), "music");
playThread.setDaemon(true);
playThread.start();
}
@Override
public synchronized void fadeOut() {
stopCurrent();
}
@Override
public synchronized void setGainDb(float gainDb) {
targetGainDb = gainDb;
SourceDataLine l = line;
if (l != null) {
applyGain(l, gainDb);
}
}
@Override
public synchronized void shutdown() {
stopCurrent();
}
/** Signals the play thread to stop and waits for its fade-out + cleanup. */
private void stopCurrent() {
Thread t = playThread;
if (t == null) {
return;
}
stopping = true;
try {
t.join(FADE_MS + 600L);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
playThread = null;
}
private void playLoop(Path file, float gainDb) {
SourceDataLine l = null;
try {
while (!stopping) {
try (AudioInputStream in = AudioSystem.getAudioInputStream(file.toFile())) {
AudioFormat base = in.getFormat();
AudioFormat pcm = new AudioFormat(AudioFormat.Encoding.PCM_SIGNED,
base.getSampleRate(), 16, base.getChannels(),
base.getChannels() * 2, base.getSampleRate(), false);
try (AudioInputStream din = AudioSystem.getAudioInputStream(pcm, in)) {
if (l == null) {
l = AudioSystem.getSourceDataLine(pcm);
l.open(pcm);
l.start();
line = l;
ramp(l, FLOOR_DB, gainDb); // fade in once
}
byte[] buf = new byte[BUFFER];
int n;
while (!stopping && (n = din.read(buf, 0, buf.length)) != -1) {
l.write(buf, 0, n);
}
}
}
}
} catch (Exception e) {
log.warn("Music playback failed for {}: {}", file, e.getMessage());
} finally {
if (l != null) {
try {
ramp(l, currentGain(l), FLOOR_DB); // fade out
} catch (RuntimeException ignored) {
// best-effort fade
}
l.stop();
l.close();
}
line = null;
}
}
private void ramp(SourceDataLine l, float fromDb, float toDb) {
for (int i = 0; i <= FADE_STEPS; i++) {
applyGain(l, fromDb + (toDb - fromDb) * i / FADE_STEPS);
sleep(FADE_MS / FADE_STEPS);
}
}
private float currentGain(SourceDataLine l) {
try {
return ((FloatControl) l.getControl(FloatControl.Type.MASTER_GAIN)).getValue();
} catch (IllegalArgumentException e) {
return targetGainDb;
}
}
private void applyGain(SourceDataLine l, float db) {
try {
FloatControl gain = (FloatControl) l.getControl(FloatControl.Type.MASTER_GAIN);
gain.setValue(Math.max(gain.getMinimum(), Math.min(gain.getMaximum(), db)));
} catch (IllegalArgumentException ignored) {
// line has no master-gain control on this platform
}
}
private void sleep(long ms) {
try {
Thread.sleep(ms);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
}

View File

@@ -0,0 +1,60 @@
package thb.jeanluc.adventure.io;
import thb.jeanluc.adventure.io.text.QuestText;
import thb.jeanluc.adventure.io.text.QuestView;
import thb.jeanluc.adventure.io.text.Span;
import thb.jeanluc.adventure.io.text.Style;
import javax.swing.JTextPane;
import javax.swing.SwingUtilities;
import javax.swing.text.BadLocationException;
import javax.swing.text.SimpleAttributeSet;
import javax.swing.text.StyleConstants;
import javax.swing.text.StyledDocument;
import java.awt.Color;
import java.awt.Font;
import java.awt.Insets;
/** Read-only styled view of the quest log, shown under the map. */
public class QuestPanel extends JTextPane {
public QuestPanel(Font font) {
setEditable(false);
setBackground(new Color(0x0b, 0x0e, 0x13));
setForeground(new Color(0xcf, 0xd6, 0xe0));
setFont(font.deriveFont(12f));
setMargin(new Insets(8, 10, 8, 10));
}
public void show(QuestView view) {
SwingUtilities.invokeLater(() -> {
StyledDocument doc = getStyledDocument();
try {
doc.remove(0, doc.getLength());
for (Span sp : QuestText.render(view).spans()) {
SimpleAttributeSet as = new SimpleAttributeSet();
StyleConstants.setForeground(as, colorFor(sp.style()));
if (sp.style() == Style.HEADING) {
StyleConstants.setBold(as, true);
}
doc.insertString(doc.getLength(), sp.text(), as);
}
setCaretPosition(0);
} catch (BadLocationException ignored) {
// replace-only; cannot occur with getLength()
}
});
}
private Color colorFor(Style s) {
return switch (s) {
case HEADING -> new Color(0xc9, 0x8a, 0xe0);
case ITEM -> new Color(0x46, 0xc8, 0xd8);
case NPC -> new Color(0xe6, 0xc3, 0x4a);
case EXIT -> new Color(0x6f, 0xcf, 0x73);
case DANGER -> new Color(0xe0, 0x6c, 0x6c);
case DIM -> new Color(0x6b, 0x72, 0x80);
case PLAIN -> new Color(0xcf, 0xd6, 0xe0);
};
}
}

View File

@@ -1,73 +1,297 @@
package thb.jeanluc.adventure.io; package thb.jeanluc.adventure.io;
import thb.jeanluc.adventure.io.text.Hud;
import thb.jeanluc.adventure.io.text.MapView;
import thb.jeanluc.adventure.io.text.QuestView;
import thb.jeanluc.adventure.io.text.RoomView;
import thb.jeanluc.adventure.io.text.Span;
import thb.jeanluc.adventure.io.text.Style;
import thb.jeanluc.adventure.io.text.StyledText;
import javax.swing.AbstractAction;
import javax.swing.BorderFactory; import javax.swing.BorderFactory;
import javax.swing.Box;
import javax.swing.BoxLayout;
import javax.swing.JButton;
import javax.swing.JComponent;
import javax.swing.JFrame; import javax.swing.JFrame;
import javax.swing.JLabel;
import javax.swing.JPanel;
import javax.swing.JScrollPane; import javax.swing.JScrollPane;
import javax.swing.JTextArea;
import javax.swing.JTextField; import javax.swing.JTextField;
import javax.swing.JTextPane;
import javax.swing.KeyStroke;
import javax.swing.SwingConstants;
import javax.swing.SwingUtilities; import javax.swing.SwingUtilities;
import javax.swing.text.BadLocationException;
import javax.swing.text.SimpleAttributeSet;
import javax.swing.text.StyleConstants;
import javax.swing.text.StyledDocument;
import java.awt.BorderLayout; import java.awt.BorderLayout;
import java.awt.Color; import java.awt.Color;
import java.awt.Component;
import java.awt.Cursor;
import java.awt.Dimension;
import java.awt.Font; import java.awt.Font;
import java.awt.GraphicsConfiguration;
import java.awt.GraphicsEnvironment;
import java.awt.GridBagLayout;
import java.awt.Insets;
import java.awt.Toolkit;
import java.awt.event.ActionEvent;
import java.awt.event.ComponentAdapter;
import java.awt.event.ComponentEvent;
import java.awt.event.KeyEvent;
import java.io.InputStream;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.LinkedBlockingQueue; import java.util.concurrent.LinkedBlockingQueue;
/** /**
* {@link GameIO} backed by a Swing window. A read-only output area shows * {@link GameIO} backed by a Swing window with four regions: a HUD bar (top),
* game text; a bottom text field collects player input. The input from * a styled output pane (centre), a side panel for exits / future map (right),
* the EDT is handed to the (worker-thread) game loop through a * and an input field (bottom). A bundled TrueType font is loaded if present so
* {@link LinkedBlockingQueue}. * colours and glyphs work without the player installing anything.
*
* <p>Font sizes are multiplied by a detected display scale (HiDPI) and by a
* user-adjustable zoom ({@code Ctrl +} / {@code Ctrl -} / {@code Ctrl 0}), so
* text stays readable on high-DPI laptops even when the JVM does not pick up
* the desktop scaling factor.</p>
*/ */
public class SwingIO implements GameIO { public class SwingIO implements GameIO {
/** Bridge between the EDT and the game loop. One element = one line. */ /** Unscaled base size for the main output/input font, in points. */
private static final float BASE_SIZE = 15f;
/** Unscaled size for the HUD and side-panel font, in points. */
private static final float SMALL_SIZE = 12.5f;
/** Delay between rooms while animating a go-to auto-walk, in milliseconds. */
private static final long TRAVEL_STEP_MS = 300L;
/** Side-panel width as a fraction of the window width, clamped by the bounds below. */
private static final double SIDE_RATIO = 0.28;
private static final int SIDE_MIN = 180;
private static final int SIDE_MAX = 460;
private final MusicController music = new MusicController(new OggMusicBackend());
private final LinkedBlockingQueue<String> inputs = new LinkedBlockingQueue<>(); private final LinkedBlockingQueue<String> inputs = new LinkedBlockingQueue<>();
/** Main window. */
private final JFrame frame; private final JFrame frame;
private final JTextPane output;
/** Output text area; appended to from any thread via {@link SwingUtilities#invokeLater}. */ private final StyledDocument doc;
private final JTextArea output;
/** Input text field; ENTER pushes the line into {@link #inputs}. */
private final JTextField input; private final JTextField input;
private final JLabel hud;
private final MapPanel map;
private final JScrollPane sideScroll;
private final QuestPanel quests;
private final JScrollPane questScroll;
private final JPanel sidePanel;
private final Font baseFont;
/** Detected display scale (≥ 1.0); HiDPI panels report > 1.0 where supported. */
private final float uiScale;
/** User zoom multiplier, adjusted live via keyboard. */
private float zoom = 1f;
/**
* Creates and shows the window with the given title. Must be called on
* the EDT (typical pattern: from {@code SwingUtilities.invokeLater}).
*
* @param title window title
*/
public SwingIO(String title) { public SwingIO(String title) {
frame = new JFrame(title); baseFont = loadFont();
output = new JTextArea(); uiScale = detectScale();
input = new JTextField();
output = new JTextPane();
output.setEditable(false); output.setEditable(false);
output.setLineWrap(true); output.setBackground(new Color(0x0b, 0x0e, 0x13));
output.setWrapStyleWord(true); output.setForeground(new Color(0xcf, 0xd6, 0xe0));
output.setBackground(Color.BLACK); output.setMargin(new Insets(8, 8, 8, 8));
output.setForeground(Color.LIGHT_GRAY); doc = output.getStyledDocument();
output.setFont(new Font(Font.MONOSPACED, Font.PLAIN, 14));
output.setBorder(BorderFactory.createEmptyBorder(8, 8, 8, 8));
input.setFont(new Font(Font.MONOSPACED, Font.PLAIN, 14)); hud = new JLabel(" ");
hud.setOpaque(true);
hud.setBackground(new Color(0x11, 0x15, 0x1c));
hud.setForeground(new Color(0x8b, 0x94, 0xa3));
hud.setBorder(BorderFactory.createEmptyBorder(6, 12, 6, 12));
map = new MapPanel(baseFont);
sideScroll = new JScrollPane(map);
sideScroll.getViewport().setBackground(new Color(0x0e, 0x12, 0x18));
quests = new QuestPanel(baseFont);
questScroll = new JScrollPane(quests);
questScroll.setBorder(BorderFactory.createMatteBorder(1, 0, 0, 0, new Color(0x2a, 0x2f, 0x3a)));
sidePanel = new JPanel(new BorderLayout());
sidePanel.add(sideScroll, BorderLayout.CENTER);
sidePanel.add(questScroll, BorderLayout.SOUTH);
input = new JTextField();
input.addActionListener(e -> { input.addActionListener(e -> {
String line = input.getText(); String line = input.getText();
input.setText(""); input.setText("");
output.append("> " + line + "\n"); appendSpans(List.of(new Span("> " + line + "\n", Style.DIM)));
inputs.offer(line); inputs.offer(line);
}); });
frame = new JFrame(title);
frame.setLayout(new BorderLayout()); frame.setLayout(new BorderLayout());
frame.add(hud, BorderLayout.NORTH);
frame.add(new JScrollPane(output), BorderLayout.CENTER); frame.add(new JScrollPane(output), BorderLayout.CENTER);
frame.add(sidePanel, BorderLayout.EAST);
frame.add(input, BorderLayout.SOUTH); frame.add(input, BorderLayout.SOUTH);
frame.setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE); frame.setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE);
frame.setSize(800, 600); frame.setSize((int) (900 * uiScale), (int) (600 * uiScale));
frame.setLocationRelativeTo(null); frame.setLocationRelativeTo(null);
frame.addComponentListener(new ComponentAdapter() {
@Override
public void componentResized(ComponentEvent e) {
updateSideWidth();
applyFonts();
rescaleMenuOverlayIfVisible();
}
});
installZoomKeys();
applyFonts();
updateSideWidth();
frame.setVisible(true); frame.setVisible(true);
input.requestFocusInWindow(); input.requestFocusInWindow();
} }
/** Loads the bundled font if present, else falls back to a logical monospaced font. */
private Font loadFont() {
try (InputStream in = getClass().getResourceAsStream("/fonts/game.ttf")) {
if (in != null) {
Font f = Font.createFont(Font.TRUETYPE_FONT, in);
GraphicsEnvironment.getLocalGraphicsEnvironment().registerFont(f);
return f;
}
} catch (Exception e) {
// fall through to the logical monospaced fallback
}
return new Font(Font.MONOSPACED, Font.PLAIN, 12);
}
/** Detects the display scale; HiDPI-aware where the platform reports it, else uses DPI. */
private float detectScale() {
try {
GraphicsConfiguration gc = GraphicsEnvironment.getLocalGraphicsEnvironment()
.getDefaultScreenDevice().getDefaultConfiguration();
double transform = gc.getDefaultTransform().getScaleX();
if (transform > 1.0) {
return (float) transform;
}
} catch (Exception ignored) {
// fall through to DPI heuristic
}
try {
int dpi = Toolkit.getDefaultToolkit().getScreenResolution();
return Math.max(1f, dpi / 96f);
} catch (Exception ignored) {
return 1f;
}
}
/** Sizes the side panel to a fraction of the window width, clamped to [min, max] (scaled). */
private void updateSideWidth() {
int min = (int) (SIDE_MIN * uiScale);
int max = (int) (SIDE_MAX * uiScale);
int target = Math.max(min, Math.min(max, (int) (frame.getWidth() * SIDE_RATIO)));
sidePanel.setPreferredSize(new Dimension(target, 0));
questScroll.setPreferredSize(new Dimension(target, (int) (frame.getHeight() * 0.32)));
sidePanel.revalidate();
frame.validate();
// The side panel changed size: let the map re-fit into it.
map.revalidate();
map.repaint();
}
/**
* Auto-scale derived from the window size relative to the design size
* (900x600). At the initial size this equals {@link #uiScale}; growing the
* window grows the text, mirroring how the map fits its panel.
*/
private float frameScale() {
double s = Math.min(frame.getWidth() / 900.0, frame.getHeight() / 600.0);
return (float) Math.max(0.6, Math.min(2.6, s));
}
/** Re-derives and applies all component fonts from the window auto-scale and zoom. */
private void applyFonts() {
float auto = frameScale();
float main = BASE_SIZE * auto * zoom;
float small = SMALL_SIZE * auto * zoom;
output.setFont(baseFont.deriveFont(main));
input.setFont(baseFont.deriveFont(main));
hud.setFont(baseFont.deriveFont(small));
quests.setFont(baseFont.deriveFont(small));
map.setZoom(zoom);
frame.revalidate();
frame.repaint();
}
/** Binds Ctrl +, Ctrl - and Ctrl 0 to live font zoom. */
private void installZoomKeys() {
JComponent root = frame.getRootPane();
bind(root, KeyStroke.getKeyStroke(KeyEvent.VK_PLUS, KeyEvent.CTRL_DOWN_MASK), "zoomIn");
bind(root, KeyStroke.getKeyStroke(KeyEvent.VK_EQUALS, KeyEvent.CTRL_DOWN_MASK), "zoomIn");
bind(root, KeyStroke.getKeyStroke(KeyEvent.VK_ADD, KeyEvent.CTRL_DOWN_MASK), "zoomIn");
bind(root, KeyStroke.getKeyStroke(KeyEvent.VK_MINUS, KeyEvent.CTRL_DOWN_MASK), "zoomOut");
bind(root, KeyStroke.getKeyStroke(KeyEvent.VK_SUBTRACT, KeyEvent.CTRL_DOWN_MASK), "zoomOut");
bind(root, KeyStroke.getKeyStroke(KeyEvent.VK_0, KeyEvent.CTRL_DOWN_MASK), "zoomReset");
root.getActionMap().put("zoomIn", action(e -> setZoom(zoom + 0.1f)));
root.getActionMap().put("zoomOut", action(e -> setZoom(zoom - 0.1f)));
root.getActionMap().put("zoomReset", action(e -> setZoom(1f)));
}
private void bind(JComponent c, KeyStroke ks, String name) {
c.getInputMap(JComponent.WHEN_IN_FOCUSED_WINDOW).put(ks, name);
}
private AbstractAction action(java.util.function.Consumer<ActionEvent> body) {
return new AbstractAction() {
@Override
public void actionPerformed(ActionEvent e) {
body.accept(e);
}
};
}
private void setZoom(float z) {
zoom = Math.max(0.6f, Math.min(3.0f, z));
applyFonts();
}
private Color colorFor(Style s) {
return switch (s) {
case HEADING -> new Color(0xc9, 0x8a, 0xe0);
case ITEM -> new Color(0x46, 0xc8, 0xd8);
case NPC -> new Color(0xe6, 0xc3, 0x4a);
case EXIT -> new Color(0x6f, 0xcf, 0x73);
case DANGER -> new Color(0xe0, 0x6c, 0x6c);
case DIM -> new Color(0x6b, 0x72, 0x80);
case PLAIN -> new Color(0xcf, 0xd6, 0xe0);
};
}
private void appendSpans(List<Span> spans) {
SwingUtilities.invokeLater(() -> {
try {
for (Span sp : spans) {
SimpleAttributeSet as = new SimpleAttributeSet();
StyleConstants.setForeground(as, colorFor(sp.style()));
if (sp.style() == Style.HEADING) {
StyleConstants.setBold(as, true);
}
doc.insertString(doc.getLength(), sp.text(), as);
}
output.setCaretPosition(doc.getLength());
} catch (BadLocationException ignored) {
// append-only; cannot occur with getLength()
}
});
}
@Override @Override
public String readLine() { public String readLine() {
try { try {
@@ -79,7 +303,216 @@ public class SwingIO implements GameIO {
} }
@Override @Override
public void write(String s) { public void print(StyledText text) {
SwingUtilities.invokeLater(() -> output.append(s + "\n")); List<Span> spans = new ArrayList<>(text.spans());
spans.add(new Span("\n", Style.PLAIN));
appendSpans(spans);
}
@Override
public void showRoom(RoomView room) {
List<Span> spans = new ArrayList<>();
spans.add(new Span(room.name() + "\n", Style.HEADING));
spans.add(new Span(room.description().stripTrailing() + "\n", Style.PLAIN));
if (!room.items().isEmpty()) {
spans.add(new Span("You see ", Style.PLAIN));
addList(spans, room.items(), Style.ITEM);
spans.add(new Span(".\n", Style.PLAIN));
}
if (!room.npcs().isEmpty()) {
addList(spans, room.npcs(), Style.NPC);
spans.add(new Span(" is here.\n", Style.PLAIN));
}
appendSpans(spans);
// Exits are shown by the map panel, not as text.
}
@Override
public void setHud(Hud h) {
String txt = " " + h.location() + " gold " + h.gold() + " turn " + h.turn()
+ " light " + (h.lightOn() ? "on" : "off");
SwingUtilities.invokeLater(() -> hud.setText(txt));
}
@Override
public void setMusic(String track) {
music.room(track);
}
@Override
public void setMusicLevel(MusicLevel level) {
music.level(level);
}
@Override
public void setMap(MapView view) {
map.show(view);
}
@Override
public void travelStep(MapView view) {
setMap(view);
try {
Thread.sleep(TRAVEL_STEP_MS);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
@Override
public void setQuests(QuestView view) {
quests.show(view);
}
@Override
public void showQuests(QuestView view) {
// The GUI quest box is always visible; nothing extra to do.
}
@Override
public void showMap(MapView view) {
// The GUI map panel is always visible; nothing extra to do on the 'map' command.
}
@Override
public int choose(String title, List<String> options) {
// Full-window overlay (glass pane), not a floating dialog: it covers the game
// chrome so no panels/outlines show behind the menu, and it fills + scales with
// the window. EDT↔worker handoff as in readLine: the worker blocks on take(),
// a button click / Escape offers the chosen index.
LinkedBlockingQueue<Integer> picked = new LinkedBlockingQueue<>();
SwingUtilities.invokeLater(() -> showMenuOverlay(title, options, picked));
try {
return picked.take();
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
return options.size() - 1;
}
}
/** Current menu being shown, so a window resize can rebuild it at the new scale. */
private String menuTitle;
private List<String> menuOptions;
private LinkedBlockingQueue<Integer> menuPicked;
/** Rebuilds the visible menu overlay at the current window scale (called on resize). */
private void rescaleMenuOverlayIfVisible() {
if (menuOptions != null && frame.getGlassPane().isVisible()) {
showMenuOverlay(menuTitle, menuOptions, menuPicked);
}
}
/** Installs the menu as the frame's glass pane (covers everything) and shows it. */
private void showMenuOverlay(String title, List<String> options, LinkedBlockingQueue<Integer> picked) {
menuTitle = title;
menuOptions = options;
menuPicked = picked;
float scale = frameScale() * zoom; // same scaling the rest of the UI uses
JPanel glass = new JPanel(new GridBagLayout());
glass.setOpaque(true);
glass.setBackground(MENU_BG);
glass.addMouseListener(new java.awt.event.MouseAdapter() { }); // swallow backdrop clicks
JPanel card = new JPanel();
card.setOpaque(false);
card.setLayout(new BoxLayout(card, BoxLayout.Y_AXIS));
JLabel heading = new JLabel(title.toUpperCase());
heading.setForeground(MENU_TITLE);
heading.setFont(baseFont.deriveFont(Font.BOLD, BASE_SIZE * scale * 1.6f));
heading.setAlignmentX(Component.CENTER_ALIGNMENT);
heading.setBorder(BorderFactory.createEmptyBorder(0, 0, (int) (22 * scale), 0));
card.add(heading);
Runnable cancel = () -> {
hideMenuOverlay();
picked.offer(options.size() - 1);
};
for (int i = 0; i < options.size(); i++) {
int idx = i;
JButton b = menuButton(options.get(i), scale);
b.addActionListener(e -> {
hideMenuOverlay();
picked.offer(idx);
});
card.add(b);
if (i < options.size() - 1) {
card.add(Box.createVerticalStrut((int) (10 * scale)));
}
}
// Escape selects the last (safe/cancel) option — mirrors the console EOF convention.
glass.getInputMap(JComponent.WHEN_IN_FOCUSED_WINDOW)
.put(KeyStroke.getKeyStroke(KeyEvent.VK_ESCAPE, 0), "menuCancel");
glass.getActionMap().put("menuCancel", action(e -> cancel.run()));
glass.add(card); // single child → centered by GridBagLayout
frame.setGlassPane(glass);
glass.setVisible(true);
glass.requestFocusInWindow();
}
private void hideMenuOverlay() {
menuOptions = null; // stop resize rebuilds once the menu is dismissed
frame.getGlassPane().setVisible(false);
}
/** Palette for the themed menu overlay, matching the game window's dark look. */
private static final Color MENU_BG = new Color(0x0b, 0x0e, 0x13);
private static final Color MENU_TITLE = new Color(0xc9, 0x8a, 0xe0);
private static final Color BTN_BG = new Color(0x16, 0x1b, 0x24);
private static final Color BTN_BG_HOVER = new Color(0x22, 0x2a, 0x37);
private static final Color BTN_FG = new Color(0xcf, 0xd6, 0xe0);
private static final Color BTN_LINE = new Color(0x2a, 0x2f, 0x3a);
private static final Color BTN_LINE_HOVER = new Color(0x6f, 0xcf, 0x73);
/** A flat, dark, fixed-width menu button matching the game palette, with hover feedback. */
private JButton menuButton(String text, float scale) {
int padV = (int) (11 * scale);
int padH = (int) (18 * scale);
JButton b = new JButton(text);
b.setFont(baseFont.deriveFont(BASE_SIZE * scale));
b.setForeground(BTN_FG);
b.setBackground(BTN_BG);
b.setFocusPainted(false);
b.setOpaque(true);
b.setHorizontalAlignment(SwingConstants.LEFT);
b.setCursor(Cursor.getPredefinedCursor(Cursor.HAND_CURSOR));
b.setAlignmentX(Component.CENTER_ALIGNMENT);
b.setBorder(menuButtonBorder(BTN_LINE, padV, padH));
Dimension size = new Dimension((int) (340 * scale), b.getPreferredSize().height);
b.setPreferredSize(size);
b.setMinimumSize(size);
b.setMaximumSize(size);
b.addMouseListener(new java.awt.event.MouseAdapter() {
@Override
public void mouseEntered(java.awt.event.MouseEvent e) {
b.setBackground(BTN_BG_HOVER);
b.setBorder(menuButtonBorder(BTN_LINE_HOVER, padV, padH));
}
@Override
public void mouseExited(java.awt.event.MouseEvent e) {
b.setBackground(BTN_BG);
b.setBorder(menuButtonBorder(BTN_LINE, padV, padH));
}
});
return b;
}
private static javax.swing.border.Border menuButtonBorder(Color line, int padV, int padH) {
return BorderFactory.createCompoundBorder(
BorderFactory.createLineBorder(line, 1),
BorderFactory.createEmptyBorder(padV, padH, padV, padH));
}
private void addList(List<Span> spans, List<String> xs, Style st) {
for (int i = 0; i < xs.size(); i++) {
if (i > 0) {
spans.add(new Span(", ", Style.PLAIN));
}
spans.add(new Span(xs.get(i), st));
}
} }
} }

View File

@@ -0,0 +1,127 @@
package thb.jeanluc.adventure.io.text;
import java.util.HashMap;
import java.util.HashSet;
import java.util.Map;
import java.util.Set;
/**
* Renders a {@link MapView} as a styled ASCII grid: boxed rooms connected by
* corridor lines. Box-drawing uses Unicode or pure ASCII depending on {@code unicode}.
*/
public final class AsciiMap {
private static final int MIN_INNER = 7; // minimum room name field width
private static final int MAX_INNER = 14; // cap so the map stays compact
private static final int GAP = 3; // horizontal corridor width
private AsciiMap() {
}
public static StyledText render(MapView view, boolean unicode) {
if (view.cells().isEmpty()) {
return StyledText.of("(no map yet)");
}
int maxX = 0;
int maxY = 0;
int inner = MIN_INNER;
Map<Long, RoomCell> at = new HashMap<>();
for (RoomCell c : view.cells()) {
maxX = Math.max(maxX, c.x());
maxY = Math.max(maxY, c.y());
inner = Math.max(inner, Math.min(MAX_INNER, c.name().length()));
at.put(key(c.x(), c.y()), c);
}
Set<Long> hEdge = new HashSet<>();
Set<Long> vEdge = new HashSet<>();
for (Connection cn : view.connections()) {
RoomCell a = cn.from();
RoomCell b = cn.to();
if (a.y() == b.y()) {
hEdge.add(key(Math.min(a.x(), b.x()), a.y()));
} else if (a.x() == b.x()) {
vEdge.add(key(a.x(), Math.min(a.y(), b.y())));
}
}
String tl = unicode ? "" : "+";
String tr = unicode ? "" : "+";
String bl = unicode ? "" : "+";
String br = unicode ? "" : "+";
String hz = unicode ? "" : "-";
String vt = unicode ? "" : "|";
int boxW = inner + 2;
int leftPad = (boxW - 1) / 2;
int rightPad = boxW - 1 - leftPad;
StyledText.Builder b = StyledText.builder();
for (int gy = 0; gy <= maxY; gy++) {
for (int line = 0; line < 3; line++) {
for (int gx = 0; gx <= maxX; gx++) {
RoomCell c = at.get(key(gx, gy));
if (c == null) {
b.plain(" ".repeat(boxW));
} else if (line == 0) {
styled(b, tl + hz.repeat(inner) + tr, c.state());
} else if (line == 2) {
styled(b, bl + hz.repeat(inner) + br, c.state());
} else {
styled(b, vt + center(label(c, inner), inner) + vt, c.state());
}
if (gx < maxX) {
if (line == 1 && hEdge.contains(key(gx, gy))) {
b.dim(hz.repeat(GAP));
} else {
b.plain(" ".repeat(GAP));
}
}
}
b.plain("\n");
}
if (gy < maxY) {
for (int gx = 0; gx <= maxX; gx++) {
if (vEdge.contains(key(gx, gy))) {
b.plain(" ".repeat(leftPad)).dim(vt).plain(" ".repeat(rightPad));
} else {
b.plain(" ".repeat(boxW));
}
if (gx < maxX) {
b.plain(" ".repeat(GAP));
}
}
b.plain("\n");
}
}
return b.build();
}
private static void styled(StyledText.Builder b, String s, CellState state) {
switch (state) {
case CURRENT -> b.heading(s);
case KNOWN -> b.dim(s);
default -> b.plain(s);
}
}
private static String label(RoomCell c, int inner) {
String n = c.name();
if (n.length() > inner) {
n = n.substring(0, inner);
}
return n;
}
private static String center(String s, int width) {
int total = width - s.length();
int left = total / 2;
int right = total - left;
return " ".repeat(left) + s + " ".repeat(right);
}
private static long key(int x, int y) {
return (((long) x) << 32) ^ (y & 0xffffffffL);
}
}

View File

@@ -0,0 +1,18 @@
package thb.jeanluc.adventure.io.text;
/** Builds styled banners for big moments (game start, finale). */
public final class Banner {
private Banner() {
}
/** A framed, heading-styled welcome banner for the given title. */
public static StyledText welcome(String title) {
String bar = "=".repeat(Math.max(8, title.length() + 8));
return StyledText.builder()
.heading(bar + "\n")
.heading(" " + title + "\n")
.heading(bar)
.build();
}
}

View File

@@ -0,0 +1,4 @@
package thb.jeanluc.adventure.io.text;
/** Fog-of-war state of a room on the map. */
public enum CellState { CURRENT, VISITED, KNOWN }

View File

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

View File

@@ -0,0 +1,4 @@
package thb.jeanluc.adventure.io.text;
/** Status snapshot for the HUD region. */
public record Hud(String location, int gold, int turn, boolean lightOn) {}

View File

@@ -0,0 +1,11 @@
package thb.jeanluc.adventure.io.text;
import java.util.List;
/** Frontend-agnostic snapshot of the visible map: placed cells + connections. */
public record MapView(List<RoomCell> cells, List<Connection> connections) {
public MapView {
cells = List.copyOf(cells);
connections = List.copyOf(connections);
}
}

View File

@@ -0,0 +1,4 @@
package thb.jeanluc.adventure.io.text;
/** An active quest's title and its current objective. */
public record QuestEntry(String title, String objective) {}

View File

@@ -0,0 +1,29 @@
package thb.jeanluc.adventure.io.text;
/** Renders a {@link QuestView} as styled text for the console quest log. */
public final class QuestText {
private QuestText() {
}
public static StyledText render(QuestView view) {
if (view.active().isEmpty() && view.completed().isEmpty()) {
return StyledText.of("You have no quests yet.");
}
StyledText.Builder b = StyledText.builder();
b.heading("QUESTS");
if (view.active().isEmpty()) {
b.plain("\n").dim(" (none active)");
}
for (QuestEntry e : view.active()) {
b.plain("\n").heading(e.title()).plain("\n").exit(" -> ").plain(e.objective());
}
if (!view.completed().isEmpty()) {
b.plain("\n\n").dim("Completed:");
for (String t : view.completed()) {
b.plain("\n").dim("" + t);
}
}
return b.build();
}
}

View File

@@ -0,0 +1,11 @@
package thb.jeanluc.adventure.io.text;
import java.util.List;
/** Frontend-agnostic snapshot of the quest log. */
public record QuestView(List<QuestEntry> active, List<String> completed) {
public QuestView {
active = List.copyOf(active);
completed = List.copyOf(completed);
}
}

View File

@@ -0,0 +1,49 @@
package thb.jeanluc.adventure.io.text;
import java.util.List;
/** Default, layout-free rendering of high-level views into {@link StyledText}. */
public final class Renderings {
private Renderings() {
}
/** Generic room rendering used by non-overriding {@code GameIO}s (e.g. tests). */
public static StyledText roomToStyledText(RoomView r) {
StyledText.Builder b = StyledText.builder();
b.heading(r.name()).plain("\n");
b.plain(r.description().stripTrailing());
if (!r.items().isEmpty()) {
b.plain("\nYou see: ");
appendList(b, r.items(), Style.ITEM);
b.plain(".");
}
if (!r.npcs().isEmpty()) {
b.plain("\nHere is: ");
appendList(b, r.npcs(), Style.NPC);
b.plain(".");
}
if (r.exits().isEmpty()) {
b.plain("\nThere are no obvious exits.");
} else {
b.plain("\nExits: ");
appendList(b, r.exits(), Style.EXIT);
b.plain(".");
}
return b.build();
}
private static void appendList(StyledText.Builder b, List<String> xs, Style st) {
for (int i = 0; i < xs.size(); i++) {
if (i > 0) {
b.plain(", ");
}
switch (st) {
case ITEM -> b.item(xs.get(i));
case NPC -> b.npc(xs.get(i));
case EXIT -> b.exit(xs.get(i));
default -> b.plain(xs.get(i));
}
}
}
}

View File

@@ -0,0 +1,4 @@
package thb.jeanluc.adventure.io.text;
/** A room placed on the map grid at integer coords, with its fog-of-war state. */
public record RoomCell(String id, String name, int x, int y, CellState state) {}

View File

@@ -0,0 +1,13 @@
package thb.jeanluc.adventure.io.text;
import java.util.List;
/** Semantic snapshot of a room for rendering. Display-ready strings, no layout. */
public record RoomView(String name, String description,
List<String> items, List<String> npcs, List<String> exits) {
public RoomView {
items = List.copyOf(items);
npcs = List.copyOf(npcs);
exits = List.copyOf(exits);
}
}

View File

@@ -0,0 +1,13 @@
package thb.jeanluc.adventure.io.text;
/** A run of text carrying a single semantic {@link Style}. */
public record Span(String text, Style style) {
public Span {
if (text == null) {
throw new IllegalArgumentException("text must not be null");
}
if (style == null) {
throw new IllegalArgumentException("style must not be null");
}
}
}

View File

@@ -0,0 +1,4 @@
package thb.jeanluc.adventure.io.text;
/** Semantic output roles. Renderers map each role to concrete colours/attributes. */
public enum Style { PLAIN, HEADING, ITEM, NPC, EXIT, DANGER, DIM }

View File

@@ -0,0 +1,59 @@
package thb.jeanluc.adventure.io.text;
import java.util.ArrayList;
import java.util.List;
/** An ordered, immutable list of styled {@link Span}s. Built via {@link Builder}. */
public final class StyledText {
private final List<Span> spans;
private StyledText(List<Span> spans) {
this.spans = List.copyOf(spans);
}
/** @return the spans in order (unmodifiable). */
public List<Span> spans() {
return spans;
}
/** @return all span text concatenated, without any styling. */
public String plainText() {
StringBuilder sb = new StringBuilder();
for (Span s : spans) {
sb.append(s.text());
}
return sb.toString();
}
/** @return a styled text of a single {@link Style#PLAIN} span. */
public static StyledText of(String s) {
return new StyledText(List.of(new Span(s, Style.PLAIN)));
}
public static Builder builder() {
return new Builder();
}
/** Fluent builder; one method per {@link Style} role. */
public static final class Builder {
private final List<Span> spans = new ArrayList<>();
private Builder add(String text, Style style) {
spans.add(new Span(text, style));
return this;
}
public Builder plain(String s) { return add(s, Style.PLAIN); }
public Builder heading(String s) { return add(s, Style.HEADING); }
public Builder item(String s) { return add(s, Style.ITEM); }
public Builder npc(String s) { return add(s, Style.NPC); }
public Builder exit(String s) { return add(s, Style.EXIT); }
public Builder danger(String s) { return add(s, Style.DANGER); }
public Builder dim(String s) { return add(s, Style.DIM); }
public StyledText build() {
return new StyledText(spans);
}
}
}

View File

@@ -0,0 +1,53 @@
package thb.jeanluc.adventure.loader;
import thb.jeanluc.adventure.loader.dto.CombinationDto;
import thb.jeanluc.adventure.loader.dto.ConditionDto;
import thb.jeanluc.adventure.loader.dto.EffectDto;
import thb.jeanluc.adventure.model.Combination;
import thb.jeanluc.adventure.model.item.Item;
import java.util.List;
import java.util.Map;
/** Builds {@link Combination}s from {@link CombinationDto}s, validating item ids. */
public final class CombinationFactory {
private CombinationFactory() {
}
public static Combination fromDto(CombinationDto dto, Map<String, Item> items) {
requireItem(items, dto.a(), "a");
requireItem(items, dto.b(), "b");
if (dto.a().equals(dto.b())) {
throw new WorldLoadException(
"Combination cannot combine an item with itself: '" + dto.a() + "'");
}
if (dto.consume() != null) {
for (String id : dto.consume()) {
requireItem(items, id, "consume");
}
}
if (dto.produce() != null) {
requireItem(items, dto.produce(), "produce");
}
return new Combination(
dto.a(),
dto.b(),
ConditionDto.toModelList(dto.requires()),
dto.consume() == null ? List.of() : List.copyOf(dto.consume()),
dto.produce(),
EffectDto.toModelList(dto.effects()),
dto.response(),
dto.failText());
}
private static void requireItem(Map<String, Item> items, String id, String field) {
if (id == null) {
throw new WorldLoadException("Combination '" + field + "' is missing an item id");
}
if (!items.containsKey(id)) {
throw new WorldLoadException(
"Combination references unknown item '" + id + "' (field '" + field + "')");
}
}
}

View File

@@ -0,0 +1,21 @@
package thb.jeanluc.adventure.loader;
import thb.jeanluc.adventure.loader.dto.ConditionDto;
import thb.jeanluc.adventure.loader.dto.EndingDto;
import thb.jeanluc.adventure.model.Ending;
/** Builds {@link Ending} objects from {@link EndingDto}s. */
public final class EndingFactory {
private EndingFactory() {
}
public static Ending fromDto(EndingDto dto) {
return new Ending(
dto.id(),
dto.title(),
Boolean.TRUE.equals(dto.victory()),
ConditionDto.toModelList(dto.when()),
dto.text());
}
}

View File

@@ -30,12 +30,14 @@ public final class ItemFactory {
.id(dto.id()) .id(dto.id())
.name(dto.name()) .name(dto.name())
.description(dto.description()) .description(dto.description())
.light(Boolean.TRUE.equals(dto.light()))
.build(); .build();
case "readable" -> ReadableItem.builder() case "readable" -> ReadableItem.builder()
.id(dto.id()) .id(dto.id())
.name(dto.name()) .name(dto.name())
.description(dto.description()) .description(dto.description())
.readText(dto.readText()) .readText(dto.readText())
.light(Boolean.TRUE.equals(dto.light()))
.build(); .build();
case "switchable" -> SwitchableItem.builder() case "switchable" -> SwitchableItem.builder()
.id(dto.id()) .id(dto.id())
@@ -44,6 +46,8 @@ public final class ItemFactory {
.state(Boolean.TRUE.equals(dto.initialState())) .state(Boolean.TRUE.equals(dto.initialState()))
.onText(dto.onText()) .onText(dto.onText())
.offText(dto.offText()) .offText(dto.offText())
.effects(thb.jeanluc.adventure.loader.dto.EffectDto.toModelList(dto.effects()))
.light(Boolean.TRUE.equals(dto.light()))
.build(); .build();
default -> throw new WorldLoadException( default -> throw new WorldLoadException(
"Unknown item type '" + dto.type() + "' on item '" + dto.id() + "'"); "Unknown item type '" + dto.type() + "' on item '" + dto.id() + "'");

View File

@@ -0,0 +1,36 @@
package thb.jeanluc.adventure.loader;
import thb.jeanluc.adventure.loader.dto.ConditionDto;
import thb.jeanluc.adventure.loader.dto.EffectDto;
import thb.jeanluc.adventure.loader.dto.QuestDto;
import thb.jeanluc.adventure.loader.dto.QuestStageDto;
import thb.jeanluc.adventure.model.Quest;
import thb.jeanluc.adventure.model.QuestStage;
import java.util.ArrayList;
import java.util.List;
/** Builds {@link Quest} objects from {@link QuestDto}s (no reference resolution needed). */
public final class QuestFactory {
private QuestFactory() {
}
public static Quest fromDto(QuestDto dto) {
List<QuestStage> stages = new ArrayList<>();
if (dto.stages() != null) {
for (QuestStageDto s : dto.stages()) {
stages.add(new QuestStage(
s.objective(),
ConditionDto.toModelList(s.completion()),
EffectDto.toModelList(s.onComplete())));
}
}
return new Quest(
dto.id(),
dto.title(),
Boolean.TRUE.equals(dto.autoStart()),
stages,
EffectDto.toModelList(dto.onComplete()));
}
}

View File

@@ -1,10 +1,18 @@
package thb.jeanluc.adventure.loader; package thb.jeanluc.adventure.loader;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import thb.jeanluc.adventure.loader.dto.ConditionDto;
import thb.jeanluc.adventure.loader.dto.DescriptionStateDto;
import thb.jeanluc.adventure.loader.dto.DialogueLineDto;
import thb.jeanluc.adventure.loader.dto.EffectDto;
import thb.jeanluc.adventure.loader.dto.ExitLockDto;
import thb.jeanluc.adventure.loader.dto.NpcDto; import thb.jeanluc.adventure.loader.dto.NpcDto;
import thb.jeanluc.adventure.loader.dto.ReactionDto; import thb.jeanluc.adventure.loader.dto.ReactionDto;
import thb.jeanluc.adventure.loader.dto.RoomDto; import thb.jeanluc.adventure.loader.dto.RoomDto;
import thb.jeanluc.adventure.model.DescriptionState;
import thb.jeanluc.adventure.model.DialogueLine;
import thb.jeanluc.adventure.model.Direction; import thb.jeanluc.adventure.model.Direction;
import thb.jeanluc.adventure.model.ExitLock;
import thb.jeanluc.adventure.model.Npc; import thb.jeanluc.adventure.model.Npc;
import thb.jeanluc.adventure.model.NpcReaction; import thb.jeanluc.adventure.model.NpcReaction;
import thb.jeanluc.adventure.model.Room; import thb.jeanluc.adventure.model.Room;
@@ -75,6 +83,25 @@ public class ReferenceResolver {
room.addNpc(npc); room.addNpc(npc);
} }
} }
if (dto.exitLocks() != null) {
for (ExitLockDto lock : dto.exitLocks()) {
Direction direction;
try {
direction = Direction.fromString(lock.direction());
} catch (IllegalArgumentException ex) {
throw new WorldLoadException(
"Room '" + dto.id() + "' has exit lock with unknown direction '" + lock.direction() + "'");
}
room.addExitLock(direction, new ExitLock(
ConditionDto.toModelList(lock.requires()), lock.blocked()));
}
}
if (dto.descriptionStates() != null) {
for (DescriptionStateDto ds : dto.descriptionStates()) {
room.addDescriptionState(new DescriptionState(
ConditionDto.toModelList(ds.when()), ds.text()));
}
}
} }
} }
@@ -86,10 +113,15 @@ public class ReferenceResolver {
*/ */
public void resolveNpcs(List<NpcDto> npcDtos) { public void resolveNpcs(List<NpcDto> npcDtos) {
for (NpcDto dto : npcDtos) { for (NpcDto dto : npcDtos) {
Npc npc = npcs.get(dto.id());
if (dto.dialogue() != null) {
for (DialogueLineDto dl : dto.dialogue()) {
npc.addDialogue(new DialogueLine(ConditionDto.toModelList(dl.when()), dl.text()));
}
}
if (dto.reactions() == null) { if (dto.reactions() == null) {
continue; continue;
} }
Npc npc = npcs.get(dto.id());
for (ReactionDto r : dto.reactions()) { for (ReactionDto r : dto.reactions()) {
if (r.onReceive() == null) { if (r.onReceive() == null) {
throw new WorldLoadException( throw new WorldLoadException(
@@ -123,6 +155,8 @@ public class ReferenceResolver {
.consumes(consumes) .consumes(consumes)
.gives(gives) .gives(gives)
.response(r.response()) .response(r.response())
.requires(ConditionDto.toModelList(r.requires()))
.effects(EffectDto.toModelList(r.effects()))
.build(); .build();
npc.putReaction(r.onReceive(), reaction); npc.putReaction(r.onReceive(), reaction);
} }

View File

@@ -19,6 +19,9 @@ public final class RoomFactory {
* @return the freshly built room shell * @return the freshly built room shell
*/ */
public static Room shellFromDto(RoomDto dto) { public static Room shellFromDto(RoomDto dto) {
return new Room(dto.id(), dto.name(), dto.description()); Room room = new Room(dto.id(), dto.name(), dto.description());
room.setDark(Boolean.TRUE.equals(dto.dark()));
room.setMusic(dto.music());
return room;
} }
} }

View File

@@ -0,0 +1,63 @@
package thb.jeanluc.adventure.loader;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.dataformat.yaml.YAMLFactory;
import thb.jeanluc.adventure.loader.dto.TutorialDto;
import thb.jeanluc.adventure.loader.dto.TutorialStepDto;
import thb.jeanluc.adventure.model.Tutorial;
import thb.jeanluc.adventure.model.TutorialStep;
import java.io.IOException;
import java.io.InputStream;
import java.util.ArrayList;
import java.util.List;
/**
* Loads the optional onboarding tutorial from {@code <base>/tutorial.yaml}.
* Kept separate from {@link WorldLoader}: the tutorial is onboarding, not world
* content. Returns {@link Tutorial#none()} when the file is absent.
*/
public class TutorialLoader {
/** Default classpath base directory. */
public static final String DEFAULT_BASE = "/world";
private final ObjectMapper yaml = new ObjectMapper(new YAMLFactory());
private final String basePath;
public TutorialLoader() {
this(DEFAULT_BASE);
}
public TutorialLoader(String basePath) {
this.basePath = basePath;
}
/** Reads the tutorial; absent file → {@link Tutorial#none()}. */
public Tutorial load() {
String resource = basePath + "/tutorial.yaml";
try (InputStream in = getClass().getResourceAsStream(resource)) {
if (in == null) {
return Tutorial.none();
}
TutorialDto dto = yaml.readValue(in, TutorialDto.class);
if (dto == null) {
return Tutorial.none();
}
List<TutorialStep> steps = new ArrayList<>();
if (dto.steps() != null) {
for (TutorialStepDto s : dto.steps()) {
steps.add(new TutorialStep(
s.instruction(),
s.expect(),
s.minArgs() == null ? 0 : s.minArgs(),
s.confirm(),
s.hint()));
}
}
return new Tutorial(dto.intro(), steps, dto.closingTips());
} catch (IOException e) {
throw new WorldLoadException("Failed to parse " + resource, e);
}
}
}

View File

@@ -5,18 +5,25 @@ import com.fasterxml.jackson.databind.type.CollectionType;
import com.fasterxml.jackson.dataformat.yaml.YAMLFactory; import com.fasterxml.jackson.dataformat.yaml.YAMLFactory;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import thb.jeanluc.adventure.loader.dto.CombinationDto;
import thb.jeanluc.adventure.loader.dto.EndingDto;
import thb.jeanluc.adventure.loader.dto.GameDto; import thb.jeanluc.adventure.loader.dto.GameDto;
import thb.jeanluc.adventure.loader.dto.ItemDto; import thb.jeanluc.adventure.loader.dto.ItemDto;
import thb.jeanluc.adventure.loader.dto.NpcDto; import thb.jeanluc.adventure.loader.dto.NpcDto;
import thb.jeanluc.adventure.loader.dto.QuestDto;
import thb.jeanluc.adventure.loader.dto.RoomDto; import thb.jeanluc.adventure.loader.dto.RoomDto;
import thb.jeanluc.adventure.model.Combination;
import thb.jeanluc.adventure.model.Ending;
import thb.jeanluc.adventure.model.Npc; import thb.jeanluc.adventure.model.Npc;
import thb.jeanluc.adventure.model.Player; import thb.jeanluc.adventure.model.Player;
import thb.jeanluc.adventure.model.Quest;
import thb.jeanluc.adventure.model.Room; import thb.jeanluc.adventure.model.Room;
import thb.jeanluc.adventure.model.World; import thb.jeanluc.adventure.model.World;
import thb.jeanluc.adventure.model.item.Item; import thb.jeanluc.adventure.model.item.Item;
import java.io.IOException; import java.io.IOException;
import java.io.InputStream; import java.io.InputStream;
import java.util.ArrayList;
import java.util.HashMap; import java.util.HashMap;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
@@ -68,11 +75,17 @@ public class WorldLoader {
List<ItemDto> itemDtos = readList(basePath + "/items.yaml", ItemDto.class); List<ItemDto> itemDtos = readList(basePath + "/items.yaml", ItemDto.class);
List<NpcDto> npcDtos = readList(basePath + "/npcs.yaml", NpcDto.class); List<NpcDto> npcDtos = readList(basePath + "/npcs.yaml", NpcDto.class);
List<RoomDto> roomDtos = readList(basePath + "/rooms.yaml", RoomDto.class); List<RoomDto> roomDtos = readList(basePath + "/rooms.yaml", RoomDto.class);
List<QuestDto> questDtos = readListOptional(basePath + "/quests.yaml", QuestDto.class);
List<EndingDto> endingDtos = readListOptional(basePath + "/endings.yaml", EndingDto.class);
List<CombinationDto> combinationDtos =
readListOptional(basePath + "/combinations.yaml", CombinationDto.class);
GameDto gameDto = readSingle(basePath + "/game.yaml", GameDto.class); GameDto gameDto = readSingle(basePath + "/game.yaml", GameDto.class);
requireUniqueIds("item", itemDtos.stream().map(ItemDto::id).toList()); requireUniqueIds("item", itemDtos.stream().map(ItemDto::id).toList());
requireUniqueIds("npc", npcDtos.stream().map(NpcDto::id).toList()); requireUniqueIds("npc", npcDtos.stream().map(NpcDto::id).toList());
requireUniqueIds("room", roomDtos.stream().map(RoomDto::id).toList()); requireUniqueIds("room", roomDtos.stream().map(RoomDto::id).toList());
requireUniqueIds("quest", questDtos.stream().map(QuestDto::id).toList());
requireUniqueIds("ending", endingDtos.stream().map(EndingDto::id).toList());
Map<String, Item> items = new HashMap<>(); Map<String, Item> items = new HashMap<>();
for (ItemDto dto : itemDtos) { for (ItemDto dto : itemDtos) {
@@ -86,6 +99,24 @@ public class WorldLoader {
for (RoomDto dto : roomDtos) { for (RoomDto dto : roomDtos) {
rooms.put(dto.id(), RoomFactory.shellFromDto(dto)); rooms.put(dto.id(), RoomFactory.shellFromDto(dto));
} }
Map<String, Quest> quests = new HashMap<>();
for (QuestDto dto : questDtos) {
quests.put(dto.id(), QuestFactory.fromDto(dto));
}
List<Ending> endings = new ArrayList<>();
for (EndingDto dto : endingDtos) {
endings.add(EndingFactory.fromDto(dto));
}
Map<String, Combination> combinations = new HashMap<>();
for (CombinationDto dto : combinationDtos) {
Combination c = CombinationFactory.fromDto(dto, items);
String key = c.key();
if (combinations.containsKey(key)) {
throw new WorldLoadException("Duplicate combination for pair '" + key + "'");
}
combinations.put(key, c);
}
ReferenceResolver resolver = new ReferenceResolver(items, npcs, rooms); ReferenceResolver resolver = new ReferenceResolver(items, npcs, rooms);
resolver.resolveRooms(roomDtos); resolver.resolveRooms(roomDtos);
@@ -98,7 +129,7 @@ public class WorldLoader {
Player player = new Player(start, gold); Player player = new Player(start, gold);
World world = new World(rooms, items, npcs, World world = new World(rooms, items, npcs,
gameDto.title(), gameDto.welcomeMessage()); gameDto.title(), gameDto.welcomeMessage(), quests, endings, combinations);
log.info("World '{}' loaded: {} rooms, {} items, {} npcs", log.info("World '{}' loaded: {} rooms, {} items, {} npcs",
gameDto.title(), rooms.size(), items.size(), npcs.size()); gameDto.title(), rooms.size(), items.size(), npcs.size());
return new LoadResult(world, player); return new LoadResult(world, player);
@@ -114,6 +145,19 @@ public class WorldLoader {
} }
} }
private <T> List<T> readListOptional(String resource, Class<T> elementType) {
try (InputStream in = getClass().getResourceAsStream(resource)) {
if (in == null) {
return List.of();
}
CollectionType type = yaml.getTypeFactory().constructCollectionType(List.class, elementType);
List<T> result = yaml.readValue(in, type);
return result == null ? List.of() : result;
} catch (IOException e) {
throw new WorldLoadException("Failed to parse " + resource, e);
}
}
private <T> T readSingle(String resource, Class<T> type) { private <T> T readSingle(String resource, Class<T> type) {
try (InputStream in = openResource(resource)) { try (InputStream in = openResource(resource)) {
return yaml.readValue(in, type); return yaml.readValue(in, type);

View File

@@ -0,0 +1,16 @@
package thb.jeanluc.adventure.loader.dto;
import java.util.List;
/** YAML representation of an item-combination recipe. */
public record CombinationDto(
String a,
String b,
List<ConditionDto> requires,
List<String> consume,
String produce,
List<EffectDto> effects,
String response,
String failText
) {
}

View File

@@ -0,0 +1,34 @@
package thb.jeanluc.adventure.loader.dto;
import thb.jeanluc.adventure.loader.WorldLoadException;
import thb.jeanluc.adventure.model.Condition;
import java.util.ArrayList;
import java.util.List;
/** YAML condition: exactly one of the fields is set. */
public record ConditionDto(String flag, String notFlag, String hasItem) {
public Condition toModel() {
if (flag != null) {
return new Condition(Condition.Type.FLAG, flag);
}
if (notFlag != null) {
return new Condition(Condition.Type.NOT_FLAG, notFlag);
}
if (hasItem != null) {
return new Condition(Condition.Type.HAS_ITEM, hasItem);
}
throw new WorldLoadException("Condition must set one of flag/notFlag/hasItem");
}
public static List<Condition> toModelList(List<ConditionDto> dtos) {
List<Condition> out = new ArrayList<>();
if (dtos != null) {
for (ConditionDto d : dtos) {
out.add(d.toModel());
}
}
return out;
}
}

View File

@@ -0,0 +1,7 @@
package thb.jeanluc.adventure.loader.dto;
import java.util.List;
/** YAML representation of a conditional room description. */
public record DescriptionStateDto(List<ConditionDto> when, String text) {
}

View File

@@ -0,0 +1,7 @@
package thb.jeanluc.adventure.loader.dto;
import java.util.List;
/** YAML representation of a conditional NPC line. */
public record DialogueLineDto(List<ConditionDto> when, String text) {
}

View File

@@ -0,0 +1,50 @@
package thb.jeanluc.adventure.loader.dto;
import thb.jeanluc.adventure.loader.WorldLoadException;
import thb.jeanluc.adventure.model.Effect;
import java.util.ArrayList;
import java.util.List;
/** YAML effect: exactly one of the fields is set. */
public record EffectDto(String setFlag, String clearFlag, String giveItem, String removeItem,
String say, String startQuest) {
/** Backward-compatible constructor without startQuest. */
public EffectDto(String setFlag, String clearFlag, String giveItem, String removeItem, String say) {
this(setFlag, clearFlag, giveItem, removeItem, say, null);
}
public Effect toModel() {
if (setFlag != null) {
return new Effect(Effect.Type.SET_FLAG, setFlag);
}
if (clearFlag != null) {
return new Effect(Effect.Type.CLEAR_FLAG, clearFlag);
}
if (giveItem != null) {
return new Effect(Effect.Type.GIVE_ITEM, giveItem);
}
if (removeItem != null) {
return new Effect(Effect.Type.REMOVE_ITEM, removeItem);
}
if (say != null) {
return new Effect(Effect.Type.SAY, say);
}
if (startQuest != null) {
return new Effect(Effect.Type.START_QUEST, startQuest);
}
throw new WorldLoadException(
"Effect must set one of setFlag/clearFlag/giveItem/removeItem/say/startQuest");
}
public static List<Effect> toModelList(List<EffectDto> dtos) {
List<Effect> out = new ArrayList<>();
if (dtos != null) {
for (EffectDto d : dtos) {
out.add(d.toModel());
}
}
return out;
}
}

View File

@@ -0,0 +1,7 @@
package thb.jeanluc.adventure.loader.dto;
import java.util.List;
/** YAML representation of a game ending. */
public record EndingDto(String id, String title, Boolean victory, List<ConditionDto> when, String text) {
}

View File

@@ -0,0 +1,7 @@
package thb.jeanluc.adventure.loader.dto;
import java.util.List;
/** YAML representation of a single condition-gated exit. */
public record ExitLockDto(String direction, List<ConditionDto> requires, String blocked) {
}

View File

@@ -1,5 +1,7 @@
package thb.jeanluc.adventure.loader.dto; package thb.jeanluc.adventure.loader.dto;
import java.util.List;
/** /**
* YAML representation of a single item. Fields outside of the type's * YAML representation of a single item. Fields outside of the type's
* scope (e.g. {@code readText} on a switchable) are simply ignored. * scope (e.g. {@code readText} on a switchable) are simply ignored.
@@ -12,6 +14,8 @@ package thb.jeanluc.adventure.loader.dto;
* @param initialState initial on/off state of a switchable item * @param initialState initial on/off state of a switchable item
* @param onText message printed when a switchable transitions to on * @param onText message printed when a switchable transitions to on
* @param offText message printed when a switchable transitions to off * @param offText message printed when a switchable transitions to off
* @param effects effects applied when a switchable transitions to on; nullable
* @param light whether this item is a light source; nullable
*/ */
public record ItemDto( public record ItemDto(
String type, String type,
@@ -21,6 +25,13 @@ public record ItemDto(
String readText, String readText,
Boolean initialState, Boolean initialState,
String onText, String onText,
String offText String offText,
List<EffectDto> effects,
Boolean light
) { ) {
/** Backward-compatible constructor without effects/light. */
public ItemDto(String type, String id, String name, String description,
String readText, Boolean initialState, String onText, String offText) {
this(type, id, name, description, readText, initialState, onText, offText, null, null);
}
} }

View File

@@ -10,12 +10,18 @@ import java.util.List;
* @param description examine description * @param description examine description
* @param greeting text the NPC says on {@code talk} * @param greeting text the NPC says on {@code talk}
* @param reactions list of trigger/response definitions; may be null * @param reactions list of trigger/response definitions; may be null
* @param dialogue optional condition-gated dialogue lines; may be null
*/ */
public record NpcDto( public record NpcDto(
String id, String id,
String name, String name,
String description, String description,
String greeting, String greeting,
List<ReactionDto> reactions List<ReactionDto> reactions,
List<DialogueLineDto> dialogue
) { ) {
/** Backward-compatible constructor without dialogue. */
public NpcDto(String id, String name, String description, String greeting, List<ReactionDto> reactions) {
this(id, name, description, greeting, reactions, null);
}
} }

View File

@@ -0,0 +1,8 @@
package thb.jeanluc.adventure.loader.dto;
import java.util.List;
/** YAML representation of a quest. */
public record QuestDto(String id, String title, Boolean autoStart,
List<QuestStageDto> stages, List<EffectDto> onComplete) {
}

View File

@@ -0,0 +1,7 @@
package thb.jeanluc.adventure.loader.dto;
import java.util.List;
/** YAML representation of a quest stage. */
public record QuestStageDto(String objective, List<ConditionDto> completion, List<EffectDto> onComplete) {
}

View File

@@ -1,5 +1,7 @@
package thb.jeanluc.adventure.loader.dto; package thb.jeanluc.adventure.loader.dto;
import java.util.List;
/** /**
* YAML representation of a single NPC reaction. * YAML representation of a single NPC reaction.
* *
@@ -7,11 +9,19 @@ package thb.jeanluc.adventure.loader.dto;
* @param response text the NPC says after the exchange * @param response text the NPC says after the exchange
* @param gives id of the item handed back; nullable * @param gives id of the item handed back; nullable
* @param consumes id of the item taken from the player; nullable * @param consumes id of the item taken from the player; nullable
* @param requires conditions that must hold; nullable
* @param effects effects applied after a successful exchange; nullable
*/ */
public record ReactionDto( public record ReactionDto(
String onReceive, String onReceive,
String response, String response,
String gives, String gives,
String consumes String consumes,
List<ConditionDto> requires,
List<EffectDto> effects
) { ) {
/** Backward-compatible constructor without requires/effects. */
public ReactionDto(String onReceive, String response, String gives, String consumes) {
this(onReceive, response, gives, consumes, null, null);
}
} }

View File

@@ -12,6 +12,10 @@ import java.util.Map;
* @param exits direction-name to target-room-id map * @param exits direction-name to target-room-id map
* @param items ids of items initially in this room * @param items ids of items initially in this room
* @param npcs ids of NPCs initially in this room * @param npcs ids of NPCs initially in this room
* @param exitLocks optional condition-gates per exit direction
* @param descriptionStates optional condition-gated description variants
* @param dark whether this room is dark; nullable
* @param music optional GUI background-music track filename; null = none
*/ */
public record RoomDto( public record RoomDto(
String id, String id,
@@ -19,6 +23,15 @@ public record RoomDto(
String description, String description,
Map<String, String> exits, Map<String, String> exits,
List<String> items, List<String> items,
List<String> npcs List<String> npcs,
List<ExitLockDto> exitLocks,
List<DescriptionStateDto> descriptionStates,
Boolean dark,
String music
) { ) {
/** Backward-compatible constructor without the optional state fields. */
public RoomDto(String id, String name, String description,
Map<String, String> exits, List<String> items, List<String> npcs) {
this(id, name, description, exits, items, npcs, null, null, null, null);
}
} }

View File

@@ -0,0 +1,7 @@
package thb.jeanluc.adventure.loader.dto;
import java.util.List;
/** YAML representation of the tutorial document. */
public record TutorialDto(String intro, List<TutorialStepDto> steps, String closingTips) {
}

View File

@@ -0,0 +1,11 @@
package thb.jeanluc.adventure.loader.dto;
/** YAML representation of a tutorial step. */
public record TutorialStepDto(
String instruction,
String expect,
Integer minArgs,
String confirm,
String hint
) {
}

Some files were not shown because too many files have changed in this diff Show More