From 5546b63d475614a25e52585f1b3ed720c373108e Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Sun, 31 May 2026 21:46:29 +0200 Subject: [PATCH] feat(game): world-state engine (GameState, Condition/Effect, evaluator/applier) Co-Authored-By: Claude Opus 4.8 (1M context) --- .../jeanluc/adventure/game/Conditions.java | 33 +++++++++++ .../thb/jeanluc/adventure/game/Effects.java | 42 ++++++++++++++ .../jeanluc/adventure/game/GameContext.java | 3 + .../thb/jeanluc/adventure/game/GameState.java | 27 +++++++++ .../jeanluc/adventure/model/Condition.java | 6 ++ .../thb/jeanluc/adventure/model/Effect.java | 6 ++ .../adventure/game/ConditionsTest.java | 47 ++++++++++++++++ .../jeanluc/adventure/game/EffectsTest.java | 56 +++++++++++++++++++ .../jeanluc/adventure/game/GameStateTest.java | 16 ++++++ 9 files changed, 236 insertions(+) create mode 100644 Semesterprojekt/src/main/java/thb/jeanluc/adventure/game/Conditions.java create mode 100644 Semesterprojekt/src/main/java/thb/jeanluc/adventure/game/Effects.java create mode 100644 Semesterprojekt/src/main/java/thb/jeanluc/adventure/game/GameState.java create mode 100644 Semesterprojekt/src/main/java/thb/jeanluc/adventure/model/Condition.java create mode 100644 Semesterprojekt/src/main/java/thb/jeanluc/adventure/model/Effect.java create mode 100644 Semesterprojekt/src/test/java/thb/jeanluc/adventure/game/ConditionsTest.java create mode 100644 Semesterprojekt/src/test/java/thb/jeanluc/adventure/game/EffectsTest.java create mode 100644 Semesterprojekt/src/test/java/thb/jeanluc/adventure/game/GameStateTest.java diff --git a/Semesterprojekt/src/main/java/thb/jeanluc/adventure/game/Conditions.java b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/game/Conditions.java new file mode 100644 index 0000000..1e96113 --- /dev/null +++ b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/game/Conditions.java @@ -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 conditions, GameContext ctx) { + if (conditions == null) { + return true; + } + for (Condition c : conditions) { + if (!holds(c, ctx)) { + return false; + } + } + return true; + } +} diff --git a/Semesterprojekt/src/main/java/thb/jeanluc/adventure/game/Effects.java b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/game/Effects.java new file mode 100644 index 0000000..317faa3 --- /dev/null +++ b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/game/Effects.java @@ -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 effects, GameContext ctx) { + if (effects == null) { + return; + } + for (Effect e : effects) { + apply(e, ctx); + } + } +} diff --git a/Semesterprojekt/src/main/java/thb/jeanluc/adventure/game/GameContext.java b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/game/GameContext.java index 7f3f41c..48617fe 100644 --- a/Semesterprojekt/src/main/java/thb/jeanluc/adventure/game/GameContext.java +++ b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/game/GameContext.java @@ -23,4 +23,7 @@ public class GameContext { /** IO channel for reading input and writing output. */ private final GameIO io; + + /** Mutable world flags. Created fresh per context; not a constructor arg. */ + private final GameState state = new GameState(); } diff --git a/Semesterprojekt/src/main/java/thb/jeanluc/adventure/game/GameState.java b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/game/GameState.java new file mode 100644 index 0000000..135f03f --- /dev/null +++ b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/game/GameState.java @@ -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 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 all() { + return Collections.unmodifiableSet(flags); + } +} diff --git a/Semesterprojekt/src/main/java/thb/jeanluc/adventure/model/Condition.java b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/model/Condition.java new file mode 100644 index 0000000..8381f34 --- /dev/null +++ b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/model/Condition.java @@ -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 } +} diff --git a/Semesterprojekt/src/main/java/thb/jeanluc/adventure/model/Effect.java b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/model/Effect.java new file mode 100644 index 0000000..c876efa --- /dev/null +++ b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/model/Effect.java @@ -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 } +} diff --git a/Semesterprojekt/src/test/java/thb/jeanluc/adventure/game/ConditionsTest.java b/Semesterprojekt/src/test/java/thb/jeanluc/adventure/game/ConditionsTest.java new file mode 100644 index 0000000..1476f64 --- /dev/null +++ b/Semesterprojekt/src/test/java/thb/jeanluc/adventure/game/ConditionsTest.java @@ -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(); + } +} diff --git a/Semesterprojekt/src/test/java/thb/jeanluc/adventure/game/EffectsTest.java b/Semesterprojekt/src/test/java/thb/jeanluc/adventure/game/EffectsTest.java new file mode 100644 index 0000000..44d1d2c --- /dev/null +++ b/Semesterprojekt/src/test/java/thb/jeanluc/adventure/game/EffectsTest.java @@ -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 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"); + } +} diff --git a/Semesterprojekt/src/test/java/thb/jeanluc/adventure/game/GameStateTest.java b/Semesterprojekt/src/test/java/thb/jeanluc/adventure/game/GameStateTest.java new file mode 100644 index 0000000..d4f91c2 --- /dev/null +++ b/Semesterprojekt/src/test/java/thb/jeanluc/adventure/game/GameStateTest.java @@ -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(); + } +}