fix: EOF ends game loop (ConsoleIO returns null on EOF); settings.json excluded from save slots

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-01 15:39:08 +02:00
parent 88e959adee
commit 182a6911a0
4 changed files with 51 additions and 5 deletions

View File

@@ -71,10 +71,11 @@ public class ConsoleIO implements GameIO {
out.print("> "); out.print("> ");
out.flush(); out.flush();
try { try {
String line = in.readLine(); // null propagates as true EOF (ends the game loop); "" stays "" (blank input re-prompts).
return line == null ? "" : line; return in.readLine();
} catch (IOException e) { } catch (IOException e) {
return ""; // Treat a stream failure as EOF so input ends cleanly rather than spinning.
return null;
} }
} }

View File

@@ -23,6 +23,8 @@ import java.util.stream.Stream;
@Slf4j @Slf4j
public class SaveService { public class SaveService {
private static final String SETTINGS_FILENAME = "settings.json";
private final Path dir; private final Path dir;
private final ObjectMapper mapper = new ObjectMapper(); private final ObjectMapper mapper = new ObjectMapper();
@@ -38,7 +40,11 @@ public class SaveService {
/** Slug used for the on-disk filename of a slot name. */ /** Slug used for the on-disk filename of a slot name. */
public static String slug(String slotName) { public static String slug(String slotName) {
String s = slotName.trim().toLowerCase(Locale.ROOT).replaceAll("[^a-z0-9_-]+", "_"); String s = slotName.trim().toLowerCase(Locale.ROOT).replaceAll("[^a-z0-9_-]+", "_");
return s.isBlank() ? "save" : s; if (s.isBlank()) {
return "save";
}
// Never let a user-named save collide with the settings file.
return s.equals("settings") ? "settings_" : s;
} }
/** Writes the session to {@code <slug>.json} atomically. */ /** Writes the session to {@code <slug>.json} atomically. */
@@ -71,7 +77,10 @@ public class SaveService {
} }
List<SaveSlotInfo> out = new ArrayList<>(); List<SaveSlotInfo> out = new ArrayList<>();
try (Stream<Path> files = Files.list(dir)) { try (Stream<Path> files = Files.list(dir)) {
for (Path f : files.filter(p -> p.toString().endsWith(".json")).toList()) { for (Path f : files
.filter(p -> p.toString().endsWith(".json"))
.filter(p -> !p.getFileName().toString().equals(SETTINGS_FILENAME))
.toList()) {
try { try {
SaveData d = mapper.readValue(f.toFile(), SaveData.class); SaveData d = mapper.readValue(f.toFile(), SaveData.class);
String slug = f.getFileName().toString().replaceFirst("\\.json$", ""); String slug = f.getFileName().toString().replaceFirst("\\.json$", "");

View File

@@ -50,6 +50,25 @@ class ConsoleIORenderTest {
assertThat(out).contains("Kitchen").contains("turn 7").contains("light: off"); assertThat(out).contains("Kitchen").contains("turn 7").contains("light: off");
} }
@Test
void readLine_returnsNullOnEof() {
ByteArrayOutputStream sink = new ByteArrayOutputStream();
PrintStream ps = new PrintStream(sink, true, StandardCharsets.UTF_8);
ConsoleIO io = new ConsoleIO(new BufferedReader(new StringReader("")), ps,
ConsoleIO.ColorMode.OFF, ConsoleIO.GlyphMode.UNICODE);
assertThat(io.readLine()).isNull();
}
@Test
void readLine_returnsEmptyStringForBlankLineThenNullAtEof() {
ByteArrayOutputStream sink = new ByteArrayOutputStream();
PrintStream ps = new PrintStream(sink, true, StandardCharsets.UTF_8);
ConsoleIO io = new ConsoleIO(new BufferedReader(new StringReader("\n")), ps,
ConsoleIO.ColorMode.OFF, ConsoleIO.GlyphMode.UNICODE);
assertThat(io.readLine()).isEqualTo(""); // blank interactive line, re-prompt
assertThat(io.readLine()).isNull(); // stream now exhausted → EOF
}
@Test @Test
void asciiGlyphMode_usesPlusDashFrame() { void asciiGlyphMode_usesPlusDashFrame() {
ByteArrayOutputStream sink = new ByteArrayOutputStream(); ByteArrayOutputStream sink = new ByteArrayOutputStream();

View File

@@ -55,6 +55,23 @@ class SaveServiceTest {
assertThat(new SaveService(dir).list()).isEmpty(); assertThat(new SaveService(dir).list()).isEmpty();
} }
@Test
void listExcludesSettingsFile(@TempDir Path dir) throws Exception {
SaveService svc = new SaveService(dir);
GameSession s = freshSession("Real Save");
svc.save(s);
Files.writeString(dir.resolve("settings.json"), "{\"color\":\"on\"}");
List<SaveSlotInfo> slots = svc.list();
assertThat(slots).hasSize(1);
assertThat(slots.getFirst().slotName()).isEqualTo("Real Save");
}
@Test
void slugForSettingsDoesNotCollideWithSettingsFile() {
assertThat(SaveService.slug("settings")).isNotEqualTo("settings");
}
@Test @Test
void loadCorruptFileThrowsSaveException(@TempDir Path dir) throws Exception { void loadCorruptFileThrowsSaveException(@TempDir Path dir) throws Exception {
Files.writeString(dir.resolve("bad.json"), "{ not json"); Files.writeString(dir.resolve("bad.json"), "{ not json");