Merge light & darkness mechanic into develop
Room.dark + Item.light flags and a Light helper (isLit/carryingLight). Model B gating: you can't enter a dark room without a lit light source; look/take/examine are obscured in the dark; the HUD light field is wired. Entry-gate prevents soft-locks. Demo: the dark dungeon needs the lit lamp to reach the generator. 127 tests green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -122,6 +122,10 @@ des Bogens; einzelne Teilziele schalten je den nächsten Bereich frei.
|
|||||||
Hebel/Ventile setzen Flags und öffnen Bereiche.
|
Hebel/Ventile setzen Flags und öffnen Bereiche.
|
||||||
3. **Licht & Dunkelheit** – dunkle Räume brauchen eine brennende Lichtquelle,
|
3. **Licht & Dunkelheit** – dunkle Räume brauchen eine brennende Lichtquelle,
|
||||||
sonst keine Sicht auf Exits/Items (Atmosphäre, einfach umzusetzen).
|
sonst keine Sicht auf Exits/Items (Atmosphäre, einfach umzusetzen).
|
||||||
|
> ✅ Umgesetzt (Branch `feature/light-darkness`): `Room.dark` + `Item.light`,
|
||||||
|
> `Light`-Helper, Gating in go/look/take/examine (Modell B: kein Eintritt ohne
|
||||||
|
> Licht), HUD-Licht verdrahtet. Demo: dunkler Dungeon braucht die brennende
|
||||||
|
> Lampe für den Generator.
|
||||||
4. **Item-Kombination** – z.B. `match + candle → lit candle`. Achtung: v1.0 hat
|
4. **Item-Kombination** – z.B. `match + candle → lit candle`. Achtung: v1.0 hat
|
||||||
bewusst argloses `use X` ohne Targets gewählt → Command-Grammatik muss erweitert
|
bewusst argloses `use X` ohne Targets gewählt → Command-Grammatik muss erweitert
|
||||||
werden (jetzt erlaubt, da über MVP hinaus).
|
werden (jetzt erlaubt, da über MVP hinaus).
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
# Spec: Licht & Dunkelheit (Mechanik-Erweiterung)
|
||||||
|
|
||||||
|
Stand: 2026-05-31. Kleine Mechanik aus dem Spine (#3 der Mechanik-Liste), die das
|
||||||
|
Fundament (Flags/Items) nutzt. Strenge: **Modell B** (Dunkelheit blockiert).
|
||||||
|
|
||||||
|
## 1. Kontext & Ziel
|
||||||
|
|
||||||
|
Dunkle Räume sollen den schaltbaren `lamp` sinnvoll machen: Ohne aktive
|
||||||
|
Lichtquelle kann man einen dunklen Raum **nicht betreten**, nichts sehen
|
||||||
|
(`look`), und nichts aufnehmen/untersuchen. Die HUD-Zeile `light: on/off` wird
|
||||||
|
endlich verdrahtet.
|
||||||
|
|
||||||
|
## 2. Scope
|
||||||
|
|
||||||
|
**In Scope:**
|
||||||
|
- `Room.dark` (Bool, optional), `Item.light` (Bool, optional).
|
||||||
|
- `Light`-Helper: `isLit`, `carryingLight`.
|
||||||
|
- Command-Gating: `go` (Eintritt), `look`, `take`, `examine`; HUD-`lightOn`.
|
||||||
|
- Demo: Dungeon dunkel → Lampe nötig.
|
||||||
|
|
||||||
|
**Out of Scope:** Lampen-Brennstoff/zeitlich begrenztes Licht; Lichtradius über
|
||||||
|
Räume hinweg; Zufall.
|
||||||
|
|
||||||
|
## 3. Daten (rückwärtskompatibel)
|
||||||
|
|
||||||
|
- **`Room.dark`**: `@Setter`-Feld (default `false`), **kein** Konstruktor-Argument →
|
||||||
|
keine Test-Brüche. Vom `RoomFactory` aus `RoomDto.dark` gesetzt. YAML: `dark: true`.
|
||||||
|
- **`Item.light`**: `boolean` am abstrakten `Item` (via `@SuperBuilder`), default
|
||||||
|
`false`. Vom `ItemFactory` aus `ItemDto.light` gesetzt. YAML: `light: true`.
|
||||||
|
- **Aktive Lichtquelle** = `item.light` **und** (kein Switchable **oder**
|
||||||
|
`SwitchableItem.isOn()`). Die `lamp` leuchtet also nur eingeschaltet.
|
||||||
|
|
||||||
|
## 4. Helper `Light` (game)
|
||||||
|
|
||||||
|
```java
|
||||||
|
static boolean isLit(GameContext ctx, Room room) {
|
||||||
|
if (!room.isDark()) return true;
|
||||||
|
return hasActiveLight(player-inventory) || hasActiveLight(room-items);
|
||||||
|
}
|
||||||
|
static boolean carryingLight(GameContext ctx); // aktive Lichtquelle im Inventar
|
||||||
|
```
|
||||||
|
`hasActiveLight(items)` iteriert; `isActiveLight(item)` = `item.isLight()` &&
|
||||||
|
(`!(item instanceof SwitchableItem s)` || `s.isOn()`).
|
||||||
|
|
||||||
|
Räume gelten als beleuchtet, wenn sie nicht dunkel sind **oder** eine aktive
|
||||||
|
Lichtquelle getragen wird **oder** im Raum liegt.
|
||||||
|
|
||||||
|
## 5. Command-Gating (Modell B)
|
||||||
|
|
||||||
|
| Command | Verhalten bei dunklem, unbeleuchtetem Zielraum/aktuellem Raum |
|
||||||
|
|---|---|
|
||||||
|
| `GoCommand` | **Eintritt blockiert**, wenn `!isLit(ctx, target)`: Hinweis-Text, kein Wechsel. (Lichträume bleiben immer betretbar → **kein Soft-Lock**, Rückzug stets möglich.) |
|
||||||
|
| `LookCommand` | `!isLit(ctx, current)` → „It's pitch black; you can't make anything out." statt `RoomView` (Fall: Lampe ging im Raum aus). |
|
||||||
|
| `TakeCommand` | `!isLit(ctx, current)` → „It's too dark to see that." |
|
||||||
|
| `ExamineCommand` | `!isLit(ctx, current)` → „It's too dark to see that." |
|
||||||
|
| HUD | `lightOn` = `Light.carryingLight(ctx)` (in `Game.publishHud`). |
|
||||||
|
|
||||||
|
Erlaubt im Dunkeln bleiben: `inventory`, `drop`, **`use`** (z. B. Lampe anzünden),
|
||||||
|
`quests`, `map`, `help`. So kann man eine getragene Lampe im Dunkeln anzünden.
|
||||||
|
|
||||||
|
## 6. Fehlerbehandlung / Soft-Lock
|
||||||
|
|
||||||
|
Eintrittssperre statt Innen-Sperre: Man betritt einen dunklen Raum nur **mit**
|
||||||
|
Licht. Geht das Licht drinnen aus, sind `look`/`take`/`examine` blockiert, aber der
|
||||||
|
Rückweg in einen hellen Raum bleibt (Zielraum hell → `isLit` true). Kein Soft-Lock.
|
||||||
|
|
||||||
|
## 7. Demo
|
||||||
|
|
||||||
|
- `lamp`: `light: true` (bereits schaltbar; `use lamp` → an).
|
||||||
|
- `dungeon`: `dark: true`; der Generator steht dort. Ablauf:
|
||||||
|
`take lamp` → `use lamp` (an) → `go south` (jetzt betretbar) → `use generator`.
|
||||||
|
HUD zeigt `light: on`, sobald die Lampe brennt. Gibt man die Lampe später dem
|
||||||
|
alten Mann, verliert man das Licht (thematisch).
|
||||||
|
|
||||||
|
## 8. Testing
|
||||||
|
|
||||||
|
- `Light.isLit`: nicht dunkel → hell; dunkel ohne Licht → dunkel; dunkel + getragene
|
||||||
|
brennende Lampe → hell; dunkel + Lampe **aus** → dunkel; dunkel + brennende Lampe
|
||||||
|
**im Raum** → hell. `carryingLight`.
|
||||||
|
- Command-Gates: `go` in dunklen Raum ohne Licht blockt, mit brennender Lampe ok;
|
||||||
|
`look`/`take` im dunklen Raum blockt.
|
||||||
|
- End-to-End (Konsole): `go south` ohne Licht blockt; `take lamp`/`use lamp`/`go south`
|
||||||
|
betritt; HUD `light: on`.
|
||||||
|
|
||||||
|
## 9. Offene Detailfragen (in Implementierung)
|
||||||
|
|
||||||
|
- Genauer Hinweis-Text der Eintrittssperre (nennt `use lamp`).
|
||||||
@@ -2,6 +2,7 @@ package thb.jeanluc.adventure.command.impl;
|
|||||||
|
|
||||||
import thb.jeanluc.adventure.command.Command;
|
import thb.jeanluc.adventure.command.Command;
|
||||||
import thb.jeanluc.adventure.game.GameContext;
|
import thb.jeanluc.adventure.game.GameContext;
|
||||||
|
import thb.jeanluc.adventure.game.Light;
|
||||||
import thb.jeanluc.adventure.model.Npc;
|
import thb.jeanluc.adventure.model.Npc;
|
||||||
import thb.jeanluc.adventure.model.item.Item;
|
import thb.jeanluc.adventure.model.item.Item;
|
||||||
|
|
||||||
@@ -20,6 +21,10 @@ public class ExamineCommand implements Command {
|
|||||||
ctx.getIo().write("Examine what?");
|
ctx.getIo().write("Examine what?");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (!Light.isLit(ctx, ctx.getPlayer().getCurrentRoom())) {
|
||||||
|
ctx.getIo().write("It's too dark to see that.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
String id = args.getFirst();
|
String id = args.getFirst();
|
||||||
Optional<Item> item = ctx.getPlayer().findItem(id);
|
Optional<Item> item = ctx.getPlayer().findItem(id);
|
||||||
if (item.isEmpty()) {
|
if (item.isEmpty()) {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package thb.jeanluc.adventure.command.impl;
|
|||||||
import thb.jeanluc.adventure.command.Command;
|
import thb.jeanluc.adventure.command.Command;
|
||||||
import thb.jeanluc.adventure.game.Conditions;
|
import thb.jeanluc.adventure.game.Conditions;
|
||||||
import thb.jeanluc.adventure.game.GameContext;
|
import thb.jeanluc.adventure.game.GameContext;
|
||||||
|
import thb.jeanluc.adventure.game.Light;
|
||||||
import thb.jeanluc.adventure.model.Direction;
|
import thb.jeanluc.adventure.model.Direction;
|
||||||
import thb.jeanluc.adventure.model.ExitLock;
|
import thb.jeanluc.adventure.model.ExitLock;
|
||||||
import thb.jeanluc.adventure.model.Room;
|
import thb.jeanluc.adventure.model.Room;
|
||||||
@@ -39,6 +40,11 @@ public class GoCommand implements Command {
|
|||||||
ctx.getIo().write(lock.blocked());
|
ctx.getIo().write(lock.blocked());
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (!Light.isLit(ctx, next.get())) {
|
||||||
|
ctx.getIo().write("It's pitch black beyond the doorway — you need a lit light source "
|
||||||
|
+ "(try lighting your lamp with 'use lamp').");
|
||||||
|
return;
|
||||||
|
}
|
||||||
ctx.getPlayer().setCurrentRoom(next.get());
|
ctx.getPlayer().setCurrentRoom(next.get());
|
||||||
new LookCommand().execute(ctx, List.of());
|
new LookCommand().execute(ctx, List.of());
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package thb.jeanluc.adventure.command.impl;
|
|||||||
import thb.jeanluc.adventure.command.Command;
|
import thb.jeanluc.adventure.command.Command;
|
||||||
import thb.jeanluc.adventure.game.Conditions;
|
import thb.jeanluc.adventure.game.Conditions;
|
||||||
import thb.jeanluc.adventure.game.GameContext;
|
import thb.jeanluc.adventure.game.GameContext;
|
||||||
|
import thb.jeanluc.adventure.game.Light;
|
||||||
import thb.jeanluc.adventure.io.text.RoomView;
|
import thb.jeanluc.adventure.io.text.RoomView;
|
||||||
import thb.jeanluc.adventure.model.DescriptionState;
|
import thb.jeanluc.adventure.model.DescriptionState;
|
||||||
import thb.jeanluc.adventure.model.Direction;
|
import thb.jeanluc.adventure.model.Direction;
|
||||||
@@ -21,6 +22,10 @@ public class LookCommand implements Command {
|
|||||||
@Override
|
@Override
|
||||||
public void execute(GameContext ctx, List<String> args) {
|
public void execute(GameContext ctx, List<String> args) {
|
||||||
Room room = ctx.getPlayer().getCurrentRoom();
|
Room room = ctx.getPlayer().getCurrentRoom();
|
||||||
|
if (!Light.isLit(ctx, room)) {
|
||||||
|
ctx.getIo().write("It's pitch black; you can't make anything out. You need a light source.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
List<String> items = room.getItems().values().stream().map(Item::getName).toList();
|
List<String> items = room.getItems().values().stream().map(Item::getName).toList();
|
||||||
List<String> npcs = room.getNpcs().values().stream().map(Npc::getName).toList();
|
List<String> npcs = room.getNpcs().values().stream().map(Npc::getName).toList();
|
||||||
List<String> exits = room.getExits().keySet().stream().map(Direction::getLabel).toList();
|
List<String> exits = room.getExits().keySet().stream().map(Direction::getLabel).toList();
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package thb.jeanluc.adventure.command.impl;
|
|||||||
|
|
||||||
import thb.jeanluc.adventure.command.Command;
|
import thb.jeanluc.adventure.command.Command;
|
||||||
import thb.jeanluc.adventure.game.GameContext;
|
import thb.jeanluc.adventure.game.GameContext;
|
||||||
|
import thb.jeanluc.adventure.game.Light;
|
||||||
import thb.jeanluc.adventure.model.Room;
|
import thb.jeanluc.adventure.model.Room;
|
||||||
import thb.jeanluc.adventure.model.item.Item;
|
import thb.jeanluc.adventure.model.item.Item;
|
||||||
|
|
||||||
@@ -20,6 +21,10 @@ public class TakeCommand implements Command {
|
|||||||
ctx.getIo().write("Take what?");
|
ctx.getIo().write("Take what?");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (!Light.isLit(ctx, ctx.getPlayer().getCurrentRoom())) {
|
||||||
|
ctx.getIo().write("It's too dark to see that.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
String itemId = args.getFirst();
|
String itemId = args.getFirst();
|
||||||
Room room = ctx.getPlayer().getCurrentRoom();
|
Room room = ctx.getPlayer().getCurrentRoom();
|
||||||
Optional<Item> taken = room.removeItem(itemId);
|
Optional<Item> taken = room.removeItem(itemId);
|
||||||
|
|||||||
@@ -77,7 +77,7 @@ public class Game {
|
|||||||
ctx.getPlayer().getCurrentRoom().getName(),
|
ctx.getPlayer().getCurrentRoom().getName(),
|
||||||
ctx.getPlayer().getGold(),
|
ctx.getPlayer().getGold(),
|
||||||
turn,
|
turn,
|
||||||
false));
|
Light.carryingLight(ctx)));
|
||||||
MapView map = MapLayout.compute(
|
MapView map = MapLayout.compute(
|
||||||
ctx.getWorld(),
|
ctx.getWorld(),
|
||||||
ctx.getPlayer().getVisitedRoomIds(),
|
ctx.getPlayer().getVisitedRoomIds(),
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
package thb.jeanluc.adventure.game;
|
||||||
|
|
||||||
|
import thb.jeanluc.adventure.model.Room;
|
||||||
|
import thb.jeanluc.adventure.model.item.Item;
|
||||||
|
import thb.jeanluc.adventure.model.item.SwitchableItem;
|
||||||
|
|
||||||
|
import java.util.Collection;
|
||||||
|
|
||||||
|
/** Decides whether a room is lit, given dark rooms and active light sources. */
|
||||||
|
public final class Light {
|
||||||
|
|
||||||
|
private Light() {
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A room is lit if it is not dark, or an active light source is carried or present. */
|
||||||
|
public static boolean isLit(GameContext ctx, Room room) {
|
||||||
|
if (!room.isDark()) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return hasActiveLight(ctx.getPlayer().getInventory().values())
|
||||||
|
|| hasActiveLight(room.getItems().values());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** True if the player carries an active light source (for the HUD). */
|
||||||
|
public static boolean carryingLight(GameContext ctx) {
|
||||||
|
return hasActiveLight(ctx.getPlayer().getInventory().values());
|
||||||
|
}
|
||||||
|
|
||||||
|
private static boolean hasActiveLight(Collection<Item> items) {
|
||||||
|
for (Item it : items) {
|
||||||
|
if (isActiveLight(it)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static boolean isActiveLight(Item it) {
|
||||||
|
if (!it.isLight()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return !(it instanceof SwitchableItem s) || s.isOn();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -30,12 +30,14 @@ public final class ItemFactory {
|
|||||||
.id(dto.id())
|
.id(dto.id())
|
||||||
.name(dto.name())
|
.name(dto.name())
|
||||||
.description(dto.description())
|
.description(dto.description())
|
||||||
|
.light(Boolean.TRUE.equals(dto.light()))
|
||||||
.build();
|
.build();
|
||||||
case "readable" -> ReadableItem.builder()
|
case "readable" -> ReadableItem.builder()
|
||||||
.id(dto.id())
|
.id(dto.id())
|
||||||
.name(dto.name())
|
.name(dto.name())
|
||||||
.description(dto.description())
|
.description(dto.description())
|
||||||
.readText(dto.readText())
|
.readText(dto.readText())
|
||||||
|
.light(Boolean.TRUE.equals(dto.light()))
|
||||||
.build();
|
.build();
|
||||||
case "switchable" -> SwitchableItem.builder()
|
case "switchable" -> SwitchableItem.builder()
|
||||||
.id(dto.id())
|
.id(dto.id())
|
||||||
@@ -45,6 +47,7 @@ public final class ItemFactory {
|
|||||||
.onText(dto.onText())
|
.onText(dto.onText())
|
||||||
.offText(dto.offText())
|
.offText(dto.offText())
|
||||||
.effects(thb.jeanluc.adventure.loader.dto.EffectDto.toModelList(dto.effects()))
|
.effects(thb.jeanluc.adventure.loader.dto.EffectDto.toModelList(dto.effects()))
|
||||||
|
.light(Boolean.TRUE.equals(dto.light()))
|
||||||
.build();
|
.build();
|
||||||
default -> throw new WorldLoadException(
|
default -> throw new WorldLoadException(
|
||||||
"Unknown item type '" + dto.type() + "' on item '" + dto.id() + "'");
|
"Unknown item type '" + dto.type() + "' on item '" + dto.id() + "'");
|
||||||
|
|||||||
@@ -19,6 +19,8 @@ public final class RoomFactory {
|
|||||||
* @return the freshly built room shell
|
* @return the freshly built room shell
|
||||||
*/
|
*/
|
||||||
public static Room shellFromDto(RoomDto dto) {
|
public static Room shellFromDto(RoomDto dto) {
|
||||||
return new Room(dto.id(), dto.name(), dto.description());
|
Room room = new Room(dto.id(), dto.name(), dto.description());
|
||||||
|
room.setDark(Boolean.TRUE.equals(dto.dark()));
|
||||||
|
return room;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import java.util.List;
|
|||||||
* @param onText message printed when a switchable transitions to on
|
* @param onText message printed when a switchable transitions to on
|
||||||
* @param offText message printed when a switchable transitions to off
|
* @param offText message printed when a switchable transitions to off
|
||||||
* @param effects effects applied when a switchable transitions to on; nullable
|
* @param effects effects applied when a switchable transitions to on; nullable
|
||||||
|
* @param light whether this item is a light source; nullable
|
||||||
*/
|
*/
|
||||||
public record ItemDto(
|
public record ItemDto(
|
||||||
String type,
|
String type,
|
||||||
@@ -25,11 +26,12 @@ public record ItemDto(
|
|||||||
Boolean initialState,
|
Boolean initialState,
|
||||||
String onText,
|
String onText,
|
||||||
String offText,
|
String offText,
|
||||||
List<EffectDto> effects
|
List<EffectDto> effects,
|
||||||
|
Boolean light
|
||||||
) {
|
) {
|
||||||
/** Backward-compatible constructor without effects. */
|
/** Backward-compatible constructor without effects/light. */
|
||||||
public ItemDto(String type, String id, String name, String description,
|
public ItemDto(String type, String id, String name, String description,
|
||||||
String readText, Boolean initialState, String onText, String offText) {
|
String readText, Boolean initialState, String onText, String offText) {
|
||||||
this(type, id, name, description, readText, initialState, onText, offText, null);
|
this(type, id, name, description, readText, initialState, onText, offText, null, null);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import java.util.Map;
|
|||||||
* @param npcs ids of NPCs initially in this room
|
* @param npcs ids of NPCs initially in this room
|
||||||
* @param exitLocks optional condition-gates per exit direction
|
* @param exitLocks optional condition-gates per exit direction
|
||||||
* @param descriptionStates optional condition-gated description variants
|
* @param descriptionStates optional condition-gated description variants
|
||||||
|
* @param dark whether this room is dark; nullable
|
||||||
*/
|
*/
|
||||||
public record RoomDto(
|
public record RoomDto(
|
||||||
String id,
|
String id,
|
||||||
@@ -23,11 +24,12 @@ public record RoomDto(
|
|||||||
List<String> items,
|
List<String> items,
|
||||||
List<String> npcs,
|
List<String> npcs,
|
||||||
List<ExitLockDto> exitLocks,
|
List<ExitLockDto> exitLocks,
|
||||||
List<DescriptionStateDto> descriptionStates
|
List<DescriptionStateDto> descriptionStates,
|
||||||
|
Boolean dark
|
||||||
) {
|
) {
|
||||||
/** Backward-compatible constructor without the optional state fields. */
|
/** Backward-compatible constructor without the optional state fields. */
|
||||||
public RoomDto(String id, String name, String description,
|
public RoomDto(String id, String name, String description,
|
||||||
Map<String, String> exits, List<String> items, List<String> npcs) {
|
Map<String, String> exits, List<String> items, List<String> npcs) {
|
||||||
this(id, name, description, exits, items, npcs, null, null);
|
this(id, name, description, exits, items, npcs, null, null, null);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package thb.jeanluc.adventure.model;
|
|||||||
|
|
||||||
import lombok.Getter;
|
import lombok.Getter;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import lombok.Setter;
|
||||||
import thb.jeanluc.adventure.model.item.Item;
|
import thb.jeanluc.adventure.model.item.Item;
|
||||||
|
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
@@ -50,6 +51,10 @@ public class Room {
|
|||||||
/** Optional condition-gated description variants, first match wins. */
|
/** Optional condition-gated description variants, first match wins. */
|
||||||
private final List<DescriptionState> descriptionStates = new ArrayList<>();
|
private final List<DescriptionState> descriptionStates = new ArrayList<>();
|
||||||
|
|
||||||
|
/** Whether this room is dark (needs a light source to enter/see). */
|
||||||
|
@Setter
|
||||||
|
private boolean dark;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Connects this room to another in the given direction. Does not
|
* Connects this room to another in the given direction. Does not
|
||||||
* create the reverse connection — callers must set that up
|
* create the reverse connection — callers must set that up
|
||||||
|
|||||||
@@ -33,6 +33,9 @@ public abstract class Item {
|
|||||||
/** Long description shown by the {@code examine} command. */
|
/** Long description shown by the {@code examine} command. */
|
||||||
protected final String description;
|
protected final String description;
|
||||||
|
|
||||||
|
/** Whether this item can serve as a light source (when on, for switchables). */
|
||||||
|
protected final boolean light;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Executes the item's primary action. Side effects (text output,
|
* Executes the item's primary action. Side effects (text output,
|
||||||
* mutation of player state) flow through the given context.
|
* mutation of player state) flow through the given context.
|
||||||
|
|||||||
@@ -10,6 +10,7 @@
|
|||||||
name: Oil Lamp
|
name: Oil Lamp
|
||||||
description: An old oil lamp, heavy with fuel.
|
description: An old oil lamp, heavy with fuel.
|
||||||
initialState: false
|
initialState: false
|
||||||
|
light: true
|
||||||
onText: The lamp flares to life, casting a warm glow.
|
onText: The lamp flares to life, casting a warm glow.
|
||||||
offText: You snuff out the lamp.
|
offText: You snuff out the lamp.
|
||||||
|
|
||||||
|
|||||||
@@ -54,7 +54,8 @@
|
|||||||
- id: dungeon
|
- id: dungeon
|
||||||
name: Dungeon
|
name: Dungeon
|
||||||
description: |
|
description: |
|
||||||
A dark, damp room. A rusty generator squats in the corner.
|
A cramped stone room, black as pitch. A rusty generator squats in the corner.
|
||||||
|
dark: true
|
||||||
exits:
|
exits:
|
||||||
north: kitchen
|
north: kitchen
|
||||||
items: [generator]
|
items: [generator]
|
||||||
|
|||||||
@@ -0,0 +1,64 @@
|
|||||||
|
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");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user