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();
}
}