feat(packages): ludic.settings - a settings store as data: typed reads and writes by id, clamping, rescue values, per-key persistence, a fact per change

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Orkun ÇAKILKAYA 2026-09-25 05:51:04 +03:00
parent 3514d46954
commit 4318adc98c
9 changed files with 338 additions and 0 deletions

View file

@ -0,0 +1,38 @@
# ludic.settings
A game's settings as data, in one store with one owner. The game declares them - usually as a
registry of `SettingDef` read from a `.lres` - and everything else reads and writes them through
the store's verbs. A change is a `SettingChanged` fact the game drains to apply it. Uses
[`ludic.base`](../ludic.base/README.md) and nothing else.
```ludic
import "ludic.settings"
export registry Settings of SettingDef as SET from "assets/data/settings.lres"
for i in 0 .. SET_COUNT { settings_add(Settings[i]) } # SET_* is each setting's id
```
```
# settings.lres - the entry's name is the key in the file
bloom { kind: SETTING_BOOL, num: 1.0 }
shadow_q { kind: SETTING_INT, num: 2.0, lo: 0.0, hi: 3.0, rescue: true }
rscale { kind: SETTING_FLOAT, num: 0.78, lo: 0.5, hi: 1.0, rescue: true }
lang { kind: SETTING_TEXT, text: "en" }
```
## API
| | |
| --- | --- |
| `SettingDef { key, kind, num, text, lo, hi, rescue, saved }` | a setting: `SETTING_BOOL` / `_INT` / `_FLOAT` / `_TEXT`, its default (`num` or `text`), its range (`hi < lo` is none), whether a rescue start puts it back, whether it is written |
| `settings_add(d) -> int`, `settings_clear()`, `settings_count()`, `settings_def(id)`, `settings_id_of(key) -> int` | the table; an id is the place a setting was added |
| `settings_get_bool / _int / _float / _text(id)` | the value |
| `settings_set_bool / _int / _float / _text(id, v)`, `settings_set_value(id, Val)` | a change, held to the range (outside it is the default) and reported |
| `settings_changes() -> Queue<SettingChanged>` | the facts: `{ id, key }`, one per change |
| `settings_reset_all()`, `settings_clamp_all()`, `settings_rescue()`, `settings_is_safe(id)` | the whole store at once, quietly: defaults, ranges, the rescue set |
| `settings_to_json() -> Val`, `settings_put(v, id)`, `settings_from_json(v)` | the file, by key: a switch is 0/1, a float thousandths; a key the file lacks keeps the store's value |
## Tests
```bash
ludic build packages/ludic.settings/tests/settings_test.ludic --headless -o /tmp/settings_test && /tmp/settings_test
```

View file

@ -0,0 +1,23 @@
# ludic.settings/defs.ludic - what a setting is: its key in the file, its kind, its default, the
# range it may take, and whether a rescue start puts it back. A game's registry is a list of these.
export const SETTING_BOOL: int = 0
export const SETTING_INT: int = 1
export const SETTING_FLOAT: int = 2
export const SETTING_TEXT: int = 3
export property SettingDef {
key: string = "" # its name in the file; a registry fills it with the entry's own name
kind: int = 0 # SETTING_*
num: float = 0.0 # the default: 0 or 1 for a switch, a whole number, a float
text: string = "" # the default of a text setting
lo: float = 0.0 # the range, inclusive; a value outside it is the default again
hi: float = -1.0 # hi below lo: any value
rescue: bool = false # a rescue start puts it back to its default
saved: bool = true # false: held in memory only, never written to the file
}
# one setting changed, by its place in the store and its key
export property SettingChanged {
id: int = 0
key: string = ""
}

View file

@ -0,0 +1,54 @@
# ludic.settings/file.ludic - the store as a JSON object, by key: a switch is 0 or 1, a whole number
# is itself, a float is thousandths (sv_put_float), text is a string. A key the file lacks keeps
# what the store holds, and a key the store does not know is ignored.
export function settings_to_json() -> Val {
sg_init()
let v = Value.object()
for i in 0 .. len(sg_defs) { settings_put(v, i) }
return v
}
# one setting into `v` under its key (a game building its own file adds its extras beside them)
export function settings_put(v: Val, id: int) -> void {
let d = sg_defs[id]
if not d.saved { return }
if d.kind == SETTING_TEXT {
sv_put_str(v, d.key, sg_text[id])
} else if d.kind == SETTING_FLOAT {
sv_put_float(v, d.key, sg_num[id])
} else {
sv_put_int(v, d.key, int(sg_num[id]))
}
}
# every key `v` names, held to its range; quiet, since a load is applied whole by its caller
export function settings_from_json(v: Val) -> void {
sg_init()
if v == null { return }
for i in 0 .. len(sg_defs) { sg_read(v, i) }
}
function sg_read(v: Val, id: int) -> void {
let d = sg_defs[id]
if not d.saved or Value.kind(v) != 6 or Value.has(v, d.key) == 0 { return }
if d.kind == SETTING_TEXT {
sg_text[id] = sv_str(v, d.key, sg_text[id])
} else if d.kind == SETTING_FLOAT {
sg_num[id] = settings_held(id, sv_float(v, d.key, sg_num[id]))
} else {
sg_num[id] = settings_held(id, float(sv_int(v, d.key, int(sg_num[id]))))
}
}
# one setting from a JSON value in the file's own form (a row in a settings screen hands these):
# a change is a fact
export function settings_set_value(id: int, x: Val) -> void {
let d = sg_defs[id]
if d.kind == SETTING_TEXT {
if Value.kind(x) == 4 { settings_set_text(id, Value.as_str(x)) }
} else if d.kind == SETTING_FLOAT {
settings_set_float(id, float(Value.as_int(x)) / 1000.0)
} else {
settings_set_int(id, Value.as_int(x))
}
}

View file

@ -0,0 +1,9 @@
# ludic.settings - the settings a game declares as data (a registry of SettingDef), read and
# written only through settings_get_* / settings_set_*, each change a SettingChanged fact
module ludic_settings uses ludic_base
numbers float
import "ludic.base"
import "defs.ludic"
import "store.ludic"
import "rules.ludic"
import "file.ludic"

View file

@ -0,0 +1,5 @@
# ludic.settings - a game's settings as data: one store, one owner, a fact per change, held to
# their ranges, a safe set for a rescue start, and a file by key. Uses ludic.base. See README.md.
package "ludic.settings"
version "0.1.0"
kind source

View file

@ -0,0 +1,35 @@
# ludic.settings/rules.ludic - the whole store at once: every setting to its default, every one
# held to its range, and the rescue set. None of these is news: whoever calls them applies it all
export function settings_reset_all() -> void {
sg_init()
for i in 0 .. len(sg_defs) {
sg_num[i] = sg_defs[i].num
sg_text[i] = sg_defs[i].text
}
}
# a hand-edited file, or one from a build with more choices, never hands the game a value it has
# no case for
export function settings_clamp_all() -> void {
sg_init()
for i in 0 .. len(sg_defs) { sg_num[i] = settings_held(i, sg_num[i]) }
}
# the values every machine can run: each setting marked `rescue` back to its default, and the
# rest (keys, language, volume) left the player's
export function settings_rescue() -> void {
sg_init()
for i in 0 .. len(sg_defs) {
if sg_defs[i].rescue {
sg_num[i] = sg_defs[i].num
sg_text[i] = sg_defs[i].text
}
}
}
# whether a setting is on the rescue list at its safe value
export function settings_is_safe(id: int) -> bool {
let d = sg_defs[id]
if not d.rescue { return true }
return sg_num[id] == d.num and sg_text[id] == d.text
}

View file

@ -0,0 +1,93 @@
# ludic.settings/store.ludic - the values, by id (a setting's place in the order it was added),
# and the verbs: nothing else holds a setting, and every set that changes one is a fact
var sg_defs: []SettingDef = null
var sg_num: []float = null
var sg_text: []string = null
var sg_changes: Queue<SettingChanged> = null
function sg_init() -> void {
if sg_defs != null { return }
sg_defs = new []SettingDef
sg_num = new []float
sg_text = new []string
}
export function settings_changes() -> Queue<SettingChanged> {
if sg_changes == null { sg_changes = queue_new("settings.changes") }
return sg_changes
}
# forget every setting (a test, or a game that declares its table again)
export function settings_clear() -> void {
sg_defs = new []SettingDef
sg_num = new []float
sg_text = new []string
q_clear(settings_changes())
}
# a setting, at its default; its id is its place in the order added
export function settings_add(d: SettingDef) -> int {
sg_init()
push(sg_defs, d)
push(sg_num, d.num)
push(sg_text, d.text)
return len(sg_defs) - 1
}
export function settings_count() -> int {
sg_init()
return len(sg_defs)
}
export function settings_def(id: int) -> SettingDef { return sg_defs[id] }
# the id of `key`, or -1
export function settings_id_of(key: string) -> int {
sg_init()
for i in 0 .. len(sg_defs) { if sg_defs[i].key == key { return i } }
return -1
}
export function settings_get_float(id: int) -> float { return sg_num[id] }
export function settings_get_int(id: int) -> int { return int(sg_num[id]) }
export function settings_get_bool(id: int) -> bool { return sg_num[id] != 0.0 }
export function settings_get_text(id: int) -> string { return sg_text[id] }
function sg_changed(id: int) -> void {
let c = new SettingChanged
c.id = id
c.key = sg_defs[id].key
q_push(settings_changes(), c)
}
# a value outside the setting's range is its default: a damaged file never reaches the game
export function settings_set_float(id: int, v: float) -> void {
let h = settings_held(id, v)
if h == sg_num[id] { return }
sg_num[id] = h
sg_changed(id)
}
export function settings_set_int(id: int, v: int) -> void { settings_set_float(id, float(v)) }
export function settings_set_bool(id: int, v: bool) -> void {
if v { settings_set_float(id, 1.0) } else { settings_set_float(id, 0.0) }
}
export function settings_set_text(id: int, v: string) -> void {
if v == sg_text[id] { return }
sg_text[id] = v
sg_changed(id)
}
# `v` held to the setting's range: outside it, the default
export function settings_held(id: int, v: float) -> float {
let d = sg_defs[id]
var x = v
if d.kind == SETTING_BOOL {
if x != 0.0 { x = 1.0 }
}
if d.kind == SETTING_INT { x = float(int(Math.floor(x + 0.5))) }
if d.hi >= d.lo and (x < d.lo or x > d.hi) { return d.num }
return x
}

View file

@ -0,0 +1,75 @@
# settings_test.ludic - a registry of settings as data: defaults, typed reads, a fact per change,
# ranges, the rescue set, and the file by key
import "ludic.settings"
import "ludic.base"
program SettingsTest {
numbers float
registry Toy of SettingDef as TOY from "packages/ludic.settings/tests/toy.lres"
function declare() -> void {
settings_clear()
for i in 0 .. TOY_COUNT { settings_add(Toy[i]) }
}
function facts() -> []SettingChanged { return q_drain(settings_changes()) }
test "a registry declares the store, each at its default" {
declare()
expect_eq(settings_count(), 5)
expect(settings_get_bool(TOY_BLOOM))
expect_eq(settings_get_int(TOY_SHADOW_Q), 2)
expect(settings_get_float(TOY_RSCALE) > 0.77)
expect(settings_get_text(TOY_LANG) == "en")
expect_eq(settings_id_of("rscale"), TOY_RSCALE)
expect_eq(settings_id_of("nothing"), -1)
}
test "a change is a fact, and setting what is there is none" {
declare()
settings_set_int(TOY_SHADOW_Q, 3)
settings_set_int(TOY_SHADOW_Q, 3)
settings_set_text(TOY_LANG, "tr")
let fs = facts()
expect_eq(len(fs), 2)
expect(fs[0].key == "shadow_q")
expect_eq(fs[1].id, TOY_LANG)
}
test "a value out of range is the default" {
declare()
settings_set_int(TOY_SHADOW_Q, 9)
expect_eq(settings_get_int(TOY_SHADOW_Q), 2)
settings_set_float(TOY_RSCALE, 0.2)
expect(settings_get_float(TOY_RSCALE) > 0.77)
}
test "a rescue puts back only what it lists" {
declare()
settings_set_int(TOY_SHADOW_Q, 0)
settings_set_bool(TOY_BLOOM, false)
expect(not settings_is_safe(TOY_SHADOW_Q))
settings_rescue()
expect_eq(settings_get_int(TOY_SHADOW_Q), 2)
expect(not settings_get_bool(TOY_BLOOM))
}
test "the file is by key, and what it lacks or cannot hold keeps the store's" {
declare()
settings_set_float(TOY_RSCALE, 0.9)
settings_set_text(TOY_LANG, "tr")
settings_set_bool(TOY_HELD, true)
let text = Json.encode(settings_to_json())
declare()
settings_set_bool(TOY_BLOOM, false)
let v = Json.parse(text)
Value.put(v, "shadow_q", Value.int(7))
Value.put(v, "stranger", Value.int(1))
settings_from_json(v)
expect(settings_get_float(TOY_RSCALE) > 0.89)
expect(settings_get_text(TOY_LANG) == "tr")
expect_eq(settings_get_int(TOY_SHADOW_Q), 2)
expect(settings_get_bool(TOY_BLOOM))
expect(not settings_get_bool(TOY_HELD))
expect(Value.has(settings_to_json(), "held") == 0)
}
}

View file

@ -0,0 +1,6 @@
# toy.lres - a game's settings for settings_test
bloom { kind: SETTING_BOOL, num: 1.0 }
shadow_q { kind: SETTING_INT, num: 2.0, lo: 0.0, hi: 3.0, rescue: true }
rscale { kind: SETTING_FLOAT, num: 0.78, lo: 0.5, hi: 1.0, rescue: true }
lang { kind: SETTING_TEXT, text: "en" }
held { kind: SETTING_BOOL, saved: false }