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();
Room room = ctx.getPlayer().getCurrentRoom();
Optional<Item> taken = room.removeItem(itemId);
if (taken.isEmpty()) {
Optional<Item> found = room.findItem(itemId);
if (found.isEmpty()) {
ctx.getIo().write("There is no '" + itemId + "' here.");
return;
}
ctx.getPlayer().addItem(taken.get());
ctx.getIo().write("You take the " + taken.get().getName() + ".");
Item item = found.get();
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

View File

@@ -13,6 +13,7 @@ import thb.jeanluc.adventure.model.item.SwitchableItem;
*/
public final class ItemFactory {
/** Utility class; not instantiable. */
private ItemFactory() {
}
@@ -31,6 +32,7 @@ public final class ItemFactory {
.name(dto.name())
.description(dto.description())
.light(Boolean.TRUE.equals(dto.light()))
.fixed(Boolean.TRUE.equals(dto.fixed()))
.build();
case "readable" -> ReadableItem.builder()
.id(dto.id())
@@ -38,6 +40,7 @@ public final class ItemFactory {
.description(dto.description())
.readText(dto.readText())
.light(Boolean.TRUE.equals(dto.light()))
.fixed(Boolean.TRUE.equals(dto.fixed()))
.build();
case "switchable" -> SwitchableItem.builder()
.id(dto.id())
@@ -48,6 +51,7 @@ public final class ItemFactory {
.offText(dto.offText())
.effects(thb.jeanluc.adventure.loader.dto.EffectDto.toModelList(dto.effects()))
.light(Boolean.TRUE.equals(dto.light()))
.fixed(Boolean.TRUE.equals(dto.fixed()))
.build();
default -> throw new WorldLoadException(
"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 effects effects applied when a switchable transitions to on; 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(
String type,
@@ -27,11 +28,24 @@ public record ItemDto(
String onText,
String offText,
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,
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). */
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,
* mutation of player state) flow through the given context.

View File

@@ -40,6 +40,7 @@
- type: plain
id: valve
name: Drainage Valve
fixed: true
description: |
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.
@@ -52,6 +53,7 @@
- type: plain
id: generator
name: Generator
fixed: true
description: |
A rusty generator with an empty fuse socket. It is bolted to the floor —
you will have to work on it where it stands.
@@ -71,6 +73,7 @@
- type: plain
id: shrine
name: Stone Shrine
fixed: true
description: |
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.
@@ -80,6 +83,7 @@
- type: switchable
id: front_door
name: Front Door
fixed: true
description: The heavy front door. It would let you leave the manor for good.
initialState: false
onText: |
@@ -113,6 +117,7 @@
- type: readable
id: portrait
name: Family Portrait
fixed: true
description: A faded oil portrait above the dining-room hearth.
readText: |
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();
}
}