ludic/packages/ludic.save/README.md

67 lines
3.9 KiB
Markdown

# 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.