feat(game): EndingEngine + per-turn end check with summary

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-31 22:49:00 +02:00
parent 3ec540e41b
commit 2238aa6ff5
3 changed files with 108 additions and 0 deletions

View File

@@ -0,0 +1,46 @@
package thb.jeanluc.adventure.game;
import thb.jeanluc.adventure.io.text.StyledText;
import thb.jeanluc.adventure.model.Ending;
/** Detects the first matching ending and renders the end-of-game screen. */
public final class EndingEngine {
private EndingEngine() {
}
/** First ending whose conditions hold, in list order; null if none. */
public static Ending triggered(GameContext ctx) {
for (Ending e : ctx.getWorld().getEndings()) {
if (Conditions.all(e.when(), ctx)) {
return e;
}
}
return null;
}
/** Builds the ending screen: title banner, text, and a run summary. */
public static StyledText render(Ending e, GameContext ctx, int turns) {
int total = ctx.getWorld().getQuests().size();
int done = ctx.getQuestLog().completed().size();
String bar = "".repeat(Math.max(12, e.title().length() + 6));
StyledText.Builder b = StyledText.builder();
b.heading(bar + "\n").heading(" " + e.title() + "\n").heading(bar + "\n");
b.plain(e.text().stripTrailing()).plain("\n\n");
b.dim("Turns: " + turns + "\n");
b.dim("Quests completed: " + done + " / " + total + "\n");
b.heading("Rank: " + rank(e, total, done));
return b.build();
}
private static String rank(Ending e, int total, int done) {
if (e.victory() && total > 0 && done == total) {
return "Master of the Manor";
}
if (e.victory()) {
return "Manor Reclaimed";
}
return "Escaped — the manor keeps its secrets";
}
}

View File

@@ -8,6 +8,7 @@ import thb.jeanluc.adventure.command.ParsedCommand;
import thb.jeanluc.adventure.io.text.Hud; import thb.jeanluc.adventure.io.text.Hud;
import thb.jeanluc.adventure.io.text.MapView; import thb.jeanluc.adventure.io.text.MapView;
import thb.jeanluc.adventure.map.MapLayout; import thb.jeanluc.adventure.map.MapLayout;
import thb.jeanluc.adventure.model.Ending;
import java.util.Optional; import java.util.Optional;
@@ -39,6 +40,7 @@ public class Game {
*/ */
public void run() { public void run() {
publishHud(); publishHud();
maybeEnd();
while (running) { while (running) {
String input = ctx.getIo().readLine(); String input = ctx.getIo().readLine();
if (input == null) { if (input == null) {
@@ -56,6 +58,16 @@ public class Game {
turn++; turn++;
} }
publishHud(); publishHud();
maybeEnd();
}
}
/** Ends the game if any ending's conditions are met, printing its screen. */
private void maybeEnd() {
Ending e = EndingEngine.triggered(ctx);
if (e != null) {
ctx.getIo().print(EndingEngine.render(e, ctx, turn));
stop();
} }
} }

View File

@@ -0,0 +1,50 @@
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.Ending;
import thb.jeanluc.adventure.model.Player;
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 EndingEngineTest {
private GameContext ctx(List<Ending> endings) {
Player p = new Player(new Room("k", "K", "d"), 0);
World w = new World(Map.of(), Map.of(), Map.of(), "t", "w", Map.of(), endings);
return new GameContext(w, p, new TestIO());
}
private Ending ending(String id, boolean victory, String flag) {
return new Ending(id, id + "-title", victory,
List.of(new Condition(Condition.Type.FLAG, flag)), id + " text");
}
@Test
void firstMatchingEndingWinsByOrder() {
GameContext ctx = ctx(List.of(ending("victory", true, "won"), ending("fled", false, "fled")));
ctx.getState().set("won");
ctx.getState().set("fled");
assertThat(EndingEngine.triggered(ctx).id()).isEqualTo("victory");
}
@Test
void noEndingWhenNoConditionHolds() {
GameContext ctx = ctx(List.of(ending("victory", true, "won")));
assertThat(EndingEngine.triggered(ctx)).isNull();
}
@Test
void renderIncludesTitleTextAndSummary() {
GameContext ctx = ctx(List.of(ending("victory", true, "won")));
ctx.getState().set("won");
String out = EndingEngine.render(EndingEngine.triggered(ctx), ctx, 7).plainText();
assertThat(out).contains("victory-title").contains("victory text");
assertThat(out).contains("Turns: 7").contains("Quests completed: 0 / 0");
}
}