# ludic.save A save file's contract across releases, generic over what the file holds. Every shipped game meets it on its second release: the tables a save is indexed by grow, keys are added, meanings shift, and a file written by 0.1 has to open in 0.2 - or be refused with a sentence the player can act on, never silently read as rubbish. Uses nothing. (`ludic.base`'s save tree is the other half: a mechanic's own section inside a file, by key. This package is the file.) ```ludic import "ludic.save" def SaveMigrations trip_3_4 { format: "trip", from: 3, run: fn trip_mig_3_4 } let f = save_format("trip", 6) # this build writes 6 f.is_one = fn looks_like_a_trip # an object with a day in it let r = save_open(path, backup, f) # read, check, carry forward, or the backup if r.status == SAVE_OK { apply(r.value) } ... save_write_tree(path, backup, v) # false unless it is on disk; keeps nothing ``` ## The contract - **A version in the file**, under `version_key` (`"version"`); a file without one is 1. - **A chain of migrations**, one step per version, each rewriting the parsed object in place, so an old file is brought to today's shape once and every reader after it sees only that shape - no reader grows an `if version < n`. The game declares its steps into the open registry `SaveMigrations` (`{ format, from, run }`); a version with no step changed nothing. - **A torn write is told from a whole one** by the text (`save_text_intact`: every brace, bracket and string closed). A best-effort JSON parser hands back half a file as a shorter, plausible one. - **A backup, and only of a whole file**: `save_write` copies the old file aside when it is intact, writes, and reads the text straight back. A caller about to delete something on the strength of the answer needs to know it is on disk. `save_write_tree` does the same for a tree through `Json.write_file`, so a save holds no text of its own afterwards. - **A newer build's file is refused and read-only.** It is never replaced by its backup (that is from the newer build too), and `save_write_to` refuses a read that says `readonly`. Both copies damaged is read-only as well. Never write over a file you could not read. The package never words anything: a status is a number, and the game says it. ## API | | | | --- | --- | | `SaveFormat { name, version, oldest, version_key, is_one }`, `save_format(name, version)` | a kind of file | | `SaveMigration { key, format, from, run }`, `open registry SaveMigrations as SMIG` | one step, declared by the game | | `SaveRead { status, from, migrated, readonly, from_backup, value }` | how one read went | | `SAVE_OK`, `SAVE_NONE`, `SAVE_CORRUPT`, `SAVE_FUTURE`, `SAVE_STATUS_COUNT` | the statuses; a game's own refusals (another map) start at the count | | `save_parse(text, f)`, `save_read(path, f)` | whole, parsed, an object, one of these | | `save_upgrade(r, f) -> bool` | the version checks and the chain | | `save_open(path, backup, f) -> SaveRead` | both, with the backup standing in for a damaged file | | `save_migrate(v, f, from)`, `save_step(format, n)`, `save_steps_doubled(f)` | the chain outright; a step declared twice | | `save_write(path, backup, text) -> bool`, `save_write_to(r, path, backup, text)` | the write, backed up and read back | | `save_text_intact(s)` | the torn-write test | | `save_strip(v, key)`, `save_list_with(l, slot, n)`, `save_list_without(l, slot)`, `save_int(v, key, fallback)` | what migrations keep doing | | `save_fold(h, s)`, `save_fold_int(h, n)` | a fingerprint of the tables a file stores by position: an append refreshes it, an insert needs a step | ## Tests ```bash ludic test packages/ludic.save ``` A toy format at version 3 with two steps and another format's step beside them: torn files, rubbish and the wrong kind of file, the chain from 1, a newer file refused and read-only, the backup standing in and never over a newer file, a write that backs up only a whole file.