diff --git a/Semesterprojekt/docs/enhancement-ideas.md b/Semesterprojekt/docs/enhancement-ideas.md index c404a7e..883c3b7 100644 --- a/Semesterprojekt/docs/enhancement-ideas.md +++ b/Semesterprojekt/docs/enhancement-ideas.md @@ -122,6 +122,10 @@ des Bogens; einzelne Teilziele schalten je den nächsten Bereich frei. Hebel/Ventile setzen Flags und öffnen Bereiche. 3. **Licht & Dunkelheit** – dunkle Räume brauchen eine brennende Lichtquelle, 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 bewusst argloses `use X` ohne Targets gewählt → Command-Grammatik muss erweitert werden (jetzt erlaubt, da über MVP hinaus). 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. diff --git a/Semesterprojekt/docs/superpowers/specs/2026-05-31-light-darkness-design.md b/Semesterprojekt/docs/superpowers/specs/2026-05-31-light-darkness-design.md new file mode 100644 index 0000000..04977a8 --- /dev/null +++ b/Semesterprojekt/docs/superpowers/specs/2026-05-31-light-darkness-design.md @@ -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`). diff --git a/Semesterprojekt/src/main/java/thb/jeanluc/adventure/command/impl/ExamineCommand.java b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/command/impl/ExamineCommand.java index 17ed54f..ba1352d 100644 --- a/Semesterprojekt/src/main/java/thb/jeanluc/adventure/command/impl/ExamineCommand.java +++ b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/command/impl/ExamineCommand.java @@ -2,6 +2,7 @@ package thb.jeanluc.adventure.command.impl; import thb.jeanluc.adventure.command.Command; import thb.jeanluc.adventure.game.GameContext; +import thb.jeanluc.adventure.game.Light; import thb.jeanluc.adventure.model.Npc; import thb.jeanluc.adventure.model.item.Item; @@ -20,6 +21,10 @@ public class ExamineCommand implements Command { ctx.getIo().write("Examine what?"); return; } + if (!Light.isLit(ctx, ctx.getPlayer().getCurrentRoom())) { + ctx.getIo().write("It's too dark to see that."); + return; + } String id = args.getFirst(); Optional item = ctx.getPlayer().findItem(id); if (item.isEmpty()) { diff --git a/Semesterprojekt/src/main/java/thb/jeanluc/adventure/command/impl/GoCommand.java b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/command/impl/GoCommand.java index ac31efb..c98af8b 100644 --- a/Semesterprojekt/src/main/java/thb/jeanluc/adventure/command/impl/GoCommand.java +++ b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/command/impl/GoCommand.java @@ -3,6 +3,7 @@ package thb.jeanluc.adventure.command.impl; import thb.jeanluc.adventure.command.Command; import thb.jeanluc.adventure.game.Conditions; import thb.jeanluc.adventure.game.GameContext; +import thb.jeanluc.adventure.game.Light; import thb.jeanluc.adventure.model.Direction; import thb.jeanluc.adventure.model.ExitLock; import thb.jeanluc.adventure.model.Room; @@ -39,6 +40,11 @@ public class GoCommand implements Command { ctx.getIo().write(lock.blocked()); return; } + if (!Light.isLit(ctx, next.get())) { + ctx.getIo().write("It's pitch black beyond the doorway — you need a lit light source " + + "(try lighting your lamp with 'use lamp')."); + return; + } ctx.getPlayer().setCurrentRoom(next.get()); new LookCommand().execute(ctx, List.of()); } diff --git a/Semesterprojekt/src/main/java/thb/jeanluc/adventure/command/impl/LookCommand.java b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/command/impl/LookCommand.java index d5a08c8..e5ca6e1 100644 --- a/Semesterprojekt/src/main/java/thb/jeanluc/adventure/command/impl/LookCommand.java +++ b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/command/impl/LookCommand.java @@ -3,6 +3,7 @@ package thb.jeanluc.adventure.command.impl; import thb.jeanluc.adventure.command.Command; import thb.jeanluc.adventure.game.Conditions; import thb.jeanluc.adventure.game.GameContext; +import thb.jeanluc.adventure.game.Light; import thb.jeanluc.adventure.io.text.RoomView; import thb.jeanluc.adventure.model.DescriptionState; import thb.jeanluc.adventure.model.Direction; @@ -21,6 +22,10 @@ public class LookCommand implements Command { @Override public void execute(GameContext ctx, List args) { 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 items = room.getItems().values().stream().map(Item::getName).toList(); List npcs = room.getNpcs().values().stream().map(Npc::getName).toList(); List exits = room.getExits().keySet().stream().map(Direction::getLabel).toList(); diff --git a/Semesterprojekt/src/main/java/thb/jeanluc/adventure/command/impl/TakeCommand.java b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/command/impl/TakeCommand.java index d28721f..1d58170 100644 --- a/Semesterprojekt/src/main/java/thb/jeanluc/adventure/command/impl/TakeCommand.java +++ b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/command/impl/TakeCommand.java @@ -2,6 +2,7 @@ package thb.jeanluc.adventure.command.impl; import thb.jeanluc.adventure.command.Command; import thb.jeanluc.adventure.game.GameContext; +import thb.jeanluc.adventure.game.Light; import thb.jeanluc.adventure.model.Room; import thb.jeanluc.adventure.model.item.Item; @@ -20,6 +21,10 @@ public class TakeCommand implements Command { ctx.getIo().write("Take what?"); return; } + if (!Light.isLit(ctx, ctx.getPlayer().getCurrentRoom())) { + ctx.getIo().write("It's too dark to see that."); + return; + } String itemId = args.getFirst(); Room room = ctx.getPlayer().getCurrentRoom(); Optional taken = room.removeItem(itemId); diff --git a/Semesterprojekt/src/main/java/thb/jeanluc/adventure/game/Game.java b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/game/Game.java index 20d0f02..2cf1058 100644 --- a/Semesterprojekt/src/main/java/thb/jeanluc/adventure/game/Game.java +++ b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/game/Game.java @@ -77,7 +77,7 @@ public class Game { ctx.getPlayer().getCurrentRoom().getName(), ctx.getPlayer().getGold(), turn, - false)); + Light.carryingLight(ctx))); MapView map = MapLayout.compute( ctx.getWorld(), ctx.getPlayer().getVisitedRoomIds(), diff --git a/Semesterprojekt/src/main/java/thb/jeanluc/adventure/game/Light.java b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/game/Light.java new file mode 100644 index 0000000..875ef55 --- /dev/null +++ b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/game/Light.java @@ -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 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(); + } +} diff --git a/Semesterprojekt/src/main/java/thb/jeanluc/adventure/loader/ItemFactory.java b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/loader/ItemFactory.java index 1bcefe0..a475c56 100644 --- a/Semesterprojekt/src/main/java/thb/jeanluc/adventure/loader/ItemFactory.java +++ b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/loader/ItemFactory.java @@ -30,12 +30,14 @@ public final class ItemFactory { .id(dto.id()) .name(dto.name()) .description(dto.description()) + .light(Boolean.TRUE.equals(dto.light())) .build(); case "readable" -> ReadableItem.builder() .id(dto.id()) .name(dto.name()) .description(dto.description()) .readText(dto.readText()) + .light(Boolean.TRUE.equals(dto.light())) .build(); case "switchable" -> SwitchableItem.builder() .id(dto.id()) @@ -45,6 +47,7 @@ public final class ItemFactory { .onText(dto.onText()) .offText(dto.offText()) .effects(thb.jeanluc.adventure.loader.dto.EffectDto.toModelList(dto.effects())) + .light(Boolean.TRUE.equals(dto.light())) .build(); default -> throw new WorldLoadException( "Unknown item type '" + dto.type() + "' on item '" + dto.id() + "'"); diff --git a/Semesterprojekt/src/main/java/thb/jeanluc/adventure/loader/RoomFactory.java b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/loader/RoomFactory.java index caf93f4..5742eb9 100644 --- a/Semesterprojekt/src/main/java/thb/jeanluc/adventure/loader/RoomFactory.java +++ b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/loader/RoomFactory.java @@ -19,6 +19,8 @@ public final class RoomFactory { * @return the freshly built room shell */ 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; } } diff --git a/Semesterprojekt/src/main/java/thb/jeanluc/adventure/loader/dto/ItemDto.java b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/loader/dto/ItemDto.java index 24ec7f1..3676590 100644 --- a/Semesterprojekt/src/main/java/thb/jeanluc/adventure/loader/dto/ItemDto.java +++ b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/loader/dto/ItemDto.java @@ -15,6 +15,7 @@ import java.util.List; * @param onText message printed when a switchable transitions to on * @param offText message printed when a switchable transitions to off * @param effects effects applied when a switchable transitions to on; nullable + * @param light whether this item is a light source; nullable */ public record ItemDto( String type, @@ -25,11 +26,12 @@ public record ItemDto( Boolean initialState, String onText, String offText, - List effects + List effects, + Boolean light ) { - /** Backward-compatible constructor without effects. */ + /** 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); + this(type, id, name, description, readText, initialState, onText, offText, null, null); } } diff --git a/Semesterprojekt/src/main/java/thb/jeanluc/adventure/loader/dto/RoomDto.java b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/loader/dto/RoomDto.java index 9a42aca..e069351 100644 --- a/Semesterprojekt/src/main/java/thb/jeanluc/adventure/loader/dto/RoomDto.java +++ b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/loader/dto/RoomDto.java @@ -14,6 +14,7 @@ import java.util.Map; * @param npcs ids of NPCs initially in this room * @param exitLocks optional condition-gates per exit direction * @param descriptionStates optional condition-gated description variants + * @param dark whether this room is dark; nullable */ public record RoomDto( String id, @@ -23,11 +24,12 @@ public record RoomDto( List items, List npcs, List exitLocks, - List descriptionStates + List descriptionStates, + Boolean dark ) { /** 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); + this(id, name, description, exits, items, npcs, null, null, null); } } diff --git a/Semesterprojekt/src/main/java/thb/jeanluc/adventure/model/Room.java b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/model/Room.java index eca90b2..dcd9e8c 100644 --- a/Semesterprojekt/src/main/java/thb/jeanluc/adventure/model/Room.java +++ b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/model/Room.java @@ -2,6 +2,7 @@ package thb.jeanluc.adventure.model; import lombok.Getter; import lombok.RequiredArgsConstructor; +import lombok.Setter; import thb.jeanluc.adventure.model.item.Item; import java.util.ArrayList; @@ -50,6 +51,10 @@ public class Room { /** Optional condition-gated description variants, first match wins. */ private final List 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 * create the reverse connection — callers must set that up diff --git a/Semesterprojekt/src/main/java/thb/jeanluc/adventure/model/item/Item.java b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/model/item/Item.java index 504d805..0058aaf 100644 --- a/Semesterprojekt/src/main/java/thb/jeanluc/adventure/model/item/Item.java +++ b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/model/item/Item.java @@ -33,6 +33,9 @@ public abstract class Item { /** Long description shown by the {@code examine} command. */ 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, * mutation of player state) flow through the given context. diff --git a/Semesterprojekt/src/main/resources/world/items.yaml b/Semesterprojekt/src/main/resources/world/items.yaml index 8db6c63..1320cdb 100644 --- a/Semesterprojekt/src/main/resources/world/items.yaml +++ b/Semesterprojekt/src/main/resources/world/items.yaml @@ -10,6 +10,7 @@ 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. diff --git a/Semesterprojekt/src/main/resources/world/rooms.yaml b/Semesterprojekt/src/main/resources/world/rooms.yaml index 0fb43ef..dd9226c 100644 --- a/Semesterprojekt/src/main/resources/world/rooms.yaml +++ b/Semesterprojekt/src/main/resources/world/rooms.yaml @@ -54,7 +54,8 @@ - id: dungeon name: Dungeon 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: north: kitchen items: [generator] diff --git a/Semesterprojekt/src/test/java/thb/jeanluc/adventure/command/impl/DarknessTest.java b/Semesterprojekt/src/test/java/thb/jeanluc/adventure/command/impl/DarknessTest.java new file mode 100644 index 0000000..b4a9780 --- /dev/null +++ b/Semesterprojekt/src/test/java/thb/jeanluc/adventure/command/impl/DarknessTest.java @@ -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"); + } +} diff --git a/Semesterprojekt/src/test/java/thb/jeanluc/adventure/game/LightTest.java b/Semesterprojekt/src/test/java/thb/jeanluc/adventure/game/LightTest.java new file mode 100644 index 0000000..7ba72a9 --- /dev/null +++ b/Semesterprojekt/src/test/java/thb/jeanluc/adventure/game/LightTest.java @@ -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(); + } +}