feat(game): world-state engine (GameState, Condition/Effect, evaluator/applier)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,33 @@
|
|||||||
|
package thb.jeanluc.adventure.game;
|
||||||
|
|
||||||
|
import thb.jeanluc.adventure.model.Condition;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/** Evaluates {@link Condition}s against a {@link GameContext}. */
|
||||||
|
public final class Conditions {
|
||||||
|
|
||||||
|
private Conditions() {
|
||||||
|
}
|
||||||
|
|
||||||
|
public static boolean holds(Condition c, GameContext ctx) {
|
||||||
|
return switch (c.type()) {
|
||||||
|
case FLAG -> ctx.getState().isSet(c.arg());
|
||||||
|
case NOT_FLAG -> !ctx.getState().isSet(c.arg());
|
||||||
|
case HAS_ITEM -> ctx.getPlayer().hasItem(c.arg());
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** True iff every condition holds. A null or empty list is vacuously true. */
|
||||||
|
public static boolean all(List<Condition> conditions, GameContext ctx) {
|
||||||
|
if (conditions == null) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
for (Condition c : conditions) {
|
||||||
|
if (!holds(c, ctx)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
package thb.jeanluc.adventure.game;
|
||||||
|
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import thb.jeanluc.adventure.model.Effect;
|
||||||
|
import thb.jeanluc.adventure.model.item.Item;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/** Applies {@link Effect}s to a {@link GameContext}. */
|
||||||
|
@Slf4j
|
||||||
|
public final class Effects {
|
||||||
|
|
||||||
|
private Effects() {
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void apply(Effect e, GameContext ctx) {
|
||||||
|
switch (e.type()) {
|
||||||
|
case SET_FLAG -> ctx.getState().set(e.arg());
|
||||||
|
case CLEAR_FLAG -> ctx.getState().clear(e.arg());
|
||||||
|
case GIVE_ITEM -> {
|
||||||
|
Item it = ctx.getWorld().getItems().get(e.arg());
|
||||||
|
if (it == null) {
|
||||||
|
log.warn("Effect GIVE_ITEM references unknown item '{}'", e.arg());
|
||||||
|
} else {
|
||||||
|
ctx.getPlayer().addItem(it);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case REMOVE_ITEM -> ctx.getPlayer().removeItem(e.arg());
|
||||||
|
case SAY -> ctx.getIo().write(e.arg());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Applies each effect in order. A null list is a no-op. */
|
||||||
|
public static void applyAll(List<Effect> effects, GameContext ctx) {
|
||||||
|
if (effects == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for (Effect e : effects) {
|
||||||
|
apply(e, ctx);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -23,4 +23,7 @@ public class GameContext {
|
|||||||
|
|
||||||
/** IO channel for reading input and writing output. */
|
/** IO channel for reading input and writing output. */
|
||||||
private final GameIO io;
|
private final GameIO io;
|
||||||
|
|
||||||
|
/** Mutable world flags. Created fresh per context; not a constructor arg. */
|
||||||
|
private final GameState state = new GameState();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
package thb.jeanluc.adventure.game;
|
||||||
|
|
||||||
|
import java.util.Collections;
|
||||||
|
import java.util.LinkedHashSet;
|
||||||
|
import java.util.Set;
|
||||||
|
|
||||||
|
/** Named world flags. A flag is "set" (true) when present in the set. */
|
||||||
|
public class GameState {
|
||||||
|
|
||||||
|
private final Set<String> flags = new LinkedHashSet<>();
|
||||||
|
|
||||||
|
public boolean isSet(String flag) {
|
||||||
|
return flags.contains(flag);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void set(String flag) {
|
||||||
|
flags.add(flag);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void clear(String flag) {
|
||||||
|
flags.remove(flag);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Set<String> all() {
|
||||||
|
return Collections.unmodifiableSet(flags);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
package thb.jeanluc.adventure.model;
|
||||||
|
|
||||||
|
/** A single boolean test evaluated against world state and the player. */
|
||||||
|
public record Condition(Type type, String arg) {
|
||||||
|
public enum Type { FLAG, NOT_FLAG, HAS_ITEM }
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
package thb.jeanluc.adventure.model;
|
||||||
|
|
||||||
|
/** A single state mutation or message. */
|
||||||
|
public record Effect(Type type, String arg) {
|
||||||
|
public enum Type { SET_FLAG, CLEAR_FLAG, GIVE_ITEM, REMOVE_ITEM, SAY }
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
package thb.jeanluc.adventure.game;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import thb.jeanluc.adventure.io.TestIO;
|
||||||
|
import thb.jeanluc.adventure.model.Condition;
|
||||||
|
import thb.jeanluc.adventure.model.Player;
|
||||||
|
import thb.jeanluc.adventure.model.Room;
|
||||||
|
import thb.jeanluc.adventure.model.item.PlainItem;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
|
||||||
|
class ConditionsTest {
|
||||||
|
private GameContext ctx() {
|
||||||
|
Player p = new Player(new Room("k", "K", "d"), 0);
|
||||||
|
return new GameContext(null, p, new TestIO());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void flagAndNotFlag() {
|
||||||
|
GameContext ctx = ctx();
|
||||||
|
ctx.getState().set("power_on");
|
||||||
|
assertThat(Conditions.holds(new Condition(Condition.Type.FLAG, "power_on"), ctx)).isTrue();
|
||||||
|
assertThat(Conditions.holds(new Condition(Condition.Type.NOT_FLAG, "power_on"), ctx)).isFalse();
|
||||||
|
assertThat(Conditions.holds(new Condition(Condition.Type.NOT_FLAG, "x"), ctx)).isTrue();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void hasItem() {
|
||||||
|
GameContext ctx = ctx();
|
||||||
|
ctx.getPlayer().addItem(PlainItem.builder().id("lamp").name("Lamp").description("d").build());
|
||||||
|
assertThat(Conditions.holds(new Condition(Condition.Type.HAS_ITEM, "lamp"), ctx)).isTrue();
|
||||||
|
assertThat(Conditions.holds(new Condition(Condition.Type.HAS_ITEM, "key"), ctx)).isFalse();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void allRequiresEveryConditionAndEmptyIsTrue() {
|
||||||
|
GameContext ctx = ctx();
|
||||||
|
ctx.getState().set("a");
|
||||||
|
assertThat(Conditions.all(List.of(), ctx)).isTrue();
|
||||||
|
assertThat(Conditions.all(List.of(new Condition(Condition.Type.FLAG, "a")), ctx)).isTrue();
|
||||||
|
assertThat(Conditions.all(List.of(
|
||||||
|
new Condition(Condition.Type.FLAG, "a"),
|
||||||
|
new Condition(Condition.Type.FLAG, "b")), ctx)).isFalse();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
package thb.jeanluc.adventure.game;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import thb.jeanluc.adventure.io.TestIO;
|
||||||
|
import thb.jeanluc.adventure.model.Effect;
|
||||||
|
import thb.jeanluc.adventure.model.Player;
|
||||||
|
import thb.jeanluc.adventure.model.Room;
|
||||||
|
import thb.jeanluc.adventure.model.World;
|
||||||
|
import thb.jeanluc.adventure.model.item.Item;
|
||||||
|
import thb.jeanluc.adventure.model.item.PlainItem;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
|
||||||
|
class EffectsTest {
|
||||||
|
private GameContext ctx(Map<String, Item> items) {
|
||||||
|
Player p = new Player(new Room("k", "K", "d"), 0);
|
||||||
|
World w = new World(Map.of(), items, Map.of(), "t", "w");
|
||||||
|
return new GameContext(w, p, new TestIO());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void setAndClearFlag() {
|
||||||
|
GameContext ctx = ctx(Map.of());
|
||||||
|
Effects.applyAll(List.of(new Effect(Effect.Type.SET_FLAG, "power_on")), ctx);
|
||||||
|
assertThat(ctx.getState().isSet("power_on")).isTrue();
|
||||||
|
Effects.apply(new Effect(Effect.Type.CLEAR_FLAG, "power_on"), ctx);
|
||||||
|
assertThat(ctx.getState().isSet("power_on")).isFalse();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void giveAndRemoveItem() {
|
||||||
|
Item key = PlainItem.builder().id("key").name("Key").description("d").build();
|
||||||
|
GameContext ctx = ctx(Map.of("key", key));
|
||||||
|
Effects.apply(new Effect(Effect.Type.GIVE_ITEM, "key"), ctx);
|
||||||
|
assertThat(ctx.getPlayer().hasItem("key")).isTrue();
|
||||||
|
Effects.apply(new Effect(Effect.Type.REMOVE_ITEM, "key"), ctx);
|
||||||
|
assertThat(ctx.getPlayer().hasItem("key")).isFalse();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void giveUnknownItemIsTolerated() {
|
||||||
|
GameContext ctx = ctx(Map.of());
|
||||||
|
Effects.apply(new Effect(Effect.Type.GIVE_ITEM, "ghost"), ctx);
|
||||||
|
assertThat(ctx.getPlayer().hasItem("ghost")).isFalse();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void sayWritesToIo() {
|
||||||
|
GameContext ctx = ctx(Map.of());
|
||||||
|
Effects.apply(new Effect(Effect.Type.SAY, "boo"), ctx);
|
||||||
|
assertThat(((TestIO) ctx.getIo()).allOutput()).contains("boo");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
package thb.jeanluc.adventure.game;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
|
||||||
|
class GameStateTest {
|
||||||
|
@Test
|
||||||
|
void setIsSetClear() {
|
||||||
|
GameState s = new GameState();
|
||||||
|
assertThat(s.isSet("power_on")).isFalse();
|
||||||
|
s.set("power_on");
|
||||||
|
assertThat(s.isSet("power_on")).isTrue();
|
||||||
|
s.clear("power_on");
|
||||||
|
assertThat(s.isSet("power_on")).isFalse();
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user