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:
@@ -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";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user