feat(save): SaveCodec capture/apply over a fresh world; tolerate null collections

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-01 14:34:48 +02:00
parent c92c143058
commit 3be1036142
3 changed files with 246 additions and 0 deletions

View File

@@ -0,0 +1,126 @@
package thb.jeanluc.adventure.save;
import thb.jeanluc.adventure.game.GameSession;
import thb.jeanluc.adventure.game.QuestLog;
import thb.jeanluc.adventure.model.Player;
import thb.jeanluc.adventure.model.Room;
import thb.jeanluc.adventure.model.World;
import thb.jeanluc.adventure.model.item.Item;
import thb.jeanluc.adventure.model.item.SwitchableItem;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
/**
* Converts between a live {@link GameSession} and a {@link SaveData} snapshot.
* Pure (no disk): {@link #capture} reads a session; {@link #apply} overlays a
* snapshot onto a session built from a freshly loaded world.
*/
public final class SaveCodec {
private SaveCodec() {
}
/** Reads the mutable state of {@code session} into a {@link SaveData}. */
public static SaveData capture(GameSession session) {
World w = session.getWorld();
Player p = session.getPlayer();
Map<String, List<String>> roomItems = new LinkedHashMap<>();
for (Room room : w.getRooms().values()) {
if (!room.getItems().isEmpty()) {
roomItems.put(room.getId(), new ArrayList<>(room.getItems().keySet()));
}
}
Map<String, Boolean> switches = new LinkedHashMap<>();
for (Item item : w.getItems().values()) {
if (item instanceof SwitchableItem sw) {
switches.put(sw.getId(), sw.isOn());
}
}
Map<String, Integer> stages = new LinkedHashMap<>();
QuestLog log = session.getQuestLog();
for (String id : log.active()) {
stages.put(id, log.stageIndex(id));
}
return new SaveData(
SaveData.CURRENT_VERSION,
w.getTitle(),
session.getSlotName(),
System.currentTimeMillis(),
session.getTurn(),
p.getCurrentRoom().getId(),
new ArrayList<>(p.getVisitedRoomIds()),
p.getGold(),
new ArrayList<>(p.getInventory().keySet()),
new ArrayList<>(session.getState().all()),
stages,
new ArrayList<>(log.completed()),
roomItems,
switches);
}
/**
* Overlays {@code data} onto {@code session} (whose world was just loaded
* from YAML). Throws {@link SaveException} if the save references ids that
* no longer exist in the current world.
*/
public static void apply(SaveData data, GameSession session) {
World w = session.getWorld();
Player p = session.getPlayer();
// 1. Clear all item placements, then redistribute from the save.
for (Room room : w.getRooms().values()) {
room.getItems().clear();
}
p.getInventory().clear();
for (Map.Entry<String, List<String>> e : data.roomItemIds().entrySet()) {
Room room = requireRoom(w, e.getKey());
for (String itemId : e.getValue()) {
room.addItem(requireItem(w, itemId));
}
}
for (String itemId : data.inventoryItemIds()) {
p.addItem(requireItem(w, itemId));
}
// 2. Switch states.
for (Map.Entry<String, Boolean> e : data.switchStates().entrySet()) {
if (requireItem(w, e.getKey()) instanceof SwitchableItem sw) {
sw.setState(e.getValue());
}
}
// 3. Player position + visited + gold.
p.setCurrentRoom(requireRoom(w, data.currentRoomId()));
p.getVisitedRoomIds().clear();
p.getVisitedRoomIds().addAll(data.visitedRoomIds());
p.setGold(data.gold());
// 4. Flags, quests, turn.
session.getState().restore(data.flags());
session.getQuestLog().restore(data.questStages(), data.questCompleted());
session.setTurn(data.turn());
}
private static Room requireRoom(World w, String id) {
Room r = w.getRooms().get(id);
if (r == null) {
throw new SaveException("Save refers to unknown room '" + id
+ "'. The game content may have changed.");
}
return r;
}
private static Item requireItem(World w, String id) {
Item i = w.getItems().get(id);
if (i == null) {
throw new SaveException("Save refers to unknown item '" + id
+ "'. The game content may have changed.");
}
return i;
}
}

View File

@@ -26,4 +26,19 @@ public record SaveData(
) {
/** Current on-disk schema version. */
public static final int CURRENT_VERSION = 1;
/**
* Compact canonical constructor: normalizes any null collection component
* to an empty immutable collection so a truncated/hand-edited save JSON
* degrades gracefully instead of NPE-ing in {@link SaveCodec#apply}.
*/
public SaveData {
if (visitedRoomIds == null) visitedRoomIds = List.of();
if (inventoryItemIds == null) inventoryItemIds = List.of();
if (flags == null) flags = List.of();
if (questStages == null) questStages = Map.of();
if (questCompleted == null) questCompleted = List.of();
if (roomItemIds == null) roomItemIds = Map.of();
if (switchStates == null) switchStates = Map.of();
}
}

View File

@@ -0,0 +1,105 @@
package thb.jeanluc.adventure.save;
import org.junit.jupiter.api.Test;
import thb.jeanluc.adventure.game.GameSession;
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.model.item.Item;
import thb.jeanluc.adventure.model.item.PlainItem;
import thb.jeanluc.adventure.model.item.SwitchableItem;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import static org.assertj.core.api.Assertions.assertThat;
class SaveCodecTest {
/** Builds a fresh 2-room world: shovel in cellar, lamp (off) in kitchen. */
private GameSession freshWorld(String slot) {
Room kitchen = new Room("kitchen", "Kitchen", "k");
Room cellar = new Room("cellar", "Cellar", "c");
kitchen.addExit(Direction.SOUTH, cellar);
Item shovel = PlainItem.builder().id("shovel").name("Shovel").description("d").light(false).build();
SwitchableItem lamp = SwitchableItem.builder()
.id("lamp").name("Lamp").description("d").light(true)
.onText("on").offText("off").state(false).effects(List.of()).build();
cellar.addItem(shovel);
kitchen.addItem(lamp);
Map<String, Item> items = new LinkedHashMap<>();
items.put("shovel", shovel);
items.put("lamp", lamp);
Map<String, Room> rooms = new LinkedHashMap<>();
rooms.put("kitchen", kitchen);
rooms.put("cellar", cellar);
World w = new World(rooms, items, Map.of(), "Haunted Manor", "welcome");
return new GameSession(w, new Player(kitchen, 0), slot);
}
@Test
void captureThenApplyToFreshWorldReproducesState() {
GameSession src = freshWorld("slot-a");
// mutate: move to cellar, pick up shovel, light the lamp, set a flag, gold
src.getPlayer().setCurrentRoom(src.getWorld().getRooms().get("cellar"));
Item shovel = src.getWorld().getRooms().get("cellar").removeItem("shovel").orElseThrow();
src.getPlayer().addItem(shovel);
((SwitchableItem) src.getWorld().getItems().get("lamp")).setState(true);
src.getState().set("power_on");
src.getPlayer().setGold(5);
src.setTurn(12);
SaveData data = SaveCodec.capture(src);
GameSession dst = freshWorld("slot-a");
SaveCodec.apply(data, dst);
assertThat(dst.getPlayer().getCurrentRoom().getId()).isEqualTo("cellar");
assertThat(dst.getPlayer().hasItem("shovel")).isTrue();
assertThat(dst.getWorld().getRooms().get("cellar").findItem("shovel")).isEmpty();
assertThat(((SwitchableItem) dst.getWorld().getItems().get("lamp")).isOn()).isTrue();
assertThat(dst.getState().isSet("power_on")).isTrue();
assertThat(dst.getPlayer().getGold()).isEqualTo(5);
assertThat(dst.getTurn()).isEqualTo(12);
assertThat(dst.getPlayer().getVisitedRoomIds()).contains("kitchen", "cellar");
}
@Test
void applyRejectsUnknownRoom() {
GameSession src = freshWorld("slot-a");
SaveData data = SaveCodec.capture(src);
SaveData broken = new SaveData(
data.schemaVersion(), data.worldTitle(), data.slotName(),
data.savedAtEpochMillis(), data.turn(), "ballroom",
data.visitedRoomIds(), data.gold(), data.inventoryItemIds(),
data.flags(), data.questStages(), data.questCompleted(),
data.roomItemIds(), data.switchStates());
org.junit.jupiter.api.Assertions.assertThrows(SaveException.class,
() -> SaveCodec.apply(broken, freshWorld("slot-a")));
}
@Test
void applyToleratesMissingCollections() {
// Construct a SaveData with null collections (simulates a hand-edited / truncated save).
// The compact constructor should normalize nulls to empty collections.
SaveData data = new SaveData(
SaveData.CURRENT_VERSION, "Haunted Manor", "slot-a",
System.currentTimeMillis(), 0,
"kitchen",
null, // visitedRoomIds
0,
null, // inventoryItemIds
null, // flags
null, // questStages
null, // questCompleted
null, // roomItemIds
null // switchStates
);
GameSession dst = freshWorld("slot-a");
// Should not throw NPE; world items remain in their default positions or cleared
org.junit.jupiter.api.Assertions.assertDoesNotThrow(() -> SaveCodec.apply(data, dst));
}
}