diff --git a/packages/ludic.base/README.md b/packages/ludic.base/README.md new file mode 100644 index 00000000..e038793b --- /dev/null +++ b/packages/ludic.base/README.md @@ -0,0 +1,146 @@ +# ludic.base + +The vocabulary a game made of mechanic packages shares. A mechanic (fishing, needs, a shop, the +weather) depends on `ludic.base` and on nothing else - never on another mechanic. The game is the +only code that knows two mechanics, and it wires them together. + +(`ludic.core` is a different, older package: the engine's canonical ECS components, used by the +examples under `examples/library/`.) + +```ludic +import "ludic.base" +``` + +## The rules + +1. **A mechanic uses only this.** If a mechanic needs a second mechanic to compile, that is the + bug. Its tests are one program: `ludic.base`, the mechanic, and a fake for each port. +2. **Ports for questions.** What a mechanic needs to ASK the world is a record of function + values it owns (`FishingWorld { is_water: fn(float, float) -> bool }`), in primitive and + `ludic.base` types only. The game binds it once. +3. **Queues for facts.** What HAPPENED is pushed onto a `Queue` the mechanic owns (`caught`) + and drained by the game in a later phase. A mechanic never acts on another's behalf. +4. **Verbs for changes.** A mechanic's state changes only through its exported functions + (`fishing_cast`, `pack_add`). Nothing assigns another module's globals. +5. **Phases for order.** A system names its phase; within a phase the game's list is the order. + No system says "after fishing". +6. **Its own save section.** Each system saves under its own key with its own version and + migrates its own old versions in `load`. A missing section is a reset. +7. **Its own dice.** A mechanic is handed an `Rng`, never draws from `Random.*`, so moving it + cannot shift anyone else's rolls. + +## The API + +| | | +| --- | --- | +| `Tick { dt, frame, hours }`, `tick_new(dt, frame, hours)` | what a system's tick receives | +| `PH_INPUT`, `PH_SENSE`, `PH_SIMULATE`, `PH_RESOLVE`, `PH_COMMIT`, `PH_PRESENT`, `PH_COUNT` | the phases, in the order they run | +| `Queue`, `q_new(name)`, `q_push(q, v)`, `q_drain(q) -> []T`, `q_len(q)`, `q_clear(q)` | facts, first in first out | +| `core_undrained() -> []string` | the names of queues still holding facts; ask it at the end of a frame | +| `Rng`, `rng_new(seed)`, `rng_seed(r, seed)`, `rng_next(r)`, `rng_float(r)`, `rng_between(r, lo, hi)`, `rng_span(r, lo, hi)` | a xorshift32 stream of its own (`rng_between` is inclusive; `rng_range` is taken by `Random.range`) | +| `SaveNode { found, version, data }`, `save_tree()`, `save_section(root, key, version, v)`, `load_section(root, key) -> SaveNode`, `save_encode`, `save_decode` | the save tree: `{"fishing": {"v": 3, ...}}`; `v` is reserved in a section, and a non-object value rides under `data` | +| `sv_int`, `sv_float`, `sv_bool`, `sv_str` (key, fallback), `sv_ints` | typed reads with a fallback; a float is kept as thousandths | +| `sv_put_int`, `sv_put_float`, `sv_put_bool`, `sv_put_str`, `sv_put_ints` | the matching writes | +| `System { key, phase, version, init, reset, tick, save, load }`, `system_new(key, phase)` | a system; a null function is a verb it does not have | +| `core_add(s)`, `core_clear()`, `core_count()` | the game's system list (a key may appear once) | +| `core_init_all()`, `core_reset_all()`, `core_tick_all(t)`, `core_save_all() -> Val`, `core_load_all(v)` | the runner: in the order added, tick phase by phase | + +## A toy mechanic + +```ludic +module toy_fishing +numbers float +import "ludic.base" +export property Caught { species: int = 0, weight: float = 0.0 } +export property FishingWorld { is_water: fn(float, float) -> bool = null } # its port +var world: FishingWorld = null +var dice: Rng = null +var casts: int = 0 +export var caught: Queue = null # its facts +export function fishing_bind(w: FishingWorld) -> void { world = w } +export function fishing_cast(x: float, z: float) -> bool { # its verb + if not world.is_water(x, z) { return false } + casts += 1 + return true +} +function fishing_reset() -> void { + casts = 0 + dice = rng_new(7) + caught = q_new("fishing.caught") +} +function fishing_tick(t: Tick) -> void { + while casts > 0 { + let c = new Caught + c.species = rng_between(dice, 0, 2) + c.weight = 0.5 + rng_float(dice) + q_push(caught, c) + casts -= 1 + } +} +export function fishing_system() -> System { + let s = system_new("fishing", PH_SIMULATE) + s.reset = fn fishing_reset + s.tick = fn fishing_tick + return s +} +``` + +## A game wiring two of them + +`toy_pack` is the same shape: verbs `pack_add(kind, n)` / `pack_count(kind)` and a system in +`PH_COMMIT` that saves its counts. Neither knows the other; the game binds fishing's port, writes +the route, and orders the three. + +```ludic +import "toys/fishing" +import "toys/pack" +import "ludic.base" +program Game { + numbers float + function lake(x: float, z: float) -> bool { return x < 100.0 } + + # the route: a landed fish goes into the pack + function route_fishing_pack(t: Tick) -> void { + let fish = q_drain(caught) + for i in 0 .. len(fish) { pack_add(fish[i].species, 1) } + } + + function game_start() -> void { + let w = new FishingWorld + w.is_water = fn lake + fishing_bind(w) + core_add(fishing_system()) + let r = system_new("route.fishing_pack", PH_RESOLVE) + r.tick = fn route_fishing_pack + core_add(r) + core_add(pack_system()) + core_reset_all() + } +} +``` + +Both are compiled and run by `tests/route_test.ludic` (the mechanics are `tests/toys/`). + +## Tests + +Each piece has a program under `tests/`, built and run directly: + +```bash +ludic build packages/ludic.base/tests/queue_test.ludic --headless -o /tmp/queue_test && /tmp/queue_test +``` + +`queue_test`, `rng_test`, `save_test`, `system_test` and `route_test`. Two things the language +does not do yet shape them: a generic call inside a `test` body is not resolved (so the queue +cases are functions a test calls), and test blocks share one program's globals (so each case +that uses the runner starts with `core_clear()`); `ludic test ` with a fresh state per +test will lift the second. + +## Later + +- `port FishingWorld { ... }` / `bind` will replace the record of function values, checked at + compile time instead of a null at run time. +- `open registry Systems of System` could replace `core_add`: each mechanic `def`s its system and + the game's own `def`s give the order. Function values in a `def` work today (`def Systems a { + tick: fn a_tick }`); what is missing is a registry a package declares and a game fills, in an + order the game controls. Until then the list is built by calls, in one place. +- `module toy_fishing uses ludic_base` will make rule 1 a compile error rather than a review. diff --git a/packages/ludic.base/index.ludic b/packages/ludic.base/index.ludic new file mode 100644 index 00000000..421cf055 --- /dev/null +++ b/packages/ludic.base/index.ludic @@ -0,0 +1,10 @@ +# ludic.base - what a mechanic may use and nothing else: the frame it is handed, the phases +# that order it, queues for the facts it reports, its own dice, its save section, its system +module ludic_base +numbers float +import "tick.ludic" +import "queue.ludic" +import "rng.ludic" +import "save.ludic" +import "save_fields.ludic" +import "system.ludic" diff --git a/packages/ludic.base/package.ludic b/packages/ludic.base/package.ludic new file mode 100644 index 00000000..04e3734a --- /dev/null +++ b/packages/ludic.base/package.ludic @@ -0,0 +1,5 @@ +# ludic.base - the vocabulary every mechanic package shares and the only thing one may use: +# Tick, phases, Queue, rng streams, the save tree and the system runner. See README.md. +package "ludic.base" +version "0.1.0" +kind source diff --git a/packages/ludic.base/queue.ludic b/packages/ludic.base/queue.ludic new file mode 100644 index 00000000..b4401cba --- /dev/null +++ b/packages/ludic.base/queue.ludic @@ -0,0 +1,58 @@ +# ludic.base/queue.ludic - a mechanic reports what HAPPENED as facts in a queue it owns, and the +# game drains it in a later phase. First in, first out; nothing here rolls dice or reorders. +export property Queue { + items: []T = null + id: int = -1 +} + +# every named queue's pending count, so a frame's end can say which facts nobody read +var qb_names: []string = null +var qb_pending: []int = null + +function qb_init() -> void { + if qb_names != null { return } + qb_names = new []string + qb_pending = new []int +} + +# a queue; the name is only for core_undrained +export function q_new(name: string) -> Queue { + qb_init() + let q = new Queue + q.items = new []T + q.id = len(qb_names) + push(qb_names, name) + push(qb_pending, 0) + return q +} + +export function q_push(q: Queue, v: T) -> void { + push(q.items, v) + qb_pending[q.id] = len(q.items) +} + +# the facts in the order they were pushed; the queue is empty afterwards +export function q_drain(q: Queue) -> []T { + let out = q.items + q.items = new []T + qb_pending[q.id] = 0 + return out +} + +export function q_len(q: Queue) -> int { return len(q.items) } + +export function q_clear(q: Queue) -> void { + q.items = new []T + qb_pending[q.id] = 0 +} + +# the names of the queues still holding facts; call it at the end of a frame, where a fact +# left behind is a route nobody wrote +export function core_undrained() -> []string { + qb_init() + let out = new []string + for i in 0 .. len(qb_names) { + if qb_pending[i] > 0 { push(out, qb_names[i]) } + } + return out +} diff --git a/packages/ludic.base/rng.ludic b/packages/ludic.base/rng.ludic new file mode 100644 index 00000000..bb29013e --- /dev/null +++ b/packages/ludic.base/rng.ludic @@ -0,0 +1,44 @@ +# ludic.base/rng.ludic - a stream of dice a mechanic is handed, so it never draws from the global +# generator and moving it cannot shift anyone else's rolls. xorshift32; the same seed, the same +# rolls, on every machine. +export property Rng { + s: int = 0x2545F491 +} + +export function rng_new(seed: int) -> Rng { + let r = new Rng + rng_seed(r, seed) + return r +} + +export function rng_seed(r: Rng, seed: int) -> void { + var x = seed * 0x2C1B3C6D + 0x297A2D39 + if x == 0 { x = 0x6B43A9B5 } + r.s = x +} + +# a whole number in [0, 2^31); the right shift is masked because Ludic's is arithmetic +export function rng_next(r: Rng) -> int { + var x = r.s + x = x ^ (x << 13) + x = x ^ ((x >> 17) & 0x7FFF) + x = x ^ (x << 5) + r.s = x + return x & 0x7FFFFFFF +} + +# [0, 1), in steps of 1/65536 so it is exact in any float +export function rng_float(r: Rng) -> float { + return float(rng_next(r) & 0xFFFF) / 65536.0 +} + +# a whole number in [lo, hi]. Not rng_range: that name is the global generator's (Random.range) +export function rng_between(r: Rng, lo: int, hi: int) -> int { + if hi <= lo { return lo } + return lo + rng_next(r) % (hi - lo + 1) +} + +# a float in [lo, hi) +export function rng_span(r: Rng, lo: float, hi: float) -> float { + return lo + (hi - lo) * rng_float(r) +} diff --git a/packages/ludic.base/save.ludic b/packages/ludic.base/save.ludic new file mode 100644 index 00000000..2510ff07 --- /dev/null +++ b/packages/ludic.base/save.ludic @@ -0,0 +1,47 @@ +# ludic.base/save.ludic - the save is a tree, and each system writes its own section under its +# own key with its own version: {"fishing": {"v": 3, ...}}. By key, never by position; a missing +# key is a reset. The member "v" is the section's and a system may not use it. +export property SaveNode { + found: bool = false + version: int = 0 + data: Val = null +} + +export function save_tree() -> Val { return Value.object() } + +# writes v (an object, or anything else, which is kept under "data") at root[key] +export function save_section(root: Val, key: string, version: int, v: Val) -> void { + let sec = Value.object() + Value.put(sec, "v", Value.int(version)) + if v == null { + Value.put(root, key, sec) + return + } + if Value.kind(v) == 6 { + for i in 0 .. Value.count(v) { + let k = Value.key_at(v, i) + if k != "v" { Value.put(sec, k, Value.at(v, i)) } + } + } else { + Value.put(sec, "data", v) + } + Value.put(root, key, sec) +} + +# the section at root[key]: found false (and an empty object) when there is none +export function load_section(root: Val, key: string) -> SaveNode { + let n = new SaveNode + n.data = Value.object() + if root == null { return n } + if Value.kind(root) != 6 or Value.has(root, key) == 0 { return n } + let sec = Value.get(root, key) + if Value.kind(sec) != 6 { return n } + n.found = true + n.version = sv_int(sec, "v", 0) + n.data = sec + if Value.count(sec) == 2 and Value.has(sec, "data") != 0 { n.data = Value.get(sec, "data") } + return n +} + +export function save_encode(root: Val) -> string { return Json.encode(root) } +export function save_decode(text: string) -> Val { return Json.parse(text) } diff --git a/packages/ludic.base/save_fields.ludic b/packages/ludic.base/save_fields.ludic new file mode 100644 index 00000000..1621b231 --- /dev/null +++ b/packages/ludic.base/save_fields.ludic @@ -0,0 +1,60 @@ +# ludic.base/save_fields.ludic - reading a field with a fallback, and writing one. A float is kept +# as thousandths in an int (rounded), so a value read back and written again is the same value. +function sv_has(v: Val, key: string) -> bool { + if v == null { return false } + if Value.kind(v) != 6 { return false } + return Value.has(v, key) != 0 +} + +export function sv_int(v: Val, key: string, fallback: int) -> int { + if not sv_has(v, key) { return fallback } + return Value.as_int(Value.get(v, key)) +} + +export function sv_float(v: Val, key: string, fallback: float) -> float { + if not sv_has(v, key) { return fallback } + let f = Value.get(v, key) + if Value.kind(f) == 7 { return Value.as_float(f) } + return float(Value.as_int(f)) / 1000.0 +} + +export function sv_bool(v: Val, key: string, fallback: bool) -> bool { + if not sv_has(v, key) { return fallback } + return Value.as_int(Value.get(v, key)) != 0 +} + +export function sv_str(v: Val, key: string, fallback: string) -> string { + if not sv_has(v, key) { return fallback } + let s = Value.get(v, key) + if Value.kind(s) != 4 { return fallback } + return Value.as_str(s) +} + +# a list of ints; a missing key or a non-list is the empty list +export function sv_ints(v: Val, key: string) -> []int { + let out = new []int + if not sv_has(v, key) { return out } + let l = Value.get(v, key) + for i in 0 .. Value.count(l) { push(out, Value.as_int(Value.at(l, i))) } + return out +} + +export function sv_put_int(v: Val, key: string, n: int) -> void { Value.put(v, key, Value.int(n)) } + +export function sv_put_float(v: Val, key: string, f: float) -> void { + Value.put(v, key, Value.int(int(Math.floor(f * 1000.0 + 0.5)))) +} + +export function sv_put_bool(v: Val, key: string, b: bool) -> void { + var n = 0 + if b { n = 1 } + Value.put(v, key, Value.bool(n)) +} + +export function sv_put_str(v: Val, key: string, s: string) -> void { Value.put(v, key, Value.str(s)) } + +export function sv_put_ints(v: Val, key: string, xs: []int) -> void { + let l = Value.list() + for i in 0 .. len(xs) { Value.add(l, Value.int(xs[i])) } + Value.put(v, key, l) +} diff --git a/packages/ludic.base/system.ludic b/packages/ludic.base/system.ludic new file mode 100644 index 00000000..3d095c45 --- /dev/null +++ b/packages/ludic.base/system.ludic @@ -0,0 +1,80 @@ +# ludic.base/system.ludic - a system is a record of what it does at each verb, and the runner +# calls them: init, reset, save and load in the order added, tick phase by phase. A null +# function is a verb the system does not have. The game's list is the order co-op's dice need. +export property System { + key: string = "" + phase: int = 2 + version: int = 1 + init: fn() -> void = null + reset: fn() -> void = null + tick: fn(Tick) -> void = null + save: fn() -> Val = null + load: fn(Val, int) -> void = null +} + +var cs_list: []System = null + +function cs_all() -> []System { + if cs_list == null { cs_list = new []System } + return cs_list +} + +export function system_new(key: string, phase: int) -> System { + let s = new System + s.key = key + s.phase = phase + return s +} + +# a key is a save section's name, so two systems may not share one +export function core_add(s: System) -> void { + let all = cs_all() + for i in 0 .. len(all) { + if all[i].key == s.key { + print(`ludic.base: two systems are keyed "{s.key}"`) + exit(1) + } + } + push(all, s) +} + +export function core_clear() -> void { cs_list = new []System } +export function core_count() -> int { return len(cs_all()) } + +export function core_init_all() -> void { + let all = cs_all() + for i in 0 .. len(all) { if all[i].init != null { all[i].init() } } +} + +export function core_reset_all() -> void { + let all = cs_all() + for i in 0 .. len(all) { if all[i].reset != null { all[i].reset() } } +} + +export function core_tick_all(t: Tick) -> void { + let all = cs_all() + for ph in 0 .. PH_COUNT { + for i in 0 .. len(all) { + if all[i].phase == ph and all[i].tick != null { all[i].tick(t) } + } + } +} + +# every system that saves, under its key with its version +export function core_save_all() -> Val { + let root = save_tree() + let all = cs_all() + for i in 0 .. len(all) { + if all[i].save != null { save_section(root, all[i].key, all[i].version, all[i].save()) } + } + return root +} + +# each system its own section; one with no section (or no load) is reset instead +export function core_load_all(root: Val) -> void { + let all = cs_all() + for i in 0 .. len(all) { + let n = load_section(root, all[i].key) + if n.found and all[i].load != null { all[i].load(n.data, n.version) } else if all[i].reset != null { all[i].reset() } + } +} diff --git a/packages/ludic.base/tests/queue_test.ludic b/packages/ludic.base/tests/queue_test.ludic new file mode 100644 index 00000000..20d2f82e --- /dev/null +++ b/packages/ludic.base/tests/queue_test.ludic @@ -0,0 +1,63 @@ +# queue_test.ludic - Queue: first in first out, drain empties, undrained queues are named. +# A generic call is not resolved inside a `test` body yet, so each case is a function. +import "ludic.base" +program QueueTest { + numbers float + property Caught { species: int = 0, weight: float = 0.0 } + + function caught(sp: int, w: float) -> Caught { + let c = new Caught + c.species = sp + c.weight = w + return c + } + + function fifo_case() -> void { + let q: Queue = q_new("ints") + q_push(q, 3) + q_push(q, 1) + q_push(q, 2) + expect_eq(q_len(q), 3) + let xs = q_drain(q) + expect_eq(len(xs), 3) + expect_eq(xs[0], 3) + expect_eq(xs[1], 1) + expect_eq(xs[2], 2) + expect_eq(q_len(q), 0) + expect_eq(len(q_drain(q)), 0) + } + + function record_case() -> void { + let q: Queue = q_new("caught") + q_push(q, caught(2, 1.5)) + q_push(q, caught(4, 0.25)) + let cs = q_drain(q) + expect_eq(cs[1].species, 4) + expect(cs[0].weight == 1.5) + } + + function clear_case() -> void { + let q: Queue = q_new("words") + q_push(q, "a") + q_clear(q) + expect_eq(q_len(q), 0) + } + + function undrained_case() -> void { + let a: Queue = q_new("left_behind") + let b: Queue = q_new("read") + q_push(a, 1) + q_push(b, 2) + q_drain(b) + let names = core_undrained() + expect_eq(len(names), 1) + expect(names[0] == "left_behind") + q_drain(a) + expect_eq(len(core_undrained()), 0) + } + + test "a queue drains in the order it was pushed" { fifo_case() } + test "a queue holds records" { record_case() } + test "clear drops what is waiting" { clear_case() } + test "the undrained queues are named, and a drain clears the report" { undrained_case() } +} diff --git a/packages/ludic.base/tests/rng_test.ludic b/packages/ludic.base/tests/rng_test.ludic new file mode 100644 index 00000000..305adbc1 --- /dev/null +++ b/packages/ludic.base/tests/rng_test.ludic @@ -0,0 +1,71 @@ +# rng_test.ludic - an Rng is a stream of its own: the same seed rolls the same, two streams do +# not disturb each other or the global generator, and the ranges hold +import "ludic.base" +program RngTest { + numbers float + + # a stream seeded with seed that has already rolled n times + function rng_after(seed: int, n: int) -> Rng { + let r = rng_new(seed) + for i in 0 .. n { rng_next(r) } + return r + } + + test "the same seed rolls the same sequence" { + let a = rng_new(42) + let b = rng_new(42) + for i in 0 .. 100 { expect_eq(rng_next(a), rng_next(b)) } + } + + test "different seeds differ" { + let a = rng_new(1) + let b = rng_new(2) + var same = 0 + for i in 0 .. 50 { if rng_next(a) == rng_next(b) { same += 1 } } + expect(same < 5) + } + + test "drawing from one stream does not move another" { + let a = rng_new(7) + let b = rng_new(7) + let noise = rng_new(99) + for i in 0 .. 20 { + rng_next(noise) + expect_eq(rng_next(a), rng_next(b)) + } + } + + test "reseeding starts the sequence again" { + let r = rng_new(5) + let first = rng_next(r) + rng_next(r) + rng_seed(r, 5) + expect_eq(rng_next(r), first) + } + + test "the ranges hold and reach both ends" { + let r = rng_new(123) + var lo_seen = false + var hi_seen = false + for i in 0 .. 2000 { + let n = rng_between(r, 3, 6) + expect(n >= 3 and n <= 6) + if n == 3 { lo_seen = true } + if n == 6 { hi_seen = true } + let f = rng_float(r) + expect(f >= 0.0 and f < 1.0) + let s = rng_span(r, -2.0, 2.0) + expect(s >= -2.0 and s < 2.0) + expect(rng_next(r) >= 0) + } + expect(lo_seen and hi_seen) + expect_eq(rng_between(r, 4, 4), 4) + } + + test "a known seed gives a known roll on every machine" { + let r = rng_new(1) + let x = rng_next(r) + expect_eq(x, 1822124854) + expect_eq(rng_between(r, 1, 6), rng_between(rng_after(1, 1), 1, 6)) + } +} diff --git a/packages/ludic.base/tests/route_test.ludic b/packages/ludic.base/tests/route_test.ludic new file mode 100644 index 00000000..48a0b0b9 --- /dev/null +++ b/packages/ludic.base/tests/route_test.ludic @@ -0,0 +1,68 @@ +# route_test.ludic - a game made of two mechanics that do not know each other: the route turns +# fishing's facts into the pack's verbs, and the game binds fishing's port and orders them +import "toys/fishing" +import "toys/pack" +import "ludic.base" +program RouteTest { + numbers float + + function water_everywhere_but_land(x: float, z: float) -> bool { return x < 100.0 } + + # the route: a landed fish goes into the pack + function route_fishing_pack(t: Tick) -> void { + let fish = q_drain(caught) + for i in 0 .. len(fish) { pack_add(fish[i].species, 1) } + } + + function game_start() -> void { + core_clear() + let w = new FishingWorld + w.is_water = fn water_everywhere_but_land + fishing_bind(w) + core_add(fishing_system()) + let r = system_new("route.fishing_pack", PH_RESOLVE) + r.tick = fn route_fishing_pack + core_add(r) + core_add(pack_system()) + core_reset_all() + } + + function pack_total() -> int { return pack_count(0) + pack_count(1) + pack_count(2) } + + test "three casts on water land three fish in the pack, one on land lands nothing" { + game_start() + expect(fishing_cast(1.0, 2.0)) + expect(fishing_cast(3.0, 2.0)) + expect(fishing_cast(5.0, 2.0)) + expect(not fishing_cast(500.0, 2.0)) + core_tick_all(tick_new(1.0 / 60.0, 0, 0.0)) + expect_eq(pack_total(), 3) + expect_eq(len(core_undrained()), 0) + } + + test "the pack saves its own section and the fishing, which saves nothing, is reset" { + game_start() + fishing_cast(1.0, 1.0) + core_tick_all(tick_new(1.0 / 60.0, 0, 0.0)) + let text = save_encode(core_save_all()) + let tree = save_decode(text) + expect_eq(Value.count(tree), 1) + expect_eq(load_section(tree, "pack").version, 1) + expect(not load_section(tree, "fishing").found) + core_reset_all() + expect_eq(pack_total(), 0) + core_load_all(save_decode(text)) + expect_eq(pack_total(), 1) + } + + test "the same seed lands the same fish" { + game_start() + fishing_cast(1.0, 1.0) + core_tick_all(tick_new(1.0 / 60.0, 0, 0.0)) + let first = save_encode(core_save_all()) + game_start() + fishing_cast(1.0, 1.0) + core_tick_all(tick_new(1.0 / 60.0, 0, 0.0)) + expect(save_encode(core_save_all()) == first) + } +} diff --git a/packages/ludic.base/tests/save_test.ludic b/packages/ludic.base/tests/save_test.ludic new file mode 100644 index 00000000..cc9fab34 --- /dev/null +++ b/packages/ludic.base/tests/save_test.ludic @@ -0,0 +1,84 @@ +# save_test.ludic - the save tree: a section per key with its version, typed fields with +# fallbacks, floats as thousandths, and a round trip through JSON +import "ludic.base" +program SaveTest { + numbers float + + test "a section keeps its version and its fields" { + let root = save_tree() + let v = Value.object() + sv_put_int(v, "count", 7) + sv_put_float(v, "weight", 1.2345) + sv_put_bool(v, "lit", true) + sv_put_str(v, "name", "Crater Lake") + let xs = new []int + push(xs, 4) + push(xs, -2) + sv_put_ints(v, "pack", xs) + save_section(root, "fishing", 3, v) + let n = load_section(root, "fishing") + expect(n.found) + expect_eq(n.version, 3) + expect_eq(sv_int(n.data, "count", 0), 7) + expect(sv_float(n.data, "weight", 0.0) == 1.235) + expect(sv_bool(n.data, "lit", false)) + expect(sv_str(n.data, "name", "") == "Crater Lake") + let back = sv_ints(n.data, "pack") + expect_eq(len(back), 2) + expect_eq(back[1], -2) + } + + test "a missing section or field falls back" { + let root = save_tree() + let n = load_section(root, "weather") + expect(not n.found) + expect_eq(n.version, 0) + expect_eq(sv_int(n.data, "count", 5), 5) + expect(sv_float(n.data, "w", 2.5) == 2.5) + expect(sv_bool(n.data, "b", true)) + expect(sv_str(n.data, "s", "x") == "x") + expect_eq(len(sv_ints(n.data, "l")), 0) + expect(not load_section(Value.int(3), "weather").found) + } + + test "a section survives JSON, and each key is its own" { + let root = save_tree() + let a = Value.object() + sv_put_int(a, "n", 1) + let b = Value.object() + sv_put_int(b, "n", 2) + save_section(root, "clock", 1, a) + save_section(root, "needs", 4, b) + let text = save_encode(root) + let again = save_decode(text) + let c = load_section(again, "clock") + let d = load_section(again, "needs") + expect_eq(sv_int(c.data, "n", 0), 1) + expect_eq(sv_int(d.data, "n", 0), 2) + expect_eq(d.version, 4) + expect(save_encode(again) == text) + } + + test "a float read and written again is the same thousandths" { + let v = Value.object() + sv_put_float(v, "f", 0.1) + let once = sv_float(v, "f", 0.0) + sv_put_float(v, "f", once) + expect_eq(Value.as_int(Value.get(v, "f")), 100) + sv_put_float(v, "g", -2.5) + expect(sv_float(v, "g", 0.0) == -2.5) + Value.put(v, "h", Value.float(0.75)) + expect(sv_float(v, "h", 0.0) == 0.75) + } + + test "a section that is not an object rides under data" { + let root = save_tree() + let l = Value.list() + Value.add(l, Value.int(9)) + save_section(root, "log", 2, l) + let n = load_section(root, "log") + expect_eq(n.version, 2) + expect_eq(Value.kind(n.data), 5) + expect_eq(Value.as_int(Value.at(n.data, 0)), 9) + } +} diff --git a/packages/ludic.base/tests/system_test.ludic b/packages/ludic.base/tests/system_test.ludic new file mode 100644 index 00000000..5ffeffd2 --- /dev/null +++ b/packages/ludic.base/tests/system_test.ludic @@ -0,0 +1,105 @@ +# system_test.ludic - the runner: init, reset, save and load in the order added, tick phase by +# phase, each system its own save section, and a missing section is a reset +import "ludic.base" +program SystemTest { + numbers float + var trail: string = "" + var apples: int = 0 + var hours: float = 0.0 + + function draw_tick(t: Tick) -> void { trail = trail + "d" } + function think_tick(t: Tick) -> void { trail = trail + "t" } + function input_tick(t: Tick) -> void { trail = trail + "i" } + function commit_tick(t: Tick) -> void { trail = trail + "c" } + function apples_init() -> void { trail = trail + "A" } + function apples_reset() -> void { apples = 0 } + function apples_tick(t: Tick) -> void { apples += 1 } + function apples_save() -> Val { + let v = Value.object() + sv_put_int(v, "n", apples) + return v + } + function apples_load(v: Val, version: int) -> void { apples = sv_int(v, "n", 0) * version } + function clock_reset() -> void { hours = 6.0 } + function clock_tick(t: Tick) -> void { hours = hours + t.hours } + function clock_save() -> Val { + let v = Value.object() + sv_put_float(v, "h", hours) + return v + } + function clock_load(v: Val, version: int) -> void { hours = sv_float(v, "h", 6.0) } + + function sys(key: string, phase: int, tick: fn(Tick) -> void) -> System { + let s = system_new(key, phase) + s.tick = tick + return s + } + + function setup() -> void { + core_clear() + trail = "" + core_add(sys("draw", PH_PRESENT, fn draw_tick)) + core_add(sys("think", PH_SIMULATE, fn think_tick)) + let a = sys("apples", PH_SIMULATE, fn apples_tick) + a.init = fn apples_init + a.reset = fn apples_reset + a.save = fn apples_save + a.load = fn apples_load + a.version = 2 + core_add(a) + core_add(sys("input", PH_INPUT, fn input_tick)) + core_add(sys("commit", PH_COMMIT, fn commit_tick)) + let c = sys("clock", PH_SIMULATE, fn clock_tick) + c.reset = fn clock_reset + c.save = fn clock_save + c.load = fn clock_load + core_add(c) + } + + test "tick runs phase by phase, in the order added within a phase" { + setup() + core_tick_all(tick_new(1.0 / 60.0, 0, 0.0)) + expect(trail == "itcd") + expect_eq(apples, 1) + } + + test "init and reset run in the order added and skip a null verb" { + setup() + core_init_all() + expect(trail == "A") + apples = 9 + core_reset_all() + expect_eq(apples, 0) + expect(hours == 6.0) + expect_eq(core_count(), 6) + } + + test "save writes each system under its key with its version, and load reads it back" { + setup() + core_reset_all() + apples = 4 + core_tick_all(tick_new(0.016, 1, 0.5)) + let root = core_save_all() + expect_eq(Value.count(root), 2) + expect_eq(load_section(root, "apples").version, 2) + expect_eq(load_section(root, "clock").version, 1) + let text = save_encode(root) + core_reset_all() + core_load_all(save_decode(text)) + expect_eq(apples, 10) + expect(hours == 6.5) + } + + test "a system with no section in the save is reset" { + setup() + apples = 3 + hours = 11.0 + let root = save_tree() + let v = Value.object() + sv_put_float(v, "h", 20.25) + save_section(root, "clock", 1, v) + core_load_all(root) + expect_eq(apples, 0) + expect(hours == 20.25) + } +} diff --git a/packages/ludic.base/tests/toys/fishing/index.ludic b/packages/ludic.base/tests/toys/fishing/index.ludic new file mode 100644 index 00000000..5871c3ef --- /dev/null +++ b/packages/ludic.base/tests/toys/fishing/index.ludic @@ -0,0 +1,36 @@ +# a toy mechanic: casting a line. It uses ludic.base and nothing else. +module toy_fishing +numbers float +import "ludic.base" +export property Caught { species: int = 0, weight: float = 0.0 } +export property FishingWorld { is_water: fn(float, float) -> bool = null } # its port +var world: FishingWorld = null +var dice: Rng = null +var casts: int = 0 +export var caught: Queue = null # its facts +export function fishing_bind(w: FishingWorld) -> void { world = w } +export function fishing_cast(x: float, z: float) -> bool { # its verb + if not world.is_water(x, z) { return false } + casts += 1 + return true +} +function fishing_reset() -> void { + casts = 0 + dice = rng_new(7) + caught = q_new("fishing.caught") +} +function fishing_tick(t: Tick) -> void { + while casts > 0 { + let c = new Caught + c.species = rng_between(dice, 0, 2) + c.weight = 0.5 + rng_float(dice) + q_push(caught, c) + casts -= 1 + } +} +export function fishing_system() -> System { + let s = system_new("fishing", PH_SIMULATE) + s.reset = fn fishing_reset + s.tick = fn fishing_tick + return s +} diff --git a/packages/ludic.base/tests/toys/pack/index.ludic b/packages/ludic.base/tests/toys/pack/index.ludic new file mode 100644 index 00000000..d03597e8 --- /dev/null +++ b/packages/ludic.base/tests/toys/pack/index.ludic @@ -0,0 +1,28 @@ +# a toy mechanic: a pack of counts by kind, saved in its own section +module toy_pack +numbers float +import "ludic.base" +var counts: []int = null +export function pack_add(kind: int, n: int) -> void { counts[kind] = counts[kind] + n } +export function pack_count(kind: int) -> int { return counts[kind] } +function pack_reset() -> void { + counts = new []int + for i in 0 .. 3 { push(counts, 0) } +} +function pack_save() -> Val { + let v = Value.object() + sv_put_ints(v, "counts", counts) + return v +} +function pack_load(v: Val, version: int) -> void { + pack_reset() + let xs = sv_ints(v, "counts") + for i in 0 .. len(xs) { if i < len(counts) { counts[i] = xs[i] } } +} +export function pack_system() -> System { + let s = system_new("pack", PH_COMMIT) + s.reset = fn pack_reset + s.save = fn pack_save + s.load = fn pack_load + return s +} diff --git a/packages/ludic.base/tick.ludic b/packages/ludic.base/tick.ludic new file mode 100644 index 00000000..dfb1f039 --- /dev/null +++ b/packages/ludic.base/tick.ludic @@ -0,0 +1,23 @@ +# ludic.base/tick.ludic - what one frame hands a system, and the phases that order systems. A +# system names its phase; the game's list orders systems within one. Nobody says "after X". +export property Tick { + dt: float = 0.0 + frame: int = 0 + hours: float = 0.0 +} + +export const PH_INPUT: int = 0 +export const PH_SENSE: int = 1 +export const PH_SIMULATE: int = 2 +export const PH_RESOLVE: int = 3 +export const PH_COMMIT: int = 4 +export const PH_PRESENT: int = 5 +export const PH_COUNT: int = 6 + +export function tick_new(dt: float, frame: int, hours: float) -> Tick { + let t = new Tick + t.dt = dt + t.frame = frame + t.hours = hours + return t +}