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