feat(packages): ludic.save - versioned save files generic over their content: a version in the file, the steps a game declares into an open registry, a torn write told from a whole one, a backup of only a whole file, a write read back, a newer file refused and read-only

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Orkun ÇAKILKAYA 2026-09-25 08:28:30 +03:00
parent b24c523fa4
commit 47ba3d9028
11 changed files with 450 additions and 0 deletions

View file

@ -23,6 +23,7 @@ section. The rules are in [ludic.base](ludic.base/README.md).
| [ludic.i18n](ludic.i18n/README.md) | a game in any language: gettext `.po` files, patterns with holes, plurals, a mod folder, a font per language |
| [ludic.inventory](ludic.inventory/README.md) | a pack: a count per kind, with room the game decides |
| [ludic.needs](ludic.needs/README.md) | a body's warmth, food, water and energy, and the countdown to a collapse |
| [ludic.save](ludic.save/README.md) | versioned save files: a migration chain the game declares, torn writes told apart, a backup, a newer file refused and read-only |
| [ludic.settings](ludic.settings/README.md) | a game's settings as data: one store, a fact per change, ranges, a safe set |
| [ludic.shop](ludic.shop/README.md) | vendors: prices by standing and weekly demand, stock, buying and selling |
| [ludic.steps](ludic.steps/README.md) | chapters of steps (kind, param, need), progress, and a party's shares pooled |

View file

@ -0,0 +1,66 @@
# 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(path, backup, Json.encode(v)) # false unless it is on disk
```
## 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.
- **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.

View file

@ -0,0 +1,53 @@
# format.ludic - a kind of file, the steps that carry it forward, and how one read went
# how a read ended; a game's own refusals (another map, another account) start at SAVE_STATUS_COUNT
export const SAVE_OK: int = 0
export const SAVE_NONE: int = 1 # there is no file
export const SAVE_CORRUPT: int = 2 # it did not parse, is cut off, or is not this kind of file
export const SAVE_FUTURE: int = 3 # written by a later build: refused, and never written over
export const SAVE_STATUS_COUNT: int = 4
# a kind of file: which migrations are its, what this build writes, the oldest it will carry forward,
# the key its version is under (a file without one is version 1), and whether an object is one at all
export property SaveFormat {
name: string = "",
version: int = 1,
oldest: int = 1,
version_key: string = "version",
is_one: fn(Val) -> bool = null
}
export function save_format(name: string, version: int) -> SaveFormat {
let f = new SaveFormat
f.name = name
f.version = version
return f
}
# One step: an object of `format` at version `from` rewritten in place into version from + 1. A
# version with no step is carried forward as it stands. The game writes these as defs.
export property SaveMigration {
key: string = "",
format: string = "",
from: int = 0,
run: fn(Val) -> void = null
}
export open registry SaveMigrations of SaveMigration as SMIG
# One read: what was found, the version it was written in, whether it had to be carried forward,
# whether it must never be written over, and whether it is the backup standing in for the file
export property SaveRead {
status: int = 0,
from: int = 0,
migrated: bool = false,
readonly: bool = false,
from_backup: bool = false,
value: Val = null
}
function read_new(status: int, v: Val) -> SaveRead {
let r = new SaveRead
r.status = status
r.value = v
return r
}

View file

@ -0,0 +1,10 @@
# ludic.save - a save file's contract across releases: what version it is, what this build can read,
# the migrations that carry an older one forward, and the write that is known to be on disk
module ludic_save uses
numbers float
import "format.ludic"
import "text.ludic"
import "read.ludic"
import "migrate.ludic"
import "write.ludic"
import "values.ludic"

View file

@ -0,0 +1,31 @@
# migrate.ludic - the chain: 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
export function save_migrate(v: Val, f: SaveFormat, from: int) -> int {
var n = from
while n < f.version {
let k = save_step(f.name, n)
if k >= 0 { SaveMigrations[k].run(v) }
n += 1
}
value_put(v, f.version_key, value_int(f.version))
return n
}
# the step that carries `format` from version n, or -1: a version with no step changed nothing
export function save_step(format: string, n: int) -> int {
for i in 0 .. SMIG_COUNT {
if SaveMigrations[i].format == format and SaveMigrations[i].from == n { return i }
}
return -1
}
# the steps a format has twice (a mistake the chain would otherwise run both of, in registry order)
export function save_steps_doubled(f: SaveFormat) -> int {
var d = 0
for n in f.oldest .. f.version {
var c = 0
for i in 0 .. SMIG_COUNT { if SaveMigrations[i].format == f.name and SaveMigrations[i].from == n { c += 1 } }
if c > 1 { d += 1 }
}
return d
}

View file

@ -0,0 +1,6 @@
# ludic.save - versioned save files: a version in the file, a chain of migrations the game supplies
# (an open registry), a torn write told from a whole one, a backup, a newer file refused and made
# read-only. Generic over what a file holds. Uses nothing. See README.md.
package "ludic.save"
version "0.1.0"
kind source

View file

@ -0,0 +1,54 @@
# read.ludic - a file read far enough to know it is one of this kind, then brought up to today's
# shape; and the two together with the backup standing in for a damaged file
# the text checked whole, parsed, and asked whether it is this kind of file at all
export function save_parse(text: string, f: SaveFormat) -> SaveRead {
if text == null { return read_new(SAVE_NONE, null) }
if not save_text_intact(text) { return read_new(SAVE_CORRUPT, null) }
let v = Json.parse(text)
if value_kind(v) != 6 { return read_new(SAVE_CORRUPT, null) } # 6 is an object
if f.is_one != null and not f.is_one(v) { return read_new(SAVE_CORRUPT, null) }
return read_new(SAVE_OK, v)
}
export function save_read(path: string, f: SaveFormat) -> SaveRead { return save_parse(Fs.read_text(path), f) }
# The version checks, then the chain. True when the object is now in today's shape; a newer file is
# SAVE_FUTURE and read-only, an older one than the format carries is SAVE_CORRUPT.
export function save_upgrade(r: SaveRead, f: SaveFormat) -> bool {
if r.status != SAVE_OK or r.value == null { return false }
r.migrated = false
var ver = 1
if value_has(r.value, f.version_key) != 0 { ver = value_as_int(value_get(r.value, f.version_key)) }
r.from = ver
if ver > f.version {
r.status = SAVE_FUTURE
r.readonly = true
return false
}
if ver < f.oldest {
r.status = SAVE_CORRUPT
return false
}
if ver < f.version {
save_migrate(r.value, f, ver)
r.migrated = true
}
return true
}
# The file, else its backup when the file is damaged. A newer build's file is never replaced by its
# backup (that is from the newer build too); both copies damaged is read-only, so nothing this build
# writes can land over what it could not read. No file at all is SAVE_NONE and writable.
export function save_open(path: string, backup: string, f: SaveFormat) -> SaveRead {
let r = save_read(path, f)
if r.status == SAVE_OK and save_upgrade(r, f) { return r }
if r.status != SAVE_CORRUPT or len(backup) == 0 or not Fs.exists(backup) { return r }
let b = save_read(backup, f)
if b.status == SAVE_OK and save_upgrade(b, f) {
b.from_backup = true
return b
}
r.readonly = true
return r
}

View file

@ -0,0 +1,135 @@
# save_test.ludic - a toy "note" format at version 3 with two steps: a torn file told from a whole
# one, the chain walked from 1, a newer file refused and read-only, the backup standing in, and a
# write that backs up only a whole file and is read back
import "ludic.save"
program SaveTest {
numbers float
# 1 -> 2: "text" renamed "body"; 2 has no step; 3 is today
function mig_1_2(v: Val) -> void {
if value_has(v, "text") != 0 { value_put(v, "body", value_get(v, "text")) }
value_put(v, "moved", value_int(1))
}
function mig_2_3(v: Val) -> void { value_put(v, "pages", value_int(save_int(v, "pages", 0) + 10)) }
def SaveMigrations note_1_2 { format: "note", from: 1, run: fn mig_1_2 }
def SaveMigrations note_2_3 { format: "note", from: 2, run: fn mig_2_3 }
def SaveMigrations other_1_2 { format: "other", from: 1, run: fn mig_2_3 }
function is_note(v: Val) -> bool { return value_has(v, "body") != 0 or value_has(v, "text") != 0 }
function fmt() -> SaveFormat {
let f = save_format("note", 3)
f.is_one = fn is_note
return f
}
function dir() -> string { return Os.temp_dir() + "/ludic-save-test" }
function path() -> string { return dir() + "/note.json" }
function bak() -> string { return dir() + "/note.bak.json" }
function fresh() -> void {
Fs.mkdir(dir())
Fs.remove(path())
Fs.remove(bak())
}
test "a torn file is told from a whole one" {
expect(save_text_intact("{\"a\": [1, 2], \"b\": \"}{\"}"))
expect(not save_text_intact("{\"day\":6,\"money\":7"))
expect(not save_text_intact("{\"a\": \"cut"))
expect(not save_text_intact("]["))
expect(not save_text_intact("plain"))
expect(save_text_intact("{\"q\": \"a \\\" b\"}"))
}
test "nothing, rubbish and another kind of file" {
expect_eq(save_parse(null, fmt()).status, SAVE_NONE)
expect_eq(save_parse("[1, 2]", fmt()).status, SAVE_CORRUPT)
expect_eq(save_parse("{\"day\": 4}", fmt()).status, SAVE_CORRUPT)
expect_eq(save_parse("{\"body\": \"hi\", \"version\": 3}", fmt()).status, SAVE_OK)
}
test "a file with no version is 1, and walks every step of its own format" {
let r = save_parse("{\"text\": \"hi\"}", fmt())
expect(save_upgrade(r, fmt()))
expect_eq(r.from, 1)
expect(r.migrated)
expect(value_as_str(value_get(r.value, "body")) == "hi")
expect_eq(save_int(r.value, "moved", 0), 1)
expect_eq(save_int(r.value, "pages", 0), 10)
expect_eq(save_int(r.value, "version", 0), 3)
expect_eq(save_steps_doubled(fmt()), 0)
}
test "today's file is not migrated, and a step is not run twice" {
let r = save_parse("{\"body\": \"x\", \"version\": 2, \"pages\": 1}", fmt())
expect(save_upgrade(r, fmt()))
expect_eq(save_int(r.value, "pages", 0), 11)
expect_eq(save_int(r.value, "moved", 0), 0)
let t = save_parse("{\"body\": \"x\", \"version\": 3}", fmt())
expect(save_upgrade(t, fmt()))
expect(not t.migrated)
}
test "a newer build's file is refused and read-only, an older than the oldest is corrupt" {
let r = save_parse("{\"body\": \"x\", \"version\": 9}", fmt())
expect(not save_upgrade(r, fmt()))
expect_eq(r.status, SAVE_FUTURE)
expect(r.readonly)
expect(not save_write_to(r, path(), bak(), "{}"))
let f = fmt()
f.oldest = 2
let o = save_parse("{\"body\": \"x\", \"version\": 1}", f)
expect(not save_upgrade(o, f))
expect_eq(o.status, SAVE_CORRUPT)
}
test "a write backs up the old file only when it is whole, and is read back" {
fresh()
expect(save_write(path(), bak(), "{\"body\": \"one\", \"version\": 3}"))
expect(not Fs.exists(bak()))
expect(save_write(path(), bak(), "{\"body\": \"two\", \"version\": 3}"))
expect(Fs.read_text(bak()) == "{\"body\": \"one\", \"version\": 3}")
Fs.write_text(path(), "{\"body\": \"thr")
expect(save_write(path(), bak(), "{\"body\": \"four\", \"version\": 3}"))
expect(Fs.read_text(bak()) == "{\"body\": \"one\", \"version\": 3}")
}
test "the backup stands in for a damaged file, and both damaged is read-only" {
fresh()
Fs.write_text(path(), "{\"body\": \"cut")
Fs.write_text(bak(), "{\"body\": \"good\", \"version\": 3}")
let r = save_open(path(), bak(), fmt())
expect_eq(r.status, SAVE_OK)
expect(r.from_backup)
expect(value_as_str(value_get(r.value, "body")) == "good")
Fs.write_text(bak(), "{\"body\": ")
let d = save_open(path(), bak(), fmt())
expect_eq(d.status, SAVE_CORRUPT)
expect(d.readonly)
Fs.remove(path())
let none = save_open(path(), bak(), fmt())
expect_eq(none.status, SAVE_NONE)
expect(not none.readonly)
}
test "a newer file is never replaced by its backup" {
fresh()
Fs.write_text(path(), "{\"body\": \"new\", \"version\": 7}")
Fs.write_text(bak(), "{\"body\": \"old\", \"version\": 3}")
let r = save_open(path(), bak(), fmt())
expect_eq(r.status, SAVE_FUTURE)
expect(r.readonly)
expect(not r.from_backup)
}
test "the helpers a migration uses, and the fingerprint moves with a name" {
let v = Json.parse("{\"a\": 1, \"b\": [5, 6, 7]}")
expect_eq(value_has(save_strip(v, "a"), "a"), 0)
let l = save_list_with(value_get(v, "b"), 1, 9)
expect_eq(value_as_int(value_at(l, 1)), 9)
let w = save_list_without(value_get(v, "b"), 0)
expect_eq(value_count(w), 2)
expect_eq(value_as_int(value_at(w, 0)), 6)
let h1 = save_fold(save_fold(17, "rope"), "cloth")
let h2 = save_fold(save_fold(17, "cloth"), "rope")
expect(h1 != h2)
expect(save_fold_int(17, 3) != save_fold_int(17, 4))
}
}

View file

@ -0,0 +1,32 @@
# text.ludic - is this the whole file? Every brace and bracket closed, every string closed, at least
# one opened. A JSON parser that is best-effort hands back half a file as a shorter, plausible one
# (`{"day":6,"money":7` is a day), so the text itself is what knows whether the write finished.
export function save_text_intact(s: string) -> bool {
let n = len(s)
if n < 2 { return false }
var depth = 0
var instr = false
var esc = false
var opened = false
var i = 0
while i < n {
let c = s[i]
if instr {
if esc { esc = false }
else if c == 92 { esc = true } # a backslash: the next byte is literal
else if c == 34 { instr = false }
} else {
if c == 34 {
instr = true
} else if c == 123 or c == 91 {
depth += 1
opened = true
} else if c == 125 or c == 93 {
depth -= 1
if depth < 0 { return false }
}
}
i += 1
}
return opened and depth == 0 and not instr
}

View file

@ -0,0 +1,46 @@
# values.ludic - what a migration keeps doing to a parsed file, and a fingerprint of the tables a
# file stores by position (a table that moved without a version bump changes it)
# an object with one key left out - also how a test builds an older file out of a current one
export function save_strip(v: Val, key: string) -> Val {
let r = value_object()
for i in 0 .. value_count(v) {
let k: pointer = value_key_at(v, i)
if k == key { continue }
value_put(r, k, value_at(v, i))
}
return r
}
# a copy of a list of ints with one slot replaced
export function save_list_with(l: Val, slot: int, n: int) -> Val {
let r = value_list()
for i in 0 .. value_count(l) {
var x = value_as_int(value_at(l, i))
if i == slot { x = n }
value_add(r, value_int(x))
}
return r
}
# a copy of a list of ints with one slot taken out (an entry deleted from a table stored by position)
export function save_list_without(l: Val, slot: int) -> Val {
let r = value_list()
for i in 0 .. value_count(l) { if i != slot { value_add(r, value_int(value_as_int(value_at(l, i)))) } }
return r
}
export function save_int(v: Val, key: string, fallback: int) -> int {
if v == null or value_has(v, key) == 0 { return fallback }
return value_as_int(value_get(v, key))
}
# A fold, small and stable: a change anywhere in what is folded moves it. Fold the names of every
# table stored by position, in order, and compare with the number the last release wrote.
export function save_fold(h0: int, s: string) -> int {
var h = h0
for i in 0 .. len(s) { h = (h * 131 + s[i]) % 1000003 }
return h
}
export function save_fold_int(h0: int, n: int) -> int { return (h0 * 131 + n) % 1000003 }

View file

@ -0,0 +1,16 @@
# write.ludic - a write that is known to be on disk: the old file to the backup first (only when it
# is whole - a torn file must never replace a good backup), then the new text, read straight back
# and compared, because a caller about to delete something on the strength of it needs to know
export function save_write(path: string, backup: string, text: string) -> bool {
let old = Fs.read_text(path)
if old != null and len(backup) > 0 and save_text_intact(old) { Fs.write_text(backup, old) }
Fs.write_text(path, text)
let back = Fs.read_text(path)
return back != null and back == text
}
# a SaveRead that must not be written over says so; a write through it refuses
export function save_write_to(r: SaveRead, path: string, backup: string, text: string) -> bool {
if r != null and r.readonly { return false }
return save_write(path, backup, text)
}