feat(controllers): #59 RPG systems — ludic.rpg 7-module suite (base + extensible)
Some checks failed
commit-lint / conventional-commits (push) Waiting to run
docs / build-and-deploy (push) Waiting to run
bootstrap / cfree-fixpoint (push) Successful in 21s
ci / build-and-test (push) Has been cancelled

Seven independently-usable modules on the six-lever contract with name-keyed
data registries (zero-code content): movement (grid/free/tween, 4/8-axis, veto +
interact), inventory + equipment (stat bonuses via the gameplay modifier stack),
crafting, event-driven quests + flags, an Ink/Yarn dialog graph, Sokoban
pushables + a switch/plate/gate signal graph, and over-time status effects
through the Combat pipeline. Reuses ludic.gameplay Stats/Combat. Deterministic.
21-check example, in the suite (103 passed, 0 failed).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Orkun ÇAKILKAYA 2026-09-01 17:25:56 +03:00
parent 9ce69d23e3
commit 46c275c4c1
11 changed files with 1050 additions and 0 deletions

View file

@ -0,0 +1,3 @@
bump: minor
type: feat
Builtin RPG systems suite (#59) — the source package **ludic.rpg**, seven independently-usable modules on the six-lever contract, with name-keyed data registries so a game or mod adds content with zero code. **A Movement** — one `Mover` with a `mode` selector (grid / free / grid-tween) and 4/8-axis, tilemap walkability, and `cancellable MoveRequested` (locked doors/ice) / `TileEntered` (encounters) / `Interacted` (the action-button raycast). **B Inventory** — a name-keyed item registry, per-owner counts, gold, `ItemUse` veto, and `Equipment` whose bonuses flow through the gameplay Stats modifier stack. **C Crafting** — a data-driven recipe + ingredient registry; `Craft.can`/`Craft.make` consume from the inventory. **D Quests** — quests + objectives whose progress is driven by `Quest.notify` (route any gameplay signal in), auto-completing when met, plus a global flag store for branching. **E Dialog** — an Ink/Yarn-style graph registry (nodes + choices) with a per-speaker `Dialog` component and `cancellable DialogChoice` for skill-check gating. **F Puzzles** — Sokoban `Pushable` + a switch / pressure-plate / gate signal graph (logic puzzles with no code). **G Status** — over-time poison/regen effects routed through the shared Combat pipeline. Everything is integer-deterministic, so save/load (world_save) and rollback hold. Example: `examples/games/rpg_demo.ludic` (21 self-checks across all seven modules).

View file

@ -0,0 +1,158 @@
# rpg_demo.ludic — the ludic.rpg suite (#59), all seven modules exercised headlessly
# and deterministically: grid movement (+ wall block, MoveRequested veto, interact),
# inventory + equipment stat bonus, crafting, event-driven quests, a dialog graph,
# Sokoban pushables + a pressure-plate/gate signal graph, and a poison status effect.
#
# A full run prints: 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 (21 checks)
#
# Build (from the repo root):
# LUDIC_MODULES=packages ludicc --headless examples/games/rpg_demo.ludic
program RpgDemo {
import "ludic.rpg/mover.ludic"
import "ludic.rpg/inventory.ludic"
import "ludic.rpg/crafting.ludic"
import "ludic.rpg/quests.ludic"
import "ludic.rpg/dialog.ludic"
import "ludic.rpg/puzzles.ludic"
import "ludic.rpg/status.ludic"
property Position { x: int = 0, y: int = 0 }
property Body {
vx: fixed = 0.0, vy: fixed = 0.0, gravity: fixed = 0.0, max_fall: fixed = 0.0,
rx: fixed = 0.0, ry: fixed = 0.0, policy: int = 1,
on_ground: int = 0, hit_wall: int = 0, hit_ceiling: int = 0
}
property Solids { tile: int = 0, wall: int = 0, oneway: int = 0 }
model Hero { Position, Body, Mover, Inventory, Equipment, Stats, Dialog }
model Npc { Position }
model Block { Position, Pushable }
model Plate { Position, Pressure }
model Door { Position, Gate }
model Cfg { Solids }
@OnSpawn(Hero) handler HI { }
@OnSpawn(Npc) handler NI { }
@OnSpawn(Block) handler BI { }
@OnSpawn(Plate) handler PLI { }
@OnSpawn(Door) handler DOI { }
@OnSpawn(Cfg) handler CI { }
# a locked-door rule: veto a rightward grid step while `locked` is set (lever 4).
var locked: int = 0
@On(MoveRequested) handler LockDoor { if (locked == 1) and (dx == 1) { cancel } }
var entered: int = 0
@On(TileEntered) handler CountEnter { entered = entered + 1 }
var talked: int = 0
@On(Interacted) handler OnTalk { if target >= 0 { talked = talked + 1 } }
var quest_done: int = 0
@On(QuestCompleted) handler OnQuestDone { quest_done = quest_done + 1 }
function tick_n(n: int) -> void { var i = 0; while i < n { tick_fixed(); i = i + 1 } }
function bi(b: bool) -> int { if b { return 1 }; return 0 }
function px(e: int) -> int { let P = World.prop_id("Position"); return World.get(e, P, World.field_id(P, "x")) }
function py(e: int) -> int { let P = World.prop_id("Position"); return World.get(e, P, World.field_id(P, "y")) }
function hp(e: int) -> int { let P = World.prop_id("Stats"); return World.get(e, P, World.field_id(P, "hp")) }
entry {
let PM = World.prop_id("Mover")
# a 6x3 tile world; a wall glyph at cell (3,1)
Map.size(6, 3)
Map.row(0, "......")
Map.row(1, "...#..")
Map.row(2, "......")
spawn Cfg { Solids { tile: 16, wall: '#', oneway: 0 } }
spawn Hero { Position { x: 16, y: 16 }, Mover { mode: 0, step: 16, move_cd: 0, axes: 4 },
Stats { hp: 20, max_hp: 20, atk: 5, def: 0 } }
let h = World.query_next(PM, 0)
# ================= Module A: grid movement =================
# move down into the open cell (16,32)
Mover.move(h, 0, 1); tick_n(1); Mover.move(h, 0, 0); tick_n(1)
print(bi((px(h) == 16) and (py(h) == 32))) # 1 — stepped one cell down
print(bi(entered >= 1)) # 1 — TileEntered fired
# try to walk into the wall at cell (3,1)=(48,16): from (32,16) moving right blocked
World.set(h, World.prop_id("Position"), World.field_id(World.prop_id("Position"), "x"), 32)
World.set(h, World.prop_id("Position"), World.field_id(World.prop_id("Position"), "y"), 16)
Mover.move(h, 1, 0); tick_n(1); Mover.move(h, 0, 0)
print(bi(px(h) == 32)) # 1 — wall blocked the step
# MoveRequested veto: a locked door forbids the rightward step (open cell now)
World.set(h, World.prop_id("Position"), World.field_id(World.prop_id("Position"), "y"), 32) # row 2 open
locked = 1
Mover.move(h, 1, 0); tick_n(1); Mover.move(h, 0, 0)
print(bi(px(h) == 32)) # 1 — vetoed, no move
locked = 0
Mover.move(h, 1, 0); tick_n(1); Mover.move(h, 0, 0)
print(bi(px(h) == 48)) # 1 — now allowed
# interact: an NPC one cell ahead (facing right after the last move)
spawn Npc { Position { x: 64, y: 32 } }
print(bi(Mover.interact(h) >= 0)) # 1 — found the NPC ahead
print(bi(talked == 1)) # 1 — Interacted fired
# ================= Module B: inventory + equipment =================
Item.def("herb", 99, 1, 2)
Item.def("sword", 1, 10, 50)
Inventory.add(h, "herb", 5)
print(bi(Inventory.count(h, "herb") == 5)) # 1
print(bi(Inventory.use(h, "herb") == 1)) # 1 — consumed one (now 4)
let atk0 = Stats.total(h, 1) # base atk 5
Inventory.equip(h, 0, 1, 0, 10) # weapon: +10 flat atk
print(bi(Stats.total(h, 1) == atk0 + 10)) # 1 — equipment bonus applies
Inventory.unequip(h, 0)
print(bi(Stats.total(h, 1) == atk0)) # 1 — removed on unequip
# ================= Module C: crafting =================
Craft.recipe("potion", "herb", 1) # 2 herb -> 1 (better) herb, toy recipe
Craft.ingredient("potion", "herb", 2)
Inventory.add(h, "herb", 2) # ensure >= 2
let before = Inventory.count(h, "herb")
print(bi(Craft.can(h, "potion") == 1)) # 1
print(bi(Craft.make(h, "potion") == 1)) # 1 — consumed 2, produced 1
print(bi(Inventory.count(h, "herb") == before - 1)) # 1 — net -2 + 1
# ================= Module D: quests =================
Quest.define("hunt")
Quest.objective("hunt", 0, 0, 3) # kill any x3
Quest.start("hunt")
Quest.notify(0, 0, 1); Quest.notify(0, 0, 1); Quest.notify(0, 0, 1)
print(bi(Quest.state_of("hunt") == 3)) # 1 — auto-completed
print(bi(quest_done == 1)) # 1 — QuestCompleted fired
# ================= Module E: dialog =================
let dn0 = Dialog.node("elder", "hello")
let dn1 = Dialog.node("elder", "farewell")
Dialog.choice(dn0, "greet", dn1)
Dialog.choice(dn1, "leave", 0 - 1)
Dialog.start(h, "elder")
print(bi(Dialog.line(h) == "hello")) # 1 — entry node text
Dialog.choose(h, 0)
print(bi(Dialog.line(h) == "farewell")) # 1 — advanced
# ================= Module F: puzzles =================
spawn Block { Position { x: 16, y: 0 }, Pushable { } } # cell (1,0), push to (2,0) — both open
let blk = World.query_next(World.prop_id("Pushable"), 0)
print(bi(Puzzle.push(16, 0, 1, 0, 16) == 1)) # 1 — pushed the block right
spawn Plate { Position { x: 200, y: 200 }, Pressure { step: 16 } }
let plate = World.query_next(World.prop_id("Pressure"), 0)
spawn Door { Position { x: 300, y: 300 }, Gate { } }
let door = World.query_next(World.prop_id("Gate"), 0)
Puzzle.link(door, plate)
tick_n(2)
let closed = Puzzle.is_open(door) # 0 — nothing on the plate
spawn Block { Position { x: 200, y: 200 }, Pushable { } } # weight on the plate
tick_n(2)
print(bi((closed == 0) and (Puzzle.is_open(door) == 1))) # 1 — plate -> gate opened
# ================= Module G: status =================
let hp0 = hp(h)
Status.apply(h, 0, 3, 2, 8) # poison: 3 dmg every 2 frames for 8 frames
tick_n(8)
print(bi(hp(h) < hp0)) # 1 — poison ticked damage
quit()
}
}

View file

@ -0,0 +1,83 @@
# crafting.ludic — RPG Module C: a data-driven crafting registry (#59).
#
# Recipes are content, not code: register with Craft.recipe + Craft.ingredient, so a
# game or mod adds crafting with zero code. Craft.make consumes ingredients from the
# ludic.rpg Inventory and yields the output; every step is a veto-able event.
import "ludic.rpg/inventory.ludic"
const CRAFT_MAX: int = 128
const CRAFT_ING_MAX: int = 512
var recipe_reg: Dict = null # recipe name -> id
var recipe_n: int = 0
var recipe_out: pointers = null # output item name
var recipe_outqty: words = null
var ing_n: int = 0
var ing_rid: words = null # owning recipe id
var ing_name: pointers = null # ingredient item name
var ing_qty: words = null
function craft_ensure() -> void {
if recipe_reg == null {
recipe_reg = Dict.new()
recipe_out = bytes(CRAFT_MAX * 8); recipe_outqty = words(CRAFT_MAX)
ing_rid = words(CRAFT_ING_MAX); ing_name = bytes(CRAFT_ING_MAX * 8); ing_qty = words(CRAFT_ING_MAX)
}
}
event cancellable CraftRequested { e: int = 0, recipe: int = 0 }
event CraftCompleted { e: int = 0, recipe: int = 0 }
# Craft.recipe(name, out_item, out_qty) — declare a recipe; returns its id.
@Namespace(Craft) function craft_recipe(name: pointer, out_item: pointer, out_qty: int) -> int {
craft_ensure()
if recipe_n >= CRAFT_MAX { return 0 - 1 }
let id = recipe_n
recipe_out[id] = out_item; recipe_outqty[id] = out_qty
recipe_n = recipe_n + 1
Dict.set(recipe_reg, name, id)
return id
}
@Namespace(Craft) function craft_id(name: pointer) -> int { craft_ensure(); return Dict.get_or(recipe_reg, name, 0 - 1) }
# Craft.ingredient(recipe_name, item, qty) — add an ingredient requirement.
@Namespace(Craft) function craft_ingredient(recipe_name: pointer, item: pointer, qty: int) -> void {
craft_ensure()
let rid = craft_id(recipe_name)
if rid < 0 { return }
if ing_n >= CRAFT_ING_MAX { return }
ing_rid[ing_n] = rid; ing_name[ing_n] = item; ing_qty[ing_n] = qty
ing_n = ing_n + 1
}
# Craft.can(e, recipe_name) — does the owner hold every ingredient?
@Namespace(Craft) function craft_can(e: int, recipe_name: pointer) -> int {
craft_ensure()
let rid = craft_id(recipe_name)
if rid < 0 { return 0 }
var i = 0
while i < ing_n {
if ing_rid[i] == rid {
if Inventory.count(e, ing_name[i]) < ing_qty[i] { return 0 }
}
i = i + 1
}
return 1
}
# Craft.make(e, recipe_name) — consume ingredients + produce output; 1 on success.
@Namespace(Craft) function craft_make(e: int, recipe_name: pointer) -> int {
craft_ensure()
let rid = craft_id(recipe_name)
if rid < 0 { return 0 }
if craft_can(e, recipe_name) == 0 { return 0 }
if emit CraftRequested(e: e, recipe: rid) != 0 { return 0 }
var i = 0
while i < ing_n {
if ing_rid[i] == rid { Inventory.remove(e, ing_name[i], ing_qty[i]) }
i = i + 1
}
Inventory.add(e, recipe_out[rid], recipe_outqty[rid])
emit CraftCompleted(e: e, recipe: rid)
return 1
}

View file

@ -0,0 +1,130 @@
# dialog.ludic — RPG Module E: a data-driven dialog graph (Ink/Yarn style) (#59).
#
# A tree is nodes (a line of text) + choices (label -> next node). Writers register
# trees with zero engine code; a `Dialog` component tracks the current node per
# speaker. Choices are veto-able (skill-check gating) via cancellable DialogChoice.
const DLG_TREE_MAX: int = 64
const DLG_NODE_MAX: int = 512
const DLG_CHOICE_MAX: int = 1024
var dlg_reg: Dict = null # tree name -> tree id
var dlg_tree_n: int = 0
var dlg_tree_start: words = null # first node id per tree
var dlg_node_n: int = 0
var dlg_node_tree: words = null
var dlg_node_text: pointers = null
var dlg_choice_n: int = 0
var dlg_choice_node: words = null # owning node
var dlg_choice_label: pointers = null
var dlg_choice_next: words = null # next node id, or -1 to end
function dlg_ensure() -> void {
if dlg_reg == null {
dlg_reg = Dict.new()
dlg_tree_start = words(DLG_TREE_MAX)
dlg_node_tree = words(DLG_NODE_MAX); dlg_node_text = bytes(DLG_NODE_MAX * 8)
dlg_choice_node = words(DLG_CHOICE_MAX); dlg_choice_label = bytes(DLG_CHOICE_MAX * 8); dlg_choice_next = words(DLG_CHOICE_MAX)
}
}
property Dialog { tree: int = 0, node: int = 0, active: int = 0 }
event DialogStarted { e: int = 0, tree: int = 0 }
event DialogLine { e: int = 0, node: int = 0 }
event cancellable DialogChoice { e: int = 0, node: int = 0, choice: int = 0 }
event DialogEnded { e: int = 0 }
# Dialog.tree(name) — register (or fetch) a tree id.
@Namespace(Dialog) function dialog_tree(name: pointer) -> int {
dlg_ensure()
let ex = Dict.get_or(dlg_reg, name, 0 - 1)
if ex >= 0 { return ex }
if dlg_tree_n >= DLG_TREE_MAX { return 0 - 1 }
let id = dlg_tree_n
dlg_tree_start[id] = 0 - 1
dlg_tree_n = dlg_tree_n + 1
Dict.set(dlg_reg, name, id)
return id
}
# Dialog.node(tree_name, text) — add a line node; the first node added to a tree is
# its entry point. Returns the node id.
@Namespace(Dialog) function dialog_node(tree_name: pointer, text: pointer) -> int {
dlg_ensure()
let tid = dialog_tree(tree_name)
if tid < 0 { return 0 - 1 }
if dlg_node_n >= DLG_NODE_MAX { return 0 - 1 }
let id = dlg_node_n
dlg_node_tree[id] = tid; dlg_node_text[id] = text
dlg_node_n = dlg_node_n + 1
if dlg_tree_start[tid] < 0 { dlg_tree_start[tid] = id }
return id
}
# Dialog.choice(node, label, next_node) — add a branch (next_node -1 ends).
@Namespace(Dialog) function dialog_choice(node: int, label: pointer, next_node: int) -> int {
dlg_ensure()
if dlg_choice_n >= DLG_CHOICE_MAX { return 0 - 1 }
let id = dlg_choice_n
dlg_choice_node[id] = node; dlg_choice_label[id] = label; dlg_choice_next[id] = next_node
dlg_choice_n = dlg_choice_n + 1
return id
}
function dlg_get(e: int, name: pointer) -> int {
let P = World.prop_id("Dialog"); if P < 0 { return 0 }
return World.get(e, P, World.field_id(P, name))
}
function dlg_set(e: int, name: pointer, v: int) -> void {
let P = World.prop_id("Dialog"); if P < 0 { return }
World.set(e, P, World.field_id(P, name), v)
}
# Dialog.start(e, tree_name) — open the tree on a speaker (DialogStarted + Line).
@Namespace(Dialog) function dialog_start(e: int, tree_name: pointer) -> void {
dlg_ensure()
let tid = dialog_tree(tree_name)
if tid < 0 { return }
dlg_set(e, "tree", tid)
dlg_set(e, "node", dlg_tree_start[tid])
dlg_set(e, "active", 1)
emit DialogStarted(e: e, tree: tid)
emit DialogLine(e: e, node: dlg_tree_start[tid])
}
# Dialog.line(e) — text of the current node (empty when inactive).
@Namespace(Dialog) function dialog_line(e: int) -> pointer {
if dlg_get(e, "active") == 0 { return "" }
return dlg_node_text[dlg_get(e, "node")]
}
# Dialog.count(e) — number of choices at the current node.
@Namespace(Dialog) function dialog_count(e: int) -> int {
dlg_ensure()
if dlg_get(e, "active") == 0 { return 0 }
let node = dlg_get(e, "node")
var n = 0; var i = 0
while i < dlg_choice_n { if dlg_choice_node[i] == node { n = n + 1 }; i = i + 1 }
return n
}
# Dialog.choose(e, i) — take the i-th choice (veto-able); advance or end.
@Namespace(Dialog) function dialog_choose(e: int, idx: int) -> int {
dlg_ensure()
if dlg_get(e, "active") == 0 { return 0 }
let node = dlg_get(e, "node")
var seen = 0; var i = 0; var pick = 0 - 1
while i < dlg_choice_n {
if dlg_choice_node[i] == node {
if seen == idx { pick = i; i = dlg_choice_n } else { seen = seen + 1 }
}
i = i + 1
}
if pick < 0 { return 0 }
if emit DialogChoice(e: e, node: node, choice: idx) != 0 { return 0 }
let nxt = dlg_choice_next[pick]
if nxt < 0 {
dlg_set(e, "active", 0)
emit DialogEnded(e: e)
} else {
dlg_set(e, "node", nxt)
emit DialogLine(e: e, node: nxt)
}
return 1
}

View file

@ -0,0 +1,130 @@
# inventory.ludic — RPG Module B: inventory + a name-keyed item registry (#59).
#
# Items are data (a registry entry, like Anim.clip), so a game/mod adds items with
# zero code. Per-owner counts live in one string-keyed Dict ("<entity>/<item>"), so
# the whole inventory is captured by world_save. Equipment routes its stat effects
# through the ludic.gameplay Stats modifier stack.
import "ludic.gameplay/stats.ludic"
const INV_MAX: int = 256
# ---- item registry --------------------------------------------------------
var item_reg: Dict = null
var item_n: int = 0
var item_stackmax: words = null
var item_weight: words = null
var item_value: words = null
function item_ensure() -> void {
if item_reg == null {
item_reg = Dict.new()
item_stackmax = words(INV_MAX); item_weight = words(INV_MAX); item_value = words(INV_MAX)
}
}
# Item.def(name, stack_max, weight, value) — register an item template. Returns id.
@Namespace(Item) function item_def(name: pointer, stack_max: int, weight: int, value: int) -> int {
item_ensure()
if item_n >= INV_MAX { return 0 - 1 }
let id = item_n
item_stackmax[id] = stack_max; item_weight[id] = weight; item_value[id] = value
item_n = item_n + 1
Dict.set(item_reg, name, id)
return id
}
@Namespace(Item) function item_id(name: pointer) -> int { item_ensure(); return Dict.get_or(item_reg, name, 0 - 1) }
@Namespace(Item) function item_value_of(name: pointer) -> int {
let id = item_id(name); if id < 0 { return 0 }
return item_value[id]
}
# ---- per-owner counts -----------------------------------------------------
property Inventory { gold: int = 0, weight_max: int = 0, slots: int = 0 }
var inv_counts: Dict = null
function inv_ensure() -> void { if inv_counts == null { inv_counts = Dict.new() } }
function inv_key(e: int, name: pointer) -> pointer { return `{e}/{name}` }
event ItemAdded { e: int = 0, item: int = 0, qty: int = 0 } # item = registry id
event ItemRemoved { e: int = 0, item: int = 0, qty: int = 0 }
event cancellable ItemUse { e: int = 0, item: int = 0 } # veto a use (e.g. cursed / requirement)
# Inventory.add(e, name, n) — add n of an item; emits ItemAdded.
@Namespace(Inventory) function inventory_add(e: int, name: pointer, n: int) -> void {
inv_ensure()
let k = inv_key(e, name)
Dict.set(inv_counts, k, Dict.get_or(inv_counts, k, 0) + n)
emit ItemAdded(e: e, item: item_id(name), qty: n)
}
# Inventory.count(e, name) — how many the owner holds.
@Namespace(Inventory) function inventory_count(e: int, name: pointer) -> int {
inv_ensure()
return Dict.get_or(inv_counts, inv_key(e, name), 0)
}
@Namespace(Inventory) function inventory_has(e: int, name: pointer) -> int {
if inventory_count(e, name) > 0 { return 1 }
return 0
}
# Inventory.remove(e, name, n) — remove up to n; returns 1 if it had enough.
@Namespace(Inventory) function inventory_remove(e: int, name: pointer, n: int) -> int {
inv_ensure()
let k = inv_key(e, name)
let have = Dict.get_or(inv_counts, k, 0)
if have < n { return 0 }
Dict.set(inv_counts, k, have - n)
emit ItemRemoved(e: e, item: item_id(name), qty: n)
return 1
}
# Inventory.use(e, name) — consume one item unless a listener vetoes ItemUse.
@Namespace(Inventory) function inventory_use(e: int, name: pointer) -> int {
if inventory_count(e, name) <= 0 { return 0 }
if emit ItemUse(e: e, item: item_id(name)) != 0 { return 0 }
return inventory_remove(e, name, 1)
}
# Inventory.gold / add_gold / spend_gold
@Namespace(Inventory) function inventory_gold(e: int) -> int {
let P = World.prop_id("Inventory")
if P < 0 { return 0 }
return World.get(e, P, World.field_id(P, "gold"))
}
@Namespace(Inventory) function inventory_add_gold(e: int, n: int) -> void {
let P = World.prop_id("Inventory")
if P < 0 { return }
let f = World.field_id(P, "gold")
World.set(e, P, f, World.get(e, P, f) + n)
}
@Namespace(Inventory) function inventory_spend_gold(e: int, n: int) -> int {
let g = inventory_gold(e)
if g < n { return 0 }
inventory_add_gold(e, 0 - n)
return 1
}
# ---- equipment: stat effects flow through the gameplay modifier stack ------
# Equipment holds one modifier-entity id per slot (0 = empty); equipping pushes a
# Stats modifier, unequipping drops it. slot ids: 0 weapon, 1 armor, 2 trinket.
property Equipment { weapon: int = 0, armor: int = 0, trinket: int = 0 }
# Inventory.equip(e, slot, stat, op, amount) — apply an equipment bonus and record
# the modifier so it can be removed on unequip. Returns the modifier entity.
@Namespace(Inventory) function inventory_equip(e: int, slot: int, stat: int, op: int, amount: int) -> int {
inventory_unequip(e, slot) # replace whatever was there
let m = Stats.modify(e, stat, op, amount, 0) # permanent until unequipped
let P = World.prop_id("Equipment")
if P >= 0 {
if slot == 0 { World.set(e, P, World.field_id(P, "weapon"), m + 1) }
if slot == 1 { World.set(e, P, World.field_id(P, "armor"), m + 1) }
if slot == 2 { World.set(e, P, World.field_id(P, "trinket"), m + 1) }
}
return m
}
@Namespace(Inventory) function inventory_unequip(e: int, slot: int) -> void {
let P = World.prop_id("Equipment")
if P < 0 { return }
var f = World.field_id(P, "weapon")
if slot == 1 { f = World.field_id(P, "armor") }
if slot == 2 { f = World.field_id(P, "trinket") }
let stored = World.get(e, P, f)
if stored > 0 { despawn (stored - 1); World.set(e, P, f, 0) }
}

View file

@ -0,0 +1,177 @@
# mover.ludic — RPG Module A: movement (grid + free, 4/8-axis). One component with
# a `mode` selector, so a game picks its movement feel as data (#59).
#
# mode 0 grid discrete cell steps (RPG Maker / Pokemon), cooldown-gated
# mode 1 free continuous Body velocity (top-down Zelda), reuses esys_move
# mode 2 grid-tween grid-discrete logic, positions eased between cells
#
# Events at every decision: cancellable MoveRequested (locked doors, ice, cutscene
# lock), TileEntered (encounters, traps), Interacted (the action-button raycast).
const RPG_Q: int = 65536
property Mover {
mode: int = 0, # 0 grid, 1 free, 2 grid-tween
axes: int = 8, # 4 cardinal only, 8 with diagonals
speed: int = 3, # free: px/frame; grid-tween: px/frame slide
step: int = 16, # grid: cell size in px
facing_x: int = 0, facing_y: int = 1, # last-faced direction (for interact)
want_x: int = 0, want_y: int = 0, # intent (-1/0/1), input OR ai
cooldown: int = 0, move_cd: int = 8, # grid: frames between cell steps
tx: int = 0, ty: int = 0, sliding: int = 0, # grid-tween: target cell + state
policy: int = 0
}
event cancellable MoveRequested { e: int = 0, dx: int = 0, dy: int = 0 }
event TileEntered { e: int = 0, x: int = 0, y: int = 0 }
event Interacted { e: int = 0, target: int = 0 }
function mv_get(e: int, name: pointer) -> int {
let P = World.prop_id("Mover"); if P < 0 { return 0 }
let f = World.field_id(P, name); if f < 0 { return 0 }
return World.get(e, P, f)
}
function mv_set(e: int, name: pointer, v: int) -> void {
let P = World.prop_id("Mover"); if P < 0 { return }
let f = World.field_id(P, name); if f >= 0 { World.set(e, P, f, v) }
}
function mv_pos(e: int, name: pointer) -> int {
let P = World.prop_id("Position"); if P < 0 { return 0 }
let f = World.field_id(P, name); if f < 0 { return 0 }
return World.get(e, P, f)
}
function mv_pos_set(e: int, name: pointer, v: int) -> void {
let P = World.prop_id("Position"); if P < 0 { return }
let f = World.field_id(P, name); if f >= 0 { World.set(e, P, f, v) }
}
function mv_body_set(e: int, name: pointer, v: int) -> void {
let P = World.prop_id("Body"); if P < 0 { return }
let f = World.field_id(P, name); if f >= 0 { World.set(e, P, f, v) }
}
function mv_sign(a: int) -> int { if a > 0 { return 1 }; if a < 0 { return 0 - 1 }; return 0 }
# is the destination cell walkable? Uses the Solids tilemap when configured,
# otherwise the whole field is open.
function mv_walkable(nx: int, ny: int, step: int) -> int {
let ps = World.prop_id("Solids")
if ps < 0 { return 1 }
let se = World.query_next(ps, 0)
if se < 0 { return 1 }
let ft = World.field_id(ps, "tile")
let fw = World.field_id(ps, "wall")
if (ft < 0) or (fw < 0) { return 1 }
let ts = World.get(se, ps, ft)
if ts <= 0 { return 1 }
let wall = World.get(se, ps, fw)
if Grid.blocked(nx / ts, ny / ts, wall) { return 0 }
return 1
}
# ===========================================================================
# esys_mover (FixedUpdate) — one active branch per mode.
# ===========================================================================
@EngineSystem(Mover, FixedUpdate) function esys_mover() -> void {
let P = World.prop_id("Mover")
if P < 0 { return }
var e = World.query_next(P, 0)
while e >= 0 {
let mode = mv_get(e, "mode")
if mode == 1 { mover_free(e) }
else { if mode == 2 { mover_grid_tween(e) } else { mover_grid(e) } }
e = World.query_next(P, e + 1)
}
}
# clamp the intent to the allowed axes (4 = no diagonal: prefer the horizontal).
function mover_intent(e: int) -> int {
var wx = mv_get(e, "want_x")
var wy = mv_get(e, "want_y")
if (mv_get(e, "axes") == 4) and (wx != 0) and (wy != 0) { wy = 0 }
mv_set(e, "facing_x", 0); mv_set(e, "facing_y", 0)
if (wx != 0) or (wy != 0) { mv_set(e, "facing_x", mv_sign(wx)); mv_set(e, "facing_y", mv_sign(wy)) }
# pack signed x,y into one int for return: (wx+1)*4 + (wy+1)
return (mv_sign(wx) + 1) * 4 + (mv_sign(wy) + 1)
}
function mover_grid(e: int) -> void {
let cd = mv_get(e, "cooldown")
if cd > 0 { mv_set(e, "cooldown", cd - 1); return }
let packed = mover_intent(e)
let dx = packed / 4 - 1
let dy = packed - (packed / 4) * 4 - 1
if (dx == 0) and (dy == 0) { return }
let step = mv_get(e, "step")
let nx = mv_pos(e, "x") + dx * step
let ny = mv_pos(e, "y") + dy * step
if mv_walkable(nx, ny, step) == 0 { return }
if emit MoveRequested(e: e, dx: dx, dy: dy) != 0 { return }
mv_pos_set(e, "x", nx)
mv_pos_set(e, "y", ny)
mv_set(e, "cooldown", mv_get(e, "move_cd"))
emit TileEntered(e: e, x: nx, y: ny)
}
# grid-tween: same discrete logic, but slide the sprite between cells so it looks
# smooth while staying lockstep-discrete (the target cell is the source of truth).
function mover_grid_tween(e: int) -> void {
if mv_get(e, "sliding") == 1 {
let step = mv_get(e, "speed")
let tx = mv_get(e, "tx"); let ty = mv_get(e, "ty")
let px = mv_pos(e, "x"); let py = mv_pos(e, "y")
var nxp = px + mv_sign(tx - px) * step
var nyp = py + mv_sign(ty - py) * step
if mv_sign(tx - px) > 0 { if nxp > tx { nxp = tx } }
if mv_sign(tx - px) < 0 { if nxp < tx { nxp = tx } }
if mv_sign(ty - py) > 0 { if nyp > ty { nyp = ty } }
if mv_sign(ty - py) < 0 { if nyp < ty { nyp = ty } }
mv_pos_set(e, "x", nxp); mv_pos_set(e, "y", nyp)
if (nxp == tx) and (nyp == ty) { mv_set(e, "sliding", 0); emit TileEntered(e: e, x: tx, y: ty) }
return
}
let packed = mover_intent(e)
let dx = packed / 4 - 1
let dy = packed - (packed / 4) * 4 - 1
if (dx == 0) and (dy == 0) { return }
let step = mv_get(e, "step")
let nx = mv_pos(e, "x") + dx * step
let ny = mv_pos(e, "y") + dy * step
if mv_walkable(nx, ny, step) == 0 { return }
if emit MoveRequested(e: e, dx: dx, dy: dy) != 0 { return }
mv_set(e, "tx", nx); mv_set(e, "ty", ny); mv_set(e, "sliding", 1)
}
function mover_free(e: int) -> void {
let packed = mover_intent(e)
let dx = packed / 4 - 1
let dy = packed - (packed / 4) * 4 - 1
let sp = mv_get(e, "speed")
mv_body_set(e, "vx", dx * sp * RPG_Q)
mv_body_set(e, "vy", dy * sp * RPG_Q)
}
# ---------------------------------------------------------------------------
# Mover.* convenience
# ---------------------------------------------------------------------------
@Namespace(Mover) function mover_move(e: int, dx: int, dy: int) -> void { mv_set(e, "want_x", dx); mv_set(e, "want_y", dy) }
# interact: the entity one cell ahead in the facing direction (Interacted event).
@Namespace(Mover) function mover_interact(e: int) -> int {
let step = mv_get(e, "step")
let ax = mv_pos(e, "x") + mv_get(e, "facing_x") * step
let ay = mv_pos(e, "y") + mv_get(e, "facing_y") * step
let PP = World.prop_id("Position")
if PP < 0 { return 0 - 1 }
var o = World.query_next(PP, 0)
while o >= 0 {
if o != e {
let ox = World.get(o, PP, World.field_id(PP, "x"))
let oy = World.get(o, PP, World.field_id(PP, "y"))
let dx = ox - ax; let dy = oy - ay
var adx = dx; if adx < 0 { adx = 0 - adx }
var ady = dy; if ady < 0 { ady = 0 - ady }
if (adx < step) and (ady < step) { emit Interacted(e: e, target: o); return o }
}
o = World.query_next(PP, o + 1)
}
emit Interacted(e: e, target: 0 - 1)
return 0 - 1
}

View file

@ -0,0 +1,19 @@
# ludic.rpg — the builtin RPG systems suite (#59).
#
# Not one controller but seven independently-usable modules, each conforming to the
# six-lever contract (#57): movement (grid + free, 4/8-axis), inventory, crafting,
# quests, dialog, puzzles, and a status/combat backbone. Content is name-keyed data
# registries (items, recipes, quests, dialog trees) so a game or mod adds content
# with zero code. Reuses ludic.gameplay Stats/Combat/Cooldown. Deterministic
# (integer only), so save/load (world_save), replay and rollback all hold.
package "ludic.rpg"
version "0.1.0"
kind source
provides "Mover"
provides "Inventory"
provides "Craft"
provides "Quest"
provides "Dialog"
provides "Puzzle"
provides "Status"
require "ludic.gameplay" "0.1.0"

View file

@ -0,0 +1,152 @@
# puzzles.ludic — RPG Module F: the recurring 2D puzzle primitives (#59).
#
# Sokoban pushables on the grid, plus a signal graph of switches / pressure plates
# driving gates — logic puzzles with no code: wire a Gate to a source and the gate
# opens when the source is on. Composes with the grid Mover (Module A).
const PZ_Q: int = 65536
property Pushable { }
property Switch { on: int = 0 }
property Pressure { on: int = 0, step: int = 16 }
property Gate { open: int = 0, src: int = 0 - 1 }
event BlockPushed { block: int = 0, dx: int = 0, dy: int = 0 }
event cancellable PushBlocked { block: int = 0 }
event SignalChanged { gate: int = 0, open: int = 0 }
function pz_pos(e: int, name: pointer) -> int {
let P = World.prop_id("Position"); if P < 0 { return 0 }
return World.get(e, P, World.field_id(P, name))
}
function pz_pos_set(e: int, name: pointer, v: int) -> void {
let P = World.prop_id("Position"); if P < 0 { return }
World.set(e, P, World.field_id(P, name), v)
}
# the pushable whose cell is exactly (x, y), or -1.
function pz_block_at(x: int, y: int) -> int {
let P = World.prop_id("Pushable")
if P < 0 { return 0 - 1 }
var e = World.query_next(P, 0)
while e >= 0 {
if (pz_pos(e, "x") == x) and (pz_pos(e, "y") == y) { return e }
e = World.query_next(P, e + 1)
}
return 0 - 1
}
# reuse the Mover module's tilemap walkability check when present.
function pz_walkable(nx: int, ny: int, step: int) -> int {
let ps = World.prop_id("Solids")
if ps < 0 { return 1 }
let se = World.query_next(ps, 0)
if se < 0 { return 1 }
let ft = World.field_id(ps, "tile")
let fw = World.field_id(ps, "wall")
if (ft < 0) or (fw < 0) { return 1 }
let ts = World.get(se, ps, ft)
if ts <= 0 { return 1 }
let wall = World.get(se, ps, fw)
if Grid.blocked(nx / ts, ny / ts, wall) { return 0 }
return 1
}
# Puzzle.push(bx, by, dx, dy, step) — push the block at (bx,by) one cell along
# (dx,dy) if the destination is free (no wall, no other block). Returns 1 on a move.
@Namespace(Puzzle) function puzzle_push(bx: int, by: int, dx: int, dy: int, step: int) -> int {
let b = pz_block_at(bx, by)
if b < 0 { return 0 }
let nx = bx + dx * step
let ny = by + dy * step
if (pz_walkable(nx, ny, step) == 0) or (pz_block_at(nx, ny) >= 0) {
emit PushBlocked(block: b)
return 0
}
pz_pos_set(b, "x", nx); pz_pos_set(b, "y", ny)
emit BlockPushed(block: b, dx: dx, dy: dy)
return 1
}
# Puzzle.link(gate, source) — a gate opens when its source (a Switch or Pressure) is on.
@Namespace(Puzzle) function puzzle_link(gate: int, source: int) -> void {
let P = World.prop_id("Gate")
if P < 0 { return }
World.set(gate, P, World.field_id(P, "src"), source)
}
# Puzzle.switch(e, on) — set a manual switch.
@Namespace(Puzzle) function puzzle_switch(e: int, on: int) -> void {
let P = World.prop_id("Switch")
if P < 0 { return }
World.set(e, P, World.field_id(P, "on"), on)
}
@Namespace(Puzzle) function puzzle_is_open(gate: int) -> int {
let P = World.prop_id("Gate")
if P < 0 { return 0 }
return World.get(gate, P, World.field_id(P, "open"))
}
# read the "on" state of a signal source (Switch or Pressure), 0 if neither.
function pz_source_on(src: int) -> int {
if src < 0 { return 0 }
let PS = World.prop_id("Switch")
if (PS >= 0) and (World.has(src, PS) != 0) { return World.get(src, PS, World.field_id(PS, "on")) }
let PP = World.prop_id("Pressure")
if (PP >= 0) and (World.has(src, PP) != 0) { return World.get(src, PP, World.field_id(PP, "on")) }
return 0
}
# ===========================================================================
# esys_pressure (Update) — a plate is on while a Pushable or a Position-carrying
# body sits on its cell.
# ===========================================================================
@EngineSystem(Pressure, Update) function esys_pressure() -> void {
let P = World.prop_id("Pressure")
if P < 0 { return }
let fon = World.field_id(P, "on")
let fstep = World.field_id(P, "step")
var e = World.query_next(P, 0)
while e >= 0 {
let px = pz_pos(e, "x"); let py = pz_pos(e, "y")
let step = World.get(e, P, fstep)
var on = 0
# a pushable on the plate?
if pz_block_at(px, py) >= 0 { on = 1 }
# or any other Position entity within the same cell?
if on == 0 {
let PP = World.prop_id("Position")
var o = World.query_next(PP, 0)
while o >= 0 {
if (o != e) and (World.has(o, P) == 0) {
let ox = World.get(o, PP, World.field_id(PP, "x"))
let oy = World.get(o, PP, World.field_id(PP, "y"))
var adx = ox - px; if adx < 0 { adx = 0 - adx }
var ady = oy - py; if ady < 0 { ady = 0 - ady }
if (adx < step) and (ady < step) { on = 1; o = 0 - 1 }
}
if o >= 0 { o = World.query_next(PP, o + 1) }
}
}
World.set(e, P, fon, on)
e = World.query_next(P, e + 1)
}
}
# ===========================================================================
# esys_gate (Update, after pressure) — a gate mirrors its source's on-state.
# ===========================================================================
@EngineSystem(Gate, Update) function esys_gate() -> void {
let P = World.prop_id("Gate")
if P < 0 { return }
let fopen = World.field_id(P, "open")
let fsrc = World.field_id(P, "src")
var e = World.query_next(P, 0)
while e >= 0 {
let want = pz_source_on(World.get(e, P, fsrc))
if World.get(e, P, fopen) != want {
World.set(e, P, fopen, want)
emit SignalChanged(gate: e, open: want)
}
e = World.query_next(P, e + 1)
}
}

View file

@ -0,0 +1,118 @@
# quests.ludic — RPG Module D: quests + objectives, driven by events (#59).
#
# A quest is data: a state plus a list of objectives (kind + needed count). Progress
# is reported through Quest.notify — a game routes its existing gameplay signals
# (a kill, a pickup, a tile entered, a dialog choice) into it, and matching active
# objectives tick up; when all of a quest's objectives are met it auto-completes.
# A global flag store (Dict) records world state for branching. All state is in
# module Dicts/arrays, so world_save captures the journal for free.
#
# objective kind convention: 0 kill, 1 collect, 2 reach, 3 talk, 4 flag (extend
# with your own kind ints + a Quest.notify from the matching listener).
# quest state: 0 hidden, 1 available, 2 active, 3 done, 4 failed.
const QUEST_MAX: int = 128
const OBJ_MAX: int = 512
var quest_reg: Dict = null # quest name -> id
var quest_n: int = 0
var quest_state: words = null
var obj_n: int = 0
var obj_qid: words = null # owning quest id
var obj_kind: words = null
var obj_target: words = null # a target id/tag (kind-specific; 0 = any)
var obj_need: words = null
var obj_have: words = null
var quest_flags: Dict = null # global flag store
function quest_ensure() -> void {
if quest_reg == null {
quest_reg = Dict.new(); quest_flags = Dict.new()
quest_state = words(QUEST_MAX)
obj_qid = words(OBJ_MAX); obj_kind = words(OBJ_MAX); obj_target = words(OBJ_MAX)
obj_need = words(OBJ_MAX); obj_have = words(OBJ_MAX)
}
}
event QuestStarted { quest: int = 0 }
event QuestCompleted { quest: int = 0 }
event ObjectiveProgressed { quest: int = 0, objective: int = 0, have: int = 0, need: int = 0 }
# Quest.define(name) — register a quest (state hidden). Returns id.
@Namespace(Quest) function quest_define(name: pointer) -> int {
quest_ensure()
if quest_n >= QUEST_MAX { return 0 - 1 }
let id = quest_n
quest_state[id] = 0
quest_n = quest_n + 1
Dict.set(quest_reg, name, id)
return id
}
@Namespace(Quest) function quest_id(name: pointer) -> int { quest_ensure(); return Dict.get_or(quest_reg, name, 0 - 1) }
# Quest.objective(quest_name, kind, target, need) — add an objective.
@Namespace(Quest) function quest_objective(quest_name: pointer, kind: int, target: int, need: int) -> int {
quest_ensure()
let qid = quest_id(quest_name)
if qid < 0 { return 0 - 1 }
if obj_n >= OBJ_MAX { return 0 - 1 }
let id = obj_n
obj_qid[id] = qid; obj_kind[id] = kind; obj_target[id] = target
obj_need[id] = need; obj_have[id] = 0
obj_n = obj_n + 1
return id
}
# Quest.start(name) — activate a quest (emits QuestStarted).
@Namespace(Quest) function quest_start(name: pointer) -> void {
quest_ensure()
let qid = quest_id(name)
if qid < 0 { return }
quest_state[qid] = 2
emit QuestStarted(quest: qid)
}
@Namespace(Quest) function quest_state_of(name: pointer) -> int {
quest_ensure()
let qid = quest_id(name)
if qid < 0 { return 0 - 1 }
return quest_state[qid]
}
# is every objective of a quest satisfied?
function quest_all_done(qid: int) -> int {
var i = 0
while i < obj_n {
if obj_qid[i] == qid { if obj_have[i] < obj_need[i] { return 0 } }
i = i + 1
}
return 1
}
# Quest.notify(kind, target, amount) — report a gameplay signal. Every ACTIVE
# quest's matching objectives tick up (target 0 on the objective = "any"); a quest
# whose objectives are all met auto-completes.
@Namespace(Quest) function quest_notify(kind: int, target: int, amount: int) -> void {
quest_ensure()
var i = 0
while i < obj_n {
let qid = obj_qid[i]
if quest_state[qid] == 2 {
if obj_kind[i] == kind {
if (obj_target[i] == 0) or (obj_target[i] == target) {
if obj_have[i] < obj_need[i] {
obj_have[i] = obj_have[i] + amount
if obj_have[i] > obj_need[i] { obj_have[i] = obj_need[i] }
emit ObjectiveProgressed(quest: qid, objective: i, have: obj_have[i], need: obj_need[i])
if quest_all_done(qid) == 1 {
quest_state[qid] = 3
emit QuestCompleted(quest: qid)
}
}
}
}
}
i = i + 1
}
}
# ---- global flag store (branching / world state) --------------------------
@Namespace(Quest) function quest_flag(name: pointer, value: int) -> void { quest_ensure(); Dict.set(quest_flags, name, value) }
@Namespace(Quest) function quest_flag_get(name: pointer) -> int { quest_ensure(); return Dict.get_or(quest_flags, name, 0) }

View file

@ -0,0 +1,79 @@
# status.ludic — RPG Module G: status effects over the shared combat backbone (#59).
#
# A status is its own small entity (like a gameplay Modifier), so an actor can carry
# any number and world_save captures them. esys_status ticks each on its interval,
# routing poison/regen through the ludic.gameplay Combat pipeline (so armor, veto
# and Died all still apply), and expires it. Timed stat buffs/debuffs are already
# ludic.gameplay modifiers (Stats.modify with a ttl); this module adds the
# over-time damage/heal effects.
#
# kind: 0 poison (damage over time), 1 regen (heal over time).
import "ludic.gameplay/combat.ludic"
property Status { target: int = 0, kind: int = 0, amount: int = 0, interval: int = 30, ttl: int = 0, t: int = 0 }
model StatusFx { Status }
@OnSpawn(StatusFx) handler StatusFxInit { }
event StatusApplied { target: int = 0, kind: int = 0 }
event StatusExpired { target: int = 0, kind: int = 0 }
# Status.apply(target, kind, amount, interval, ttl) — attach a timed effect.
@Namespace(Status) function status_apply(target: int, kind: int, amount: int, interval: int, ttl: int) -> int {
let m = World.model_id("StatusFx")
if m < 0 { return 0 - 1 }
let e = World.spawn(m)
let P = World.prop_id("Status")
World.set(e, P, World.field_id(P, "target"), target)
World.set(e, P, World.field_id(P, "kind"), kind)
World.set(e, P, World.field_id(P, "amount"), amount)
World.set(e, P, World.field_id(P, "interval"), interval)
World.set(e, P, World.field_id(P, "ttl"), ttl)
World.set(e, P, World.field_id(P, "t"), interval)
emit StatusApplied(target: target, kind: kind)
return e
}
# Status.clear(target) — drop every status on an actor (dispel).
@Namespace(Status) function status_clear(target: int) -> void {
let P = World.prop_id("Status")
if P < 0 { return }
let ft = World.field_id(P, "target")
var e = World.query_next(P, 0)
while e >= 0 {
var nxt = World.query_next(P, e + 1)
if World.get(e, P, ft) == target { despawn e }
e = nxt
}
}
# ===========================================================================
# esys_status (Update) — tick intervals, apply the effect through Combat, expire.
# ===========================================================================
@EngineSystem(Status, Update) function esys_status() -> void {
let P = World.prop_id("Status")
if P < 0 { return }
let ftgt = World.field_id(P, "target")
let fkind = World.field_id(P, "kind")
let famt = World.field_id(P, "amount")
let fint = World.field_id(P, "interval")
let fttl = World.field_id(P, "ttl")
let ft = World.field_id(P, "t")
var e = World.query_next(P, 0)
while e >= 0 {
var nxt = World.query_next(P, e + 1)
let tgt = World.get(e, P, ftgt)
let kind = World.get(e, P, fkind)
let amt = World.get(e, P, famt)
# interval tick
var t = World.get(e, P, ft) - 1
if t <= 0 {
if kind == 1 { Combat.heal(tgt, amt) } else { Combat.damage(tgt, tgt, amt) }
t = World.get(e, P, fint)
}
World.set(e, P, ft, t)
# lifetime
let ttl = World.get(e, P, fttl) - 1
if ttl <= 0 { emit StatusExpired(target: tgt, kind: kind); despawn e } else { World.set(e, P, fttl, ttl) }
e = nxt
}
}

View file

@ -245,6 +245,7 @@ function cmd_test() -> int {
controller_case("games/platformer_scaffolding", "", "1 1 1 1 1 1", "platformer_scaffolding.ludic (ludic.platformer #58 layers 4-5: moving-platform rider carry, pickup->score, spring, hazard+Life i-frames)")
controller_case("games/shooter_demo", "", "1 1 1 1 1 1 1 1 1 1 1", "shooter_demo.ludic (ludic.shooter #60: decoupled move/aim, weapon registry, projectile faction-hit, spread/ring, homing, wave spawner)")
controller_case("games/npcai_demo", "", "1 1 1 1 1 1 1 1", "npcai_demo.ludic (ludic.npcai #61: perception/spot, FSM chase+attack driving the shooter weapon, utility flee, companion follower)")
controller_case("games/rpg_demo", "", "1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1", "rpg_demo.ludic (ludic.rpg #59 all 7 modules: grid move+veto+interact, inventory+equip, crafting, quests, dialog graph, sokoban+signal gate, poison status)")
spec_case("library/testing", "== 6 passed, 0 failed ==")
spec_case("library/coverage", "== 3 passed, 0 failed ==")
feat_case("library/errors", "", "5 10 0 7 1", "errors.ludic (assert guards an invariant, holds -> runs to the end; issue #8 success path)")