docs: implementation plan for light & darkness

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-31 23:02:01 +02:00
parent 5d11d7730c
commit acd797bf65

View File

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