feat(app): main-menu shell loop wiring; gitignore saves/

Replace App.run with a main-menu shell (New Game / Load / Settings /
Quit) above the game loop, wire SaveCommand + MenuCommand into the
registry with an autosave callback, and remove the now-unused
QuitCommand. Migrate GameTest from QuitCommand to MenuCommand.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-01 15:17:46 +02:00
parent 084b39e501
commit 4604952f63
4 changed files with 84 additions and 70 deletions

View File

@@ -40,3 +40,6 @@ build/
### Brainstorming visual companion ###
.superpowers/
# Local save games and settings
saves/

View File

@@ -11,23 +11,36 @@ import thb.jeanluc.adventure.command.impl.HelpCommand;
import thb.jeanluc.adventure.command.impl.InventoryCommand;
import thb.jeanluc.adventure.command.impl.LookCommand;
import thb.jeanluc.adventure.command.impl.MapCommand;
import thb.jeanluc.adventure.command.impl.MenuCommand;
import thb.jeanluc.adventure.command.impl.QuestsCommand;
import thb.jeanluc.adventure.command.impl.QuitCommand;
import thb.jeanluc.adventure.command.impl.ReadCommand;
import thb.jeanluc.adventure.command.impl.SaveCommand;
import thb.jeanluc.adventure.command.impl.TakeCommand;
import thb.jeanluc.adventure.command.impl.TalkCommand;
import thb.jeanluc.adventure.command.impl.UseCommand;
import thb.jeanluc.adventure.game.Game;
import thb.jeanluc.adventure.game.GameContext;
import thb.jeanluc.adventure.game.GameSession;
import thb.jeanluc.adventure.io.ConsoleIO;
import thb.jeanluc.adventure.io.GameIO;
import thb.jeanluc.adventure.io.text.Banner;
import thb.jeanluc.adventure.loader.WorldLoader;
import thb.jeanluc.adventure.menu.MainMenu;
import thb.jeanluc.adventure.menu.MenuAction;
import thb.jeanluc.adventure.menu.SettingsMenu;
import thb.jeanluc.adventure.save.SaveException;
import thb.jeanluc.adventure.save.SaveService;
import thb.jeanluc.adventure.save.SaveSlotInfo;
import thb.jeanluc.adventure.save.Settings;
import thb.jeanluc.adventure.save.SettingsStore;
import java.util.List;
/**
* Entry point for the console version of the game. Loads the world from
* YAML, wires up the command registry, and hands control to the
* {@link Game} loop.
* Console entry point. Loads settings, then runs the main-menu shell loop:
* New Game / Load / Settings / Quit. Each game runs the shared {@link Game}
* loop and returns here when the player saves-and-exits, reaches an ending,
* or input ends. Shared by console and GUI via {@link #run(GameIO)}.
*/
@Slf4j
public final class App {
@@ -35,31 +48,63 @@ public final class App {
private App() {
}
/**
* Standard JVM entry point.
*
* @param args ignored
*/
public static void main(String[] args) {
GameIO io = new ConsoleIO();
try {
run(io);
} catch (RuntimeException e) {
log.error("Fatal error during game startup", e);
log.error("Fatal error", e);
io.write("Fatal error: " + e.getMessage());
System.exit(1);
}
}
/**
* Boots the game on the given IO channel. Reusable by both
* {@link App} (console) and {@code AppGui} (Swing worker thread).
*
* @param io the IO channel to use
*/
/** Runs the menu shell on the given IO until the player quits. */
public static void run(GameIO io) {
SaveService saves = new SaveService();
SettingsStore settingsStore = new SettingsStore();
Settings settings = settingsStore.load();
ConsoleIO consoleIo = io instanceof ConsoleIO c ? c : null;
SettingsMenu.apply(settings, consoleIo);
boolean running = true;
while (running) {
switch (MainMenu.show(io)) {
case NEW_GAME -> play(io, saves, newSession(io, saves));
case LOAD -> {
SaveSlotInfo slot = MainMenu.pickSlot(io, saves);
if (slot != null) {
try {
play(io, saves, saves.load(slot.slug()));
} catch (SaveException e) {
io.write("Could not load: " + e.getUserMessage());
}
}
}
case SETTINGS -> settings = SettingsMenu.show(io, settings, settingsStore, consoleIo);
case QUIT -> running = false;
}
}
io.write("Farewell.");
}
/** Loads a fresh world and binds it to a newly named session. */
private static GameSession newSession(GameIO io, SaveService saves) {
WorldLoader.LoadResult loaded = new WorldLoader().load();
GameContext ctx = new GameContext(loaded.world(), loaded.player(), io);
String name = MainMenu.promptName(io, "Manor");
return new GameSession(loaded.world(), loaded.player(), name);
}
/** Builds the registry, runs one game to completion, autosaving to its slot. */
private static void play(GameIO io, SaveService saves, GameSession session) {
GameContext ctx = new GameContext(session, io);
ctx.setSaveCallback(() -> {
try {
saves.save(session);
} catch (SaveException e) {
log.warn("Autosave failed", e);
}
});
CommandRegistry registry = new CommandRegistry();
registry.register(new GoCommand(), "go", "move", "walk");
@@ -74,17 +119,18 @@ public final class App {
registry.register(new QuestsCommand(), "quests", "log", "journal");
registry.register(new TalkCommand(), "talk", "speak");
registry.register(new GiveCommand(), "give");
registry.register(new HelpCommand(registry), "help", "?");
QuitCommand quit = new QuitCommand();
registry.register(quit, "quit", "exit");
registry.register(new SaveCommand(saves), "save");
Game game = new Game(ctx, registry, new CommandParser());
quit.bind(game);
io.print(Banner.welcome(loaded.world().getTitle()));
io.write(loaded.world().getWelcomeMessage());
new LookCommand().execute(ctx, java.util.List.of());
MenuCommand menu = new MenuCommand(saves);
menu.bind(game);
registry.register(menu, "menu", "quit", "exit");
registry.register(new HelpCommand(registry), "help", "?");
io.print(Banner.welcome(session.getWorld().getTitle()));
io.write(session.getWorld().getWelcomeMessage());
new LookCommand().execute(ctx, List.of());
game.run();
}

View File

@@ -1,41 +0,0 @@
package thb.jeanluc.adventure.command.impl;
import thb.jeanluc.adventure.command.Command;
import thb.jeanluc.adventure.game.Game;
import thb.jeanluc.adventure.game.GameContext;
import java.util.List;
/**
* Ends the game loop. Holds a reference to the {@link Game} so it can
* flip the loop flag. Usage: {@code quit}.
*/
public class QuitCommand implements Command {
/** The game whose loop is to be stopped. Set via {@link #bind(Game)}. */
private Game game;
/**
* Binds the game instance after construction. Two-phase wiring is
* necessary because the registry is typically built before the game
* starts running.
*
* @param game the running game; must not be null
*/
public void bind(Game game) {
this.game = game;
}
@Override
public void execute(GameContext ctx, List<String> args) {
ctx.getIo().write("Goodbye.");
if (game != null) {
game.stop();
}
}
@Override
public String help() {
return "quit - exit the game (alias: exit)";
}
}

View File

@@ -1,17 +1,20 @@
package thb.jeanluc.adventure.game;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import thb.jeanluc.adventure.command.CommandParser;
import thb.jeanluc.adventure.command.CommandRegistry;
import thb.jeanluc.adventure.command.impl.GoCommand;
import thb.jeanluc.adventure.command.impl.LookCommand;
import thb.jeanluc.adventure.command.impl.QuitCommand;
import thb.jeanluc.adventure.command.impl.MenuCommand;
import thb.jeanluc.adventure.io.TestIO;
import thb.jeanluc.adventure.model.Direction;
import thb.jeanluc.adventure.model.Player;
import thb.jeanluc.adventure.model.Room;
import thb.jeanluc.adventure.model.World;
import thb.jeanluc.adventure.save.SaveService;
import java.nio.file.Path;
import java.util.LinkedHashMap;
import java.util.Map;
@@ -19,6 +22,9 @@ import static org.assertj.core.api.Assertions.assertThat;
class GameTest {
@TempDir
Path savesDir;
@Test
void run_dispatchesCommands_andQuitsCleanly() {
Room kitchen = new Room("kitchen", "Kitchen", "kd");
@@ -36,7 +42,7 @@ class GameTest {
CommandRegistry registry = new CommandRegistry();
registry.register(new GoCommand(), "go");
registry.register(new LookCommand(), "look");
QuitCommand quit = new QuitCommand();
MenuCommand quit = new MenuCommand(new SaveService(savesDir));
registry.register(quit, "quit");
Game game = new Game(ctx, registry, new CommandParser());
@@ -44,7 +50,7 @@ class GameTest {
game.run();
assertThat(player.getCurrentRoom()).isEqualTo(hallway);
assertThat(io.allOutput()).contains("Goodbye");
assertThat(io.allOutput()).contains("Returning to the main menu");
}
@Test
@@ -55,7 +61,7 @@ class GameTest {
GameContext ctx = new GameContext(new World(Map.of("r", r), Map.of(), Map.of(), "t", "w"), player, io);
CommandRegistry registry = new CommandRegistry();
QuitCommand quit = new QuitCommand();
MenuCommand quit = new MenuCommand(new SaveService(savesDir));
registry.register(quit, "quit");
Game game = new Game(ctx, registry, new CommandParser());
quit.bind(game);