Save Load System - UQcsse3200/2026-studio-3 GitHub Wiki
Team 5 | Save/Load System — Core, Game State Snapshot, Restore Logic, UI, and Sprint 3 autosave, item-state and card-discovery extensions connected to real run state | Initial integration merged to main via #226; Sprint 3 work reaches main via #405; later work is tracked by the PR links in each section
This page describes how a player's run is saved to disk and restored later. The system is split into three layers, each with a clear owner and a narrow interface to the others:
-
Save/Load Core (Anran Zhang) — the plain-data DTOs, JSON persistence, slot metadata, and structured error results. Owns
SaveGameDataand everything that gets written to a file. -
Game State Snapshot (Manpreet Singh) — reads the live run state (player, deck, map) and converts it into
SaveGameData. -
Restore Logic & UI (Luqishu "William" Wu, with production wiring by Anran) — validates a loaded
SaveGameDataand applies it back to live run state, and the actual Save/Load screen players interact with.
Core rule for the whole system: nothing outside GameStateSnapshotProvider and SaveGameRestoreService should ever serialise or reconstruct a libGDX Entity, Component, Screen, or MapNode object directly. Every DTO in this system holds only primitives and stable IDs — see Why DTOs, Not Live Objects.
Important: player state is deliberately not read from a live Entity. Gameplay screens create and dispose their own player entities on every transition (e.g. Map → Battle → Map), so a screen-owned Entity cannot be a reliable source or destination for save/load. RunState.getOrCreatePlayerState() and RunState.getOrCreatePlayerDeck() hold the durable values instead — see Why RunState, Not a Screen's Entity.
For discovery states and the monster manual UI, see Bestiary System. Bestiary progress shares this save format but is documented separately. Card discovery progress (Team 6) is also saved in this format — see Card Discovery Progress.
Saving and loading, as wired in SaveLoadScreen:
RunState runState = game.getRunState();
CardLibrary cardLibrary = new CardLibrary(CardConfigLoader.loadCards());
PlayerRunState playerState = runState.getOrCreatePlayerState();
PlayerDeck playerDeck = runState.getOrCreatePlayerDeck(cardLibrary);
BestiaryService bestiaryService = game.getBestiaryService();
CardDiscoveryService cardDiscoveryService = game.getCardDiscoveryService();
SaveGameService saveGameService = new SaveGameService(
new JsonSaveGameRepository(),
new GameStateSnapshotProvider(
playerState, playerDeck, runState, bestiaryService, cardDiscoveryService));
SaveGameRestoreService restoreService =
new SaveGameRestoreService(
playerState, playerDeck, runState, bestiaryService, cardDiscoveryService);
// SaveLoadPanel calls saveGameService.saveGame(slotId) and restoreService.restore(...)
// internally, and shows readable errors via SaveErrorMessages on failure.Loading is deliberately two internal steps inside SaveLoadPanel.onLoad(): SaveGameService.loadGame() only reads and validates the file — a corrupted or missing file returns LoadResult.failure(...) without touching live state. Only if that succeeds does SaveGameRestoreService.restore() get called to actually apply the data.
graph TB
subgraph UI["UI"]
SaveLoadScreen["SaveLoadScreen"]
SaveLoadPanel["SaveLoadPanel<br/>(slot list, Save/Load/Delete)"]
PauseMenu["Pause Menu<br/>Save/Load entry (Team 4, #390)"]
end
subgraph Core["Save/Load Core (Anran)"]
SaveGameService["SaveGameService<br/>saveGame() / loadGame()"]
Autosave["AutosaveCoordinator<br/>(slot 4)"]
JsonRepo["JsonSaveGameRepository<br/>(atomic write, validated round-trip)"]
SaveGameData["SaveGameData<br/>(root DTO: player, deck, map, progress)"]
end
subgraph Snapshot["Game State Snapshot (Manpreet)"]
SnapshotProvider["GameStateSnapshotProvider<br/>capture()"]
end
subgraph Restore["Restore Logic (William)"]
RestoreService["SaveGameRestoreService<br/>restore()"]
end
subgraph LiveState["Live Run State (not screen-owned Entities)"]
RunState["RunState<br/>activeNodeId, encounterSeed"]
PlayerRunState["PlayerRunState<br/>health, gold, ownedItems"]
PlayerDeck["PlayerDeck<br/>CardInstance list"]
MapGraph["MapGraph<br/>nodes, currentNode"]
BestiaryService["BestiaryService<br/>discovery progress"]
CardDiscovery["CardDiscoveryService<br/>LOCKED / SEEN"]
end
SaveLoadScreen -->|creates| SaveLoadPanel
PauseMenu -->|creates| SaveLoadPanel
SaveLoadPanel -->|saveGame slotId| SaveGameService
SaveLoadPanel -->|loadGame slotId, then restore| SaveGameService
SaveLoadPanel -->|restore data| RestoreService
Autosave -->|saveGame slot 4| SaveGameService
SaveGameService -->|capture| SnapshotProvider
SaveGameService -->|read/write| JsonRepo
JsonRepo -->|produces/consumes| SaveGameData
SnapshotProvider -->|builds| SaveGameData
RestoreService -->|validates then applies| SaveGameData
SnapshotProvider -.reads.-> RunState
SnapshotProvider -.reads.-> PlayerRunState
SnapshotProvider -.reads.-> PlayerDeck
SnapshotProvider -.reads.-> MapGraph
SnapshotProvider -.reads.-> BestiaryService
SnapshotProvider -.reads.-> CardDiscovery
RestoreService -.writes.-> RunState
RestoreService -.writes.-> PlayerRunState
RestoreService -.writes.-> PlayerDeck
RestoreService -.writes.-> MapGraph
RestoreService -.writes.-> BestiaryService
RestoreService -.merges.-> CardDiscovery
GameStateSnapshotProvider and SaveGameRestoreService both take PlayerRunState (not a screen's Entity), PlayerDeck, RunState, the game's shared BestiaryService, and the shared CardDiscoveryService. These objects exist independently of whichever screen happens to be showing.
Before this was fixed, BattleScreen created a fresh player Entity (and a fresh starter deck) every time it was entered — meaning health, gold, and any deck changes were silently lost on every single screen transition, independent of save/load ever being used. PlayerRunState (health/maxHealth/gold/ownedItems) and RunState.getOrCreatePlayerDeck() fix this at the source: BattleScreen now calls playerState.applyTo(player) when creating its player entity and playerState.captureFrom(player) on dispose(), so values survive the transition either way.
This also fixes save/load itself: an earlier version of SaveGameRestoreService restored directly into a screen's Entity, which was fragile — if that screen disposed and recreated its player right after loading, the restored values would be lost again. Restoring into PlayerRunState instead means the values are safe regardless of what the screen does afterward.
-
SaveGameService.saveGame(slotId)callssnapshotProvider.capture(). -
GameStateSnapshotProviderreads:-
Player —
PlayerRunState.getCurrentHealth()/getMaxHealth()/getGold(),ownedItems, plus the map-progressionlevel(see the note below). -
Deck — every
CardInstanceinPlayerDeck.getCards(), capturinginstanceId,cardId, andupgradeLevelfor each owned card (see Card Instance Persistence). -
Map — every
MapNodeinRunState.getMapGraph().getNodes(), converted toMapNodeSaveData(see Why DTOs, Not Live Objects). Current position comes fromMapGraph.getCurrentNode(), notRunState.getActiveNodeId()— see the note below. -
Bestiary — every non-
LOCKEDentry, converted toBestiaryProgressSaveDatausing its stable enemy ID and unlock-state name. -
Card discovery — the discovery state of each card, converted to
CardProgressSaveData. -
Progress —
pendingRewardId(always empty, see Cross-Team Notes),resumeScreen(always"MAP") andencounterSeed.
-
Player —
- The resulting
SaveGameDatais handed toJsonSaveGameRepository, which writes it to a temporary file, validates it round-trips correctly, then atomically replaces the destination file. -
SaveResultis returned —success(SaveSlotMetadata)orfailure(SaveError, message).
A note on currentNodeId vs. activeEncounterNodeId: MapGraph.getCurrentNode() is the player's physical position on the map. RunState.getActiveNodeId() is a different concept — it tracks the node whose encounter is currently being resolved, and is null outside of an active encounter. MapSaveData stores both, separately, on purpose.
A note on level: this field is a snapshot-only mirror of the player's map-progression level, which PlayerStatsTopDisplay displays live. MapGraph is the single authoritative source for level (restored directly via restoreCurrentNode, read live via RunState.getMapProgression()), so level is captured for save-file schema completeness only — nothing reads it back on restore, and nothing needs to, since the real source of truth is never lost across a save/reload. It was originally named piety and hardcoded to 0 (confirmed unimplemented, Team 7, 9/10); #303 fixed it to capture mapGraph.getCurrentNode().getHeight(), after review (Amber) correctly caught that the original fix's test wasn't actually exercising the restore path, and #379 later renamed the field to level. Old saves that still say piety load correctly — see File Format.
Autosave uses the dedicated slot 4; manual slots 1–3 keep their existing behaviour. AutosaveCoordinator associates each pending request with the current MapGraph, so starting or loading another run cannot accidentally write the new run using a stale request. Duplicate callbacks are idempotent, and save failures are logged without blocking navigation.
- A successful Battle, Shop, or Event encounter queues a request. Defeat and abandoned encounters do not.
- Shop and Event requests are flushed when the map is ready, after the outgoing screen has released its live state.
- For a victorious battle, the authoritative durable checkpoint is after the selected reward has been applied.
RewardDisplayinvokes the checkpoint callback before it emits navigation events, so the saved player state contains the reward. - The victory screen ignores its generic continue action until a reward has been chosen. If the game closes before selection, the previous valid autosave remains untouched; no reward is silently consumed or duplicated.
- The following map transition may call
saveIfPending()again, but the request has already been cleared and no duplicate file write occurs.
The original slot-4 implementation is in PR #287. The durable post-reward checkpoint and its regression tests are in PR #324 / issue #314.
-
SaveGameService.loadGame(slotId)reads the file viaJsonSaveGameRepository, deserialises it, and returns aLoadResult. A malformed or missing file simply returnsLoadResult.failure(...). -
SaveLoadPanel.onLoad()only proceeds to restore ifloadResult.success(). -
SaveGameRestoreService.restore(data)validates every part of the data (player values in range, owned item names valid, deck cards well-formed and compatible withPlayerDeck, map structure consistent, and Bestiary and card-discovery IDs/states well formed) before mutating anything. If any part fails, nothing is applied. - On success:
PlayerRunState.restore(...)is called directly (not a liveEntity), its validatedownedItemslist is replaced,PlayerDeckis rebuilt fromDeckSaveData(each card reconstructed as a realCardInstance), the map graph is reconstructed fromMapSaveData, Bestiary progress is replaced throughBestiaryService.replaceProgress(), and card-discovery progress is merged throughCardDiscoveryService.mergeProgress(). -
RestoreResult.resumeScreen()tells the caller which screen to show — currently alwaysMAP.SaveLoadPanel's post-load callback checksrunState.isRunActive()to decide between returning toMAPor falling back toMAIN_MENUif no run is active.
| DTO | Fields | Notes |
|---|---|---|
SaveGameData |
player, deck, map, progress
|
Root object written to each save file. |
PlayerSaveData |
currentHealth, maxHealth, gold, level, ownedItems: List<String>
|
A hand-written Json.Serializable (#379), so any new field must be added to both write() and read() or it is silently dropped. read() accepts the legacy piety key only when level is absent; writing always uses level. ownedItems stores stable ItemType.name() values, and missing item data in older saves becomes an empty list. level is a snapshot-only mirror of the player's map-progression level (see mapGraph.getCurrentNode().getHeight()) — captured for schema completeness, not read back on restore since MapGraph itself is the authoritative source. Previously hardcoded to 0 as piety; fixed in #303. |
DeckSaveData |
cards: List<CardInstanceSaveData> |
Each entry captures one owned card's identity and upgrade state. DeckSaveData.ofCardIds(...) is a convenience factory for callers that only need plain card IDs (tests, legacy-style data) — not used by the real capture path. |
CardInstanceSaveData |
instanceId, cardId, upgradeLevel
|
Mirrors CardInstance's three fields. Duplicate copies of the same card (e.g. two base Strikes, or a base and an upgraded Strike) keep independent identity and upgrade state through a full save/restore — see Card Instance Persistence. |
MapSaveData |
nodes: List<MapNodeSaveData>, currentNodeId, activeEncounterNodeId
|
See the note on the two node-ID fields above. |
MapNodeSaveData |
nodeId, roomType, state, connectionIds: List<Integer>
|
Connections are stored as IDs, not MapNode references. |
ProgressSaveData |
pendingRewardId, resumeScreen, bestiary: List<BestiaryProgressSaveData>, cards: List<CardProgressSaveData>, encounterSeed
|
Missing Bestiary or card data in an old save is treated as an empty list. completedEncounterIds was removed — see Cross-Team Notes. encounterSeed is nullable, added in #312 — see Cross-Team Notes. |
BestiaryProgressSaveData |
enemyId, unlockState
|
Stores only non-locked entries using stable Enemy System IDs. |
CardProgressSaveData |
cardId, unlockState
|
Card discovery state (LOCKED or SEEN) keyed by stable card ID. |
MapNode.getConnections() returns other MapNode objects, which themselves reference other nodes — serialising that graph directly would either fail (circular structure) or dump far more data than needed. MapNodeSaveData instead stores each connection as a plain nodeId. Reconstruction on load is two-pass: every node is created first, then connections are wired up by resolving IDs back to the real node objects.
Every operation returns a result object rather than throwing.
| Result type | Returned by |
|---|---|
SaveResult |
saveGame() |
LoadResult |
loadGame() |
DeleteSaveResult |
deleteSave() |
SaveSlotListResult |
listSaveSlots() |
RestoreResult |
SaveGameRestoreService.restore() |
SaveError codes: INVALID_SLOT, NO_SAVE_DATA, CAPTURE_FAILED, SLOT_NOT_FOUND, MALFORMED_SAVE, UNSUPPORTED_VERSION, IO_ERROR, NONE. RestoreError codes: NO_SAVE_DATA, MISSING_PLAYER_DATA, MISSING_DECK_DATA, MISSING_MAP_DATA, INVALID_PLAYER_STATE, INVALID_DECK_STATE, INVALID_MAP_STATE, INVALID_PROGRESS_STATE, APPLY_FAILED, NONE.
SaveErrorMessages.forSave() / forLoad() / forDelete() translate any SaveError into a readable UI string. A unit test asserts every non-NONE value has a message.
Saves are written via libGDX external storage to DECO2800Game/saves/slot-{id}.json, one file per slot:
{
"player": {
"currentHealth": 65,
"maxHealth": 100,
"gold": 120,
"level": 3,
"ownedItems": ["LUCKY_COIN", "ENERGY_CRYSTAL"]
},
"deck": { "cards": [
{ "instanceId": "a1b2c3d4", "cardId": "strike", "upgradeLevel": 0 },
{ "instanceId": "e5f6g7h8", "cardId": "strike", "upgradeLevel": 1 },
{ "instanceId": "i9j0k1l2", "cardId": "defend", "upgradeLevel": 0 },
{ "instanceId": "m3n4o5p6", "cardId": "bandage", "upgradeLevel": 0 }
] },
"map": {
"nodes": [
{ "nodeId": 0, "roomType": "COMBAT", "state": "COMPLETED", "connectionIds": [1] },
{ "nodeId": 1, "roomType": "SHOP", "state": "CURRENT", "connectionIds": [0] }
],
"currentNodeId": 1,
"activeEncounterNodeId": null
},
"progress": {
"pendingRewardId": "",
"resumeScreen": "MAP",
"bestiary": [
{ "enemyId": "lesser_shade", "unlockState": "DEFEATED" },
{ "enemyId": "boss_knight", "unlockState": "ENCOUNTERED" }
],
"cards": [
{ "cardId": "strike", "unlockState": "SEEN" },
{ "cardId": "defend", "unlockState": "SEEN" }
],
"encounterSeed": 4821093571834721
}
}Backward compatibility: saves created before Bestiary persistence omit progress.bestiary; these load with all records locked. Saves created before owned-item persistence omit player.ownedItems; these load with an empty item list and no derived item effects. Saves created before card-discovery persistence omit progress.cards; these merge no card progress. Saves that use piety instead of level still load, because PlayerSaveData.read() accepts piety when level is absent. Older files may also contain a completedEncounterIds field, which the JSON parser safely ignores because completion status now comes from each node's state in MapSaveData. Saves created before card-instance persistence landed used a flat deck.cardIds: List<String> — see Card Instance Persistence for how that migration was handled. Saves created before encounterSeed existed simply omit it; restore gives them a fresh seed.
Writes go to a temporary file first, are validated, then atomically replace the real slot file.
The main menu has a load button, wired to GdxGame.ScreenType.SAVE_LOAD — SaveLoadScreen constructs a SaveGameService and SaveGameRestoreService from the current run's RunState and passes both into SaveLoadPanel (slot list, Save/Load/Delete buttons, status feedback). After a successful load, the panel navigates to MAP if a run is active, or back to MAIN_MENU otherwise.
Team 4 (Jayden) added a Save and Load entry point to the in-game pause menu in PR #390. It embeds the same SaveLoadPanel in place and builds the same SaveGameService and SaveGameRestoreService wiring as the main screen. PR #390 is now merged into main; see Cross-Team Notes for a real bug this integration surfaced and how it was fixed.
When Save/Load was tested against Team 4's new main menu, two real issues surfaced and were fixed:
-
Crash on Load Game —
SaveLoadScreennever loadedmain_menu_background.pnginto itsResourceServicebeforeSaveLoadPaneltried to use it, throwingGdxRuntimeException: Asset not loaded. Fixed with the sameloadAssets()/unload patternMainMenuScreenalready used. -
Visual mismatch —
SaveLoadPanelwas still using the default flat-earth skin (yellow background, stock green buttons) next to Team 4's themed menu. The initial fix rebuilt the panel onMenuThemewith generated rounded-pill drawables; PR #304 later replaced those generated pills with the currentsave_load_button_frame.pngartwork described below. -
Resource lifetime — one cached
TextButton.TextButtonStylereuses the asset-managed frame and its state tints across every button and everyrefresh()call. The original approach regenerated ~40 new 160×64 textures per save/load/delete action — an unbounded GPU memory leak caught in review. - Disabled buttons use a dimmed frame and muted text while keeping the same themed artwork, so unavailable actions remain visually distinct without reverting to the stock skin.
Later in Sprint 3 (PR #304, William), the procedurally-generated pills were replaced with real themed art assets for the Save/Load, Bestiary, and Card Library archive screens. The style is still cached once and reused (same principle as above), but the cache now holds a single TextButton.TextButtonStyle built from an asset-managed texture rather than three manually-generated NinePatchDrawables — texture disposal is correctly left to the resource service's own lifecycle rather than manually disposed, since it's no longer a runtime-generated Pixmap.
Team 6's CardInstance refactor (PR #236) gave every owned card its own identity — instanceId, cardId, and upgradeLevel — instead of representing a card purely by its shared cardId. This means two copies of the same card (e.g. two Strikes) are genuinely distinct objects, and a card can be upgraded independently of other copies. Save/Load needed a matching update — otherwise every restored card would silently come back as a fresh, non-upgraded instance with a brand-new identity, since the old schema only captured cardId.
What changed (PR #243):
-
DeckSaveDatamoved from a flatList<String> cardIdstoList<CardInstanceSaveData>, each entry carryinginstanceId,cardId, andupgradeLevel. -
GameStateSnapshotProvider.captureDeck()now reads all three fields from everyCardInstanceinPlayerDeck.getCards(). -
SaveGameRestoreService.restoreDeck()rebuilds realCardInstanceobjects and adds them viaPlayerDeck.addCard(CardInstance), rather than the old batchcardId-only path. -
DeckSaveData.ofCardIds(...)was added as a convenience factory so existing tests that only cared about plain card IDs needed a mechanical rename rather than a full rewrite.
Validation bug, caught in review (PR #248): the first version of validateDeck() only checked that upgradeLevel was in range (0 or 1) and that cardId was a registered card — not whether that specific card actually has an upgrade definition. An in-range-but-incompatible upgrade would pass validation, then throw partway through restoreDeck() — after restorePlayer() and playerDeck.clear() had already mutated live state, breaking the "no partial mutation on failure" guarantee this whole service is built around. Also fixed in the same PR: a malformed cards: [null] entry caused a NullPointerException before validateDeck() could return a proper RestoreResult.
Fixed by validating against a throwaway copy of the live deck (PlayerDeck.copy(), same CardService) using the real addCard() path, rather than re-implementing its acceptance rules by hand — this avoids drifting out of sync if those rules change again later.
PlayerRunState.ownedItems is the durable source of truth for item ownership and its derived player effects. GameStateSnapshotProvider records each item using its stable ItemType.name(). SaveGameRestoreService validates every name before mutating live state, restores the list into PlayerRunState, and the existing PlayerRunState.applyTo() path rebuilds maximum energy, gold bonus, and shop discount without storing duplicate derived-stat fields.
Older save files do not contain ownedItems; JSON deserialisation and restore treat that as an empty list. PR #393 adds the capture/validation/restore path and a real repository round trip covering every current item type. This work is tracked by issue #326 and reaches main with the final Feature-5 integration (#405).
Card discovery (Team 6) decides which cards the Card Library shows. Its progress is saved in two places:
-
In each save, under
progress.cards, asCardProgressSaveDataentries. -
Globally across restarts and save slots, in a discovery store file (
DECO2800Game/card_discovery.json), so discovered cards stay discovered however the game is restarted.
On load, SaveGameRestoreService merges the saved card progress into the live state with CardDiscoveryService.mergeProgress(...) instead of replacing it. Merging only upgrades a card from LOCKED to SEEN, never the reverse, so loading an older save cannot re-lock a card the player has already seen. The merge validates every entry first and applies nothing if any entry is invalid, and unknown card IDs are skipped. Restore validation rejects a save whose card progress has a blank, untrimmed or duplicate card ID, or an unknown unlock state, with INVALID_PROGRESS_STATE, before anything is mutated.
The Bestiary still uses replaceProgress(), so its progress follows the loaded save. Making it global like card discovery was discussed and parked, because several parallel changes touch the same files.
Team 4 (map): MapGraph.restoreCurrentNode(nodeId) and RunState.restoreRun(mapGraph, activeNodeId) exist specifically to set position directly for a restore, bypassing the connectivity validation that startRun()/moveToNode() correctly enforce for normal gameplay.
Team 4 (menu): Save/Load's UI remains its own screen, and is also embedded in the pause menu (#390). A real pause-menu integration issue was found and fixed this sprint: exiting a battle via the pause menu's "Return to Main Menu" left the map node permanently stuck, since PauseMenuActions.onExitToMenu() never told RunState the encounter was abandoned. Fixed with RunState.abandonEncounter() / MapGraph.abandonCurrentNode().
Later in Sprint 3, Jayden (Team 4) added an actual save/load entry point to the pause menu itself — and this surfaced a related but distinct bug: saving mid-battle, then loading that save, left the node permanently stuck too. Entering a battle marks that node CURRENT; only a completed encounter flips it to COMPLETED. Since a load always resumes to the Map (never mid-battle), a node still CURRENT after restore is one whose encounter never actually finished — and MapSelectionController only allows selecting AVAILABLE nodes. Worse, MapGraph.currentNode was separately restored to point at that same interrupted node, so even reverting the node's own state to AVAILABLE wasn't enough — moveToNode() requires the target to be a connection of currentNode, and a node is never its own connection. previousNode isn't persisted (the same known gap Zaidan flagged in #220 for the abandon case), so it can't be used to recover the correct prior position directly. Fixed in RunState.restoreRun() (PR #315): a node found CURRENT on restore reverts to AVAILABLE, and currentNode is moved to a real COMPLETED neighbour of that node via restoreCurrentNode(), which sets it directly without the connectivity check that would otherwise reject the move. This is safe at the very first battle too: entering any encounter closes off the node the player came from, so a mid-battle save always has a COMPLETED neighbour (Zaidan's review question, covered by a test). This state combination (a save made mid-battle) was previously unreachable before the pause-menu save button existed.
Team 2 (encounters): ProgressSaveData.pendingRewardId is confirmed to always stay empty — Team 2's Chance and Shop outcomes apply immediately, so there's no "pending reward" state to persist. The resume rule (always return to MAP, never resume mid-encounter) was agreed for the same reason: nothing is ever left "in progress" that could be replayed or double-applied.
Team 6 (cards): the CardInstance refactor (PR #236) and its downstream Save/Load work (PR #243, #248) — see Card Instance Persistence above. Card discovery (PR #379) added progress.cards, merge-on-load, and a fifth CardDiscoveryService argument to the SaveGameRestoreService constructor. It also made PlayerSaveData a custom serializer and renamed piety to level — see Card Discovery Progress.
Team 7 (items): items are persisted through ownedItems (#326, #393), reported by Team 7 (Keira). Team 7 and Team 5 also built the Item Library screen together (#362).
Team 1 (encounter composition): a per-run encounterSeed was added to RunState, ProgressSaveData, and the save/restore paths (PR #312, Yan) so a node's chosen enemies stay fixed for the whole run, including after a reload — otherwise reloading a save could reroll a fight the player had already seen. Nullable in ProgressSaveData for backward compatibility; saves without a seed get a fresh one on restore.
Originally ProgressSaveData.completedEncounterIds was a List<String> that didn't match the Integer node-ID contract used everywhere else, and duplicated information already present in MapNodeSaveData.state. Resolved by removing the field entirely — completion is derived solely from map node state, with a backward-compatibility test confirming old save files with the field still load correctly.
- Unit tests for
GameStateSnapshotProvider— player and map capture correctness, including a test that connections are captured as IDs, not object references. -
saveCloseRelaunchAndRestoreRecoversTheWholeRun()— a full round-trip end-to-end test: saves via the realSaveGameService, simulates a relaunch with entirely fresh objects, loads and restores via the realSaveGameRestoreService, and asserts player, deck, and map state all come back correctly. -
shouldIgnoreLegacyCompletedEncounterIds()— loads a hand-written legacy-format JSON file directly throughJsonSaveGameRepositoryand confirms it succeeds, verifying backward compatibility at the actual file-parsing layer. - Unit tests for
SaveGameRestoreService, covering successful restore and eachRestoreErrorcase, including card-progress validation. -
restoresDuplicateCardsPreservingInstanceIdentityAndUpgradeLevel()(Manpreet, PR #243) — a base and an upgraded copy of the same card, verifying both instance identity and upgrade level survive a full restore, not just card ID. - Bestiary snapshot/restore tests cover unlock-state round trips, legacy saves without the field, unknown enemy IDs and invalid progress that must not partially mutate live state.
- Unit tests for
SaveErrorMessages, including a test that everySaveErrorvalue maps to a non-blank message. -
PlayerRunStateTest— validation and correct apply/capture behaviour for the durable player state holder. -
saveRestorePreservesGeneratedMapTopologyAndNodeStates()(William, PR #223) — tests against an actualMapGenerationController-produced map, not just the small hand-built fixture used elsewhere, verifying node states, connections, and current position all survive a full save/restore round trip. -
capturesPietyAsCurrentNodeHeightForSchemaCompleteness()(Manpreet, PR #303) — confirms a non-zero current-node height is actually captured into the player's level field. -
restoringRunFixesNodeStuckMidBattle()/restoringRunWithNoActiveEncounterLeavesNodesUnaffected()(Manpreet, PR #315) — regression tests confirming a node interrupted mid-battle is correctly recovered on restore (state +currentNodepointer), and that normal restores with no interrupted encounter are left untouched. -
firstBattleOfARunAlwaysLeavesACompletedNeighbourToFallBackOn()(Manpreet, PR #315) — confirms a mid-battle save at the very first battle of a run still has aCOMPLETEDneighbour to fall back to. -
AutosaveCoordinatorTest— slot-4 isolation, stale-run rejection, duplicate prevention, non-blocking failures, previous-checkpoint preservation before reward selection, and save/relaunch/load after the reward is applied. -
RewardDisplayTest.checkpointsAppliedRewardBeforeNavigationAndOnlyOnce()— proves reward state is mutated before the durable checkpoint and navigation events, with only one write callback. -
PlayerItemEffectsSaveTest(PR #393) — saves all current item types through the real JSON repository, recreates run-scoped objects, restores the owned-item list, and verifies the derived effects are rebuilt; a second test covers old saves with a missing item field. -
PlayerSaveDataTest— the custom serializer, including the legacypietyfallback. - Card discovery tests (PR #379) — merge-on-load only upgrades state and never re-locks a card.
- Card Effect Resolution (Team 5, Sprint 1 — documentation style reference)
- Player & Battle Deck (Team 5)
- Bestiary System (Team 5, Sprint 2)
- Debug Terminal — Commands and Cheats (Team 5, Sprint 3)
- Anran Zhang (@Amber-729) — Save/Load Core, production integration
- Manpreet Singh (@manpreet0101) — Game State Snapshot
- Luqishu Wu (@wuwilliam-uq) — Restore Logic & UI