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.
|
||||
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).
|
||||
|
||||
@@ -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> item = ctx.getPlayer().findItem(id);
|
||||
if (item.isEmpty()) {
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
@@ -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<String> 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<String> items = room.getItems().values().stream().map(Item::getName).toList();
|
||||
List<String> npcs = room.getNpcs().values().stream().map(Npc::getName).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.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<Item> taken = room.removeItem(itemId);
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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())
|
||||
.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() + "'");
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<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,
|
||||
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 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<String> items,
|
||||
List<String> npcs,
|
||||
List<ExitLockDto> exitLocks,
|
||||
List<DescriptionStateDto> descriptionStates
|
||||
List<DescriptionStateDto> descriptionStates,
|
||||
Boolean dark
|
||||
) {
|
||||
/** Backward-compatible constructor without the optional state fields. */
|
||||
public RoomDto(String id, String name, String description,
|
||||
Map<String, String> exits, List<String> items, List<String> npcs) {
|
||||
this(id, name, description, exits, items, npcs, null, null);
|
||||
this(id, name, description, exits, items, npcs, null, null, null);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<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
|
||||
* 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. */
|
||||
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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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