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