Compare commits
121 Commits
92005c72f5
...
f8f7eb90f0
| Author | SHA1 | Date | |
|---|---|---|---|
| f8f7eb90f0 | |||
| 675bf4c2ea | |||
| d0966c3ce4 | |||
| 1cbf606bd4 | |||
| 7ea11058a1 | |||
| aa802582ec | |||
| d8e1a662bc | |||
| 88cad77c83 | |||
| 702f7ac0d4 | |||
| 830a95c9f0 | |||
| f1aedebfcb | |||
| 7380dcae41 | |||
| c6ed199f7e | |||
| bf36730524 | |||
| fa7851b6e0 | |||
| 66e43830be | |||
| 977872e77a | |||
| 3dd27a1b63 | |||
| e7db45f98d | |||
| 705e515a8e | |||
| 00d3f97c9b | |||
| ebeda36b4a | |||
| 1ff475c085 | |||
| 236ed0a180 | |||
| c315d3f4b7 | |||
| e27a12e4fe | |||
| af319508f6 | |||
| dbfdf86810 | |||
| 91f79b1537 | |||
| 0250eadd50 | |||
| 0af172ff64 | |||
| d20f3fd7d6 | |||
| 97e2809719 | |||
| f1b9695c5e | |||
| 62b7663e34 | |||
| 86e138223a | |||
| e0e8612982 | |||
| 3711ef5a67 | |||
| 4ab5ad9d42 | |||
| 8257eb0c02 | |||
| 9152aded4b | |||
| 6eabebff00 | |||
| ae7f9d6b05 | |||
| 2747659800 | |||
| 100b54c4d1 | |||
| c91bc3afea | |||
| 022973dc75 | |||
| cf592edc57 | |||
| b41ac80194 | |||
| 1d0cbc25e4 | |||
| 182a6911a0 | |||
| 88e959adee | |||
| 98b1561ef2 | |||
| 361fd0939c | |||
| 4604952f63 | |||
| 084b39e501 | |||
| 0f05680170 | |||
| e4238e4553 | |||
| 687e88e20a | |||
| d847928587 | |||
| 206fc758a5 | |||
| ed41418434 | |||
| 6a68d7e2f5 | |||
| 3c7c9d2ad4 | |||
| 3be1036142 | |||
| c92c143058 | |||
| 310ee65285 | |||
| c392f80ed0 | |||
| 58352087c4 | |||
| 18336ecc44 | |||
| d34104f928 | |||
| 4ed1fa1010 | |||
| 569cef20fe | |||
| c3d80f86d7 | |||
| d7edc78882 | |||
| b23f706521 | |||
| 2238aa6ff5 | |||
| 3ec540e41b | |||
| c652c33a9a | |||
| 1b22c996e1 | |||
| e3fb0c9fa6 | |||
| 310d476264 | |||
| a3600eadd7 | |||
| 513c7cb8d9 | |||
| dd16d1326b | |||
| eeee609c26 | |||
| a0b80f2a4b | |||
| 00bad46661 | |||
| 258ab4f63b | |||
| 268c24d4e3 | |||
| c9c019cea1 | |||
| df4a46ae5b | |||
| 228efaffcb | |||
| 5546b63d47 | |||
| 9b28e2927e | |||
| 8ab39cd535 | |||
| e80c6b65bb | |||
| f6f62b66d8 | |||
| f1ec321e50 | |||
| 5ebbd2ce42 | |||
| 7948aa2088 | |||
| a0560a6ec5 | |||
| fcf8083b7c | |||
| 9ce939167c | |||
| c03123d5ae | |||
| e10ff2cf99 | |||
| 42dbcd9696 | |||
| f1b83f1f81 | |||
| 13f34e1694 | |||
| b042d7e0ef | |||
| 9316f67c31 | |||
| d3a9bd06cc | |||
| 650bbbe58c | |||
| 963ddfb89e | |||
| 5f9d52ca41 | |||
| 9274eef4c0 | |||
| 64cc654803 | |||
| 44fde78c64 | |||
| 02e448d61a | |||
| d57a1ab9a4 | |||
| 230177dfb5 |
3
Semesterprojekt/.gitignore
vendored
3
Semesterprojekt/.gitignore
vendored
@@ -41,6 +41,9 @@ build/
|
|||||||
### Brainstorming visual companion ###
|
### Brainstorming visual companion ###
|
||||||
.superpowers/
|
.superpowers/
|
||||||
|
|
||||||
|
# Planning specs & implementation plans (kept locally, not tracked)
|
||||||
|
docs/superpowers/
|
||||||
|
|
||||||
# Local save games and settings
|
# Local save games and settings
|
||||||
saves/
|
saves/
|
||||||
|
|
||||||
|
|||||||
3
Semesterprojekt/.mvn/wrapper/maven-wrapper.properties
vendored
Normal file
3
Semesterprojekt/.mvn/wrapper/maven-wrapper.properties
vendored
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
wrapperVersion=3.3.4
|
||||||
|
distributionType=only-script
|
||||||
|
distributionUrl=https://repo.maven.apache.org/maven2/org/apache/maven/apache-maven/3.9.16/apache-maven-3.9.16-bin.zip
|
||||||
@@ -20,25 +20,43 @@ werden aus YAML-Dateien geladen, nicht im Code festverdrahtet.
|
|||||||
## Voraussetzungen
|
## Voraussetzungen
|
||||||
|
|
||||||
- Java 25+
|
- Java 25+
|
||||||
- Maven 3.9+
|
- Maven 3.9+ **oder** der mitgelieferte Maven Wrapper (`./mvnw`) – damit ist
|
||||||
|
keine separate Maven-Installation nötig; der Wrapper lädt die passende
|
||||||
|
Maven-Version beim ersten Aufruf selbst herunter.
|
||||||
|
|
||||||
|
## Schnellstart ohne Maven
|
||||||
|
|
||||||
|
Im Ordner [`release/`](release/) liegt eine fertig gebaute, eigenständige JAR
|
||||||
|
(`HauntedManor.jar`) mit allen Abhängigkeiten. Sie braucht nur Java 25:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
java -jar release/HauntedManor.jar # Konsolen-Version
|
||||||
|
java -cp release/HauntedManor.jar thb.jeanluc.adventure.AppGui # Swing-GUI
|
||||||
|
```
|
||||||
|
|
||||||
## Bauen und Testen
|
## Bauen und Testen
|
||||||
|
|
||||||
|
Mit dem Wrapper (empfohlen, keine Maven-Installation nötig) – unter Windows
|
||||||
|
`mvnw.cmd` statt `./mvnw`:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
mvn clean test # alle Tests ausführen (67 Tests)
|
./mvnw clean test # alle Tests ausführen (233 Tests)
|
||||||
mvn clean package # JAR bauen
|
./mvnw clean package # baut die eigenständige JAR nach release/HauntedManor.jar
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Ein systemweit installiertes `mvn` funktioniert identisch (`mvn clean test` …).
|
||||||
|
|
||||||
## Spielen
|
## Spielen
|
||||||
|
|
||||||
Über die im `pom.xml` konfigurierten Run-Profile des `exec-maven-plugin`:
|
Über die im `pom.xml` konfigurierten Run-Profile des `exec-maven-plugin`:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
mvn exec:java@run # Konsolen-Version (thb.jeanluc.adventure.App)
|
./mvnw exec:java@run # Konsolen-Version (thb.jeanluc.adventure.App)
|
||||||
mvn exec:java@gui # Swing-GUI (thb.jeanluc.adventure.AppGui)
|
./mvnw exec:java@gui # Swing-GUI (thb.jeanluc.adventure.AppGui)
|
||||||
```
|
```
|
||||||
|
|
||||||
`mvn exec:java` ohne Profil startet ebenfalls die Konsolen-Version.
|
`./mvnw exec:java` ohne Profil startet ebenfalls die Konsolen-Version.
|
||||||
|
Alternativ direkt aus der gebauten JAR (siehe [Schnellstart](#schnellstart-ohne-maven)).
|
||||||
|
|
||||||
### Befehle im Spiel
|
### Befehle im Spiel
|
||||||
|
|
||||||
|
|||||||
@@ -1,458 +0,0 @@
|
|||||||
# 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.
|
|
||||||
@@ -1,485 +0,0 @@
|
|||||||
# 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
@@ -1,860 +0,0 @@
|
|||||||
# 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).
|
|
||||||
```
|
|
||||||
@@ -1,609 +0,0 @@
|
|||||||
# `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.
|
|
||||||
@@ -1,822 +0,0 @@
|
|||||||
# 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.
|
|
||||||
```
|
|
||||||
@@ -1,957 +0,0 @@
|
|||||||
# `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.
|
|
||||||
@@ -1,116 +0,0 @@
|
|||||||
# 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).
|
|
||||||
@@ -1,87 +0,0 @@
|
|||||||
# 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`).
|
|
||||||
@@ -1,151 +0,0 @@
|
|||||||
# 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→y−1, S→y+1, O→x+1, W→x−1). 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).
|
|
||||||
@@ -1,222 +0,0 @@
|
|||||||
# 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.
|
|
||||||
@@ -1,185 +0,0 @@
|
|||||||
# 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`).
|
|
||||||
@@ -1,163 +0,0 @@
|
|||||||
# 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`).
|
|
||||||
@@ -1,281 +0,0 @@
|
|||||||
# Spec: Hauptmenü + Speichern/Laden (+ minimale Settings)
|
|
||||||
|
|
||||||
Stand: 2026-06-01. Erstes Teilprojekt der Erweiterungsrunde nach Abschluss von
|
|
||||||
Phasen 1–7 (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.
|
|
||||||
@@ -1,226 +0,0 @@
|
|||||||
# 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).
|
|
||||||
@@ -1,194 +0,0 @@
|
|||||||
# 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.
|
|
||||||
@@ -1,254 +0,0 @@
|
|||||||
# 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.
|
|
||||||
@@ -1,206 +0,0 @@
|
|||||||
# 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.
|
|
||||||
@@ -1,220 +0,0 @@
|
|||||||
# Spec: Content-Ausbau — „Haunted Manor" (14 Räume)
|
|
||||||
|
|
||||||
Stand: 2026-06-02. Backlog #4 (+ #5b Mechanik-Spine). Erster reiner **Content**-
|
|
||||||
Ausbau: die Engines stehen alle — diese Erweiterung ist überwiegend **YAML**
|
|
||||||
(rooms/items/npcs/quests/endings/combinations), plus die nötigen Test-Anpassungen.
|
|
||||||
|
|
||||||
## 1. Kontext & Ziel
|
|
||||||
|
|
||||||
Die Welt hat heute 5 Räume und eine Mini-Demo. Ziel: eine **kondensierte, designte**
|
|
||||||
Spielwelt (~14 Räume, 3 Ebenen) mit **einem zentralen Bogen** („Strom wiederherstellen
|
|
||||||
→ entkommen") der den Spieler raumweise führt und **alle fünf Mechaniken stapelt**,
|
|
||||||
plus ein optionaler **Geister-Strang** (den ruhelosen Geist zur Ruhe betten) für die
|
|
||||||
beste Ende-Variante. **Kein Hard-Fail** (keine Tod/Lose-Enden), puzzle-fokussiert.
|
|
||||||
|
|
||||||
Bestätigte Entscheidungen (Brainstorming 2026-06-02):
|
|
||||||
- **Größe**: ~14 Räume, kondensiert/handgebaut (kein Füllmaterial).
|
|
||||||
- **Zentraler Bogen**: Strom wiederherstellen (drain → fuse → generator → `power_on`)
|
|
||||||
schaltet das Obergeschoss frei; **leichter Geister-Strang** speist die Enden.
|
|
||||||
- **Enden**: 3, priorisiert, **kein Hard-Fail**: *Into the Night* (geflohen) /
|
|
||||||
*Manor Reclaimed* (Strom + raus) / *Spirit at Rest* (Strom + Geist gebannt + raus).
|
|
||||||
- **Mechanik-Abdeckung**: Licht/Dunkelheit (Keller), verschlossene Exits (Treppe via
|
|
||||||
Strom, Haustür = Ausgang), Flags, Item-Kombination (mehrfach), Quest-Kette + Enden,
|
|
||||||
`go to <raum>`, Musik pro Raum.
|
|
||||||
|
|
||||||
## 2. Scope
|
|
||||||
|
|
||||||
**In Scope (Content, YAML in `src/main/resources/world/`):**
|
|
||||||
- `rooms.yaml`: 14 Räume, Exits (N/S/E/W), `dark`, `music`, exitLocks, descriptionStates.
|
|
||||||
- `items.yaml`: ~16 Items (siehe §5), inkl. produzierter (unplatzierter) Items.
|
|
||||||
- `combinations.yaml`: 5 Rezepte (§6).
|
|
||||||
- `npcs.yaml`: der `old_man` (Hinweis-NPC, flag-bedingte Dialoge, 1 Reaktion).
|
|
||||||
- `quests.yaml`: `restore_power` (autoStart, 2 Stages) + `lay_to_rest` (per Effekt gestartet).
|
|
||||||
- `endings.yaml`: 3 priorisierte Enden.
|
|
||||||
- `game.yaml`: Titel/Startraum/Welcome aktualisieren (Start = `foyer`).
|
|
||||||
- `tutorial.yaml`: ggf. minimal anpassen (Start ist jetzt `foyer`; der `go`-Schritt
|
|
||||||
führt nach Norden in die Grand Hall — bestehende verb-basierte Schritte bleiben gültig).
|
|
||||||
|
|
||||||
**In Scope (Tests / eine Welt):**
|
|
||||||
- **Toy-Test-Fixture entfernen** (`src/test/resources/world/*`), damit die loader-basierten
|
|
||||||
Tests die **echte ausgelieferte Welt** prüfen (heute beschattet das Fixture die
|
|
||||||
Produktionswelt → Produktion ist ungetestet). `src/test/resources/badtut/` bleibt
|
|
||||||
(separate Basis für den Malformed-Test).
|
|
||||||
- `WorldLoaderTest` + `TutorialLoaderTest` an die echte Welt anpassen (Form-Invarianten
|
|
||||||
statt Toy-Fixture-Werte).
|
|
||||||
- Neuer **`WorldSolvableTest`**: läuft den kritischen Pfad programmatisch (Bewegung +
|
|
||||||
`Combinations.tryUse` + Flag-Checks) und prüft, dass `power_on`, `ghost_banished` und
|
|
||||||
ein Sieg-Ende erreichbar sind — automatische Lösbarkeits-Garantie.
|
|
||||||
|
|
||||||
**Out of Scope:**
|
|
||||||
- Engine-/Code-Änderungen (außer Tests) — alle Mechaniken existieren bereits.
|
|
||||||
- Audiodateien (externer `music/`-Ordner; YAML referenziert nur Namen).
|
|
||||||
- Neue Mechaniken (Zahlenschloss/Code-Eingabe etc.) — bewusst vermieden (nur
|
|
||||||
vorhandene item+item-Kombinationen, Flags, Locks, Licht).
|
|
||||||
- Hard-Fail/Tod-Enden.
|
|
||||||
|
|
||||||
## 3. Weltkarte (Kompass-Exits, ≤4 pro Raum — keine UP/DOWN; `Direction` ist N/S/E/W)
|
|
||||||
|
|
||||||
Ebenen sind abstrakt über Kompassrichtungen verdrahtet (wie heute Keller=Süd etc.).
|
|
||||||
|
|
||||||
| Raum | Exits | dark | Items | NPC | Lock |
|
|
||||||
|---|---|---|---|---|---|
|
|
||||||
| **foyer** (START) | N→grand_hall | – | front_door, **lamp** | – | – |
|
|
||||||
| **grand_hall** | S→foyer, N→dining_room, E→kitchen, W→library | – | – | – | – |
|
|
||||||
| **kitchen** | W→grand_hall, S→cellar_stairs | – | matches, letter | – | – |
|
|
||||||
| **library** | E→grand_hall | – | shovel, journal | old_man | – |
|
|
||||||
| **dining_room** | S→grand_hall, N→conservatory, E→grand_staircase | – | torch, portrait | – | – |
|
|
||||||
| **conservatory** | S→dining_room | – | valve_wheel | – | – |
|
|
||||||
| **grand_staircase** | W→dining_room, N→landing | – | – | – | **N→landing requires `power_on`** |
|
|
||||||
| **cellar_stairs** | N→kitchen, E→flooded_cellar, W→boiler_room | **dark** | – | – | – |
|
|
||||||
| **flooded_cellar** | W→cellar_stairs | **dark** | valve | – | – |
|
|
||||||
| **boiler_room** | E→cellar_stairs | **dark** | generator | – | – |
|
|
||||||
| **landing** | S→grand_staircase, N→master_bedroom, E→study, W→chapel | – | – | – | – |
|
|
||||||
| **master_bedroom** | S→landing | – | locket | – | – |
|
|
||||||
| **study** | W→landing | – | photograph | – | – |
|
|
||||||
| **chapel** | E→landing | – | shrine | – | – |
|
|
||||||
|
|
||||||
Alle Verbindungen **bidirektional** (Gegen-Exit am Zielraum). Der Power-Lock sitzt nur
|
|
||||||
auf dem **Aufwärts**-Exit `grand_staircase → landing`; der Rückweg `landing → grand_staircase`
|
|
||||||
ist immer offen. Keller-Räume sind `dark` → Eintritt nur mit getragener Lichtquelle
|
|
||||||
(`GoCommand`/`Light` gaten das bereits). Der Strom versorgt das **Obergeschoss** (Treppe/
|
|
||||||
Lift), nicht die Kellerverkabelung → der Keller bleibt dunkel (Lichtquelle nötig).
|
|
||||||
|
|
||||||
## 4. Kritischer Pfad (Lösbarkeit)
|
|
||||||
|
|
||||||
1. Erkunden Erdgeschoss; in der **kitchen** `lamp` + `matches` nehmen; `use lamp` → Lampe
|
|
||||||
an (Lichtquelle). (Alternativweg: `torch` aus dining + `use matches on torch` → `lit_torch`.)
|
|
||||||
2. **conservatory** (dining N): `valve_wheel` nehmen.
|
|
||||||
3. In den Keller (kitchen S→cellar_stairs — braucht Licht) → **flooded_cellar** (E):
|
|
||||||
`use valve_wheel on valve` → Flag `cellar_drained` + erhält `fuse`.
|
|
||||||
4. **boiler_room** (cellar_stairs W): `use fuse on generator` → Flag `power_on`.
|
|
||||||
(Quest `restore_power` abgeschlossen → startet `lay_to_rest`.)
|
|
||||||
5. `grand_staircase → landing` jetzt offen → Obergeschoss.
|
|
||||||
6. **master_bedroom**: `locket`. **study**: `photograph`. **chapel**:
|
|
||||||
`use locket on shrine` → `locket_placed`; `use photograph on shrine`
|
|
||||||
(requires `locket_placed`) → `ghost_banished`.
|
|
||||||
7. Zurück zur **foyer**: `use front_door` → Flag `left_manor` → Ende (Priorität, §8).
|
|
||||||
|
|
||||||
## 5. Items (~16)
|
|
||||||
|
|
||||||
| id | Typ | Ort | Zweck |
|
|
||||||
|---|---|---|---|
|
|
||||||
| lamp | switchable, light, initialState off | **foyer** (Start) | Primäre Lichtquelle (`use lamp` → an); zugleich gutartiges Tutorial-Ziel (examine/take/use) |
|
|
||||||
| matches | plain | kitchen | für lit_torch |
|
|
||||||
| letter | readable | kitchen | Lore; old_man-Reaktion |
|
|
||||||
| torch | plain | dining_room | für lit_torch |
|
|
||||||
| lit_torch | plain, light | **unplatziert** | Ergebnis matches+torch (alternative Lichtquelle) |
|
|
||||||
| shovel | plain | library | Flavor/Lore (kein kritischer Pfad) |
|
|
||||||
| journal | readable | library | Lore + Hinweise (Keller, Schrein) |
|
|
||||||
| valve_wheel | plain | conservatory | dräniert den Keller |
|
|
||||||
| valve | plain (Fixture) | flooded_cellar | Kombi-Ziel |
|
|
||||||
| fuse | plain | **unplatziert** | Ergebnis valve_wheel+valve |
|
|
||||||
| generator | plain (Fixture) | boiler_room | Kombi-Ziel → power_on |
|
|
||||||
| front_door | switchable, initialState off | foyer | `use front_door` → setFlag `left_manor` |
|
|
||||||
| locket | plain | master_bedroom | Relikt 1 |
|
|
||||||
| photograph | plain | study | Relikt 2 |
|
|
||||||
| shrine | plain (Fixture) | chapel | Kombi-Ziel der Relikte |
|
|
||||||
| portrait | readable | dining_room | Lore |
|
|
||||||
|
|
||||||
„Fixture" = ein nicht-tragbares Element nur als Kombi-Operand gedacht (technisch ein
|
|
||||||
`plain`-Item im Raum; der Spieler könnte es theoretisch `take`n — unkritisch, optional
|
|
||||||
per Flavor-Beschreibung entmutigt). `lit_torch`/`fuse` sind in `items.yaml` definiert,
|
|
||||||
aber **keinem Raum zugewiesen** (entstehen nur per Kombination; `CombinationFactory`
|
|
||||||
validiert ihre Existenz, `SaveCodec` erfasst sie nach dem Erzeugen).
|
|
||||||
|
|
||||||
## 6. Kombinationen (`combinations.yaml`, 5)
|
|
||||||
|
|
||||||
| a | b | requires | consume | produce | effects | response (Kurz) |
|
|
||||||
|---|---|---|---|---|---|---|
|
|
||||||
| matches | torch | – | matches, torch | lit_torch | – | „Der Streichholz entflammt die Fackel." |
|
|
||||||
| valve_wheel | valve | – | – | fuse | setFlag cellar_drained | „Du kurbelst das Ventil; das Wasser sinkt und gibt eine Sicherung frei." |
|
|
||||||
| fuse | generator | – | fuse | – | setFlag power_on | „Die Sicherung sitzt; der Generator springt an, das Haus erwacht." |
|
|
||||||
| locket | shrine | – | locket | – | setFlag locket_placed | „Du legst das Medaillon auf den Schrein; die Luft erstarrt." |
|
|
||||||
| photograph | shrine | flag locket_placed | photograph | – | setFlag ghost_banished | erfolg: „…der Geist findet Frieden." / **failText**: „Der Schrein wirkt unvollständig — etwas fehlt noch." |
|
|
||||||
|
|
||||||
(Reihenfolge-unabhängig; Operanden aus Inventar **oder** aktuellem Raum — Fixtures
|
|
||||||
liegen im Raum, Relikte/Werkzeuge im Inventar.)
|
|
||||||
|
|
||||||
## 7. Flags, Quests, NPC
|
|
||||||
|
|
||||||
**Flags:** `cellar_drained`, `power_on`, `locket_placed`, `ghost_banished`, `left_manor`.
|
|
||||||
|
|
||||||
**`restore_power`** (autoStart):
|
|
||||||
- Stage 1 — Objective „Drain the flooded cellar." · completion: `flag cellar_drained`.
|
|
||||||
- Stage 2 — Objective „Get the manor's power running." · completion: `flag power_on`.
|
|
||||||
- onComplete: effects `[startQuest lay_to_rest, say "With the power back, the upper floor is reachable — and something stirs up there."]`.
|
|
||||||
|
|
||||||
**`lay_to_rest`** (autoStart: **false**; via startQuest):
|
|
||||||
- Stage 1 — Objective „Lay the restless spirit to rest in the chapel." · completion: `flag ghost_banished`.
|
|
||||||
|
|
||||||
**NPC `old_man`** (library): Greeting + **bedingte Dialoge**:
|
|
||||||
- ohne `cellar_drained`: Hinweis auf den gefluteten Keller / das Ventil.
|
|
||||||
- mit `power_on` ohne `ghost_banished`: Hinweis auf Obergeschoss/Schrein/Relikte.
|
|
||||||
- mit `ghost_banished`: dankbarer Abschiedssatz.
|
|
||||||
- **Reaktion**: onReceive `letter` → poignante Lore-Antwort, `consumes: letter`,
|
|
||||||
effects `[setFlag heard_tale]` (rein optional/Flavor; nicht auf dem kritischen Pfad).
|
|
||||||
|
|
||||||
## 8. Enden (`endings.yaml`, Reihenfolge = Priorität, erstes Match gewinnt)
|
|
||||||
|
|
||||||
1. **spirit_at_rest** — when `[flag left_manor, flag ghost_banished]` · victory **true** ·
|
|
||||||
„Du trittst hinaus; hinter dir liegt das Haus endlich still. (Bester Ausgang.)"
|
|
||||||
2. **manor_reclaimed** — when `[flag left_manor, flag power_on]` · victory **true** ·
|
|
||||||
„Licht in allen Fenstern, der Bann des Hauses gebrochen — du gehst erhobenen Hauptes."
|
|
||||||
3. **fled** — when `[flag left_manor]` · victory **false** ·
|
|
||||||
„Du fliehst in die Nacht. Sicher — aber das Haus behält seine Geheimnisse."
|
|
||||||
|
|
||||||
(`ghost_banished` ⇒ `power_on` ⇒ Obergeschoss erreicht, daher genügt in #1 die Kombination
|
|
||||||
`left_manor + ghost_banished`. Reihenfolge stellt sicher, dass das spezifischste Ende zuerst greift.)
|
|
||||||
|
|
||||||
## 9. Beschreibungs-Varianten & Musik (Politur)
|
|
||||||
|
|
||||||
- `flooded_cellar` descriptionState: vor `cellar_drained` „knietief im schwarzen Wasser…",
|
|
||||||
danach „das abgelassene Becken gibt den schlammigen Boden frei…".
|
|
||||||
- `chapel` descriptionState: vor `ghost_banished` „eine kalte Gegenwart drückt…",
|
|
||||||
danach „nur noch stilles Mondlicht…".
|
|
||||||
- Musik (optionale `music:`-Felder; Dateien extern, fehlend → still): Erdgeschoss
|
|
||||||
`manor-theme.ogg`; Keller `cellar-drone.ogg`; Obergeschoss `musicbox.ogg`;
|
|
||||||
chapel `lament.ogg`.
|
|
||||||
|
|
||||||
## 10. Tests / „eine Welt"-Konsolidierung
|
|
||||||
|
|
||||||
- **Entfernen**: `src/test/resources/world/{game,rooms,items,npcs,quests,endings,combinations,tutorial}.yaml`
|
|
||||||
(Toy-Fixture „Test Manor"). Danach lösen alle `new WorldLoader().load()`-Aufrufe in Tests
|
|
||||||
die **echte** Welt (target/classes/world). `src/test/resources/badtut/tutorial.yaml` bleibt.
|
|
||||||
- **`WorldLoaderTest`** anpassen: Titel „Haunted Manor", Startraum `foyer`, Raum-Anzahl (14),
|
|
||||||
Vorhandensein zentraler Items/Exits — Form-Invarianten der echten Welt statt Toy-Werte.
|
|
||||||
- **`TutorialLoaderTest`** anpassen: lädt jetzt das echte `tutorial.yaml` (Intro vorhanden,
|
|
||||||
≥1 Schritt, erster expect=`look`) statt der 2-Schritt-Fixture.
|
|
||||||
- **`CombinationLoadingTest`**: bleibt gültig (Produktion enthält matches+torch→lit_torch).
|
|
||||||
- **`SaveServiceTest`/`MenuTest`**: laden irgendeine Welt → unverändert grün (State-Roundtrip).
|
|
||||||
- **Neu `WorldSolvableTest`** (lädt die echte Welt; treibt den kritischen Pfad rein über
|
|
||||||
Domäne + `Combinations.tryUse`, ohne IO/Loop):
|
|
||||||
- Lichtquelle besorgen, in den Keller, `valve_wheel`+`valve` → `cellar_drained` + fuse,
|
|
||||||
`fuse`+`generator` → `power_on`; Treppe-Lock öffnet; Relikte holen,
|
|
||||||
`locket`+`shrine` → `locket_placed`, `photograph`+`shrine` → `ghost_banished`;
|
|
||||||
`use front_door`; `EndingEngine.triggered` liefert **spirit_at_rest**.
|
|
||||||
- Zweiter Pfad: nur `power_on` + `left_manor` → **manor_reclaimed**; nur `left_manor`
|
|
||||||
→ **fled** (prüft die Priorisierung + Lösbarkeit der Hauptkette).
|
|
||||||
|
|
||||||
## 11. Architektur-Werte / Risiken
|
|
||||||
|
|
||||||
- **Reiner Content** auf vorhandenen Engines; datengetrieben (YAML), keine neue Mechanik.
|
|
||||||
- **Kondensiert/designt**: jeder Raum hat Zweck (Item, Lock, Lore oder Knotenpunkt); ≤4
|
|
||||||
Exits/Raum (Kompass-Constraint respektiert; keine UP/DOWN).
|
|
||||||
- **Lösbarkeit automatisiert** über `WorldSolvableTest` — ein gebrochener Lock/Flag, der die
|
|
||||||
Welt unlösbar macht, lässt den Build scheitern statt erst im Spiel aufzufallen.
|
|
||||||
- **Eine Welt** (Toy-Fixture entfernt) → die ausgelieferte Welt wird tatsächlich getestet;
|
|
||||||
Risiko: `WorldLoaderTest`/`TutorialLoaderTest` sind nun an den Content gekoppelt (gewollt
|
|
||||||
— sie prüfen die echte Welt). Form-Invarianten statt fragiler Exakt-Werte halten die
|
|
||||||
Kopplung beherrschbar.
|
|
||||||
- **Fixtures als `take`-bar**: `valve`/`generator`/`shrine` sind technisch tragbar; per
|
|
||||||
Beschreibung als ortsfest dargestellt. Wenn der Spieler eines `take`t, bricht nichts
|
|
||||||
(Kombination findet das Operand auch im Inventar). Bewusst akzeptiert.
|
|
||||||
- **Tutorial**: Start ist `foyer` mit der **`lamp`** als gutartigem Ziel — die Schritte
|
|
||||||
examine/take/use greifen auf die Lampe (der `use`-Schritt = „Lampe anzünden", exakt der
|
|
||||||
erste sinnvolle Zug), `go` führt nach Norden in die Grand Hall, `go to` zurück zur foyer
|
|
||||||
(zwei besuchte Räume). So landet ein neuer Spieler **nicht** versehentlich auf
|
|
||||||
`use front_door` (das `front_door` liegt zwar auch in der foyer, aber die Anweisungen
|
|
||||||
lenken auf die Lampe; und Enden sind während des Tutorials ohnehin unterdrückt). Die
|
|
||||||
bestehenden `tutorial.yaml`-Texte bleiben gültig; Start-Welcome/Banner in `game.yaml`
|
|
||||||
an „Haunted Manor / foyer" anpassen.
|
|
||||||
295
Semesterprojekt/mvnw
vendored
Executable file
295
Semesterprojekt/mvnw
vendored
Executable file
@@ -0,0 +1,295 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# ----------------------------------------------------------------------------
|
||||||
|
# Licensed to the Apache Software Foundation (ASF) under one
|
||||||
|
# or more contributor license agreements. See the NOTICE file
|
||||||
|
# distributed with this work for additional information
|
||||||
|
# regarding copyright ownership. The ASF licenses this file
|
||||||
|
# to you under the Apache License, Version 2.0 (the
|
||||||
|
# "License"); you may not use this file except in compliance
|
||||||
|
# with the License. You may obtain a copy of the License at
|
||||||
|
#
|
||||||
|
# http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
#
|
||||||
|
# Unless required by applicable law or agreed to in writing,
|
||||||
|
# software distributed under the License is distributed on an
|
||||||
|
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||||
|
# KIND, either express or implied. See the License for the
|
||||||
|
# specific language governing permissions and limitations
|
||||||
|
# under the License.
|
||||||
|
# ----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
# ----------------------------------------------------------------------------
|
||||||
|
# Apache Maven Wrapper startup batch script, version 3.3.4
|
||||||
|
#
|
||||||
|
# Optional ENV vars
|
||||||
|
# -----------------
|
||||||
|
# JAVA_HOME - location of a JDK home dir, required when download maven via java source
|
||||||
|
# MVNW_REPOURL - repo url base for downloading maven distribution
|
||||||
|
# MVNW_USERNAME/MVNW_PASSWORD - user and password for downloading maven
|
||||||
|
# MVNW_VERBOSE - true: enable verbose log; debug: trace the mvnw script; others: silence the output
|
||||||
|
# ----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
set -euf
|
||||||
|
[ "${MVNW_VERBOSE-}" != debug ] || set -x
|
||||||
|
|
||||||
|
# OS specific support.
|
||||||
|
native_path() { printf %s\\n "$1"; }
|
||||||
|
case "$(uname)" in
|
||||||
|
CYGWIN* | MINGW*)
|
||||||
|
[ -z "${JAVA_HOME-}" ] || JAVA_HOME="$(cygpath --unix "$JAVA_HOME")"
|
||||||
|
native_path() { cygpath --path --windows "$1"; }
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
# set JAVACMD and JAVACCMD
|
||||||
|
set_java_home() {
|
||||||
|
# For Cygwin and MinGW, ensure paths are in Unix format before anything is touched
|
||||||
|
if [ -n "${JAVA_HOME-}" ]; then
|
||||||
|
if [ -x "$JAVA_HOME/jre/sh/java" ]; then
|
||||||
|
# IBM's JDK on AIX uses strange locations for the executables
|
||||||
|
JAVACMD="$JAVA_HOME/jre/sh/java"
|
||||||
|
JAVACCMD="$JAVA_HOME/jre/sh/javac"
|
||||||
|
else
|
||||||
|
JAVACMD="$JAVA_HOME/bin/java"
|
||||||
|
JAVACCMD="$JAVA_HOME/bin/javac"
|
||||||
|
|
||||||
|
if [ ! -x "$JAVACMD" ] || [ ! -x "$JAVACCMD" ]; then
|
||||||
|
echo "The JAVA_HOME environment variable is not defined correctly, so mvnw cannot run." >&2
|
||||||
|
echo "JAVA_HOME is set to \"$JAVA_HOME\", but \"\$JAVA_HOME/bin/java\" or \"\$JAVA_HOME/bin/javac\" does not exist." >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
JAVACMD="$(
|
||||||
|
'set' +e
|
||||||
|
'unset' -f command 2>/dev/null
|
||||||
|
'command' -v java
|
||||||
|
)" || :
|
||||||
|
JAVACCMD="$(
|
||||||
|
'set' +e
|
||||||
|
'unset' -f command 2>/dev/null
|
||||||
|
'command' -v javac
|
||||||
|
)" || :
|
||||||
|
|
||||||
|
if [ ! -x "${JAVACMD-}" ] || [ ! -x "${JAVACCMD-}" ]; then
|
||||||
|
echo "The java/javac command does not exist in PATH nor is JAVA_HOME set, so mvnw cannot run." >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# hash string like Java String::hashCode
|
||||||
|
hash_string() {
|
||||||
|
str="${1:-}" h=0
|
||||||
|
while [ -n "$str" ]; do
|
||||||
|
char="${str%"${str#?}"}"
|
||||||
|
h=$(((h * 31 + $(LC_CTYPE=C printf %d "'$char")) % 4294967296))
|
||||||
|
str="${str#?}"
|
||||||
|
done
|
||||||
|
printf %x\\n $h
|
||||||
|
}
|
||||||
|
|
||||||
|
verbose() { :; }
|
||||||
|
[ "${MVNW_VERBOSE-}" != true ] || verbose() { printf %s\\n "${1-}"; }
|
||||||
|
|
||||||
|
die() {
|
||||||
|
printf %s\\n "$1" >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
trim() {
|
||||||
|
# MWRAPPER-139:
|
||||||
|
# Trims trailing and leading whitespace, carriage returns, tabs, and linefeeds.
|
||||||
|
# Needed for removing poorly interpreted newline sequences when running in more
|
||||||
|
# exotic environments such as mingw bash on Windows.
|
||||||
|
printf "%s" "${1}" | tr -d '[:space:]'
|
||||||
|
}
|
||||||
|
|
||||||
|
scriptDir="$(dirname "$0")"
|
||||||
|
scriptName="$(basename "$0")"
|
||||||
|
|
||||||
|
# parse distributionUrl and optional distributionSha256Sum, requires .mvn/wrapper/maven-wrapper.properties
|
||||||
|
while IFS="=" read -r key value; do
|
||||||
|
case "${key-}" in
|
||||||
|
distributionUrl) distributionUrl=$(trim "${value-}") ;;
|
||||||
|
distributionSha256Sum) distributionSha256Sum=$(trim "${value-}") ;;
|
||||||
|
esac
|
||||||
|
done <"$scriptDir/.mvn/wrapper/maven-wrapper.properties"
|
||||||
|
[ -n "${distributionUrl-}" ] || die "cannot read distributionUrl property in $scriptDir/.mvn/wrapper/maven-wrapper.properties"
|
||||||
|
|
||||||
|
case "${distributionUrl##*/}" in
|
||||||
|
maven-mvnd-*bin.*)
|
||||||
|
MVN_CMD=mvnd.sh _MVNW_REPO_PATTERN=/maven/mvnd/
|
||||||
|
case "${PROCESSOR_ARCHITECTURE-}${PROCESSOR_ARCHITEW6432-}:$(uname -a)" in
|
||||||
|
*AMD64:CYGWIN* | *AMD64:MINGW*) distributionPlatform=windows-amd64 ;;
|
||||||
|
:Darwin*x86_64) distributionPlatform=darwin-amd64 ;;
|
||||||
|
:Darwin*arm64) distributionPlatform=darwin-aarch64 ;;
|
||||||
|
:Linux*x86_64*) distributionPlatform=linux-amd64 ;;
|
||||||
|
*)
|
||||||
|
echo "Cannot detect native platform for mvnd on $(uname)-$(uname -m), use pure java version" >&2
|
||||||
|
distributionPlatform=linux-amd64
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
distributionUrl="${distributionUrl%-bin.*}-$distributionPlatform.zip"
|
||||||
|
;;
|
||||||
|
maven-mvnd-*) MVN_CMD=mvnd.sh _MVNW_REPO_PATTERN=/maven/mvnd/ ;;
|
||||||
|
*) MVN_CMD="mvn${scriptName#mvnw}" _MVNW_REPO_PATTERN=/org/apache/maven/ ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
# apply MVNW_REPOURL and calculate MAVEN_HOME
|
||||||
|
# maven home pattern: ~/.m2/wrapper/dists/{apache-maven-<version>,maven-mvnd-<version>-<platform>}/<hash>
|
||||||
|
[ -z "${MVNW_REPOURL-}" ] || distributionUrl="$MVNW_REPOURL$_MVNW_REPO_PATTERN${distributionUrl#*"$_MVNW_REPO_PATTERN"}"
|
||||||
|
distributionUrlName="${distributionUrl##*/}"
|
||||||
|
distributionUrlNameMain="${distributionUrlName%.*}"
|
||||||
|
distributionUrlNameMain="${distributionUrlNameMain%-bin}"
|
||||||
|
MAVEN_USER_HOME="${MAVEN_USER_HOME:-${HOME}/.m2}"
|
||||||
|
MAVEN_HOME="${MAVEN_USER_HOME}/wrapper/dists/${distributionUrlNameMain-}/$(hash_string "$distributionUrl")"
|
||||||
|
|
||||||
|
exec_maven() {
|
||||||
|
unset MVNW_VERBOSE MVNW_USERNAME MVNW_PASSWORD MVNW_REPOURL || :
|
||||||
|
exec "$MAVEN_HOME/bin/$MVN_CMD" "$@" || die "cannot exec $MAVEN_HOME/bin/$MVN_CMD"
|
||||||
|
}
|
||||||
|
|
||||||
|
if [ -d "$MAVEN_HOME" ]; then
|
||||||
|
verbose "found existing MAVEN_HOME at $MAVEN_HOME"
|
||||||
|
exec_maven "$@"
|
||||||
|
fi
|
||||||
|
|
||||||
|
case "${distributionUrl-}" in
|
||||||
|
*?-bin.zip | *?maven-mvnd-?*-?*.zip) ;;
|
||||||
|
*) die "distributionUrl is not valid, must match *-bin.zip or maven-mvnd-*.zip, but found '${distributionUrl-}'" ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
# prepare tmp dir
|
||||||
|
if TMP_DOWNLOAD_DIR="$(mktemp -d)" && [ -d "$TMP_DOWNLOAD_DIR" ]; then
|
||||||
|
clean() { rm -rf -- "$TMP_DOWNLOAD_DIR"; }
|
||||||
|
trap clean HUP INT TERM EXIT
|
||||||
|
else
|
||||||
|
die "cannot create temp dir"
|
||||||
|
fi
|
||||||
|
|
||||||
|
mkdir -p -- "${MAVEN_HOME%/*}"
|
||||||
|
|
||||||
|
# Download and Install Apache Maven
|
||||||
|
verbose "Couldn't find MAVEN_HOME, downloading and installing it ..."
|
||||||
|
verbose "Downloading from: $distributionUrl"
|
||||||
|
verbose "Downloading to: $TMP_DOWNLOAD_DIR/$distributionUrlName"
|
||||||
|
|
||||||
|
# select .zip or .tar.gz
|
||||||
|
if ! command -v unzip >/dev/null; then
|
||||||
|
distributionUrl="${distributionUrl%.zip}.tar.gz"
|
||||||
|
distributionUrlName="${distributionUrl##*/}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# verbose opt
|
||||||
|
__MVNW_QUIET_WGET=--quiet __MVNW_QUIET_CURL=--silent __MVNW_QUIET_UNZIP=-q __MVNW_QUIET_TAR=''
|
||||||
|
[ "${MVNW_VERBOSE-}" != true ] || __MVNW_QUIET_WGET='' __MVNW_QUIET_CURL='' __MVNW_QUIET_UNZIP='' __MVNW_QUIET_TAR=v
|
||||||
|
|
||||||
|
# normalize http auth
|
||||||
|
case "${MVNW_PASSWORD:+has-password}" in
|
||||||
|
'') MVNW_USERNAME='' MVNW_PASSWORD='' ;;
|
||||||
|
has-password) [ -n "${MVNW_USERNAME-}" ] || MVNW_USERNAME='' MVNW_PASSWORD='' ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
if [ -z "${MVNW_USERNAME-}" ] && command -v wget >/dev/null; then
|
||||||
|
verbose "Found wget ... using wget"
|
||||||
|
wget ${__MVNW_QUIET_WGET:+"$__MVNW_QUIET_WGET"} "$distributionUrl" -O "$TMP_DOWNLOAD_DIR/$distributionUrlName" || die "wget: Failed to fetch $distributionUrl"
|
||||||
|
elif [ -z "${MVNW_USERNAME-}" ] && command -v curl >/dev/null; then
|
||||||
|
verbose "Found curl ... using curl"
|
||||||
|
curl ${__MVNW_QUIET_CURL:+"$__MVNW_QUIET_CURL"} -f -L -o "$TMP_DOWNLOAD_DIR/$distributionUrlName" "$distributionUrl" || die "curl: Failed to fetch $distributionUrl"
|
||||||
|
elif set_java_home; then
|
||||||
|
verbose "Falling back to use Java to download"
|
||||||
|
javaSource="$TMP_DOWNLOAD_DIR/Downloader.java"
|
||||||
|
targetZip="$TMP_DOWNLOAD_DIR/$distributionUrlName"
|
||||||
|
cat >"$javaSource" <<-END
|
||||||
|
public class Downloader extends java.net.Authenticator
|
||||||
|
{
|
||||||
|
protected java.net.PasswordAuthentication getPasswordAuthentication()
|
||||||
|
{
|
||||||
|
return new java.net.PasswordAuthentication( System.getenv( "MVNW_USERNAME" ), System.getenv( "MVNW_PASSWORD" ).toCharArray() );
|
||||||
|
}
|
||||||
|
public static void main( String[] args ) throws Exception
|
||||||
|
{
|
||||||
|
setDefault( new Downloader() );
|
||||||
|
java.nio.file.Files.copy( java.net.URI.create( args[0] ).toURL().openStream(), java.nio.file.Paths.get( args[1] ).toAbsolutePath().normalize() );
|
||||||
|
}
|
||||||
|
}
|
||||||
|
END
|
||||||
|
# For Cygwin/MinGW, switch paths to Windows format before running javac and java
|
||||||
|
verbose " - Compiling Downloader.java ..."
|
||||||
|
"$(native_path "$JAVACCMD")" "$(native_path "$javaSource")" || die "Failed to compile Downloader.java"
|
||||||
|
verbose " - Running Downloader.java ..."
|
||||||
|
"$(native_path "$JAVACMD")" -cp "$(native_path "$TMP_DOWNLOAD_DIR")" Downloader "$distributionUrl" "$(native_path "$targetZip")"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# If specified, validate the SHA-256 sum of the Maven distribution zip file
|
||||||
|
if [ -n "${distributionSha256Sum-}" ]; then
|
||||||
|
distributionSha256Result=false
|
||||||
|
if [ "$MVN_CMD" = mvnd.sh ]; then
|
||||||
|
echo "Checksum validation is not supported for maven-mvnd." >&2
|
||||||
|
echo "Please disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties." >&2
|
||||||
|
exit 1
|
||||||
|
elif command -v sha256sum >/dev/null; then
|
||||||
|
if echo "$distributionSha256Sum $TMP_DOWNLOAD_DIR/$distributionUrlName" | sha256sum -c - >/dev/null 2>&1; then
|
||||||
|
distributionSha256Result=true
|
||||||
|
fi
|
||||||
|
elif command -v shasum >/dev/null; then
|
||||||
|
if echo "$distributionSha256Sum $TMP_DOWNLOAD_DIR/$distributionUrlName" | shasum -a 256 -c >/dev/null 2>&1; then
|
||||||
|
distributionSha256Result=true
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
echo "Checksum validation was requested but neither 'sha256sum' or 'shasum' are available." >&2
|
||||||
|
echo "Please install either command, or disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if [ $distributionSha256Result = false ]; then
|
||||||
|
echo "Error: Failed to validate Maven distribution SHA-256, your Maven distribution might be compromised." >&2
|
||||||
|
echo "If you updated your Maven version, you need to update the specified distributionSha256Sum property." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# unzip and move
|
||||||
|
if command -v unzip >/dev/null; then
|
||||||
|
unzip ${__MVNW_QUIET_UNZIP:+"$__MVNW_QUIET_UNZIP"} "$TMP_DOWNLOAD_DIR/$distributionUrlName" -d "$TMP_DOWNLOAD_DIR" || die "failed to unzip"
|
||||||
|
else
|
||||||
|
tar xzf${__MVNW_QUIET_TAR:+"$__MVNW_QUIET_TAR"} "$TMP_DOWNLOAD_DIR/$distributionUrlName" -C "$TMP_DOWNLOAD_DIR" || die "failed to untar"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Find the actual extracted directory name (handles snapshots where filename != directory name)
|
||||||
|
actualDistributionDir=""
|
||||||
|
|
||||||
|
# First try the expected directory name (for regular distributions)
|
||||||
|
if [ -d "$TMP_DOWNLOAD_DIR/$distributionUrlNameMain" ]; then
|
||||||
|
if [ -f "$TMP_DOWNLOAD_DIR/$distributionUrlNameMain/bin/$MVN_CMD" ]; then
|
||||||
|
actualDistributionDir="$distributionUrlNameMain"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# If not found, search for any directory with the Maven executable (for snapshots)
|
||||||
|
if [ -z "$actualDistributionDir" ]; then
|
||||||
|
# enable globbing to iterate over items
|
||||||
|
set +f
|
||||||
|
for dir in "$TMP_DOWNLOAD_DIR"/*; do
|
||||||
|
if [ -d "$dir" ]; then
|
||||||
|
if [ -f "$dir/bin/$MVN_CMD" ]; then
|
||||||
|
actualDistributionDir="$(basename "$dir")"
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
set -f
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -z "$actualDistributionDir" ]; then
|
||||||
|
verbose "Contents of $TMP_DOWNLOAD_DIR:"
|
||||||
|
verbose "$(ls -la "$TMP_DOWNLOAD_DIR")"
|
||||||
|
die "Could not find Maven distribution directory in extracted archive"
|
||||||
|
fi
|
||||||
|
|
||||||
|
verbose "Found extracted Maven distribution directory: $actualDistributionDir"
|
||||||
|
printf %s\\n "$distributionUrl" >"$TMP_DOWNLOAD_DIR/$actualDistributionDir/mvnw.url"
|
||||||
|
mv -- "$TMP_DOWNLOAD_DIR/$actualDistributionDir" "$MAVEN_HOME" || [ -d "$MAVEN_HOME" ] || die "fail to move MAVEN_HOME"
|
||||||
|
|
||||||
|
clean || :
|
||||||
|
exec_maven "$@"
|
||||||
189
Semesterprojekt/mvnw.cmd
vendored
Normal file
189
Semesterprojekt/mvnw.cmd
vendored
Normal file
@@ -0,0 +1,189 @@
|
|||||||
|
<# : batch portion
|
||||||
|
@REM ----------------------------------------------------------------------------
|
||||||
|
@REM Licensed to the Apache Software Foundation (ASF) under one
|
||||||
|
@REM or more contributor license agreements. See the NOTICE file
|
||||||
|
@REM distributed with this work for additional information
|
||||||
|
@REM regarding copyright ownership. The ASF licenses this file
|
||||||
|
@REM to you under the Apache License, Version 2.0 (the
|
||||||
|
@REM "License"); you may not use this file except in compliance
|
||||||
|
@REM with the License. You may obtain a copy of the License at
|
||||||
|
@REM
|
||||||
|
@REM http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
@REM
|
||||||
|
@REM Unless required by applicable law or agreed to in writing,
|
||||||
|
@REM software distributed under the License is distributed on an
|
||||||
|
@REM "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||||
|
@REM KIND, either express or implied. See the License for the
|
||||||
|
@REM specific language governing permissions and limitations
|
||||||
|
@REM under the License.
|
||||||
|
@REM ----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@REM ----------------------------------------------------------------------------
|
||||||
|
@REM Apache Maven Wrapper startup batch script, version 3.3.4
|
||||||
|
@REM
|
||||||
|
@REM Optional ENV vars
|
||||||
|
@REM MVNW_REPOURL - repo url base for downloading maven distribution
|
||||||
|
@REM MVNW_USERNAME/MVNW_PASSWORD - user and password for downloading maven
|
||||||
|
@REM MVNW_VERBOSE - true: enable verbose log; others: silence the output
|
||||||
|
@REM ----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@IF "%__MVNW_ARG0_NAME__%"=="" (SET __MVNW_ARG0_NAME__=%~nx0)
|
||||||
|
@SET __MVNW_CMD__=
|
||||||
|
@SET __MVNW_ERROR__=
|
||||||
|
@SET __MVNW_PSMODULEP_SAVE=%PSModulePath%
|
||||||
|
@SET PSModulePath=
|
||||||
|
@FOR /F "usebackq tokens=1* delims==" %%A IN (`powershell -noprofile "& {$scriptDir='%~dp0'; $script='%__MVNW_ARG0_NAME__%'; icm -ScriptBlock ([Scriptblock]::Create((Get-Content -Raw '%~f0'))) -NoNewScope}"`) DO @(
|
||||||
|
IF "%%A"=="MVN_CMD" (set __MVNW_CMD__=%%B) ELSE IF "%%B"=="" (echo %%A) ELSE (echo %%A=%%B)
|
||||||
|
)
|
||||||
|
@SET PSModulePath=%__MVNW_PSMODULEP_SAVE%
|
||||||
|
@SET __MVNW_PSMODULEP_SAVE=
|
||||||
|
@SET __MVNW_ARG0_NAME__=
|
||||||
|
@SET MVNW_USERNAME=
|
||||||
|
@SET MVNW_PASSWORD=
|
||||||
|
@IF NOT "%__MVNW_CMD__%"=="" ("%__MVNW_CMD__%" %*)
|
||||||
|
@echo Cannot start maven from wrapper >&2 && exit /b 1
|
||||||
|
@GOTO :EOF
|
||||||
|
: end batch / begin powershell #>
|
||||||
|
|
||||||
|
$ErrorActionPreference = "Stop"
|
||||||
|
if ($env:MVNW_VERBOSE -eq "true") {
|
||||||
|
$VerbosePreference = "Continue"
|
||||||
|
}
|
||||||
|
|
||||||
|
# calculate distributionUrl, requires .mvn/wrapper/maven-wrapper.properties
|
||||||
|
$distributionUrl = (Get-Content -Raw "$scriptDir/.mvn/wrapper/maven-wrapper.properties" | ConvertFrom-StringData).distributionUrl
|
||||||
|
if (!$distributionUrl) {
|
||||||
|
Write-Error "cannot read distributionUrl property in $scriptDir/.mvn/wrapper/maven-wrapper.properties"
|
||||||
|
}
|
||||||
|
|
||||||
|
switch -wildcard -casesensitive ( $($distributionUrl -replace '^.*/','') ) {
|
||||||
|
"maven-mvnd-*" {
|
||||||
|
$USE_MVND = $true
|
||||||
|
$distributionUrl = $distributionUrl -replace '-bin\.[^.]*$',"-windows-amd64.zip"
|
||||||
|
$MVN_CMD = "mvnd.cmd"
|
||||||
|
break
|
||||||
|
}
|
||||||
|
default {
|
||||||
|
$USE_MVND = $false
|
||||||
|
$MVN_CMD = $script -replace '^mvnw','mvn'
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# apply MVNW_REPOURL and calculate MAVEN_HOME
|
||||||
|
# maven home pattern: ~/.m2/wrapper/dists/{apache-maven-<version>,maven-mvnd-<version>-<platform>}/<hash>
|
||||||
|
if ($env:MVNW_REPOURL) {
|
||||||
|
$MVNW_REPO_PATTERN = if ($USE_MVND -eq $False) { "/org/apache/maven/" } else { "/maven/mvnd/" }
|
||||||
|
$distributionUrl = "$env:MVNW_REPOURL$MVNW_REPO_PATTERN$($distributionUrl -replace "^.*$MVNW_REPO_PATTERN",'')"
|
||||||
|
}
|
||||||
|
$distributionUrlName = $distributionUrl -replace '^.*/',''
|
||||||
|
$distributionUrlNameMain = $distributionUrlName -replace '\.[^.]*$','' -replace '-bin$',''
|
||||||
|
|
||||||
|
$MAVEN_M2_PATH = "$HOME/.m2"
|
||||||
|
if ($env:MAVEN_USER_HOME) {
|
||||||
|
$MAVEN_M2_PATH = "$env:MAVEN_USER_HOME"
|
||||||
|
}
|
||||||
|
|
||||||
|
if (-not (Test-Path -Path $MAVEN_M2_PATH)) {
|
||||||
|
New-Item -Path $MAVEN_M2_PATH -ItemType Directory | Out-Null
|
||||||
|
}
|
||||||
|
|
||||||
|
$MAVEN_WRAPPER_DISTS = $null
|
||||||
|
if ((Get-Item $MAVEN_M2_PATH).Target[0] -eq $null) {
|
||||||
|
$MAVEN_WRAPPER_DISTS = "$MAVEN_M2_PATH/wrapper/dists"
|
||||||
|
} else {
|
||||||
|
$MAVEN_WRAPPER_DISTS = (Get-Item $MAVEN_M2_PATH).Target[0] + "/wrapper/dists"
|
||||||
|
}
|
||||||
|
|
||||||
|
$MAVEN_HOME_PARENT = "$MAVEN_WRAPPER_DISTS/$distributionUrlNameMain"
|
||||||
|
$MAVEN_HOME_NAME = ([System.Security.Cryptography.SHA256]::Create().ComputeHash([byte[]][char[]]$distributionUrl) | ForEach-Object {$_.ToString("x2")}) -join ''
|
||||||
|
$MAVEN_HOME = "$MAVEN_HOME_PARENT/$MAVEN_HOME_NAME"
|
||||||
|
|
||||||
|
if (Test-Path -Path "$MAVEN_HOME" -PathType Container) {
|
||||||
|
Write-Verbose "found existing MAVEN_HOME at $MAVEN_HOME"
|
||||||
|
Write-Output "MVN_CMD=$MAVEN_HOME/bin/$MVN_CMD"
|
||||||
|
exit $?
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $distributionUrlNameMain -or ($distributionUrlName -eq $distributionUrlNameMain)) {
|
||||||
|
Write-Error "distributionUrl is not valid, must end with *-bin.zip, but found $distributionUrl"
|
||||||
|
}
|
||||||
|
|
||||||
|
# prepare tmp dir
|
||||||
|
$TMP_DOWNLOAD_DIR_HOLDER = New-TemporaryFile
|
||||||
|
$TMP_DOWNLOAD_DIR = New-Item -Itemtype Directory -Path "$TMP_DOWNLOAD_DIR_HOLDER.dir"
|
||||||
|
$TMP_DOWNLOAD_DIR_HOLDER.Delete() | Out-Null
|
||||||
|
trap {
|
||||||
|
if ($TMP_DOWNLOAD_DIR.Exists) {
|
||||||
|
try { Remove-Item $TMP_DOWNLOAD_DIR -Recurse -Force | Out-Null }
|
||||||
|
catch { Write-Warning "Cannot remove $TMP_DOWNLOAD_DIR" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
New-Item -Itemtype Directory -Path "$MAVEN_HOME_PARENT" -Force | Out-Null
|
||||||
|
|
||||||
|
# Download and Install Apache Maven
|
||||||
|
Write-Verbose "Couldn't find MAVEN_HOME, downloading and installing it ..."
|
||||||
|
Write-Verbose "Downloading from: $distributionUrl"
|
||||||
|
Write-Verbose "Downloading to: $TMP_DOWNLOAD_DIR/$distributionUrlName"
|
||||||
|
|
||||||
|
$webclient = New-Object System.Net.WebClient
|
||||||
|
if ($env:MVNW_USERNAME -and $env:MVNW_PASSWORD) {
|
||||||
|
$webclient.Credentials = New-Object System.Net.NetworkCredential($env:MVNW_USERNAME, $env:MVNW_PASSWORD)
|
||||||
|
}
|
||||||
|
[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12
|
||||||
|
$webclient.DownloadFile($distributionUrl, "$TMP_DOWNLOAD_DIR/$distributionUrlName") | Out-Null
|
||||||
|
|
||||||
|
# If specified, validate the SHA-256 sum of the Maven distribution zip file
|
||||||
|
$distributionSha256Sum = (Get-Content -Raw "$scriptDir/.mvn/wrapper/maven-wrapper.properties" | ConvertFrom-StringData).distributionSha256Sum
|
||||||
|
if ($distributionSha256Sum) {
|
||||||
|
if ($USE_MVND) {
|
||||||
|
Write-Error "Checksum validation is not supported for maven-mvnd. `nPlease disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties."
|
||||||
|
}
|
||||||
|
Import-Module $PSHOME\Modules\Microsoft.PowerShell.Utility -Function Get-FileHash
|
||||||
|
if ((Get-FileHash "$TMP_DOWNLOAD_DIR/$distributionUrlName" -Algorithm SHA256).Hash.ToLower() -ne $distributionSha256Sum) {
|
||||||
|
Write-Error "Error: Failed to validate Maven distribution SHA-256, your Maven distribution might be compromised. If you updated your Maven version, you need to update the specified distributionSha256Sum property."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# unzip and move
|
||||||
|
Expand-Archive "$TMP_DOWNLOAD_DIR/$distributionUrlName" -DestinationPath "$TMP_DOWNLOAD_DIR" | Out-Null
|
||||||
|
|
||||||
|
# Find the actual extracted directory name (handles snapshots where filename != directory name)
|
||||||
|
$actualDistributionDir = ""
|
||||||
|
|
||||||
|
# First try the expected directory name (for regular distributions)
|
||||||
|
$expectedPath = Join-Path "$TMP_DOWNLOAD_DIR" "$distributionUrlNameMain"
|
||||||
|
$expectedMvnPath = Join-Path "$expectedPath" "bin/$MVN_CMD"
|
||||||
|
if ((Test-Path -Path $expectedPath -PathType Container) -and (Test-Path -Path $expectedMvnPath -PathType Leaf)) {
|
||||||
|
$actualDistributionDir = $distributionUrlNameMain
|
||||||
|
}
|
||||||
|
|
||||||
|
# If not found, search for any directory with the Maven executable (for snapshots)
|
||||||
|
if (!$actualDistributionDir) {
|
||||||
|
Get-ChildItem -Path "$TMP_DOWNLOAD_DIR" -Directory | ForEach-Object {
|
||||||
|
$testPath = Join-Path $_.FullName "bin/$MVN_CMD"
|
||||||
|
if (Test-Path -Path $testPath -PathType Leaf) {
|
||||||
|
$actualDistributionDir = $_.Name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!$actualDistributionDir) {
|
||||||
|
Write-Error "Could not find Maven distribution directory in extracted archive"
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Verbose "Found extracted Maven distribution directory: $actualDistributionDir"
|
||||||
|
Rename-Item -Path "$TMP_DOWNLOAD_DIR/$actualDistributionDir" -NewName $MAVEN_HOME_NAME | Out-Null
|
||||||
|
try {
|
||||||
|
Move-Item -Path "$TMP_DOWNLOAD_DIR/$MAVEN_HOME_NAME" -Destination $MAVEN_HOME_PARENT | Out-Null
|
||||||
|
} catch {
|
||||||
|
if (! (Test-Path -Path "$MAVEN_HOME" -PathType Container)) {
|
||||||
|
Write-Error "fail to move MAVEN_HOME"
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
try { Remove-Item $TMP_DOWNLOAD_DIR -Recurse -Force | Out-Null }
|
||||||
|
catch { Write-Warning "Cannot remove $TMP_DOWNLOAD_DIR" }
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Output "MVN_CMD=$MAVEN_HOME/bin/$MVN_CMD"
|
||||||
@@ -109,6 +109,36 @@
|
|||||||
<artifactId>maven-surefire-plugin</artifactId>
|
<artifactId>maven-surefire-plugin</artifactId>
|
||||||
<version>3.5.2</version>
|
<version>3.5.2</version>
|
||||||
</plugin>
|
</plugin>
|
||||||
|
<plugin>
|
||||||
|
<groupId>org.apache.maven.plugins</groupId>
|
||||||
|
<artifactId>maven-shade-plugin</artifactId>
|
||||||
|
<version>3.6.0</version>
|
||||||
|
<executions>
|
||||||
|
<execution>
|
||||||
|
<phase>package</phase>
|
||||||
|
<goals>
|
||||||
|
<goal>shade</goal>
|
||||||
|
</goals>
|
||||||
|
<configuration>
|
||||||
|
<!-- Self-contained, runnable JAR with all dependencies bundled. -->
|
||||||
|
<outputFile>${project.basedir}/release/HauntedManor.jar</outputFile>
|
||||||
|
<createDependencyReducedPom>false</createDependencyReducedPom>
|
||||||
|
<transformers>
|
||||||
|
<!-- Default entry point: console version. GUI via -cp ... AppGui. -->
|
||||||
|
<transformer
|
||||||
|
implementation="org.apache.maven.plugins.shade.resource.ManifestResourceTransformer">
|
||||||
|
<manifestEntries>
|
||||||
|
<Main-Class>thb.jeanluc.adventure.App</Main-Class>
|
||||||
|
</manifestEntries>
|
||||||
|
</transformer>
|
||||||
|
<!-- Merge META-INF/services so the OGG Vorbis SPI keeps working. -->
|
||||||
|
<transformer
|
||||||
|
implementation="org.apache.maven.plugins.shade.resource.ServicesResourceTransformer"/>
|
||||||
|
</transformers>
|
||||||
|
</configuration>
|
||||||
|
</execution>
|
||||||
|
</executions>
|
||||||
|
</plugin>
|
||||||
<plugin>
|
<plugin>
|
||||||
<groupId>org.codehaus.mojo</groupId>
|
<groupId>org.codehaus.mojo</groupId>
|
||||||
<artifactId>exec-maven-plugin</artifactId>
|
<artifactId>exec-maven-plugin</artifactId>
|
||||||
|
|||||||
BIN
Semesterprojekt/release/HauntedManor.jar
Normal file
BIN
Semesterprojekt/release/HauntedManor.jar
Normal file
Binary file not shown.
Reference in New Issue
Block a user