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:
parent
b24c523fa4
commit
47ba3d9028
11 changed files with 450 additions and 0 deletions
|
|
@ -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 |
|
||||
|
|
|
|||
66
packages/ludic.save/README.md
Normal file
66
packages/ludic.save/README.md
Normal 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.
|
||||
53
packages/ludic.save/format.ludic
Normal file
53
packages/ludic.save/format.ludic
Normal 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
|
||||
}
|
||||
10
packages/ludic.save/index.ludic
Normal file
10
packages/ludic.save/index.ludic
Normal 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"
|
||||
31
packages/ludic.save/migrate.ludic
Normal file
31
packages/ludic.save/migrate.ludic
Normal 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
|
||||
}
|
||||
6
packages/ludic.save/package.ludic
Normal file
6
packages/ludic.save/package.ludic
Normal 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
|
||||
54
packages/ludic.save/read.ludic
Normal file
54
packages/ludic.save/read.ludic
Normal 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
|
||||
}
|
||||
135
packages/ludic.save/tests/save_test.ludic
Normal file
135
packages/ludic.save/tests/save_test.ludic
Normal 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))
|
||||
}
|
||||
}
|
||||
32
packages/ludic.save/text.ludic
Normal file
32
packages/ludic.save/text.ludic
Normal 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
|
||||
}
|
||||
46
packages/ludic.save/values.ludic
Normal file
46
packages/ludic.save/values.ludic
Normal 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 }
|
||||
16
packages/ludic.save/write.ludic
Normal file
16
packages/ludic.save/write.ludic
Normal 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)
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue