feat(packages): ludic.hints - a rail of things that are true now and can be acted on now: cards as an open registry (urgency, a glyph or an item's picture, the chip's line, three steps and how not to be here again) asked through a port whether each is true, the most urgent few once a pass in registry order within an urgency, a card opened off the rail, walked along it and put down when its subject stops being true, and "I know this" muting kept by the card's key (a key this build lacks is carried through); opened, closed, muted and unmuted as facts

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Orkun ÇAKILKAYA 2026-09-25 09:36:10 +03:00
parent 1005a677f2
commit fe9d28bf4e
12 changed files with 540 additions and 0 deletions

View file

@ -23,6 +23,7 @@ section. The rules are in [ludic.base](ludic.base/README.md).
| [ludic.effects](ludic.effects/README.md) | timed modifiers that run down in game time (a meal's warmth, a drink's legs) |
| [ludic.fire](ludic.fire/README.md) | a camp fire: fuel, the rain on it, warmth at a distance, whether it will cook |
| [ludic.fishing](ludic.fishing/README.md) | a rod at the water: cast, bite, the reel's fight, the landing; species as an open registry |
| [ludic.hints](ludic.hints/README.md) | a rail of what is true now, most urgent first, each a card that teaches it and can be muted by key |
| [ludic.i18n](ludic.i18n/README.md) | a game in any language: gettext `.po` files, patterns with holes, plurals, a mod folder, a font per language |
| [ludic.inventory](ludic.inventory/README.md) | a pack: a count per kind, with room the game decides |
| [ludic.needs](ludic.needs/README.md) | a body's warmth, food, water and energy, and the countdown to a collapse |

View file

@ -0,0 +1,75 @@
# ludic.hints
A rail of things that are true now and can be acted on now - thirsty, the fire burning low, a
bear near, a job ready to hand in - sorted by how much they matter, each one a card that teaches
it in three steps and can be muted for good. Uses [`ludic.base`](../ludic.base/README.md) and
nothing else.
```ludic
import "ludic.hints"
```
The package never reads a key and never draws: the game's input calls the verbs (the key opens
and puts down, the arrows walk, a third key mutes), its interface reads the rail and the card,
and its words are the cards' own text keys, translated where they are drawn.
## Cards are data
```ludic
def Hints from "assets/data/hints.lres" # the game's cards, in an open registry
```
```
thirsty {
urgency: HINTS_NOTE, icon: "bottle", item: IT_BOTTLE
line: "Thirsty - drink", title: "Water"
s1: "Drink at any shore, for nothing", pic1: "waterfull"
s2: "Fill a bottle there as well", item2: IT_BOTTLE
s3: "Boil it in the pot to be safe", item3: IT_BOILED
later: "Buy or make a bottle, fill it at the shore and boil it."
}
```
`urgency` is `HINTS_TIP`, `HINTS_NOTE`, `HINTS_WARN` or `HINTS_URGENT`. A picture is a glyph by
name (`icon`, `picN`) or a thing's picture by number (`item`, `itemN`, -1 for none) - both the
game's to interpret.
## The rules it keeps
1. **The most urgent few.** Once a pass (a second by default) the rail is the true, unmuted cards,
most urgent first and in registry order within an urgency, up to `hints_config`'s count.
2. **A card follows its subject.** As the rail re-sorts under an open card, the pick follows it;
when what it was about stops being true (or the rail stops showing) it is put down, with no fact.
3. **A lesson lands once.** `hints_mute_card()` takes the open card off the rail until
`hints_unmute_all()`. Mutes are kept **by key**, so a card added or removed moves nobody else's,
and a key this build does not know is carried through a load and a save.
## The port
```ludic
export port HintsWorld {
true_now: fn(int) -> bool # is card i true now? pure and cheap (unbound: never)
showing: fn() -> bool # the rail is wanted: in play, the player's switch on (unbound: yes)
}
```
## API
| | |
| --- | --- |
| `HintCard { key, urgency, icon, item, line, title, s1..s3, pic1..pic3, item1..item3, later }`, `open registry Hints ... as HINT` | a card |
| `hints_config(shown, every)` | how many the rail holds (3) and seconds between passes (1) |
| `hints_tick(dt)`, `hints_sort()`, `hints_refresh()` | a pass when due, a pass now, the next tick's pass now |
| `hints_rail() -> []int`, `hints_count()`, `hints_at(k)`, `hints_pick()`, `hints_hot(k)` | the rail |
| `hints_card()`, `hints_key()`, `hints_open(k)`, `hints_close()`, `hints_move(d)` | the card being read (-1) and its verbs |
| `hints_step_text(id, n)`, `hints_step_icon(id, n)`, `hints_step_item(id, n)` | a step, n 1..3 |
| `hints_mute_card()`, `hints_mute(id, on)`, `hints_muted(id)`, `hints_mute_count()`, `hints_unmute_all()` | "I know this" |
| `hints_mutes_save() -> Val`, `hints_mutes_load(v)` | the mutes as a list of keys, for the game's settings file |
| `hints_facts() -> Queue<HintsFact>` | `{ what, card }`: `HINTS_F_OPENED`, `_CLOSED`, `_MUTED`, `_UNMUTED` |
| `hints_system() -> System` | `"hints"`, `PH_PRESENT`: reset and the tick; nothing in a trip's save |
## Tests
```bash
ludic test packages/ludic.hints
```

View file

@ -0,0 +1,62 @@
# card.ludic - a card read off the rail: the key opens the picked chip and puts it down, the arrows
# walk the rail with the card up, and "I know this" mutes the card being read
export function hints_card() -> int { return hn_card }
# the key: the picked card opened, or the open one put down
export function hints_key() -> void {
if hn_card >= 0 {
hints_close()
return
}
hints_open(hn_pick)
}
# the k-th chip on the rail opened as a card
export function hints_open(k: int) -> void {
if k < 0 or k >= hn_n { return }
hn_pick = k
hn_card = hn_top[k]
hn_fact(HINTS_F_OPENED, hn_card)
}
export function hints_close() -> void {
if hn_card < 0 { return }
let c = hn_card
hn_card = -1
hn_fact(HINTS_F_CLOSED, c)
}
# the arrows, with a card up: the next chip along (d = 1) or the one before, round the ends
export function hints_move(d: int) -> void {
if hn_card < 0 or hn_n <= 1 { return }
hints_open((hn_pick + hn_n + d) % hn_n)
}
# "I know this - do not show it again": the card off the rail until the mutes are given back
export function hints_mute_card() -> void {
if hn_card < 0 { return }
let c = hn_card
hints_mute(c, true)
hn_card = -1
hn_t = 0.0
hn_fact(HINTS_F_MUTED, c)
}
# a step's words and its picture (n 1..3): the glyph's name, or the item (-1 when it is a glyph)
export function hints_step_text(id: int, n: int) -> string {
if n == 1 { return Hints[id].s1 }
if n == 2 { return Hints[id].s2 }
return Hints[id].s3
}
export function hints_step_icon(id: int, n: int) -> string {
if n == 1 { return Hints[id].pic1 }
if n == 2 { return Hints[id].pic2 }
return Hints[id].pic3
}
export function hints_step_item(id: int, n: int) -> int {
if n == 1 { return Hints[id].item1 }
if n == 2 { return Hints[id].item2 }
return Hints[id].item3
}

View file

@ -0,0 +1,39 @@
# defs.ludic - a card as the game writes it, how much it matters, and the facts
export const HINTS_TIP: int = 0 # worth knowing
export const HINTS_NOTE: int = 1 # worth doing soon
export const HINTS_WARN: int = 2 # worth doing now
export const HINTS_URGENT: int = 3 # do it or lose the day
# The words are the lesson's text keys (the game translates them where it draws). Pictures are
# the game's: `icon` / `picN` name a glyph, `item` / `itemN` a thing's picture instead (-1: none).
export property HintCard {
key: string = ""
urgency: int = HINTS_TIP
icon: string = ""
item: int = -1
line: string = "" # the chip on the rail: five or six words
title: string = "" # the card's heading
s1: string = "" # three steps, each with a picture
s2: string = ""
s3: string = ""
pic1: string = ""
pic2: string = ""
pic3: string = ""
item1: int = -1
item2: int = -1
item3: int = -1
later: string = "" # how not to be here next time
}
# the game's cards; a card's mute is kept by its key, so their order is free
export open registry Hints of HintCard as HINT
export const HINTS_F_OPENED: int = 0 # a card was opened, or the arrows moved to another
export const HINTS_F_CLOSED: int = 1 # put down by the player (not when it stopped being true)
export const HINTS_F_MUTED: int = 2 # "I know this": off the rail for good
export const HINTS_F_UNMUTED: int = 3 # every muted card given back (card -1)
export property HintsFact {
what: int = 0
card: int = -1
}

View file

@ -0,0 +1,11 @@
# ludic.hints - cards as an open registry, a port that says which are true, the rail of the most
# urgent few, a card walked along it, and "I know this" kept by the card's key
module ludic_hints uses ludic_base
numbers float
import "ludic.base"
import "defs.ludic"
import "port.ludic"
import "rail.ludic"
import "card.ludic"
import "mute.ludic"
import "system.ludic"

View file

@ -0,0 +1,62 @@
# mute.ludic - the cards a player has said they know, kept BY KEY: a card added, removed or moved in
# the registry leaves every other mute where it was, and a key this build does not know is carried
var hn_mute: []bool = null
var hn_foreign: []string = null # muted keys with no card in this build, written back as read
function hn_mute_ensure() -> void {
if hn_mute != null { return }
hn_mute = new []bool
for i in 0 .. HINT_COUNT { push(hn_mute, false) }
hn_foreign = new []string
}
export function hints_muted(id: int) -> bool {
hn_mute_ensure()
if id < 0 or id >= HINT_COUNT { return false }
return hn_mute[id]
}
export function hints_mute(id: int, on: bool) -> void {
hn_mute_ensure()
if id < 0 or id >= HINT_COUNT { return }
hn_mute[id] = on
}
export function hints_mute_count() -> int {
hn_mute_ensure()
var n = 0
for i in 0 .. HINT_COUNT { if hn_mute[i] { n += 1 } }
return n
}
# every muted card given back, from the next pass
export function hints_unmute_all() -> void {
hn_mute_ensure()
for i in 0 .. HINT_COUNT { hn_mute[i] = false }
hn_foreign = new []string
hn_t = 0.0
hn_fact(HINTS_F_UNMUTED, -1)
}
# the mutes as a list of keys, for wherever the game keeps the player's own choices
export function hints_mutes_save() -> Val {
hn_mute_ensure()
let l = Value.list()
for i in 0 .. HINT_COUNT { if hn_mute[i] { Value.add(l, Value.str(Hints[i].key)) } }
for i in 0 .. len(hn_foreign) { Value.add(l, Value.str(hn_foreign[i])) }
return l
}
# a list of keys read back; anything that is not a list is no mutes
export function hints_mutes_load(l: Val) -> void {
hn_mute = null
hn_mute_ensure()
if l == null or Value.kind(l) != 5 { return }
for i in 0 .. Value.count(l) {
let e = Value.at(l, i)
if Value.kind(e) != 4 { continue }
let k = Value.as_str(e)
let id = hints_find(k)
if id >= 0 { hn_mute[id] = true } else { push(hn_foreign, k) }
}
}

View file

@ -0,0 +1,5 @@
# ludic.hints - a rail of things that are true now and can be acted on now, sorted by how much they
# matter, each one a card that teaches it and can be muted for good. Uses ludic.base and nothing else.
package "ludic.hints"
version "0.1.0"
kind source

View file

@ -0,0 +1,8 @@
# port.ludic - what the rail asks the game. Unbound: nothing is ever true, and the rail may show.
export port HintsWorld {
true_now: fn(int) -> bool = fn hn_never # is card i true right now? Pure and cheap: asked once a pass
showing: fn() -> bool = fn hn_yes # the rail is wanted (in play, the player's switch on)
}
function hn_never(i: int) -> bool { return false }
function hn_yes() -> bool { return true }

View file

@ -0,0 +1,102 @@
# rail.ludic - the rail: once a pass (a second by default) the most urgent true cards, up to a few,
# muted ones left out; in a tie the registry's order decides
var hn_shown: int = 3 # how many the rail holds
var hn_every: float = 1.0 # seconds between passes
var hn_top: []int = null # the cards on the rail this pass
var hn_n: int = 0
var hn_t: float = 0.0 # seconds to the next pass
var hn_pick: int = 0 # which chip the key would open
var hn_card: int = -1 # the card being read, or -1
var hn_facts: Queue<HintsFact> = null
export function hints_facts() -> Queue<HintsFact> {
if hn_facts == null { hn_facts = queue_new("hints.facts") }
return hn_facts
}
function hn_fact(what: int, card: int) -> void {
let f = new HintsFact
f.what = what
f.card = card
q_push(hints_facts(), f)
}
# how many the rail holds and how often it is sorted
export function hints_config(shown: int, every: float) -> void {
hn_shown = shown
hn_every = every
hn_top = null
}
function hn_ensure() -> void {
if hn_top == null or len(hn_top) != hn_shown {
hn_top = new []int
for i in 0 .. hn_shown { push(hn_top, -1) }
}
hn_mute_ensure()
}
# the next pass comes on the next tick rather than in a second
export function hints_refresh() -> void { hn_t = 0.0 }
export function hints_tick(dt: float) -> void {
hn_ensure()
hn_t = hn_t - dt
if hn_t > 0.0 { return }
hn_t = hn_every
hints_sort()
}
# one pass now: the rail re-sorted, the pick kept in range, and the card following its subject
# along the rail - or put down when what it was about stopped being true
export function hints_sort() -> void {
hn_ensure()
hn_n = 0
let on = HintsWorld.showing()
for u in 0 .. 4 {
let want = HINTS_URGENT - u
for id in 0 .. HINT_COUNT {
if not on or hn_n >= hn_shown { break }
if Hints[id].urgency != want or hints_muted(id) { continue }
if not HintsWorld.true_now(id) { continue }
hn_top[hn_n] = id
hn_n += 1
}
}
if hn_pick >= hn_n { hn_pick = 0 }
if hn_card < 0 { return }
if not on or hints_muted(hn_card) or not HintsWorld.true_now(hn_card) {
hn_card = -1
return
}
for k in 0 .. hn_n { if hn_top[k] == hn_card { hn_pick = k } }
}
# the cards on the rail, most urgent first (empty while the rail is not showing)
export function hints_rail() -> []int {
hn_ensure()
let out = new []int
if not HintsWorld.showing() { return out }
for k in 0 .. hn_n { push(out, hn_top[k]) }
return out
}
export function hints_count() -> int { return hn_n }
export function hints_at(k: int) -> int {
if k < 0 or k >= hn_n { return -1 }
return hn_top[k]
}
export function hints_pick() -> int { return hn_pick }
export function hints_hot(k: int) -> bool { return k == hn_pick }
export function hints_reset() -> void {
hn_top = null
hn_n = 0
hn_t = 0.0
hn_pick = 0
hn_card = -1
q_clear(hints_facts())
}

View file

@ -0,0 +1,10 @@
# system.ludic - the rail as a system in PH_PRESENT: it only reads the world, and a trip saves
# nothing of it (the mutes are the player's, kept wherever the game keeps its settings)
function hn_tick(t: Tick) -> void { hints_tick(t.dt) }
export function hints_system() -> System {
let s = system_new("hints", PH_PRESENT)
s.reset = fn hints_reset
s.tick = fn hn_tick
return s
}

View file

@ -0,0 +1,3 @@
# cards.lres - two cards from a file, after the test's own defs
bear { urgency: HINTS_URGENT, line: "Bear near", title: "A bear", icon: "bear" }
fire { urgency: HINTS_NOTE, line: "Light the fire", later: "Carry three logs." }

View file

@ -0,0 +1,162 @@
# hints_test.ludic - the rail sorted by urgency and capped, ties in registry order, the card walked
# along it and put down when it stops being true, muting by key across a save, cards from a file
import "ludic.hints"
import "ludic.base"
program HintsTest {
numbers float
def Hints thirsty { urgency: HINTS_NOTE, line: "Thirsty - drink", title: "Water", s1: "Drink at a shore", pic1: "water", item2: 7 }
def Hints parched { urgency: HINTS_URGENT, line: "You must drink now", title: "Water, now" }
def Hints rain { urgency: HINTS_TIP, line: "Rain coming" }
def Hints cold { urgency: HINTS_WARN, line: "Cold" }
def Hints night { urgency: HINTS_TIP, line: "Night soon" }
def Hints from "cards.lres"
var on: []bool = null
var showing: bool = true
function fake_true(i: int) -> bool { return on[i] }
function fake_showing() -> bool { return showing }
bind HintsWorld { true_now: fn fake_true, showing: fn fake_showing }
function fresh() -> void {
on = new []bool
for i in 0 .. HINT_COUNT { push(on, false) }
showing = true
hints_config(3, 1.0)
hints_reset()
hints_mutes_load(Value.list())
}
function count(what: int) -> int {
let fs = q_drain(hints_facts())
var n = 0
for i in 0 .. len(fs) { if fs[i].what == what { n += 1 } }
return n
}
test "the registry is the game's, and a file adds to it" {
expect_eq(HINT_COUNT, 7)
expect_eq(hints_find("parched"), HINT_PARCHED)
expect_eq(Hints[HINT_BEAR].urgency, HINTS_URGENT)
expect(Hints[HINT_FIRE].later == "Carry three logs.")
expect(hints_step_icon(HINT_THIRSTY, 1) == "water")
expect_eq(hints_step_item(HINT_THIRSTY, 2), 7)
expect(hints_step_text(HINT_THIRSTY, 1) == "Drink at a shore")
}
test "the most urgent true cards, capped, ties in registry order" {
fresh()
for i in 0 .. HINT_COUNT { on[i] = true }
hints_tick(1.0)
expect_eq(hints_count(), 3)
expect_eq(hints_at(0), HINT_PARCHED)
expect_eq(hints_at(1), HINT_BEAR)
expect_eq(hints_at(2), HINT_COLD)
on[HINT_PARCHED] = false
on[HINT_BEAR] = false
hints_tick(0.5)
expect_eq(hints_at(0), HINT_PARCHED) # not a second yet: the rail holds
hints_tick(0.6)
expect_eq(hints_at(0), HINT_COLD)
expect_eq(hints_at(1), HINT_THIRSTY)
expect_eq(hints_at(2), HINT_FIRE)
}
test "nothing on the rail while it is not showing" {
fresh()
on[HINT_COLD] = true
showing = false
hints_sort()
expect_eq(len(hints_rail()), 0)
expect_eq(hints_count(), 0)
showing = true
hints_sort()
expect_eq(len(hints_rail()), 1)
}
test "the key opens the picked chip, the arrows walk round, the key puts it down" {
fresh()
on[HINT_COLD] = true
on[HINT_THIRSTY] = true
on[HINT_RAIN] = true
hints_sort()
hints_key()
expect_eq(hints_card(), HINT_COLD)
hints_move(1)
expect_eq(hints_card(), HINT_THIRSTY)
expect(hints_hot(1))
hints_move(-1)
hints_move(-1)
expect_eq(hints_card(), HINT_RAIN)
hints_key()
expect_eq(hints_card(), -1)
expect_eq(count(HINTS_F_OPENED), 4)
}
test "the card follows its subject as the rail re-sorts, and goes when it is no longer true" {
fresh()
on[HINT_THIRSTY] = true
on[HINT_RAIN] = true
hints_sort()
hints_open(1)
expect_eq(hints_card(), HINT_RAIN)
on[HINT_COLD] = true
hints_sort()
expect_eq(hints_card(), HINT_RAIN)
expect_eq(hints_pick(), 2)
on[HINT_RAIN] = false
hints_sort()
expect_eq(hints_card(), -1)
expect_eq(count(HINTS_F_CLOSED), 0) # it went; nobody put it down
}
test "I know this: muted off the rail, counted, given back" {
fresh()
on[HINT_COLD] = true
on[HINT_THIRSTY] = true
hints_sort()
hints_key()
hints_mute_card()
expect_eq(hints_card(), -1)
expect(hints_muted(HINT_COLD))
expect_eq(hints_mute_count(), 1)
expect_eq(count(HINTS_F_MUTED), 1)
hints_tick(0.0) # the next pass comes at once
expect_eq(hints_at(0), HINT_THIRSTY)
expect_eq(hints_count(), 1)
hints_unmute_all()
hints_tick(0.0)
expect_eq(hints_at(0), HINT_COLD)
expect_eq(count(HINTS_F_UNMUTED), 1)
}
test "mutes are kept by key, and a key this build lacks is carried through" {
fresh()
hints_mute(HINT_NIGHT, true)
hints_mute(HINT_BEAR, true)
let l = hints_mutes_save()
expect_eq(Value.count(l), 2)
Value.add(l, Value.str("gone_card"))
Value.add(l, Value.int(3)) # not a key: ignored
hints_mutes_load(l)
expect(hints_muted(HINT_NIGHT))
expect(hints_muted(HINT_BEAR))
expect(not hints_muted(HINT_RAIN))
expect_eq(hints_mute_count(), 2)
let back = hints_mutes_save()
expect_eq(Value.count(back), 3)
expect(Value.as_str(Value.at(back, 2)) == "gone_card")
hints_mutes_load(null)
expect_eq(hints_mute_count(), 0)
}
test "as a system it ticks the rail" {
fresh()
core_clear()
core_add(hints_system())
core_reset_all()
on[HINT_FIRE] = true
core_tick_all(tick_new(1.0, 1, 0.0))
expect_eq(hints_at(0), HINT_FIRE)
}
}