From 4318adc98c5248f4722484a94e44e1f5124e0ca2 Mon Sep 17 00:00:00 2001 From: Orkuncakilkaya Date: Fri, 25 Sep 2026 05:51:04 +0300 Subject: [PATCH] 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 --- packages/ludic.settings/README.md | 38 ++++++++ packages/ludic.settings/defs.ludic | 23 +++++ packages/ludic.settings/file.ludic | 54 +++++++++++ packages/ludic.settings/index.ludic | 9 ++ packages/ludic.settings/package.ludic | 5 + packages/ludic.settings/rules.ludic | 35 +++++++ packages/ludic.settings/store.ludic | 93 +++++++++++++++++++ .../ludic.settings/tests/settings_test.ludic | 75 +++++++++++++++ packages/ludic.settings/tests/toy.lres | 6 ++ 9 files changed, 338 insertions(+) create mode 100644 packages/ludic.settings/README.md create mode 100644 packages/ludic.settings/defs.ludic create mode 100644 packages/ludic.settings/file.ludic create mode 100644 packages/ludic.settings/index.ludic create mode 100644 packages/ludic.settings/package.ludic create mode 100644 packages/ludic.settings/rules.ludic create mode 100644 packages/ludic.settings/store.ludic create mode 100644 packages/ludic.settings/tests/settings_test.ludic create mode 100644 packages/ludic.settings/tests/toy.lres diff --git a/packages/ludic.settings/README.md b/packages/ludic.settings/README.md new file mode 100644 index 00000000..dcf9e078 --- /dev/null +++ b/packages/ludic.settings/README.md @@ -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` | 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 +``` diff --git a/packages/ludic.settings/defs.ludic b/packages/ludic.settings/defs.ludic new file mode 100644 index 00000000..9632e214 --- /dev/null +++ b/packages/ludic.settings/defs.ludic @@ -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 = "" +} diff --git a/packages/ludic.settings/file.ludic b/packages/ludic.settings/file.ludic new file mode 100644 index 00000000..556de859 --- /dev/null +++ b/packages/ludic.settings/file.ludic @@ -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)) + } +} diff --git a/packages/ludic.settings/index.ludic b/packages/ludic.settings/index.ludic new file mode 100644 index 00000000..7976adf7 --- /dev/null +++ b/packages/ludic.settings/index.ludic @@ -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" diff --git a/packages/ludic.settings/package.ludic b/packages/ludic.settings/package.ludic new file mode 100644 index 00000000..08daabc8 --- /dev/null +++ b/packages/ludic.settings/package.ludic @@ -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 diff --git a/packages/ludic.settings/rules.ludic b/packages/ludic.settings/rules.ludic new file mode 100644 index 00000000..e5c42278 --- /dev/null +++ b/packages/ludic.settings/rules.ludic @@ -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 +} diff --git a/packages/ludic.settings/store.ludic b/packages/ludic.settings/store.ludic new file mode 100644 index 00000000..7b2ef165 --- /dev/null +++ b/packages/ludic.settings/store.ludic @@ -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 = 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 { + 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 +} diff --git a/packages/ludic.settings/tests/settings_test.ludic b/packages/ludic.settings/tests/settings_test.ludic new file mode 100644 index 00000000..c8df2d3c --- /dev/null +++ b/packages/ludic.settings/tests/settings_test.ludic @@ -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) + } +} diff --git a/packages/ludic.settings/tests/toy.lres b/packages/ludic.settings/tests/toy.lres new file mode 100644 index 00000000..5cd11f7f --- /dev/null +++ b/packages/ludic.settings/tests/toy.lres @@ -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 }