feat(packages): ludic.crafting - recipes as an open registry, can / take / refund / make through a pack port, stations through a port, the hold-to-make, a Crafted fact

A recipe is `def CraftRecipes key { out, n, ins: [...], ns: [...], station, minutes, hold,
group, learned }`. The pack is the CraftPack port (count, take, add, room, and `leaves`: what
an item leaves once used, so water gives back its bottle); stations are CraftStations (ready,
start), unbound meaning hands only. craft_make hands a click recipe over or starts a timed one
at its station, refunding when the station refuses; the hold runs from the screen that shows it.
Uses ludic.base only; ten tests with a fake pack and fake stations.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Orkun ÇAKILKAYA 2026-09-25 06:37:57 +03:00
parent 7f8a7ea7ed
commit cc9cec922a
9 changed files with 475 additions and 0 deletions

View file

@ -0,0 +1,72 @@
# ludic.crafting
Recipes and making them: what goes in, what comes out, where it is made and how long it takes,
whether it can be made right now, taking the makings, giving them back, and a recipe made by
holding a button rather than clicking it. Uses [`ludic.base`](../ludic.base/README.md) and
nothing else - the pack and the stations are the game's, asked through ports.
```ludic
import "ludic.crafting"
```
## The recipes
`CraftRecipes` is an `open registry` the game fills, one `def` per recipe; `CRAFT_<KEY>` is its
index. A station that holds a recipe holds its index, so a game that saves that appends.
```ludic
def CraftRecipes torch { out: IT_TORCH, ins: [IT_WOOD, IT_CLOTH], ns: [1, 1] }
def CraftRecipes rope { out: IT_ROPE, n: 2, ins: [IT_WILLOW], ns: [4], hold: 2.6 }
def CraftRecipes stew { out: IT_STEW, ins: [IT_MUSHROOM, IT_WATER], ns: [2, 1], station: POT, minutes: 25 }
```
| field | |
| --- | --- |
| `out`, `n` | what it makes, and how many |
| `ins`, `ns` | the makings and how many of each, side by side |
| `station` | where it is made: `CRAFT_HANDS` (0, the default) is anywhere; any other number is the game's |
| `minutes` | game minutes at its station (0: made at once); a timed recipe at a station is started there |
| `hold` | seconds of holding to make it (0: a click) |
| `group` | the game's grouping (a tab, a heading) |
| `learned` | false: never made |
## The ports
```ludic
export port CraftPack {
count: fn(int) -> int # how many of an item
take: fn(int, int) -> bool
add: fn(int, int) -> int
room: fn(int) -> int # how many more fit (unbound: no limit)
leaves: fn(int) -> int # what an item leaves once used, -1 nothing (unbound: nothing)
}
export port CraftStations { # unbound: only the hands; nothing can be started
ready: fn(int) -> bool # can this station be used right now?
start: fn(int, int) -> bool # put recipe in station; false gives the makings back
}
```
A game with an inventory package binds `CraftPack` to it; this package never names one.
## API
| | |
| --- | --- |
| `craft_count()`, `craft_ok(i)`, `craft_recipe(i)`, `craft_find(item)`, `craft_by_key(key)` | the table |
| `craft_inputs(i)`, `craft_input(i, k)`, `craft_input_n(i, k)` | a recipe's makings |
| `craft_has(i)` | every making is in the pack |
| `craft_can(i)` | learned, the makings, its station ready, and room for what it makes |
| `craft_take(i)`, `craft_refund(i)` | the makings out (and what they leave in), and back again |
| `craft_make(i) -> bool` | make it: at once into the pack, or started at its station; false and nothing taken when it cannot |
| `craft_holds(i)`, `craft_hold_start(i) -> bool`, `craft_hold_stop()`, `craft_holding()`, `craft_hold_frac(i)`, `craft_hold_run(dt) -> bool` | the hold: `run` is true on the frame it is made; it stops itself when the recipe can no longer be made |
| `craft_facts() -> Queue<Crafted>` | `{ recipe, item, n, station, queued }`, one per making (`queued`: started at a station) |
| `craft_reset()`, `craft_system() -> System` | `"crafting"`, `PH_SIMULATE`: a reset; nothing is saved |
The hold is run by whatever screen shows it (`craft_hold_run(dt)` from that screen's tick), since a
screen may pause the world.
## Tests
```bash
ludic test packages/ludic.crafting
```

View file

@ -0,0 +1,42 @@
# hold.ludic - a recipe made by holding a button for its `hold` seconds: letting go wastes
# nothing, and it stops by itself the moment it can no longer be made
var cr_hold: int = -1
var cr_hold_t: float = 0.0
export function craft_holds(i: int) -> bool { return craft_ok(i) and CraftRecipes[i].hold > 0.0 }
# the recipe being held, or -1
export function craft_holding() -> int { return cr_hold }
# starts holding one that is made by holding and can be made now
export function craft_hold_start(i: int) -> bool {
if not craft_holds(i) or not craft_can(i) { return false }
cr_hold = i
cr_hold_t = 0.0
return true
}
export function craft_hold_stop() -> void {
cr_hold = -1
cr_hold_t = 0.0
}
# how far through the hold on `i` is, 0 to 1
export function craft_hold_frac(i: int) -> float {
if cr_hold != i or not craft_holds(i) { return 0.0 }
return Math.clamp(cr_hold_t / CraftRecipes[i].hold, 0.0, 1.0)
}
# `dt` real seconds of holding; true on the frame it is made
export function craft_hold_run(dt: float) -> bool {
if cr_hold < 0 { return false }
if not craft_can(cr_hold) {
craft_hold_stop()
return false
}
cr_hold_t = cr_hold_t + dt
if cr_hold_t <= CraftRecipes[cr_hold].hold { return false }
let i = cr_hold
craft_hold_stop()
return craft_make(i)
}

View file

@ -0,0 +1,10 @@
# ludic.crafting - recipes as an open registry; the pack and the stations are the game's, asked
# through ports; making one takes its makings and either hands the result over or starts a station
module ludic_crafting uses ludic_base
numbers float
import "ludic.base"
import "recipes.ludic"
import "ports.ludic"
import "verbs.ludic"
import "hold.ludic"
import "system.ludic"

View file

@ -0,0 +1,5 @@
# ludic.crafting - recipes (what goes in, what comes out, where and how long), whether one can be
# made, making it, giving it back, and the hold-to-make. Uses ludic.base and nothing else.
package "ludic.crafting"
version "0.1.0"
kind source

View file

@ -0,0 +1,20 @@
# ports.ludic - what crafting asks the game: the pack, and the stations
# the pack. `leaves` is what an item leaves behind once used (water leaves its bottle), -1 nothing.
export port CraftPack {
count: fn(int) -> int
take: fn(int, int) -> bool
add: fn(int, int) -> int
room: fn(int) -> int = fn cr_no_limit
leaves: fn(int) -> int = fn cr_leaves_nothing
}
function cr_no_limit(it: int) -> int { return 2147483647 }
function cr_leaves_nothing(it: int) -> int { return -1 }
# the stations. Unbound, there are none: only the hands are ready, and nothing can be started.
# `start(station, recipe)` puts a timed recipe in; false gives the makings back.
export port CraftStations {
ready: fn(int) -> bool = fn cr_hands_only
start: fn(int, int) -> bool = fn cr_no_start
}
function cr_hands_only(station: int) -> bool { return station == CRAFT_HANDS }
function cr_no_start(station: int, recipe: int) -> bool { return false }

View file

@ -0,0 +1,59 @@
# recipes.ludic - a recipe, the registry the game fills, and the fact a making leaves
# station 0 is the hands: anywhere, always ready
export const CRAFT_HANDS: int = 0
# `ins` are items and `ns` how many of each, side by side; `minutes` is game time at a station
# (0 is made at once) and `hold` seconds of holding the button (0 is a click)
export property CraftRecipe {
key: string = ""
out: int = 0
n: int = 1
ins: []int = null
ns: []int = null
station: int = 0
minutes: int = 0
hold: float = 0.0
group: int = 0
learned: bool = true
}
# the game fills it: `def CraftRecipes torch { out: IT_TORCH, ins: [IT_WOOD], ns: [1] }`. A
# station holds a recipe by its index, so a game that saves that appends, never inserts.
export open registry CraftRecipes of CraftRecipe as CRAFT
# a recipe was made: handed over (`queued` false) or put into its station (`queued` true)
export property Crafted {
recipe: int = 0
item: int = 0
n: int = 0
station: int = 0
queued: bool = false
}
export function craft_ok(i: int) -> bool { return i >= 0 and i < CRAFT_COUNT }
export function craft_count() -> int { return CRAFT_COUNT }
export function craft_recipe(i: int) -> CraftRecipe { return CraftRecipes[i] }
# how many makings a recipe has
export function craft_inputs(i: int) -> int {
if not craft_ok(i) or CraftRecipes[i].ins == null { return 0 }
return len(CraftRecipes[i].ins)
}
export function craft_input(i: int, k: int) -> int { return CraftRecipes[i].ins[k] }
export function craft_input_n(i: int, k: int) -> int { return CraftRecipes[i].ns[k] }
# the first recipe that makes `item`, or -1
export function craft_find(item: int) -> int {
for i in 0 .. CRAFT_COUNT { if CraftRecipes[i].out == item { return i } }
return -1
}
# the recipe the save calls `key`, or -1
export function craft_by_key(key: string) -> int {
for i in 0 .. CRAFT_COUNT { if CraftRecipes[i].key == key { return i } }
return -1
}

View file

@ -0,0 +1,12 @@
# system.ludic - crafting as a system: a reset, and nothing saved (a hold never outlives its
# screen; what a station holds is the station's)
export function craft_reset() -> void {
craft_hold_stop()
q_clear(craft_facts())
}
export function craft_system() -> System {
let s = system_new("crafting", PH_SIMULATE)
s.reset = fn craft_reset
return s
}

View file

@ -0,0 +1,178 @@
# crafting_test.ludic - a recipe made from a fake pack: the makings, the station's say, room for
# the result, what water leaves behind, a station that refuses, the hold, and the facts
import "ludic.crafting"
import "ludic.base"
program CraftingTest {
numbers float
const WOOD: int = 0
const CLOTH: int = 1
const TORCH: int = 2
const ROPE: int = 3
const WATER: int = 4
const BOTTLE: int = 5
const TEA: int = 6
const HERB: int = 7
const BENCH: int = 1
const POT: int = 2
def CraftRecipes torch { out: TORCH, ins: [WOOD, CLOTH], ns: [1, 1] }
def CraftRecipes rope { out: ROPE, n: 2, ins: [HERB], ns: [4], hold: 2.0 }
def CraftRecipes tea { out: TEA, ins: [HERB, WATER], ns: [2, 1], station: POT, minutes: 8 }
def CraftRecipes chair { out: TORCH, ins: [WOOD], ns: [3], station: BENCH }
def CraftRecipes secret { out: TORCH, ins: [WOOD], ns: [1], learned: false }
var pack: []int = null
var bench_near: bool = true
var pot_takes: bool = true
var pot_got: int = -1
function count(it: int) -> int { return pack[it] }
function take(it: int, n: int) -> bool {
if pack[it] < n { return false }
pack[it] = pack[it] - n
return true
}
function add(it: int, n: int) -> int {
pack[it] = pack[it] + n
return n
}
function room(it: int) -> int {
if it == TORCH { return 3 - pack[it] }
return 99
}
function leaves(it: int) -> int {
if it == WATER { return BOTTLE }
return -1
}
function ready(st: int) -> bool {
if st == BENCH { return bench_near }
return true
}
function start(st: int, recipe: int) -> bool {
if not pot_takes { return false }
pot_got = recipe
return true
}
bind CraftPack { count: fn count, take: fn take, add: fn add, room: fn room, leaves: fn leaves }
bind CraftStations { ready: fn ready, start: fn start }
function fresh() -> void {
pack = new []int
for i in 0 .. 8 { push(pack, 0) }
craft_reset()
}
function facts() -> []Crafted { return q_drain(craft_facts()) }
test "the registry is the game's, in the order written" {
expect_eq(CRAFT_COUNT, 5)
expect_eq(CRAFT_TEA, 2)
expect_eq(craft_find(ROPE), CRAFT_ROPE)
expect_eq(craft_by_key("chair"), CRAFT_CHAIR)
expect_eq(craft_inputs(CRAFT_TEA), 2)
expect_eq(craft_find(99), -1)
}
test "made at once from the makings, and said" {
fresh()
expect(not craft_can(CRAFT_TORCH))
pack[WOOD] = 2
pack[CLOTH] = 1
expect(craft_make(CRAFT_TORCH))
expect_eq(pack[WOOD], 1)
expect_eq(pack[CLOTH], 0)
expect_eq(pack[TORCH], 1)
let fs = facts()
expect_eq(len(fs), 1)
expect_eq(fs[0].item, TORCH)
expect(not fs[0].queued)
}
test "nothing is taken when it cannot be made" {
fresh()
pack[WOOD] = 1
expect(not craft_make(CRAFT_TORCH))
expect_eq(pack[WOOD], 1)
expect_eq(len(facts()), 0)
}
test "the station decides, and so does the room for what it makes" {
fresh()
pack[WOOD] = 9
bench_near = false
expect(craft_has(CRAFT_CHAIR))
expect(not craft_can(CRAFT_CHAIR))
bench_near = true
expect(craft_can(CRAFT_CHAIR))
pack[TORCH] = 3
expect(not craft_can(CRAFT_CHAIR))
}
test "an unlearned recipe is never made" {
fresh()
pack[WOOD] = 5
expect(not craft_can(CRAFT_SECRET))
}
test "a timed recipe goes into its station, and water leaves its bottle" {
fresh()
pack[HERB] = 2
pack[WATER] = 1
expect(craft_make(CRAFT_TEA))
expect_eq(pot_got, CRAFT_TEA)
expect_eq(pack[TEA], 0)
expect_eq(pack[WATER], 0)
expect_eq(pack[BOTTLE], 1)
let fs = facts()
expect(fs[0].queued)
expect_eq(fs[0].station, POT)
}
test "a station that says no gives everything back" {
fresh()
pot_takes = false
pack[HERB] = 2
pack[WATER] = 1
expect(not craft_make(CRAFT_TEA))
expect_eq(pack[HERB], 2)
expect_eq(pack[WATER], 1)
expect_eq(pack[BOTTLE], 0)
expect_eq(len(facts()), 0)
}
test "a hold makes it after its seconds, and not before" {
fresh()
pack[HERB] = 4
expect(craft_holds(CRAFT_ROPE))
expect(not craft_holds(CRAFT_TORCH))
expect(craft_hold_start(CRAFT_ROPE))
expect(not craft_hold_run(1.0))
expect(craft_hold_frac(CRAFT_ROPE) == 0.5)
expect(not craft_hold_run(0.9))
expect(craft_hold_run(0.2))
expect_eq(pack[ROPE], 2)
expect_eq(craft_holding(), -1)
}
test "letting go wastes nothing, and losing the makings stops the hold" {
fresh()
pack[HERB] = 4
craft_hold_start(CRAFT_ROPE)
craft_hold_run(1.5)
craft_hold_stop()
expect_eq(pack[HERB], 4)
craft_hold_start(CRAFT_ROPE)
pack[HERB] = 3
expect(not craft_hold_run(5.0))
expect_eq(craft_holding(), -1)
expect_eq(pack[ROPE], 0)
}
test "a click recipe cannot be held" {
fresh()
pack[WOOD] = 1
pack[CLOTH] = 1
expect(not craft_hold_start(CRAFT_TORCH))
}
}

View file

@ -0,0 +1,77 @@
# verbs.ludic - can it be made, taking and giving back its makings, and making it
var cr_facts: Queue<Crafted> = null
export function craft_facts() -> Queue<Crafted> {
if cr_facts == null { cr_facts = queue_new("crafting.crafted") }
return cr_facts
}
# every making is in the pack
export function craft_has(i: int) -> bool {
if not craft_ok(i) { return false }
for k in 0 .. craft_inputs(i) {
if CraftPack.count(craft_input(i, k)) < craft_input_n(i, k) { return false }
}
return true
}
# learned, the makings in the pack, its station ready, and room for what it makes
export function craft_can(i: int) -> bool {
if not craft_ok(i) { return false }
let r = CraftRecipes[i]
if not r.learned or not craft_has(i) { return false }
if not CraftStations.ready(r.station) { return false }
return CraftPack.room(r.out) >= r.n
}
# the makings out of the pack, and what they leave behind into it
export function craft_take(i: int) -> void {
for k in 0 .. craft_inputs(i) {
let it = craft_input(i, k)
let n = craft_input_n(i, k)
CraftPack.take(it, n)
let left = CraftPack.leaves(it)
if left >= 0 { CraftPack.add(left, n) }
}
}
# the makings back, and what they left behind out again (a station said no after all)
export function craft_refund(i: int) -> void {
for k in 0 .. craft_inputs(i) {
let it = craft_input(i, k)
let n = craft_input_n(i, k)
CraftPack.add(it, n)
let left = CraftPack.leaves(it)
if left >= 0 { CraftPack.take(left, n) }
}
}
function cr_fact(i: int, queued: bool) -> void {
let r = CraftRecipes[i]
let c = new Crafted
c.recipe = i
c.item = r.out
c.n = r.n
c.station = r.station
c.queued = queued
q_push(craft_facts(), c)
}
# make it: a timed recipe at a station goes into the station (given back if it will not take it),
# anything else is handed over at once. False, and the pack untouched, when it cannot be made.
export function craft_make(i: int) -> bool {
if not craft_can(i) { return false }
let r = CraftRecipes[i]
craft_take(i)
if r.minutes > 0 and r.station != CRAFT_HANDS {
if not CraftStations.start(r.station, i) {
craft_refund(i)
return false
}
cr_fact(i, true)
return true
}
CraftPack.add(r.out, r.n)
cr_fact(i, false)
return true
}