From cc2221121d3a7ce483ab6d3dce7e5bbf50f1466b Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Tue, 14 Jul 2026 12:38:26 +0200 Subject: [PATCH] fix(game): apply the darkness rule to every command, not just three MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../adventure/command/impl/GiveCommand.java | 5 ++ .../adventure/command/impl/ReadCommand.java | 6 ++ .../adventure/command/impl/TalkCommand.java | 5 ++ .../adventure/command/impl/UseCommand.java | 9 +- .../jeanluc/adventure/game/Combinations.java | 27 +++++- .../thb/jeanluc/adventure/game/Light.java | 30 ++++++- .../command/impl/DarknessGatingTest.java | 84 +++++++++++++++++++ 7 files changed, 158 insertions(+), 8 deletions(-) create mode 100644 Semesterprojekt/src/test/java/thb/jeanluc/adventure/command/impl/DarknessGatingTest.java diff --git a/Semesterprojekt/src/main/java/thb/jeanluc/adventure/command/impl/GiveCommand.java b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/command/impl/GiveCommand.java index a48ca1b..64a7fe7 100644 --- a/Semesterprojekt/src/main/java/thb/jeanluc/adventure/command/impl/GiveCommand.java +++ b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/command/impl/GiveCommand.java @@ -4,6 +4,7 @@ import thb.jeanluc.adventure.command.Command; import thb.jeanluc.adventure.game.Conditions; import thb.jeanluc.adventure.game.Effects; import thb.jeanluc.adventure.game.GameContext; +import thb.jeanluc.adventure.game.Light; import thb.jeanluc.adventure.model.Npc; import thb.jeanluc.adventure.model.NpcReaction; import thb.jeanluc.adventure.model.item.Item; @@ -24,6 +25,10 @@ public class GiveCommand implements Command { ctx.getIo().write("Usage: give ."); return; } + if (!Light.canSeeRoom(ctx)) { + ctx.getIo().write("It's too dark to make out anyone here."); + return; + } String itemId = args.get(0); String npcId = args.get(1); diff --git a/Semesterprojekt/src/main/java/thb/jeanluc/adventure/command/impl/ReadCommand.java b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/command/impl/ReadCommand.java index 5f554c3..2ce6bb3 100644 --- a/Semesterprojekt/src/main/java/thb/jeanluc/adventure/command/impl/ReadCommand.java +++ b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/command/impl/ReadCommand.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.item.Item; import thb.jeanluc.adventure.model.item.ReadableItem; @@ -21,6 +22,11 @@ public class ReadCommand implements Command { ctx.getIo().write("Read what?"); 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(); Optional item = ctx.getPlayer().findItem(itemId); if (item.isEmpty()) { diff --git a/Semesterprojekt/src/main/java/thb/jeanluc/adventure/command/impl/TalkCommand.java b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/command/impl/TalkCommand.java index 5c249b5..9055f85 100644 --- a/Semesterprojekt/src/main/java/thb/jeanluc/adventure/command/impl/TalkCommand.java +++ b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/command/impl/TalkCommand.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.DialogueLine; import thb.jeanluc.adventure.model.Npc; @@ -20,6 +21,10 @@ public class TalkCommand implements Command { ctx.getIo().write("Talk to whom?"); return; } + if (!Light.canSeeRoom(ctx)) { + ctx.getIo().write("It's too dark to make out anyone here."); + return; + } String npcId = args.getFirst(); Optional npc = ctx.getPlayer().getCurrentRoom().findNpc(npcId); if (npc.isEmpty()) { diff --git a/Semesterprojekt/src/main/java/thb/jeanluc/adventure/command/impl/UseCommand.java b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/command/impl/UseCommand.java index 3f08406..f7e32a1 100644 --- a/Semesterprojekt/src/main/java/thb/jeanluc/adventure/command/impl/UseCommand.java +++ b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/command/impl/UseCommand.java @@ -3,6 +3,7 @@ package thb.jeanluc.adventure.command.impl; import thb.jeanluc.adventure.command.Command; import thb.jeanluc.adventure.game.Combinations; import thb.jeanluc.adventure.game.GameContext; +import thb.jeanluc.adventure.game.Light; import thb.jeanluc.adventure.model.item.Item; import java.util.List; @@ -27,12 +28,16 @@ public class UseCommand implements Command { return; } 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 = ctx.getPlayer().findItem(itemId); - if (item.isEmpty()) { + if (item.isEmpty() && Light.canSeeRoom(ctx)) { item = ctx.getPlayer().getCurrentRoom().findItem(itemId); } 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; } item.get().use(ctx); diff --git a/Semesterprojekt/src/main/java/thb/jeanluc/adventure/game/Combinations.java b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/game/Combinations.java index 01f5ed1..33e03af 100644 --- a/Semesterprojekt/src/main/java/thb/jeanluc/adventure/game/Combinations.java +++ b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/game/Combinations.java @@ -18,14 +18,18 @@ public final class Combinations { * 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, * 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) { if (!present(ctx, idX)) { - ctx.getIo().write(noSuch(idX)); + ctx.getIo().write(unresolved(ctx, idX)); return; } if (!present(ctx, idY)) { - ctx.getIo().write(noSuch(idY)); + ctx.getIo().write(unresolved(ctx, idY)); return; } 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) { 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) { if (ctx.getPlayer().removeItem(id).isEmpty()) { 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."; } } diff --git a/Semesterprojekt/src/main/java/thb/jeanluc/adventure/game/Light.java b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/game/Light.java index 875ef55..93ddee0 100644 --- a/Semesterprojekt/src/main/java/thb/jeanluc/adventure/game/Light.java +++ b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/game/Light.java @@ -12,7 +12,13 @@ public final class 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) { if (!room.isDark()) { return true; @@ -21,11 +27,30 @@ public final class Light { || 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) { 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 items) { for (Item it : items) { if (isActiveLight(it)) { @@ -35,6 +60,7 @@ public final class Light { return false; } + /** A light-emitting item counts only while switched on; non-switchable ones always count. */ private static boolean isActiveLight(Item it) { if (!it.isLight()) { return false; diff --git a/Semesterprojekt/src/test/java/thb/jeanluc/adventure/command/impl/DarknessGatingTest.java b/Semesterprojekt/src/test/java/thb/jeanluc/adventure/command/impl/DarknessGatingTest.java new file mode 100644 index 0000000..cb277c4 --- /dev/null +++ b/Semesterprojekt/src/test/java/thb/jeanluc/adventure/command/impl/DarknessGatingTest.java @@ -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."); + } +}