feat(game): QuestEngine (auto-advance + announcements) and QuestView
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,94 @@
|
|||||||
|
package thb.jeanluc.adventure.game;
|
||||||
|
|
||||||
|
import thb.jeanluc.adventure.io.text.QuestEntry;
|
||||||
|
import thb.jeanluc.adventure.io.text.QuestView;
|
||||||
|
import thb.jeanluc.adventure.io.text.StyledText;
|
||||||
|
import thb.jeanluc.adventure.model.Quest;
|
||||||
|
import thb.jeanluc.adventure.model.QuestStage;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/** Per-turn quest progression and view building, driven by world conditions. */
|
||||||
|
public final class QuestEngine {
|
||||||
|
|
||||||
|
private QuestEngine() {
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void tick(GameContext ctx) {
|
||||||
|
Map<String, Quest> quests = ctx.getWorld().getQuests();
|
||||||
|
for (Quest q : quests.values()) {
|
||||||
|
if (q.autoStart() && !ctx.getQuestLog().isActive(q.id()) && !ctx.getQuestLog().isCompleted(q.id())) {
|
||||||
|
ctx.getQuestLog().start(q.id());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
int guard = 0;
|
||||||
|
int max = totalStages(quests) + quests.size() + 1;
|
||||||
|
boolean changed = true;
|
||||||
|
while (changed && guard++ < max) {
|
||||||
|
changed = false;
|
||||||
|
for (String id : new ArrayList<>(ctx.getQuestLog().active())) {
|
||||||
|
Quest q = quests.get(id);
|
||||||
|
if (q == null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
int idx = ctx.getQuestLog().stageIndex(id);
|
||||||
|
if (idx >= q.stages().size()) {
|
||||||
|
finish(ctx, q);
|
||||||
|
changed = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
QuestStage stage = q.stages().get(idx);
|
||||||
|
if (Conditions.all(stage.completion(), ctx)) {
|
||||||
|
Effects.applyAll(stage.onComplete(), ctx);
|
||||||
|
ctx.getIo().print(StyledText.builder()
|
||||||
|
.heading("✓ Objective complete: ").plain(stage.objective()).build());
|
||||||
|
ctx.getQuestLog().advance(id);
|
||||||
|
if (ctx.getQuestLog().stageIndex(id) >= q.stages().size()) {
|
||||||
|
finish(ctx, q);
|
||||||
|
}
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void finish(GameContext ctx, Quest q) {
|
||||||
|
if (ctx.getQuestLog().isCompleted(q.id())) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Effects.applyAll(q.onComplete(), ctx);
|
||||||
|
ctx.getQuestLog().complete(q.id());
|
||||||
|
ctx.getIo().print(StyledText.builder()
|
||||||
|
.heading("★ Quest complete: ").plain(q.title()).build());
|
||||||
|
}
|
||||||
|
|
||||||
|
public static QuestView viewOf(GameContext ctx) {
|
||||||
|
Map<String, Quest> quests = ctx.getWorld().getQuests();
|
||||||
|
List<QuestEntry> active = new ArrayList<>();
|
||||||
|
for (String id : ctx.getQuestLog().active()) {
|
||||||
|
Quest q = quests.get(id);
|
||||||
|
if (q == null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
int idx = ctx.getQuestLog().stageIndex(id);
|
||||||
|
String objective = idx < q.stages().size() ? q.stages().get(idx).objective() : "";
|
||||||
|
active.add(new QuestEntry(q.title(), objective));
|
||||||
|
}
|
||||||
|
List<String> completed = new ArrayList<>();
|
||||||
|
for (String id : ctx.getQuestLog().completed()) {
|
||||||
|
Quest q = quests.get(id);
|
||||||
|
completed.add(q == null ? id : q.title());
|
||||||
|
}
|
||||||
|
return new QuestView(active, completed);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int totalStages(Map<String, Quest> quests) {
|
||||||
|
int total = 0;
|
||||||
|
for (Quest q : quests.values()) {
|
||||||
|
total += q.stages().size();
|
||||||
|
}
|
||||||
|
return total;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
package thb.jeanluc.adventure.io.text;
|
||||||
|
|
||||||
|
/** An active quest's title and its current objective. */
|
||||||
|
public record QuestEntry(String title, String objective) {}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
package thb.jeanluc.adventure.io.text;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/** Frontend-agnostic snapshot of the quest log. */
|
||||||
|
public record QuestView(List<QuestEntry> active, List<String> completed) {
|
||||||
|
public QuestView {
|
||||||
|
active = List.copyOf(active);
|
||||||
|
completed = List.copyOf(completed);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -29,4 +29,13 @@ public class World {
|
|||||||
|
|
||||||
/** Welcome message printed once when the game starts. */
|
/** Welcome message printed once when the game starts. */
|
||||||
private final String welcomeMessage;
|
private final String welcomeMessage;
|
||||||
|
|
||||||
|
/** Global lookup of quests by id. */
|
||||||
|
private final Map<String, Quest> quests;
|
||||||
|
|
||||||
|
/** Backward-compatible constructor for worlds without quests. */
|
||||||
|
public World(Map<String, Room> rooms, Map<String, Item> items, Map<String, Npc> npcs,
|
||||||
|
String title, String welcomeMessage) {
|
||||||
|
this(rooms, items, npcs, title, welcomeMessage, Map.of());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,63 @@
|
|||||||
|
package thb.jeanluc.adventure.game;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import thb.jeanluc.adventure.io.TestIO;
|
||||||
|
import thb.jeanluc.adventure.io.text.QuestView;
|
||||||
|
import thb.jeanluc.adventure.model.Condition;
|
||||||
|
import thb.jeanluc.adventure.model.Effect;
|
||||||
|
import thb.jeanluc.adventure.model.Player;
|
||||||
|
import thb.jeanluc.adventure.model.Quest;
|
||||||
|
import thb.jeanluc.adventure.model.QuestStage;
|
||||||
|
import thb.jeanluc.adventure.model.Room;
|
||||||
|
import thb.jeanluc.adventure.model.World;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
|
||||||
|
class QuestEngineTest {
|
||||||
|
private GameContext ctx(Quest quest) {
|
||||||
|
Player p = new Player(new Room("k", "K", "d"), 0);
|
||||||
|
World w = new World(Map.of(), Map.of(), Map.of(), "t", "w", Map.of(quest.id(), quest));
|
||||||
|
return new GameContext(w, p, new TestIO());
|
||||||
|
}
|
||||||
|
|
||||||
|
private Quest twoStage() {
|
||||||
|
return new Quest("q", "Quest", true, List.of(
|
||||||
|
new QuestStage("Stage one", List.of(new Condition(Condition.Type.FLAG, "a")), List.of()),
|
||||||
|
new QuestStage("Stage two", List.of(new Condition(Condition.Type.FLAG, "b")),
|
||||||
|
List.of(new Effect(Effect.Type.SET_FLAG, "done")))), List.of());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void autoStartsAndShowsFirstObjective() {
|
||||||
|
GameContext ctx = ctx(twoStage());
|
||||||
|
QuestEngine.tick(ctx);
|
||||||
|
QuestView v = QuestEngine.viewOf(ctx);
|
||||||
|
assertThat(v.active()).hasSize(1);
|
||||||
|
assertThat(v.active().getFirst().objective()).isEqualTo("Stage one");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void advancesWhenConditionHolds() {
|
||||||
|
GameContext ctx = ctx(twoStage());
|
||||||
|
QuestEngine.tick(ctx);
|
||||||
|
ctx.getState().set("a");
|
||||||
|
QuestEngine.tick(ctx);
|
||||||
|
assertThat(QuestEngine.viewOf(ctx).active().getFirst().objective()).isEqualTo("Stage two");
|
||||||
|
assertThat(((TestIO) ctx.getIo()).allOutput()).contains("Objective complete");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void completesAfterLastStageAndAppliesReward() {
|
||||||
|
GameContext ctx = ctx(twoStage());
|
||||||
|
ctx.getState().set("a");
|
||||||
|
ctx.getState().set("b");
|
||||||
|
QuestEngine.tick(ctx); // cascades through both stages in one tick
|
||||||
|
QuestView v = QuestEngine.viewOf(ctx);
|
||||||
|
assertThat(v.active()).isEmpty();
|
||||||
|
assertThat(v.completed()).containsExactly("Quest");
|
||||||
|
assertThat(ctx.getState().isSet("done")).isTrue();
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user