diff --git a/Semesterprojekt/docs/superpowers/plans/2026-05-31-light-darkness.md b/Semesterprojekt/docs/superpowers/plans/2026-05-31-light-darkness.md new file mode 100644 index 0000000..a5b74a0 --- /dev/null +++ b/Semesterprojekt/docs/superpowers/plans/2026-05-31-light-darkness.md @@ -0,0 +1,485 @@ +# Light & Darkness Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Dark rooms you can't enter, see, or interact with unless you carry a lit light source (the lamp). + +**Architecture:** A `Room.dark` flag and an `Item.light` flag, plus one `Light` helper (`isLit`/`carryingLight`). Four commands gate on it (`go` entry, `look`, `take`, `examine`); the HUD's `light` field is wired to it. Reuses the switchable lamp and is fully backward-compatible (optional flags). + +**Tech Stack:** Java 25, Maven, JUnit 5 + AssertJ, Lombok. + +Spec: [docs/superpowers/specs/2026-05-31-light-darkness-design.md](../specs/2026-05-31-light-darkness-design.md) + +--- + +## Task 1: Data flags + `Light` helper + +**Files:** +- Modify: `model/item/Item.java`, `loader/dto/ItemDto.java`, `loader/ItemFactory.java`, `model/Room.java`, `loader/dto/RoomDto.java`, `loader/RoomFactory.java` +- Create: `game/Light.java` +- Test: `game/LightTest.java` + +- [ ] **Step 1: Write failing test** + +`src/test/java/thb/jeanluc/adventure/game/LightTest.java`: +```java +package thb.jeanluc.adventure.game; + +import org.junit.jupiter.api.Test; +import thb.jeanluc.adventure.io.TestIO; +import thb.jeanluc.adventure.model.Player; +import thb.jeanluc.adventure.model.Room; +import thb.jeanluc.adventure.model.item.PlainItem; +import thb.jeanluc.adventure.model.item.SwitchableItem; + +import static org.assertj.core.api.Assertions.assertThat; + +class LightTest { + private GameContext ctx(Player p) { + return new GameContext(null, p, new TestIO()); + } + + private SwitchableItem lamp(boolean on) { + return SwitchableItem.builder().id("lamp").name("Lamp").description("d") + .state(on).onText("on").offText("off").light(true).build(); + } + + @Test + void litRoomIsAlwaysLit() { + Room bright = new Room("r", "R", "d"); // dark defaults to false + assertThat(Light.isLit(ctx(new Player(bright, 0)), bright)).isTrue(); + } + + @Test + void darkRoomNeedsActiveLight() { + Room dark = new Room("r", "R", "d"); + dark.setDark(true); + Player p = new Player(dark, 0); + GameContext ctx = ctx(p); + assertThat(Light.isLit(ctx, dark)).isFalse(); + + p.addItem(lamp(false)); + assertThat(Light.isLit(ctx, dark)).isFalse(); // lamp off + + p.removeItem("lamp"); + p.addItem(lamp(true)); + assertThat(Light.isLit(ctx, dark)).isTrue(); // lamp on, carried + assertThat(Light.carryingLight(ctx)).isTrue(); + } + + @Test + void litLampLyingInRoomLightsIt() { + Room dark = new Room("r", "R", "d"); + dark.setDark(true); + dark.addItem(lamp(true)); + Player p = new Player(dark, 0); + assertThat(Light.isLit(ctx(p), dark)).isTrue(); + assertThat(Light.carryingLight(ctx(p))).isFalse(); // not carried + } + + @Test + void nonLightItemDoesNotLight() { + Room dark = new Room("r", "R", "d"); + dark.setDark(true); + Player p = new Player(dark, 0); + p.addItem(PlainItem.builder().id("rock").name("Rock").description("d").build()); + assertThat(Light.isLit(ctx(p), dark)).isFalse(); + } +} +``` + +- [ ] **Step 2: Run to verify failure** + +Run: `mvn -q test -Dtest=LightTest` +Expected: FAIL — `Item.light` / `Room.setDark` / `Light` missing. + +- [ ] **Step 3: Add `light` to `Item.java`** + +Add the field after `description`: +```java + /** Whether this item can serve as a light source (when on, for switchables). */ + protected final boolean light; +``` +(`@SuperBuilder` exposes `.light(...)` on every subclass builder; `@Getter` generates `isLight()`. Unset builders default to `false`.) + +- [ ] **Step 4: Add `light` to `ItemDto.java` and wire `ItemFactory.java`** + +In `ItemDto.java` append a component (after `effects`) and update the back-compat constructor: +```java + List 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 descriptionStates, + Boolean dark +``` +```java + /** Backward-compatible constructor without the optional state fields. */ + public RoomDto(String id, String name, String description, + Map exits, List items, List 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 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.