feat(ludic.base): the vocabulary mechanic packages share - Tick, phases, Queue<T>, rng streams, the save tree, the system runner
A mechanic package depends on ludic.base and nothing else: ports (records of function values for now) for questions, queues for facts, verbs for changes, phases for order and its own versioned save section. The runner inits, resets, saves and loads systems in the order added and ticks them phase by phase; a missing save section is a reset. Tests for each piece and a worked route between two toy mechanics live under tests/. The old ludic.core (engine ECS components) is used by examples/library and stays. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
ec9a650e65
commit
dd6a449921
16 changed files with 928 additions and 0 deletions
146
packages/ludic.base/README.md
Normal file
146
packages/ludic.base/README.md
Normal file
|
|
@ -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<T>` 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<T>`, `q_new<T>(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<Caught> = 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 <dir>` 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.
|
||||
10
packages/ludic.base/index.ludic
Normal file
10
packages/ludic.base/index.ludic
Normal file
|
|
@ -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"
|
||||
5
packages/ludic.base/package.ludic
Normal file
5
packages/ludic.base/package.ludic
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
# ludic.base - the vocabulary every mechanic package shares and the only thing one may use:
|
||||
# Tick, phases, Queue<T>, rng streams, the save tree and the system runner. See README.md.
|
||||
package "ludic.base"
|
||||
version "0.1.0"
|
||||
kind source
|
||||
58
packages/ludic.base/queue.ludic
Normal file
58
packages/ludic.base/queue.ludic
Normal file
|
|
@ -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<T> {
|
||||
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<T>(name: string) -> Queue<T> {
|
||||
qb_init()
|
||||
let q = new Queue<T>
|
||||
q.items = new []T
|
||||
q.id = len(qb_names)
|
||||
push(qb_names, name)
|
||||
push(qb_pending, 0)
|
||||
return q
|
||||
}
|
||||
|
||||
export function q_push<T>(q: Queue<T>, 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<T>(q: Queue<T>) -> []T {
|
||||
let out = q.items
|
||||
q.items = new []T
|
||||
qb_pending[q.id] = 0
|
||||
return out
|
||||
}
|
||||
|
||||
export function q_len<T>(q: Queue<T>) -> int { return len(q.items) }
|
||||
|
||||
export function q_clear<T>(q: Queue<T>) -> 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
|
||||
}
|
||||
44
packages/ludic.base/rng.ludic
Normal file
44
packages/ludic.base/rng.ludic
Normal file
|
|
@ -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)
|
||||
}
|
||||
47
packages/ludic.base/save.ludic
Normal file
47
packages/ludic.base/save.ludic
Normal file
|
|
@ -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) }
|
||||
60
packages/ludic.base/save_fields.ludic
Normal file
60
packages/ludic.base/save_fields.ludic
Normal file
|
|
@ -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)
|
||||
}
|
||||
80
packages/ludic.base/system.ludic
Normal file
80
packages/ludic.base/system.ludic
Normal file
|
|
@ -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() }
|
||||
}
|
||||
}
|
||||
63
packages/ludic.base/tests/queue_test.ludic
Normal file
63
packages/ludic.base/tests/queue_test.ludic
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
# queue_test.ludic - Queue<T>: 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<int> = 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<Caught> = 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<string> = q_new("words")
|
||||
q_push(q, "a")
|
||||
q_clear(q)
|
||||
expect_eq(q_len(q), 0)
|
||||
}
|
||||
|
||||
function undrained_case() -> void {
|
||||
let a: Queue<int> = q_new("left_behind")
|
||||
let b: Queue<int> = 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() }
|
||||
}
|
||||
71
packages/ludic.base/tests/rng_test.ludic
Normal file
71
packages/ludic.base/tests/rng_test.ludic
Normal file
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
68
packages/ludic.base/tests/route_test.ludic
Normal file
68
packages/ludic.base/tests/route_test.ludic
Normal file
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
84
packages/ludic.base/tests/save_test.ludic
Normal file
84
packages/ludic.base/tests/save_test.ludic
Normal file
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
105
packages/ludic.base/tests/system_test.ludic
Normal file
105
packages/ludic.base/tests/system_test.ludic
Normal file
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
36
packages/ludic.base/tests/toys/fishing/index.ludic
Normal file
36
packages/ludic.base/tests/toys/fishing/index.ludic
Normal file
|
|
@ -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<Caught> = 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
|
||||
}
|
||||
28
packages/ludic.base/tests/toys/pack/index.ludic
Normal file
28
packages/ludic.base/tests/toys/pack/index.ludic
Normal file
|
|
@ -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
|
||||
}
|
||||
23
packages/ludic.base/tick.ludic
Normal file
23
packages/ludic.base/tick.ludic
Normal file
|
|
@ -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
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue