fix(items): make scenery items fixed so they cannot be taken

Nothing stopped the player from picking up scenery. The front door is an
ordinary switchable item in the foyer whose on-transition sets the 'left_manor'
flag that every ending keys off, so it could be carried into the cellar and used
there to end the game two floors underground. The chapel shrine could likewise
be carried out and the locket/photograph ritual performed anywhere.

Item gains a 'fixed' flag; TakeCommand refuses fixed items and leaves them in
the room. Fixed items stay usable in place, so all recipes still work.

Marked fixed: front_door, shrine, generator, valve, portrait — their
descriptions already claimed they were bolted down.
This commit is contained in:
2026-07-14 12:38:16 +02:00
parent 65a8935e32
commit 285e68055e
6 changed files with 82 additions and 7 deletions

View File

@@ -27,13 +27,19 @@ public class TakeCommand implements Command {
} }
String itemId = args.getFirst(); String itemId = args.getFirst();
Room room = ctx.getPlayer().getCurrentRoom(); Room room = ctx.getPlayer().getCurrentRoom();
Optional<Item> taken = room.removeItem(itemId); Optional<Item> found = room.findItem(itemId);
if (taken.isEmpty()) { if (found.isEmpty()) {
ctx.getIo().write("There is no '" + itemId + "' here."); ctx.getIo().write("There is no '" + itemId + "' here.");
return; return;
} }
ctx.getPlayer().addItem(taken.get()); Item item = found.get();
ctx.getIo().write("You take the " + taken.get().getName() + "."); if (item.isFixed()) {
ctx.getIo().write("The " + item.getName() + " is part of the room — you can't take it.");
return;
}
room.removeItem(itemId);
ctx.getPlayer().addItem(item);
ctx.getIo().write("You take the " + item.getName() + ".");
} }
@Override @Override

View File

@@ -13,6 +13,7 @@ import thb.jeanluc.adventure.model.item.SwitchableItem;
*/ */
public final class ItemFactory { public final class ItemFactory {
/** Utility class; not instantiable. */
private ItemFactory() { private ItemFactory() {
} }
@@ -31,6 +32,7 @@ public final class ItemFactory {
.name(dto.name()) .name(dto.name())
.description(dto.description()) .description(dto.description())
.light(Boolean.TRUE.equals(dto.light())) .light(Boolean.TRUE.equals(dto.light()))
.fixed(Boolean.TRUE.equals(dto.fixed()))
.build(); .build();
case "readable" -> ReadableItem.builder() case "readable" -> ReadableItem.builder()
.id(dto.id()) .id(dto.id())
@@ -38,6 +40,7 @@ public final class ItemFactory {
.description(dto.description()) .description(dto.description())
.readText(dto.readText()) .readText(dto.readText())
.light(Boolean.TRUE.equals(dto.light())) .light(Boolean.TRUE.equals(dto.light()))
.fixed(Boolean.TRUE.equals(dto.fixed()))
.build(); .build();
case "switchable" -> SwitchableItem.builder() case "switchable" -> SwitchableItem.builder()
.id(dto.id()) .id(dto.id())
@@ -48,6 +51,7 @@ public final class ItemFactory {
.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())) .light(Boolean.TRUE.equals(dto.light()))
.fixed(Boolean.TRUE.equals(dto.fixed()))
.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

@@ -16,6 +16,7 @@ import java.util.List;
* @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 * @param light whether this item is a light source; nullable
* @param fixed whether this item is scenery that cannot be taken; nullable
*/ */
public record ItemDto( public record ItemDto(
String type, String type,
@@ -27,11 +28,24 @@ public record ItemDto(
String onText, String onText,
String offText, String offText,
List<EffectDto> effects, List<EffectDto> effects,
Boolean light Boolean light,
Boolean fixed
) { ) {
/** Backward-compatible constructor without effects/light. */ /**
* Backward-compatible constructor without effects/light/fixed, which default to null.
*
* @param type discriminator: {@code plain | readable | switchable}
* @param id unique slug
* @param name display name
* @param description examine description
* @param readText text printed when a readable item is used
* @param initialState initial on/off state of a switchable item
* @param onText message printed when a switchable transitions to on
* @param offText message printed when a switchable transitions to off
*/
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, null); this(type, id, name, description, readText, initialState, onText, offText,
null, null, null);
} }
} }

View File

@@ -36,6 +36,13 @@ public abstract class Item {
/** Whether this item can serve as a light source (when on, for switchables). */ /** Whether this item can serve as a light source (when on, for switchables). */
protected final boolean light; protected final boolean light;
/**
* Whether this item is scenery bolted to its room and cannot be taken.
* Fixed items (the front door, the chapel shrine, the boiler generator)
* remain usable in place but must never enter the player's inventory.
*/
protected final boolean fixed;
/** /**
* 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

@@ -40,6 +40,7 @@
- type: plain - type: plain
id: valve id: valve
name: Drainage Valve name: Drainage Valve
fixed: true
description: | description: |
A rusted drainage valve set into the cellar wall. Its wheel is missing — A rusted drainage valve set into the cellar wall. Its wheel is missing —
it is part of the house, not something you can carry off. it is part of the house, not something you can carry off.
@@ -52,6 +53,7 @@
- type: plain - type: plain
id: generator id: generator
name: Generator name: Generator
fixed: true
description: | description: |
A rusty generator with an empty fuse socket. It is bolted to the floor — A rusty generator with an empty fuse socket. It is bolted to the floor —
you will have to work on it where it stands. you will have to work on it where it stands.
@@ -71,6 +73,7 @@
- type: plain - type: plain
id: shrine id: shrine
name: Stone Shrine name: Stone Shrine
fixed: true
description: | description: |
A small stone shrine with a shallow offering niche. It is mortared into A small stone shrine with a shallow offering niche. It is mortared into
the chapel floor — a place to leave something, not to take it. the chapel floor — a place to leave something, not to take it.
@@ -80,6 +83,7 @@
- type: switchable - type: switchable
id: front_door id: front_door
name: Front Door name: Front Door
fixed: true
description: The heavy front door. It would let you leave the manor for good. description: The heavy front door. It would let you leave the manor for good.
initialState: false initialState: false
onText: | onText: |
@@ -113,6 +117,7 @@
- type: readable - type: readable
id: portrait id: portrait
name: Family Portrait name: Family Portrait
fixed: true
description: A faded oil portrait above the dining-room hearth. description: A faded oil portrait above the dining-room hearth.
readText: | readText: |
A stern family poses before the manor. A small brass plate reads only: A stern family poses before the manor. A small brass plate reads only:

View File

@@ -0,0 +1,39 @@
package thb.jeanluc.adventure.command.impl;
import org.junit.jupiter.api.Test;
import thb.jeanluc.adventure.model.item.PlainItem;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
/**
* Scenery items are usable in place but must never enter the inventory —
* otherwise the front door could be carried off and opened from any room.
*/
class FixedItemTest {
@Test
void take_withFixedItem_leavesItInTheRoom() {
var w = new CommandTestSupport.World();
var door = PlainItem.builder()
.id("door").name("Front Door").description("heavy").fixed(true).build();
w.kitchen.addItem(door);
new TakeCommand().execute(w.ctx, List.of("door"));
assertThat(w.player.hasItem("door")).isFalse();
assertThat(w.kitchen.findItem("door")).isPresent();
assertThat(w.io.allOutput()).contains("part of the room");
}
@Test
void take_withOrdinaryItem_stillMovesItToInventory() {
var w = new CommandTestSupport.World();
new TakeCommand().execute(w.ctx, List.of("letter"));
assertThat(w.player.hasItem("letter")).isTrue();
assertThat(w.kitchen.findItem("letter")).isEmpty();
}
}