SCUA 0.37.0 checks data where it enters your program and where it
leaves. Decoders now take a type, so
json.decode(text, Save) hands you a checked
Save, or an Error that says what was wrong and
where. Encoders take one too, and a save that could not load again never
gets written. Games get a folder of their own for saves and settings,
user:, and scua init game starts a project
that already saves the safe way. Typed code also got cheaper: an
annotation checks the value it is given one level deep, so passing a
game state with 50,000 entries to a typed function costs what passing an
empty one does.
#Load with a type
Give a decoder the type you expect and it checks the data at every
level, including nested records, list elements and where
rules:
import json
record Item { name: string, count: int where count >= 0 }
record Save { level: string, gold: int, items: { Item } = [] }
fn load(text) -> Result
let save = json.decode(text, Save)?
return Ok(`{save.level}: {save.gold} gold, {len(save.items)} items`)
end
print(load("{\"level\": \"keep\", \"gold\": 30, \"items\": [{\"name\": \"key\", \"count\": 1}]}"))
match load("{\"level\": \"keep\", \"gold\": 30, \"items\": [{\"name\": \"key\", \"count\": -2}]}")
Ok(v) -> print(v)
Error(e) -> print(e.message)
end
$ scua load.scua
Ok(keep: 30 gold, 1 items)
json.decode(text, Save): value.items[0].count violates Item's where rule (got -2)
Bad bytes and data that doesn't fit both come back as an
Error, so ? deals with a corrupt file and a
save from a broken build the same way. The error names the path to the
value, and e.at gives the same path from inside the value,
ready to put after your own prefix. durable.decode,
toml.parse and ini.parse take the type
too.
For hand-written files, where a typo would quietly fall back to a default, ask for unknown keys to be refused:
import toml
record Pity { hard: int = 90, soft: int = 70 }
record Banner { name: string, pity: Pity = {} }
let text = "name = \"Legends\"\n[pity]\nhrad = 80\n"
match toml.parse(text, Banner, { unknown = "refuse" })
Ok(b) -> print(b.pity.hard)
Error(e) -> print(e.message)
end
$ scua config.scua
toml.parse(text, Banner): value.pity.hrad is not a field of Pity; did you mean hard?
parse(value, T) does the same for data you already hold,
such as a table from a host or a message. It is the new name for
as(value, T), and it never changes the value you give
it.
#Save with a type
json.encode(value, T) and
durable.encode(value, version, T) check the whole value
before they write anything. If something has crept in that doesn't fit,
or that has no saved form, such as a function left in a field typed
any, you get an Error naming it, and no
bytes:
import json
record Item { name: string, count: int where count >= 0 }
record Save { level: string, gold: int, items: { Item } = [] }
let s: Save = { level = "keep", gold = 30 }
s.items.push({ name = "key", count = 1 })
print(json.encode(s, Save)?)
let ui: any = s
ui.items[0].count = -1
match json.encode(s, Save)
Ok(text) -> print(text)
Error(e) -> print(`not saved: {e.message}`)
end
$ scua save.scua
{"level":"keep","gold":30,"items":[{"name":"key","count":1}]}
not saved: json.encode(s, Save): s.items[0].count violates Item's where rule (got -1)
The game keeps playing and the last good save stays on disk. Where a typed encode is written, the compiler also warns about any field of the type that could hold something unsaveable, so the problem shows up while you are writing the code.
The literal pushed into s.items above is a real
Item, with its defaults filled. A table literal written
into a typed slot, whether pushed, inserted, stored in a field or passed
to a typed parameter, is now built as that record.
#A folder for saves
--allow-user=ID gives a program the folder its platform
keeps for that app: %APPDATA%\ID on Windows,
~/Library/Application Support/ID on macOS and
~/.local/share/ID on Linux. The program reads and writes it
with user: paths, beside the folder --allow-fs
grants. While you develop, --user-dir=saves uses a folder
beside the game instead, so a test build never touches the saves of the
copy you play.
scua init game puts it together: a small game whose
progress.scua loads with a typed decode, saves with a typed
encode under a temporary name and renames it over the old file, and
comes with a test that saves a representative state.
$ scua init game tinykeep
$ cd tinykeep
$ scua --user-dir=saves main.scua
level start, 10 gold
saved
$ scua --user-dir=saves main.scua
level start, 20 gold
saved
$ scua test
3 passed, 0 failed (1 file)
#Typed code that costs the same at any size
A type annotation on a parameter, a return or a let
checks the value as it crosses: its kind and, for a record, each of its
own fields. It no longer walks every nested record and list element. On
a native x86 build, handing a typed function a state with a 50,000-entry
ledger took 573 µs before this release and takes about 3 ns now, and a
tick of ten typed systems over that state went from 8.3 ms to 0.3 µs.
Data that comes from outside your program gets checked all the way down
once, where it arrives, by a typed decoder or parse, and
typed code trusts it from then on.
To check everything at every annotation while you test, run with
--check=deep, or scua test --check=deep. It
only adds faults, so code that passes with it passes without it.
#Upgrading
as(value, T)still works and warns.scua fmt --fix file.scuarenames every call in a file toparse(value, T). The release after this one refusesas.- A typed
letof loaded data no longer checks nested records. The compiler warns where that happens and suggests the typed decoder orparse. - Fault messages from a typed crossing now name it and the path, so code that matched the old text needs updating.
The changelog has everything else in 0.37.0,
including faster float loops on x86-64 and a --serve-check
for checking a server in CI without starting it.