From c3d80f86d70ffe496e3cdab8cb9c5622754beea2 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Sun, 31 May 2026 23:04:42 +0200 Subject: [PATCH 1/3] feat: Room.dark + Item.light flags and Light helper Co-Authored-By: Claude Opus 4.8 (1M context) --- .../thb/jeanluc/adventure/game/Light.java | 44 +++++++++++++ .../jeanluc/adventure/loader/ItemFactory.java | 3 + .../jeanluc/adventure/loader/RoomFactory.java | 4 +- .../jeanluc/adventure/loader/dto/ItemDto.java | 8 ++- .../jeanluc/adventure/loader/dto/RoomDto.java | 6 +- .../thb/jeanluc/adventure/model/Room.java | 5 ++ .../jeanluc/adventure/model/item/Item.java | 3 + .../thb/jeanluc/adventure/game/LightTest.java | 63 +++++++++++++++++++ 8 files changed, 130 insertions(+), 6 deletions(-) create mode 100644 Semesterprojekt/src/main/java/thb/jeanluc/adventure/game/Light.java create mode 100644 Semesterprojekt/src/test/java/thb/jeanluc/adventure/game/LightTest.java 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/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(); + } +} From 569cef20fe142aa303d8271c30728c77cfcbad80 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Sun, 31 May 2026 23:07:36 +0200 Subject: [PATCH 2/3] feat: darkness gating for go/look/take/examine + HUD light Co-Authored-By: Claude Opus 4.8 (1M context) --- .../command/impl/ExamineCommand.java | 5 ++ .../adventure/command/impl/GoCommand.java | 6 ++ .../adventure/command/impl/LookCommand.java | 5 ++ .../adventure/command/impl/TakeCommand.java | 5 ++ .../java/thb/jeanluc/adventure/game/Game.java | 2 +- .../adventure/command/impl/DarknessTest.java | 64 +++++++++++++++++++ 6 files changed, 86 insertions(+), 1 deletion(-) create mode 100644 Semesterprojekt/src/test/java/thb/jeanluc/adventure/command/impl/DarknessTest.java 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/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"); + } +} From 4ed1fa1010fd60dd024162040e6266bc26fc7bc3 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Sun, 31 May 2026 23:08:42 +0200 Subject: [PATCH 3/3] feat(content): dark dungeon needs the lit lamp Co-Authored-By: Claude Opus 4.8 (1M context) --- Semesterprojekt/docs/enhancement-ideas.md | 4 ++++ Semesterprojekt/src/main/resources/world/items.yaml | 1 + Semesterprojekt/src/main/resources/world/rooms.yaml | 3 ++- 3 files changed, 7 insertions(+), 1 deletion(-) 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/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]