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:
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -26,4 +26,19 @@ public record SaveData(
|
|||||||
) {
|
) {
|
||||||
/** Current on-disk schema version. */
|
/** Current on-disk schema version. */
|
||||||
public static final int CURRENT_VERSION = 1;
|
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();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user