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
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
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue