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:
@@ -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 <item> <npc>.");
|
||||
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);
|
||||
|
||||
|
||||
@@ -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> item = ctx.getPlayer().findItem(itemId);
|
||||
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.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> npc = ctx.getPlayer().getCurrentRoom().findNpc(npcId);
|
||||
if (npc.isEmpty()) {
|
||||
|
||||
@@ -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> 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);
|
||||
|
||||
@@ -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.";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<Item> 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;
|
||||
|
||||
@@ -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.");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user