| name | save-system |
| description | Manage 2D&D save schema v16, migration, normalization, and location recovery |
| license | MIT |
Save System
Game state is stored in localStorage by src/systems/save.ts. Shared audio
and accessibility preferences plus inventory presentation preferences are
stored separately.
Storage keys
2dnd_save: game state
2dnd_preferences: versioned audio and accessibility preferences
2dnd_inventory_prefs: inventory sort, filter, and search presentation
Legacy 2dnd_audio_prefs and 2dnd_cutscene_accessibility values migrate into
2dnd_preferences. Inventory recency derives from reverse canonical append
order. Do not add presentation fields to item ownership or increment the
campaign schema for these preferences.
Current schema
SAVE_VERSION is 16.
interface SaveData {
version: number;
player: PlayerState;
defeatedBosses: string[];
codex: CodexData;
appearanceId: string;
timestamp: number;
timeStep?: number;
weatherState?: WeatherState;
}
Set<string> values are serialized as arrays and reconstructed by the scene
loading path.
interface CodexData {
entries: Record<string, CodexEntry>;
unlockedEntryIds: string[];
}
Monster entries retain defeat, AC, drop, and elemental discovery. Knowledge
IDs are normalized against CODEX_KNOWLEDGE_ENTRIES; completion and counts are
derived.
Player persistence
Important composed fields:
interface PlayerPosition {
x: number;
y: number;
chunkX: number;
chunkY: number;
inDungeon: boolean;
dungeonId: string;
dungeonLevel: number;
inCity: boolean;
cityId: string;
cityChunkIndex: number;
}
interface PlayerProgression {
openedChests: string[];
collectedTreasures: string[];
exploredTiles: Record<string, boolean>;
discoveredCities: string[];
quests: QuestLogState;
seenCutsceneIds: CutsceneId[];
pendingCutsceneIds: CutsceneId[];
skillChecks: Record<string, SkillCheckRecord>;
trapSeed: number;
trapStates: Record<string, TrapState>;
trapGuidance: boolean;
tutorial: ;
: ;
: ;
: ;
: ;
: ;
}
{
: | | ;
: ;
: <, >;
: [];
}
{
: <, >;
: [];
}
PlayerState.activeEffects persists normalized ActiveStatusEffect values.
Codex entries persist discoveredElements. PlayerState.party persists unique
companion states, active order, independent progression/inventories/equipment,
control modes, dialogue state, and gambits. Quest progress stores status, stage,
objective counters, claimed reward IDs, and acknowledged danger warnings.
seenCutsceneIds stores stable completed-or-skipped story presentation IDs.
pendingCutsceneIds stores queued IDs awaiting completion or skip and is saved
before presentation. Replay changes neither collection; campaign completion
remains derived from quest state.
tutorial.completed prevents the new-player tutorial from reopening
automatically after completion or skip. Tutorial replay does not reset it.
Fixed non-combat checks persist the
ability, natural roll, modifier, repaired total, DC, outcome, and optional
choice ID.
worldEvents persists a stable seed, deterministic roll/cooldown counters, one
pending choice or special battle, idempotent resolved/claimed IDs, repeat
counters, and the bounded chronological World Events record.
social persists bounded alignment axes, per-town/per-faction reputation
scores, stable applied source IDs, and at most 40 recent cause entries. Names,
tiers, thresholds, shop modifiers, Codex milestones, and achievement hooks are
derived.
achievements persists earned records, once-only event counters/IDs, explicit
defeat-history validity, unlocked/equipped cosmetic titles, debug suppression,
and pending notice IDs. Definitions, categories, points, totals, and
reconstructable progress are derived from canonical data.
gathering persists a deterministic seed, stable node cooldown/depletion,
discovered node/resource IDs, once-only claimed outcomes, discipline statistics,
bounded history, and one exact pending minigame or guarded Battle outcome.
crafting persists known recipe IDs, stable applied discovery/transaction IDs,
natural craft and equipment-upgrade statistics, per-recipe counts, bounded
recent history, and the next sequence. Recipe definitions and values are
canonical derived data.
nautical persists boat ownership/condition/upgrades/cosmetics, discovered
ports/routes/islands/continents/sea tiles, bounded navigation statistics, and
recoverable pending merchant routes, hazards, and encounters. Continents, sea
zones, ports, routes, islands, boats, and encounter pools remain canonical data.
Loading and migration
Treat parsed JSON as unknown. Use typed record guards and normalization
helpers; do not cast unvalidated nested values directly.
loadGame() currently handles:
- Legacy
bestiary to codex
- Legacy flat player position fields to
player.position
- Legacy flat progression fields to
player.progression
- Schema-v3 skill-check progression to default quest + skill-check state
- Flat Ashen Road/Warden's Dispatch and recruitment progress to nested
Twelvefold Covenant state without replaying completed rewards
- Schema-v3/v4 progression to schema-v5 explicit trap state
- Missing equipment, talents, abilities, rests, bank, mount, and appearance
fields
- Missing/invalid active status effects
- Missing/invalid party state and gambit rules
- Missing, malformed, duplicate, unknown, or already-seen cutscene queue entries
through
normalizeSeenCutsceneIds() and normalizePendingCutsceneIds()
- Completed-but-unseen legacy epilogue recovery without deriving every
historically eligible campaign scene
- Missing/invalid Codex elemental discoveries
- Missing, malformed, unknown, or duplicate Codex knowledge IDs
- Schema-v9 monster-only Codex migration plus idempotent recovery from durable
city, dungeon, item, quest, and cutscene evidence
- Missing, malformed, or unknown quest entries through
normalizeQuestLog()
- Missing/invalid non-combat skill-check records
- Missing/invalid trap seed, state, and guidance fields
- Missing/invalid tutorial completion state through
normalizeTutorialProgress()
- Missing/invalid World Event state through
normalizeWorldEventState();
malformed seeds clear pending encounters, unknown events/choices are removed,
and records are bounded
- Missing/invalid social state through
normalizeSocialState(); unknown town
and faction IDs are removed, scores are clamped, applied IDs are deduplicated,
and history is validated against those IDs
- Missing/invalid achievement state through
normalizeAchievementState();
unknown/duplicate IDs are removed, counters/order are repaired, title
cross-fields are validated, and schema-v12 or older saves reconcile durable
milestones silently while retaining unknown defeat history
- Missing/invalid gathering state through
normalizeGatheringState(); schema-v13
saves gain defaults, malformed seeds clear generated node/pending state, and
pending outcome/resource/pattern/location cross-fields are validated
- Missing/invalid crafting state through
normalizeCraftingState(); schema-v14
saves gain defaults, unknown/duplicate IDs are removed, statistics are clamped,
history is bounded/resequenced, and durable discovery is reconciled
Location recovery
After migration, normalize location state:
- Dungeon and city flags are mutually exclusive.
- Unknown dungeon/city IDs are cleared.
- Dungeon levels and city district indexes are clamped.
- Coordinates must be in bounds and walkable on the resolved level/chunk.
- Invalid interior coordinates move to that level/district spawn.
- Invalid overworld chunks/tiles fall back to the Willowdale start at chunk
(4, 2), tile (3, 3).
Always resolve maps through getDungeonLevelMap() and getCityChunk() during
validation.
Adding or changing persistent data
- Update the TypeScript interface.
- Set the creation default.
- Normalize the loaded value from
unknown.
- Validate cross-field invariants.
- Increment
SAVE_VERSION for a schema change.
- Add tests for valid persistence, missing values, malformed values, and
corrupt-location recovery.
- Update README and repository instructions when the stored shape changes.
For party data, normalize after quests, skill checks, and trap fields. Then
replay completed recruitCompanion quest actions so v5 saves and debug-completed
quests converge idempotently.
Normalize seen and pending cutscene IDs on every load against CUTSCENE_IDS.
Unknown IDs are discarded, IDs remain stable after release, and pending IDs
already present in the seen list are removed. Missing lists default to empty;
legacy recovery may queue only the completed-but-unseen epilogue.
Pre-v9 saves did not have tutorial state and normalize to completed: true so
existing campaigns are not interrupted. New v9 players start false; malformed
v9 values also normalize safely to false.
Do not silently retain malformed data. Use a safe default or reject the save
when the top-level payload is unusable.
Status and element rules
- Normalize status IDs, integer durations, and sources with
normalizeActiveEffects().
- Filter Codex element values with
isElement().
- Unknown values are discarded rather than asserted into the target type.
Skill-check rules
- Normalize with
normalizeSkillCheckRecords().
- Accept only Dexterity, Intelligence, Wisdom, or Charisma records with integer
d20 rolls, modifiers, and positive DCs.
- Recompute
total and success from the saved natural roll, modifier, and DC.
- Trim optional choice IDs and discard malformed records.
- Shop, NPC, chest, and treasure IDs must remain stable across content changes.
API
saveGame(player, defeatedBosses, codex, appearanceId, timeStep, weatherState);
const save = loadGame();
hasSave();
deleteSave();
getSaveSummary();
Save failures are reported with debugLog(). Loading returns null when the
top-level save is absent or corrupt.
Tests
tests/save.test.ts covers:
- Save/load round trips
- Seen/pending cutscene round trips, malformed queue repair, and legacy epilogue
recovery
- Legacy flat-state migration
- Current schema-v16 position, objective/reward/warning quest state, skill checks,
traps, party state, pending cutscene queue, tutorial completion, and World
Event recovery, plus alignment/reputation round trips and corruption repair
- Flat schema-v4 quest migration and completed-reward preservation
- Schema-v3 skill-check saves gaining default normalized quest state
- Schema-v4 quest saves gaining default trap state
- Schema-v5 party defaults plus all-three completed recruitment replay after
malformed duplicate/unknown party entries are normalized
- Quest reward and skill-check record normalization
- Trap seed/state/guidance normalization and seed-state cross-field repair
- Dungeon-level and city-district clamping
- Invalid IDs and coordinates
- Conflicting location flags
- Status-effect persistence and normalization
- Codex elemental-discovery and knowledge-ID normalization
- Legacy monster-only Codex preservation and deterministic knowledge recovery
- Missing and malformed skill-check normalization
Common pitfalls
- Parsing directly into
SaveData without runtime validation
- Forgetting the level or district when validating coordinates
- Reusing city/dungeon fog keys across interiors
- Keeping unknown status or element strings
- Resetting valid quest progress while filling missing quest defaults
- Retaining trap states after replacing a malformed trap seed
- Storing Phaser objects or other non-serializable state
- Mutating shared game-data definitions while repairing a save