fix(game): apply the darkness rule to every command, not just three

Only look, examine, take and go checked whether the room was lit. In a pitch
black room the player could still read a letter, talk to and bribe an NPC, drop
items, and even 'use valve_wheel on valve' to drain the cellar and complete a
quest objective — all in total darkness.

Light.canSeeRoom() now gates every command that resolves room contents. The
inventory stays reachable in the dark on purpose: otherwise switching the lamp
off inside a dark room would be an unrecoverable soft-lock.

Room-item lookups in UseCommand and Combinations are gated too, and the
"too dark" message does not reveal whether the item is actually there.
This commit is contained in:
2026-07-14 12:38:26 +02:00
parent 285e68055e
commit cc2221121d
7 changed files with 158 additions and 8 deletions

View File

@@ -4,6 +4,7 @@ import thb.jeanluc.adventure.command.Command;
import thb.jeanluc.adventure.game.Conditions; import thb.jeanluc.adventure.game.Conditions;
import thb.jeanluc.adventure.game.Effects; import thb.jeanluc.adventure.game.Effects;
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.NpcReaction; import thb.jeanluc.adventure.model.NpcReaction;
import thb.jeanluc.adventure.model.item.Item; import thb.jeanluc.adventure.model.item.Item;
@@ -24,6 +25,10 @@ public class GiveCommand implements Command {
ctx.getIo().write("Usage: give <item> <npc>."); ctx.getIo().write("Usage: give <item> <npc>.");
return; return;
} }
if (!Light.canSeeRoom(ctx)) {
ctx.getIo().write("It's too dark to make out anyone here.");
return;
}
String itemId = args.get(0); String itemId = args.get(0);
String npcId = args.get(1); String npcId = args.get(1);

View File

@@ -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.item.Item; import thb.jeanluc.adventure.model.item.Item;
import thb.jeanluc.adventure.model.item.ReadableItem; import thb.jeanluc.adventure.model.item.ReadableItem;
@@ -21,6 +22,11 @@ public class ReadCommand implements Command {
ctx.getIo().write("Read what?"); ctx.getIo().write("Read what?");
return; return;
} }
// Reading needs light even for a carried item.
if (!Light.canSeeRoom(ctx)) {
ctx.getIo().write("It's too dark to read.");
return;
}
String itemId = args.getFirst(); String itemId = args.getFirst();
Optional<Item> item = ctx.getPlayer().findItem(itemId); Optional<Item> item = ctx.getPlayer().findItem(itemId);
if (item.isEmpty()) { if (item.isEmpty()) {

View File

@@ -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.DialogueLine; import thb.jeanluc.adventure.model.DialogueLine;
import thb.jeanluc.adventure.model.Npc; import thb.jeanluc.adventure.model.Npc;
@@ -20,6 +21,10 @@ public class TalkCommand implements Command {
ctx.getIo().write("Talk to whom?"); ctx.getIo().write("Talk to whom?");
return; return;
} }
if (!Light.canSeeRoom(ctx)) {
ctx.getIo().write("It's too dark to make out anyone here.");
return;
}
String npcId = args.getFirst(); String npcId = args.getFirst();
Optional<Npc> npc = ctx.getPlayer().getCurrentRoom().findNpc(npcId); Optional<Npc> npc = ctx.getPlayer().getCurrentRoom().findNpc(npcId);
if (npc.isEmpty()) { if (npc.isEmpty()) {

View File

@@ -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.Combinations; import thb.jeanluc.adventure.game.Combinations;
import thb.jeanluc.adventure.game.GameContext; import thb.jeanluc.adventure.game.GameContext;
import thb.jeanluc.adventure.game.Light;
import thb.jeanluc.adventure.model.item.Item; import thb.jeanluc.adventure.model.item.Item;
import java.util.List; import java.util.List;
@@ -27,12 +28,16 @@ public class UseCommand implements Command {
return; return;
} }
String itemId = args.getFirst(); String itemId = args.getFirst();
// Inventory stays reachable in the dark (so a lamp can be relit);
// room items may only be resolved when the room is lit.
Optional<Item> item = ctx.getPlayer().findItem(itemId); Optional<Item> item = ctx.getPlayer().findItem(itemId);
if (item.isEmpty()) { if (item.isEmpty() && Light.canSeeRoom(ctx)) {
item = ctx.getPlayer().getCurrentRoom().findItem(itemId); item = ctx.getPlayer().getCurrentRoom().findItem(itemId);
} }
if (item.isEmpty()) { if (item.isEmpty()) {
ctx.getIo().write("There is no '" + itemId + "' here or in your inventory."); ctx.getIo().write(Light.canSeeRoom(ctx)
? "There is no '" + itemId + "' here or in your inventory."
: "It's too dark to see that.");
return; return;
} }
item.get().use(ctx); item.get().use(ctx);

View File

@@ -18,14 +18,18 @@ public final class Combinations {
* operand is absent, the recipe's {@code failText} (or a generic line) if a * operand is absent, the recipe's {@code failText} (or a generic line) if a
* recipe's requirements are unmet, "Nothing happens." if there is no recipe, * recipe's requirements are unmet, "Nothing happens." if there is no recipe,
* or applies consume/produce/effects + the response on success. * or applies consume/produce/effects + the response on success.
*
* @param ctx the active game context
* @param idX id of the first operand item
* @param idY id of the second operand item
*/ */
public static void tryUse(GameContext ctx, String idX, String idY) { public static void tryUse(GameContext ctx, String idX, String idY) {
if (!present(ctx, idX)) { if (!present(ctx, idX)) {
ctx.getIo().write(noSuch(idX)); ctx.getIo().write(unresolved(ctx, idX));
return; return;
} }
if (!present(ctx, idY)) { if (!present(ctx, idY)) {
ctx.getIo().write(noSuch(idY)); ctx.getIo().write(unresolved(ctx, idY));
return; return;
} }
Combination c = ctx.getWorld().getCombinations().get(Combination.key(idX, idY)); Combination c = ctx.getWorld().getCombinations().get(Combination.key(idX, idY));
@@ -52,18 +56,33 @@ public final class Combinations {
} }
} }
/**
* An operand resolves from the inventory always, but from the room only
* while the room is lit — otherwise combining with room scenery would
* work in pitch darkness.
*/
private static boolean present(GameContext ctx, String id) { private static boolean present(GameContext ctx, String id) {
return ctx.getPlayer().hasItem(id) return ctx.getPlayer().hasItem(id)
|| ctx.getPlayer().getCurrentRoom().findItem(id).isPresent(); || (Light.canSeeRoom(ctx)
&& ctx.getPlayer().getCurrentRoom().findItem(id).isPresent());
} }
/** Consumes an operand from the inventory, falling back to the current room. */
private static void removeFromAnywhere(GameContext ctx, String id) { private static void removeFromAnywhere(GameContext ctx, String id) {
if (ctx.getPlayer().removeItem(id).isEmpty()) { if (ctx.getPlayer().removeItem(id).isEmpty()) {
ctx.getPlayer().getCurrentRoom().removeItem(id); ctx.getPlayer().getCurrentRoom().removeItem(id);
} }
} }
private static String noSuch(String id) { /**
* Message for an operand that could not be resolved. In a dark room the
* reason is always darkness, and the wording must not reveal whether the
* item is actually there.
*/
private static String unresolved(GameContext ctx, String id) {
if (!Light.canSeeRoom(ctx)) {
return "It's too dark to see that.";
}
return "There is no '" + id + "' here or in your inventory."; return "There is no '" + id + "' here or in your inventory.";
} }
} }

View File

@@ -12,7 +12,13 @@ public final class Light {
private Light() { private Light() {
} }
/** A room is lit if it is not dark, or an active light source is carried or present. */ /**
* A room is lit if it is not dark, or an active light source is carried or present.
*
* @param ctx the active game context
* @param room the room to test
* @return true if the room is lit
*/
public static boolean isLit(GameContext ctx, Room room) { public static boolean isLit(GameContext ctx, Room room) {
if (!room.isDark()) { if (!room.isDark()) {
return true; return true;
@@ -21,11 +27,30 @@ public final class Light {
|| hasActiveLight(room.getItems().values()); || hasActiveLight(room.getItems().values());
} }
/** True if the player carries an active light source (for the HUD). */ /**
* True if the player carries an active light source (for the HUD).
*
* @param ctx the active game context
* @return true if an active light source is in the inventory
*/
public static boolean carryingLight(GameContext ctx) { public static boolean carryingLight(GameContext ctx) {
return hasActiveLight(ctx.getPlayer().getInventory().values()); return hasActiveLight(ctx.getPlayer().getInventory().values());
} }
/**
* True if the player can see the contents of the room they stand in.
* Room items and NPCs may only be resolved when this holds; inventory
* items stay reachable in the dark, so a lamp can always be switched
* back on.
*
* @param ctx the active game context
* @return true if the player's current room is lit
*/
public static boolean canSeeRoom(GameContext ctx) {
return isLit(ctx, ctx.getPlayer().getCurrentRoom());
}
/** Whether any of the given items is an active light source. */
private static boolean hasActiveLight(Collection<Item> items) { private static boolean hasActiveLight(Collection<Item> items) {
for (Item it : items) { for (Item it : items) {
if (isActiveLight(it)) { if (isActiveLight(it)) {
@@ -35,6 +60,7 @@ public final class Light {
return false; return false;
} }
/** A light-emitting item counts only while switched on; non-switchable ones always count. */
private static boolean isActiveLight(Item it) { private static boolean isActiveLight(Item it) {
if (!it.isLight()) { if (!it.isLight()) {
return false; return false;

View File

@@ -0,0 +1,84 @@
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.Npc;
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.ReadableItem;
import thb.jeanluc.adventure.model.item.SwitchableItem;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
/**
* A dark room hides its contents from every command, not just look/take/go.
* The player's own inventory stays reachable, so a lamp can always be relit.
*/
class DarknessGatingTest {
/** Dark room with the player inside, carrying an unlit lamp. */
private static GameContext darkRoom(Player[] out) {
Room cave = new Room("cave", "Cave", "d");
cave.setDark(true);
Player p = new Player(cave, 0);
p.addItem(SwitchableItem.builder().id("lamp").name("Lamp").description("d")
.state(false).onText("The lamp flares.").offText("off").light(true).build());
out[0] = p;
return new GameContext(null, p, new TestIO());
}
private static String output(GameContext ctx) {
return ((TestIO) ctx.getIo()).allOutput();
}
@Test
void read_inDarkRoom_isRefused() {
Player[] p = new Player[1];
GameContext ctx = darkRoom(p);
p[0].addItem(ReadableItem.builder().id("letter").name("Letter")
.description("d").readText("SECRET").build());
new ReadCommand().execute(ctx, List.of("letter"));
assertThat(output(ctx)).contains("too dark");
assertThat(output(ctx)).doesNotContain("SECRET");
}
@Test
void talk_inDarkRoom_isRefused() {
Player[] p = new Player[1];
GameContext ctx = darkRoom(p);
p[0].getCurrentRoom().addNpc(Npc.shell("man", "Man", "d", "HELLO THERE"));
new TalkCommand().execute(ctx, List.of("man"));
assertThat(output(ctx)).contains("too dark");
assertThat(output(ctx)).doesNotContain("HELLO THERE");
}
@Test
void use_inDarkRoom_cannotReachRoomItems() {
Player[] p = new Player[1];
GameContext ctx = darkRoom(p);
p[0].getCurrentRoom().addItem(
PlainItem.builder().id("valve").name("Valve").description("d").build());
new UseCommand().execute(ctx, List.of("valve"));
assertThat(output(ctx)).contains("too dark");
}
@Test
void use_inDarkRoom_stillReachesInventorySoTheLampCanBeRelit() {
Player[] p = new Player[1];
GameContext ctx = darkRoom(p);
new UseCommand().execute(ctx, List.of("lamp"));
assertThat(output(ctx)).contains("The lamp flares.");
}
}