From 47ba3d90288a5b25959d35efd2482522e43ed64e Mon Sep 17 00:00:00 2001 From: Orkuncakilkaya Date: Fri, 25 Sep 2026 08:28:30 +0300 Subject: [PATCH] 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 --- packages/README.md | 1 + packages/ludic.save/README.md | 66 +++++++++++ packages/ludic.save/format.ludic | 53 +++++++++ packages/ludic.save/index.ludic | 10 ++ packages/ludic.save/migrate.ludic | 31 +++++ packages/ludic.save/package.ludic | 6 + packages/ludic.save/read.ludic | 54 +++++++++ packages/ludic.save/tests/save_test.ludic | 135 ++++++++++++++++++++++ packages/ludic.save/text.ludic | 32 +++++ packages/ludic.save/values.ludic | 46 ++++++++ packages/ludic.save/write.ludic | 16 +++ 11 files changed, 450 insertions(+) create mode 100644 packages/ludic.save/README.md create mode 100644 packages/ludic.save/format.ludic create mode 100644 packages/ludic.save/index.ludic create mode 100644 packages/ludic.save/migrate.ludic create mode 100644 packages/ludic.save/package.ludic create mode 100644 packages/ludic.save/read.ludic create mode 100644 packages/ludic.save/tests/save_test.ludic create mode 100644 packages/ludic.save/text.ludic create mode 100644 packages/ludic.save/values.ludic create mode 100644 packages/ludic.save/write.ludic diff --git a/packages/README.md b/packages/README.md index 6a1411bf..b36d6423 100644 --- a/packages/README.md +++ b/packages/README.md @@ -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 | diff --git a/packages/ludic.save/README.md b/packages/ludic.save/README.md new file mode 100644 index 00000000..8b731d3e --- /dev/null +++ b/packages/ludic.save/README.md @@ -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. diff --git a/packages/ludic.save/format.ludic b/packages/ludic.save/format.ludic new file mode 100644 index 00000000..b86747d7 --- /dev/null +++ b/packages/ludic.save/format.ludic @@ -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 +} diff --git a/packages/ludic.save/index.ludic b/packages/ludic.save/index.ludic new file mode 100644 index 00000000..6a7f6e70 --- /dev/null +++ b/packages/ludic.save/index.ludic @@ -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" diff --git a/packages/ludic.save/migrate.ludic b/packages/ludic.save/migrate.ludic new file mode 100644 index 00000000..a1a794ae --- /dev/null +++ b/packages/ludic.save/migrate.ludic @@ -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 +} diff --git a/packages/ludic.save/package.ludic b/packages/ludic.save/package.ludic new file mode 100644 index 00000000..663d81a3 --- /dev/null +++ b/packages/ludic.save/package.ludic @@ -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 diff --git a/packages/ludic.save/read.ludic b/packages/ludic.save/read.ludic new file mode 100644 index 00000000..454488b2 --- /dev/null +++ b/packages/ludic.save/read.ludic @@ -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 +} diff --git a/packages/ludic.save/tests/save_test.ludic b/packages/ludic.save/tests/save_test.ludic new file mode 100644 index 00000000..fe1590bc --- /dev/null +++ b/packages/ludic.save/tests/save_test.ludic @@ -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)) + } +} diff --git a/packages/ludic.save/text.ludic b/packages/ludic.save/text.ludic new file mode 100644 index 00000000..8dfe2d37 --- /dev/null +++ b/packages/ludic.save/text.ludic @@ -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 +} diff --git a/packages/ludic.save/values.ludic b/packages/ludic.save/values.ludic new file mode 100644 index 00000000..5802a807 --- /dev/null +++ b/packages/ludic.save/values.ludic @@ -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 } diff --git a/packages/ludic.save/write.ludic b/packages/ludic.save/write.ludic new file mode 100644 index 00000000..4b5e6feb --- /dev/null +++ b/packages/ludic.save/write.ludic @@ -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) +}