Files
Jander_Semester2/Semesterprojekt/docs/superpowers/plans/2026-05-31-light-darkness.md
2026-05-31 23:02:01 +02:00

16 KiB

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


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:

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:

    /** 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:

        List<EffectDto> effects,
        Boolean light
    /** Backward-compatible constructor without effects/light. */
    public ItemDto(String type, String id, String name, String description,
                   String readText, Boolean initialState, String onText, String offText) {
        this(type, id, name, description, readText, initialState, onText, offText, null, null);
    }

In ItemFactory.java, add .light(Boolean.TRUE.equals(dto.light())) to each of the three builder cases (plain, readable, switchable), e.g.:

            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:

import lombok.Setter;

Add the field after descriptionStates:

    /** 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:

        List<DescriptionStateDto> descriptionStates,
        Boolean dark
    /** 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:

    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
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
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:

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:

import thb.jeanluc.adventure.game.Light;

After the exit-lock check and before ctx.getPlayer().setCurrentRoom(next.get()), insert:

        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:

import thb.jeanluc.adventure.game.Light;

At the very start of execute, after fetching the room:

        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:

import thb.jeanluc.adventure.game.Light;

After the args.isEmpty() check:

        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:

import thb.jeanluc.adventure.game.Light;

After the args.isEmpty() check:

        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:

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):

        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
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:

- 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:

- 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:

> ✅ 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:

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
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.