@almadar/std 16.182.0 → 16.183.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (30) hide show
  1. package/behaviors/lolo/infra/atoms/std-migration-job.lolo +2 -2
  2. package/behaviors/lolo/ui/game/atoms/std-fx-trail.lolo +63 -0
  3. package/behaviors/lolo/ui/game/atoms/std-gate-lock.lolo +96 -0
  4. package/behaviors/lolo/ui/game/atoms/std-lives.lolo +121 -0
  5. package/behaviors/lolo/ui/game/atoms/std-patrol-hazard.lolo +96 -0
  6. package/behaviors/lolo/ui/game/atoms/std-tutorial-overlay.lolo +164 -0
  7. package/behaviors/lolo/ui/learning/atoms/std-learn-slope-probe.lolo +117 -0
  8. package/behaviors/lolo/ui/learning/atoms/ui-math-canvas.lolo +18 -0
  9. package/behaviors/registry/infra/atoms/std-migration-job.orb +2 -2
  10. package/behaviors/registry/ui/game/atoms/std-fx-trail.orb +376 -0
  11. package/behaviors/registry/ui/game/atoms/std-gate-lock.orb +852 -0
  12. package/behaviors/registry/ui/game/atoms/std-lives.orb +739 -0
  13. package/behaviors/registry/ui/game/atoms/std-patrol-hazard.orb +768 -0
  14. package/behaviors/registry/ui/game/atoms/std-tutorial-overlay.orb +1060 -0
  15. package/behaviors/registry/ui/learning/atoms/std-learn-slope-probe.orb +908 -0
  16. package/dist/behaviors/exports-reader.js +2674 -2410
  17. package/dist/behaviors/functions/index.d.ts +534 -1
  18. package/dist/behaviors/functions/index.js +2657 -2411
  19. package/dist/behaviors/index.d.ts +1 -1
  20. package/dist/behaviors/index.js +2675 -2411
  21. package/dist/behaviors/query.js +2674 -2410
  22. package/dist/behaviors-embeddings.hash.json +3 -3
  23. package/dist/behaviors-embeddings.json +294 -270
  24. package/dist/behaviors-registry.json +1873 -390
  25. package/dist/index.d.ts +1 -1
  26. package/dist/index.js +2675 -2411
  27. package/dist/knob-embeddings.hash.json +3 -3
  28. package/dist/knob-embeddings.json +1 -1
  29. package/dist/registry/factory-signatures.json +1 -1
  30. package/package.json +2 -2
@@ -297,9 +297,9 @@ orbital MigrationJobOrbital {
297
297
  @description "Name of the host entity this import writes into (e.g. the organism's own entity). The migration service derives the full target descriptor from the running app's schema."
298
298
  @synonyms "destination, import into, target type"
299
299
  @tier "domain"
300
- connectionRef : string = ""
300
+ connectionRef : secret = ""
301
301
  @label "Connection reference"
302
- @description "Secret-store / environment KEY holding the source credentials. The reference is stored; the secret itself never enters config, traces, or snapshots — the migration service resolves it at run time. (Typed `string` until the `secret` config-field type lands in the lolo language tracked gap.)"
302
+ @description "Secret-store / environment KEY holding the source credentials. The reference is stored; the secret itself never enters config, traces, or snapshots — the migration service resolves it at run time. (Typed `secret` the config-field type that marks a credential reference; UI password-renders it and traces mask it.)"
303
303
  @synonyms "credential key, connection string reference, secret name"
304
304
  @tier "internal"
305
305
  reviewSlot : slot = modal
@@ -0,0 +1,63 @@
1
+ app std-fx-trail "1.0.0"
2
+ "Headless motion-trail mechanic: records the moving body's recent positions as timestamped dots on a [runtime, shared] entity so the render layer can draw a fading comet trail. Rides the body authority's BODY_MOVED — appends a dot only when the body's speed exceeds minSpeed (standing still leaves no trail) and prunes dots older than ttl on the same pass, so no decay tick is needed and a stopped body's trail simply stops growing (the render side fades each dot by bornAt/ttl). RESTART clears. Renders nothing."
3
+ orbital FxTrailOrbital {
4
+ type TrailDot = {
5
+ id : string!
6
+ x : number!
7
+ y : number!
8
+ bornAt : number!
9
+ ttl : number!
10
+ }
11
+
12
+ entity FxTrailData [runtime, shared] {
13
+ id : string!
14
+ trail : [TrailDot] = []
15
+ seq : number = 0
16
+ }
17
+
18
+ trait FxTrail -> @rebindable FxTrailData [lifecycle] {
19
+ initial: active
20
+
21
+ state active {
22
+ INIT -> active
23
+ (set @entity.trail [])
24
+ (set @entity.seq 0)
25
+
26
+ BODY_MOVED -> active when @config.running
27
+ (set @entity.trail
28
+ (array/filter
29
+ (if (> @payload.speed @config.minSpeed)
30
+ (array/concat @entity.trail [ { id: (str/concat "trail-" (+ @entity.seq 1)), x: @payload.x, y: @payload.y, bornAt: (time/now), ttl: @config.ttl } ])
31
+ @entity.trail)
32
+ (fn d (< (- (time/now) (object/get @d bornAt)) (object/get @d ttl)))))
33
+ (if (> @payload.speed @config.minSpeed)
34
+ (set @entity.seq (+ @entity.seq 1))
35
+ null)
36
+
37
+ RESTART -> active
38
+ (set @entity.trail [])
39
+ }
40
+
41
+ listens {
42
+ BODY_MOVED { x : number!, y : number!, vx : number!, vy : number!, grounded : boolean!, skating : boolean!, speed : number! }
43
+ RESTART { id : string }
44
+ }
45
+
46
+ config {
47
+ running : boolean = true
48
+ @label "Running"
49
+ @description "When true, BODY_MOVED appends trail dots; toggle to freeze the trail (existing dots still age out)."
50
+ @tier "domain"
51
+ ttl : number = 600
52
+ @label "Trail Ttl (ms)"
53
+ @description "Milliseconds a trail dot lives; at a 33ms move cadence ~600ms keeps the last eighteen positions. The ttl is also the natural length cap — no separate limit needed."
54
+ @tier "presentation"
55
+ minSpeed : number = 0.5
56
+ @label "Min Speed"
57
+ @description "Body speed (units/s, from BODY_MOVED) below which no dot is appended, so a resting body doesn't pile dots on one spot."
58
+ @tier "domain"
59
+ }
60
+ }
61
+
62
+ page "/fx-trail" as FxTrailPage -> FxTrail
63
+ }
@@ -0,0 +1,96 @@
1
+ app std-gate-lock "1.0.0"
2
+ "Headless collectible-gate mechanic: doors that stay shut until the player's collected total reaches their cost. Listens COLLECT (the board's running total — stars, coins, keys) and flips every door whose cost is met, stamping open on the shared door rows and emitting GATE_OPENED for the first newly opened door (the shared entity is the render authority for the rest). While a closed door's rect contains the player it emits GATE_BLOCKED once per approach — re-arming only after the player steps out — so the board can answer with a PLACE back and a locked fx. RESTART re-locks every door and zeroes the total. Renders nothing; boards draw closed doors as bars/walls and open ones as gaps."
3
+ orbital GateLockOrbital {
4
+ type Door = {
5
+ id : string!
6
+ x : number!
7
+ y : number!
8
+ width : number!
9
+ height : number!
10
+ cost : number!
11
+ open : boolean
12
+ touching : boolean
13
+ blocked : boolean
14
+ }
15
+
16
+ entity GateLockData [runtime, shared] {
17
+ id : string!
18
+ doors : [Door] = []
19
+ total : number = 0
20
+ }
21
+
22
+ trait GateLock -> @rebindable GateLockData [lifecycle] {
23
+ initial: active
24
+
25
+ state active {
26
+ INIT -> active
27
+ (set @entity.doors (array/map @config.doors (fn d (object/merge @d { open: false, touching: false, blocked: false }))))
28
+ (set @entity.total 0)
29
+
30
+ COLLECT -> active when @config.running
31
+ (set @entity.total @payload.total)
32
+ (let ((newly (array/filter @entity.doors (fn d (and (== (object/get @d open) false) (<= (object/get @d cost) @payload.total))))))
33
+ (if (> (array/len @newly) 0)
34
+ (do
35
+ (set @entity.doors (array/map @entity.doors (fn d (if (and (== (object/get @d open) false) (<= (object/get @d cost) @payload.total)) (object/merge @d { open: true }) @d))))
36
+ (emit GATE_OPENED { id: (object/get (array/first @newly) id), cost: (object/get (array/first @newly) cost) }))
37
+ null))
38
+
39
+ BODY_MOVED -> active when @config.running
40
+ (set @entity.doors (array/map @entity.doors (fn d
41
+ (let ((inside (and (== (object/get @d open) false)
42
+ (and (>= @payload.x (- (object/get @d x) @config.reachMargin))
43
+ (and (<= @payload.x (+ (object/get @d x) (object/get @d width) @config.reachMargin))
44
+ (and (>= @payload.y (- (object/get @d y) @config.reachMargin))
45
+ (<= @payload.y (+ (object/get @d y) (object/get @d height) @config.reachMargin))))))))
46
+ (object/merge @d { touching: @inside, blocked: (and @inside (== (object/get @d touching) false)) })))))
47
+ (let ((blocked (array/filter @entity.doors (fn d (== (object/get @d blocked) true)))))
48
+ (if (> (array/len @blocked) 0)
49
+ (emit GATE_BLOCKED { id: (object/get (array/first @blocked) id), x: (object/get (array/first @blocked) x), y: (object/get (array/first @blocked) y) })
50
+ null))
51
+
52
+ RESTART -> active
53
+ (set @entity.doors (array/map @config.doors (fn d (object/merge @d { open: false, touching: false, blocked: false }))))
54
+ (set @entity.total 0)
55
+ }
56
+
57
+ emits {
58
+ GATE_OPENED -> external {
59
+ id : string!
60
+ cost : number!
61
+ }
62
+ @description "A door's cost was reached by the collected total and it flipped open; boards layer unlock fx. When several doors open on one total, the first reports and the shared entity carries the rest."
63
+ @tier "domain"
64
+ GATE_BLOCKED -> external {
65
+ id : string!
66
+ x : number!
67
+ y : number!
68
+ }
69
+ @description "The player is inside a closed door's rect; fires once per approach and re-arms after they step out. Boards answer with a PLACE back plus a locked fx."
70
+ @tier "domain"
71
+ }
72
+
73
+ listens {
74
+ COLLECT { total : number! }
75
+ BODY_MOVED { x : number!, y : number!, vx : number!, vy : number!, grounded : boolean!, skating : boolean!, speed : number! }
76
+ RESTART { id : string }
77
+ }
78
+
79
+ config {
80
+ running : boolean = true
81
+ @label "Running"
82
+ @description "When true, COLLECT opens doors and closed doors report contact; toggle to freeze every door as-is."
83
+ @tier "domain"
84
+ doors : [Door] = []
85
+ @label "Doors"
86
+ @description "One row per gate: id, min-corner rect (x, y, width, height in world units) and the collected total that opens it. open/touching are stamped at runtime."
87
+ @tier "domain"
88
+ reachMargin : number = 0.6
89
+ @label "Reach Margin"
90
+ @description "Extra world units padded around a closed door's rect when testing player contact (approximates the body's size)."
91
+ @tier "domain"
92
+ }
93
+ }
94
+
95
+ page "/gate-lock" as GateLockPage -> GateLock
96
+ }
@@ -0,0 +1,121 @@
1
+ app std-lives "1.0.0"
2
+ "Headless lives + checkpoint authority for boards with stakes: owns lives, the current respawn point, and nothing else. DAMAGE (call sites rename their hazard sources — BODY_HAZARD, BODY_FELL, PATROL_HIT — onto it) costs one life, respecting an invulnerability window so overlapping hazards can't chain-drain; losing a life emits LIFE_LOST plus RESPAWN with the current checkpoint (boards route RESPAWN to the body authority's PLACE), and losing the last emits GAME_OVER instead. Checkpoints advance automatically: BODY_MOVED crossing a configured checkpoint's atX moves the respawn point forward and emits CHECKPOINT_REACHED once per checkpoint (tracked by furthest atX reached, so checkpoints are ordered along the run). RESTART reseeds lives and respawn to the spawn. Renders nothing — HUDs read lives off the shared entity."
3
+ orbital LivesOrbital {
4
+ type Checkpoint = {
5
+ id : string!
6
+ atX : number!
7
+ x : number!
8
+ y : number!
9
+ }
10
+
11
+ entity LivesData [runtime, shared] {
12
+ id : string!
13
+ lives : number = 3
14
+ respawnX : number = 0
15
+ respawnY : number = 0
16
+ cpX : number = -1000000
17
+ lastHitAt : number = 0
18
+ }
19
+
20
+ trait Lives -> @rebindable LivesData [lifecycle] {
21
+ initial: active
22
+
23
+ state active {
24
+ INIT -> active
25
+ (set @entity.lives @config.maxLives)
26
+ (set @entity.respawnX @config.spawnX)
27
+ (set @entity.respawnY @config.spawnY)
28
+ (set @entity.cpX (- 0 1000000))
29
+ (set @entity.lastHitAt 0)
30
+
31
+ DAMAGE -> active when (and @config.running (> @entity.lives 0))
32
+ (if (>= (- (time/now) @entity.lastHitAt) @config.invulnMs)
33
+ (do
34
+ (set @entity.lastHitAt (time/now))
35
+ (set @entity.lives (- @entity.lives 1))
36
+ (if (> @entity.lives 0)
37
+ (do
38
+ (emit LIFE_LOST { lives: @entity.lives })
39
+ (emit RESPAWN { x: @entity.respawnX, y: @entity.respawnY }))
40
+ (emit GAME_OVER { id: "lives" })))
41
+ null)
42
+
43
+ BODY_MOVED -> active when @config.running
44
+ (let ((reached (array/filter @config.checkpoints (fn c (and (<= (object/get @c atX) @payload.x) (> (object/get @c atX) @entity.cpX))))))
45
+ (if (> (array/len @reached) 0)
46
+ (let ((cp (array/last @reached)))
47
+ (do
48
+ (set @entity.respawnX (object/get @cp x))
49
+ (set @entity.respawnY (object/get @cp y))
50
+ (set @entity.cpX (object/get @cp atX))
51
+ (emit CHECKPOINT_REACHED { id: (object/get @cp id) })))
52
+ null))
53
+
54
+ RESTART -> active
55
+ (set @entity.lives @config.maxLives)
56
+ (set @entity.respawnX @config.spawnX)
57
+ (set @entity.respawnY @config.spawnY)
58
+ (set @entity.cpX (- 0 1000000))
59
+ (set @entity.lastHitAt 0)
60
+ }
61
+
62
+ emits {
63
+ LIFE_LOST -> external {
64
+ lives : number!
65
+ }
66
+ @description "A life was spent (post-invulnerability check); carries the lives remaining. Boards layer damage fx / HUD heartbeat."
67
+ @tier "domain"
68
+ RESPAWN -> external {
69
+ x : number!
70
+ y : number!
71
+ }
72
+ @description "The checkpoint the body should restart from after this life loss; boards forward it to the body authority's PLACE."
73
+ @tier "domain"
74
+ GAME_OVER -> external {
75
+ id : string!
76
+ }
77
+ @description "The last life was spent; boards show the game-over overlay and wait for RESTART."
78
+ @tier "domain"
79
+ CHECKPOINT_REACHED -> external {
80
+ id : string!
81
+ }
82
+ @description "The player crossed a checkpoint's atX for the first time this run; the respawn point moved to it. Boards layer flag/save fx."
83
+ @tier "domain"
84
+ }
85
+
86
+ listens {
87
+ DAMAGE { source : string }
88
+ BODY_MOVED { x : number!, y : number!, vx : number!, vy : number!, grounded : boolean!, skating : boolean!, speed : number! }
89
+ RESTART { id : string }
90
+ }
91
+
92
+ config {
93
+ running : boolean = true
94
+ @label "Running"
95
+ @description "When true, DAMAGE and checkpoint crossings are processed; toggle to make the run harmless (practice mode)."
96
+ @tier "domain"
97
+ maxLives : number = 3
98
+ @label "Max Lives"
99
+ @description "Lives seeded on INIT/RESTART; reaching zero fires GAME_OVER."
100
+ @tier "domain"
101
+ invulnMs : number = 1500
102
+ @label "Invulnerability Window (ms)"
103
+ @description "Milliseconds after a accepted hit during which further DAMAGE is ignored, so a hazard overlapping the respawn can't chain-drain."
104
+ @tier "domain"
105
+ spawnX : number = 0
106
+ @label "Spawn X"
107
+ @description "Respawn x before any checkpoint is reached (match the body authority's spawn)."
108
+ @tier "domain"
109
+ spawnY : number = 0
110
+ @label "Spawn Y"
111
+ @description "Respawn y before any checkpoint is reached."
112
+ @tier "domain"
113
+ checkpoints : [Checkpoint] = []
114
+ @label "Checkpoints"
115
+ @description "Ordered along the run: when the player's x crosses atX the respawn point moves to (x, y). Must be sorted by atX ascending."
116
+ @tier "domain"
117
+ }
118
+ }
119
+
120
+ page "/lives" as LivesPage -> Lives
121
+ }
@@ -0,0 +1,96 @@
1
+ app std-patrol-hazard "1.0.0"
2
+ "Headless patrol-hazard mechanic for side-view boards: moves each configured patrol back and forth along its [x0, x1] beat at its own speed (33ms tick, ping-pong with clamped turnarounds), tracks the player position off the body authority's BODY_MOVED, and emits PATROL_HIT once per contact — a per-patrol hit flag re-arms only after the player separates, so damage rules stay sparse (when two patrols connect on the same tick the first reports; the other reports on the next tick). Patrols live on a [runtime, shared] entity for the render layer (boards draw them as markers/sprites); damage, lives, and respawn stay downstream (std-lives) — this atom simulates, it never judges. Renders nothing."
3
+ orbital PatrolHazardOrbital {
4
+ type Patrol = {
5
+ id : string!
6
+ x0 : number!
7
+ x1 : number!
8
+ y : number!
9
+ speed : number!
10
+ x : number
11
+ dir : number
12
+ touching : boolean
13
+ hit : boolean
14
+ }
15
+
16
+ entity PatrolState [runtime, shared] {
17
+ id : string!
18
+ patrols : [Patrol] = []
19
+ px : number = 0
20
+ py : number = 0
21
+ }
22
+
23
+ trait PatrolHazard -> @rebindable PatrolState [lifecycle] {
24
+ initial: active
25
+
26
+ state active {
27
+ INIT -> active
28
+ (set @entity.patrols (array/map @config.patrols (fn p (object/merge @p { x: (object/get @p x0), dir: 1, touching: false, hit: false }))))
29
+ (set @entity.px @config.playerSpawnX)
30
+ (set @entity.py @config.playerSpawnY)
31
+
32
+ BODY_MOVED -> active
33
+ (set @entity.px @payload.x)
34
+ (set @entity.py @payload.y)
35
+
36
+ RESTART -> active
37
+ (set @entity.patrols (array/map @config.patrols (fn p (object/merge @p { x: (object/get @p x0), dir: 1, touching: false, hit: false }))))
38
+ }
39
+
40
+ ticks {
41
+ patrolTick every 33ms
42
+ when (and @config.running (> (array/len @entity.patrols) 0))
43
+ (set @entity.patrols (array/map @entity.patrols (fn p
44
+ (let ((nx (+ (object/get @p x) (* (* (object/get @p dir) (object/get @p speed)) 0.033))))
45
+ (let ((overshoot (> @nx (object/get @p x1))) (undershoot (< @nx (object/get @p x0))))
46
+ (let ((cx (if @overshoot (object/get @p x1) (if @undershoot (object/get @p x0) @nx)))
47
+ (cdir (if @overshoot -1 (if @undershoot 1 (object/get @p dir)))))
48
+ (let ((near (and (<= (math/abs (- @entity.px @cx)) @config.hitRadius) (<= (math/abs (- @entity.py (object/get @p y))) @config.hitRadius))))
49
+ (object/merge @p { x: @cx, dir: @cdir, hit: (and @near (== (object/get @p touching) false)), touching: @near }))))))))
50
+ (let ((hits (array/filter @entity.patrols (fn p (== (object/get @p hit) true)))))
51
+ (if (> (array/len @hits) 0)
52
+ (emit PATROL_HIT { id: (object/get (array/first @hits) id), x: (object/get (array/first @hits) x), y: (object/get (array/first @hits) y) })
53
+ null))
54
+ }
55
+
56
+ emits {
57
+ PATROL_HIT -> external {
58
+ id : string!
59
+ x : number!
60
+ y : number!
61
+ }
62
+ @description "A patrol made contact with the player this tick; fires once per contact and re-arms only after separation — boards route it to their damage rule (std-lives DAMAGE)."
63
+ @tier "domain"
64
+ }
65
+
66
+ listens {
67
+ BODY_MOVED { x : number!, y : number!, vx : number!, vy : number!, grounded : boolean!, skating : boolean!, speed : number! }
68
+ RESTART { id : string }
69
+ }
70
+
71
+ config {
72
+ running : boolean = true
73
+ @label "Running"
74
+ @description "When true the 33ms tick moves patrols and tests contact; toggle to freeze every patrol in place."
75
+ @tier "domain"
76
+ patrols : [Patrol] = []
77
+ @label "Patrols"
78
+ @description "One row per patroller: id, the [x0, x1] beat it walks, its fixed y, and its speed in units/s. Current x/dir/touching/hit are stamped at runtime."
79
+ @tier "domain"
80
+ hitRadius : number = 0.7
81
+ @label "Hit Radius"
82
+ @description "Per-axis distance (world units) within which a patrol counts as touching the player; smaller values demand precise contact, larger values forgive."
83
+ @tier "domain"
84
+ playerSpawnX : number = 0
85
+ @label "Player Spawn X"
86
+ @description "Player x assumed before the first BODY_MOVED arrives (match the body authority's spawn)."
87
+ @tier "domain"
88
+ playerSpawnY : number = 0
89
+ @label "Player Spawn Y"
90
+ @description "Player y assumed before the first BODY_MOVED arrives."
91
+ @tier "domain"
92
+ }
93
+ }
94
+
95
+ page "/patrol-hazard" as PatrolHazardPage -> PatrolHazard
96
+ }
@@ -0,0 +1,164 @@
1
+ app std-tutorial-overlay "1.0.0"
2
+ "Tutorial modal with media steps for game boards and learning shells: a paged overlay — title, an animated GIF/clip showing exactly how the mechanic is done, one line of body copy, a step counter, and Back / Next / Skip — composed entirely from registered render types (stack + text + image + button) into the overlay slot, so no new UI component is needed. autoShow opens it on INIT for first-time players; a board's help button re-opens it any time via SHOW (which rewinds to step one). DISMISS (or finishing the last step) marks the run done, clears the overlay, and emits TUTORIAL_DONE so the board can unpause or log. Steps are pure config — a board teaches a new mechanic by adding one { id, title, body, media } row."
3
+ orbital TutorialOverlayOrbital {
4
+ type TutorialStep = {
5
+ id : string!
6
+ title : string!
7
+ body : string
8
+ media : string
9
+ mediaAlt : string
10
+ }
11
+
12
+ entity TutorialState [runtime, shared] {
13
+ id : string!
14
+ step : number = 0
15
+ done : boolean = false
16
+ }
17
+
18
+ trait TutorialOverlay -> @rebindable TutorialState [interaction, instance] {
19
+ initial: hidden
20
+
21
+ state hidden {
22
+ INIT -> hidden
23
+ (if @config.autoShow
24
+ (emit TUT_AUTO { id: "auto" })
25
+ (render-ui overlay null))
26
+
27
+ TUT_AUTO -> showing
28
+ (set @entity.step 0)
29
+ (render-ui overlay {
30
+ type: stack
31
+ direction: vertical
32
+ className: "bg-card text-card-foreground border border-border rounded-lg shadow-lg p-6 max-w-md mx-auto mt-16"
33
+ children: [
34
+ { type: typography, content: (object/get (array/nth @config.steps 0) title), variant: "h4", weight: "bold" }
35
+ { type: atlas-image, asset: { url: (object/get (array/nth @config.steps 0) media), name: (object/get (array/nth @config.steps 0) id), category: "tutorial" }, fit: "contain", alt: (object/get (array/nth @config.steps 0) mediaAlt), className: "rounded-md" }
36
+ { type: typography, content: (object/get (array/nth @config.steps 0) body), variant: "body" }
37
+ { type: typography, content: (str/concat "1 / " (array/len @config.steps)), variant: "caption", align: "center" }
38
+ { type: stack, direction: horizontal, justify: "center", className: "pt-2", children: [
39
+ { type: button, label: (if (> (array/len @config.steps) 1) "Next ▶" "Got it"), action: "TUT_NEXT", variant: "primary" }
40
+ { type: button, label: "Skip", action: "TUT_DISMISS", variant: "secondary" }
41
+ ] }
42
+ ]
43
+ })
44
+
45
+ SHOW -> showing
46
+ (set @entity.step 0)
47
+ (render-ui overlay {
48
+ type: stack
49
+ direction: vertical
50
+ className: "bg-card text-card-foreground border border-border rounded-lg shadow-lg p-6 max-w-md mx-auto mt-16"
51
+ children: [
52
+ { type: typography, content: (object/get (array/nth @config.steps 0) title), variant: "h4", weight: "bold" }
53
+ { type: atlas-image, asset: { url: (object/get (array/nth @config.steps 0) media), name: (object/get (array/nth @config.steps 0) id), category: "tutorial" }, fit: "contain", alt: (object/get (array/nth @config.steps 0) mediaAlt), className: "rounded-md" }
54
+ { type: typography, content: (object/get (array/nth @config.steps 0) body), variant: "body" }
55
+ { type: typography, content: (str/concat "1 / " (array/len @config.steps)), variant: "caption", align: "center" }
56
+ { type: stack, direction: horizontal, justify: "center", className: "pt-2", children: [
57
+ { type: button, label: (if (> (array/len @config.steps) 1) "Next ▶" "Got it"), action: "TUT_NEXT", variant: "primary" }
58
+ { type: button, label: "Skip", action: "TUT_DISMISS", variant: "secondary" }
59
+ ] }
60
+ ]
61
+ })
62
+ }
63
+
64
+ state showing {
65
+ TUT_NEXT -> showing when (< (+ @entity.step 1) (array/len @config.steps))
66
+ (set @entity.step (+ @entity.step 1))
67
+ (render-ui overlay {
68
+ type: stack
69
+ direction: vertical
70
+ className: "bg-card text-card-foreground border border-border rounded-lg shadow-lg p-6 max-w-md mx-auto mt-16"
71
+ children: [
72
+ { type: typography, content: (object/get (array/nth @config.steps @entity.step) title), variant: "h4", weight: "bold" }
73
+ { type: atlas-image, asset: { url: (object/get (array/nth @config.steps @entity.step) media), name: (object/get (array/nth @config.steps @entity.step) id), category: "tutorial" }, fit: "contain", alt: (object/get (array/nth @config.steps @entity.step) mediaAlt), className: "rounded-md" }
74
+ { type: typography, content: (object/get (array/nth @config.steps @entity.step) body), variant: "body" }
75
+ { type: typography, content: (str/concat (+ @entity.step 1) " / " (array/len @config.steps)), variant: "caption", align: "center" }
76
+ { type: stack, direction: horizontal, justify: "center", className: "pt-2", children: [
77
+ { type: button, label: "◀ Back", action: "TUT_BACK", variant: "secondary" }
78
+ { type: button, label: (if (< (+ @entity.step 1) (array/len @config.steps)) "Next ▶" "Got it"), action: "TUT_NEXT", variant: "primary" }
79
+ { type: button, label: "Skip", action: "TUT_DISMISS", variant: "secondary" }
80
+ ] }
81
+ ]
82
+ })
83
+
84
+ TUT_NEXT -> hidden when (>= (+ @entity.step 1) (array/len @config.steps))
85
+ (set @entity.done true)
86
+ (render-ui overlay null)
87
+ (emit TUTORIAL_DONE { id: "tutorial" })
88
+
89
+ TUT_BACK -> showing when (> @entity.step 0)
90
+ (set @entity.step (- @entity.step 1))
91
+ (render-ui overlay {
92
+ type: stack
93
+ direction: vertical
94
+ className: "bg-card text-card-foreground border border-border rounded-lg shadow-lg p-6 max-w-md mx-auto mt-16"
95
+ children: [
96
+ { type: typography, content: (object/get (array/nth @config.steps @entity.step) title), variant: "h4", weight: "bold" }
97
+ { type: atlas-image, asset: { url: (object/get (array/nth @config.steps @entity.step) media), name: (object/get (array/nth @config.steps @entity.step) id), category: "tutorial" }, fit: "contain", alt: (object/get (array/nth @config.steps @entity.step) mediaAlt), className: "rounded-md" }
98
+ { type: typography, content: (object/get (array/nth @config.steps @entity.step) body), variant: "body" }
99
+ { type: typography, content: (str/concat (+ @entity.step 1) " / " (array/len @config.steps)), variant: "caption", align: "center" }
100
+ { type: stack, direction: horizontal, justify: "center", className: "pt-2", children: [
101
+ { type: button, label: "◀ Back", action: "TUT_BACK", variant: "secondary" }
102
+ { type: button, label: (if (< (+ @entity.step 1) (array/len @config.steps)) "Next ▶" "Got it"), action: "TUT_NEXT", variant: "primary" }
103
+ { type: button, label: "Skip", action: "TUT_DISMISS", variant: "secondary" }
104
+ ] }
105
+ ]
106
+ })
107
+
108
+ TUT_DISMISS -> hidden
109
+ (set @entity.done true)
110
+ (render-ui overlay null)
111
+ (emit TUTORIAL_DONE { id: "tutorial" })
112
+ }
113
+
114
+ emits {
115
+ TUT_AUTO -> external {
116
+ id : string
117
+ }
118
+ @description "Self-emitted on INIT when autoShow is on — opens step one. (A config-valued INIT guard would be constant-folded at compose time and misfire; the self-event keeps the decision in an effect.)"
119
+ @tier "essential"
120
+ TUT_NEXT -> external {
121
+ id : string
122
+ }
123
+ @description "Advance one step; on the last step it finishes the tutorial (marks done, clears the overlay, emits TUTORIAL_DONE)."
124
+ @tier "essential"
125
+ TUT_BACK -> external {
126
+ id : string
127
+ }
128
+ @description "Return one step (inert on the first)."
129
+ @tier "essential"
130
+ TUT_DISMISS -> external {
131
+ id : string
132
+ }
133
+ @description "Skip/close the tutorial; marks done and clears the overlay."
134
+ @tier "essential"
135
+ SHOW -> external {
136
+ id : string
137
+ }
138
+ @description "Re-open the tutorial from step one — wire a board's help button here."
139
+ @tier "essential"
140
+ TUTORIAL_DONE -> external {
141
+ id : string!
142
+ }
143
+ @description "The tutorial was finished or skipped; boards unpause or log completion."
144
+ @tier "domain"
145
+ }
146
+
147
+ listens {
148
+ SHOW { id : string }
149
+ }
150
+
151
+ config {
152
+ steps : [TutorialStep] = []
153
+ @label "Steps"
154
+ @description "One row per tutorial page: title, one line of body copy, and media — the URL of an animated GIF/clip showing exactly how the mechanic is performed (recorded from real gameplay)."
155
+ @tier "presentation"
156
+ autoShow : boolean = true
157
+ @label "Auto Show"
158
+ @description "When true the tutorial opens on INIT for this run; when false it stays hidden until SHOW."
159
+ @tier "domain"
160
+ }
161
+ }
162
+
163
+ page "/tutorial-overlay" as TutorialOverlayPage -> TutorialOverlay
164
+ }