feat: Room.dark + Item.light flags and Light helper

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-31 23:04:42 +02:00
parent d7edc78882
commit c3d80f86d7
8 changed files with 130 additions and 6 deletions

View File

@@ -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();
}
}

View File

@@ -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() + "'");

View File

@@ -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;
} }
} }

View File

@@ -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);
} }
} }

View File

@@ -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);
} }
} }

View File

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

View File

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

View File

@@ -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();
}
}