diff --git a/Semesterprojekt/src/main/java/thb/jeanluc/adventure/save/SaveCodec.java b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/save/SaveCodec.java new file mode 100644 index 0000000..3cce28b --- /dev/null +++ b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/save/SaveCodec.java @@ -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> roomItems = new LinkedHashMap<>(); + for (Room room : w.getRooms().values()) { + if (!room.getItems().isEmpty()) { + roomItems.put(room.getId(), new ArrayList<>(room.getItems().keySet())); + } + } + Map switches = new LinkedHashMap<>(); + for (Item item : w.getItems().values()) { + if (item instanceof SwitchableItem sw) { + switches.put(sw.getId(), sw.isOn()); + } + } + Map 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> 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 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; + } +} diff --git a/Semesterprojekt/src/main/java/thb/jeanluc/adventure/save/SaveData.java b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/save/SaveData.java index f6a96fe..75fa852 100644 --- a/Semesterprojekt/src/main/java/thb/jeanluc/adventure/save/SaveData.java +++ b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/save/SaveData.java @@ -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(); + } } diff --git a/Semesterprojekt/src/test/java/thb/jeanluc/adventure/save/SaveCodecTest.java b/Semesterprojekt/src/test/java/thb/jeanluc/adventure/save/SaveCodecTest.java new file mode 100644 index 0000000..ed1b1ca --- /dev/null +++ b/Semesterprojekt/src/test/java/thb/jeanluc/adventure/save/SaveCodecTest.java @@ -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 items = new LinkedHashMap<>(); + items.put("shovel", shovel); + items.put("lamp", lamp); + Map 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)); + } +}