Collapse Tetris' variable clusters, and give the long runs a home

Next move held four clusters of Set variable in a row, fourteen single nodes
in all; clear-lines read three variables one after the other. They are now
one plural node each, which takes Next move 40 -> 30 nodes and clear-lines
25 -> 23. Every remaining single node was checked and really is single: ok/no
are the same variable in opposite branches, take/keep the same one across a
write, and the three key setters hang on three different events.

Two contracts carry those merges, both pinned as tests. Set variables writes
per port with writeTyped exactly as the singular node does, so a Pack{rot}
still merges instead of overwriting - without it, turning would have erased
the other three fields. And the interpreter collects every input before the
node runs, so no input may come from a variable the same node writes; a data
node is evaluated once per pulse and keeps its answer. That second rule is
invisible in the game, because OnUpdate re-reads each frame and heals it, so
it lives in a unit test rather than in the smoke.

/opt/node22 and /opt/npm are gone from the tree. build.sh ran into the empty
path once they were moved away; the other seven mentions were documentation.

e2e.sh and check.sh replace the bare npm calls. A long run does not survive
the end of its shell - four full e2e runs died mid-suite with oom_kill 0 -
so e2e.sh start hands the run to a transient game-maker-e2e.service, which
does. e2e.sh status reports the running job or the last result, and calls a
log without a closing line ABORTED rather than reporting the passes it got
to. Both scripts end on an explicit exit line, because set -o pipefail inside
a script does not protect the caller: measured, ./check.sh | tail still
reports 0. Logs carry a timestamp and land in logs/, which is ignored.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
rootandClaude Opus 5 committed 2026-09-10 16:00:28 +02:00
1 parent f60358c951
commit fe3c63c48c
15 files changed
+439 -183

No files matched your search

+1
View File
@@ -6,4 +6,5 @@ data/projects/*.tmp
# Uploaded images are user data like the projects: content-addressed, written by the API, # Uploaded images are user data like the projects: content-addressed, written by the API,
# reproducible only by uploading them again. # reproducible only by uploading them again.
data/textures/ data/textures/
logs/
.DS_Store .DS_Store
+27 -2
View File
@@ -575,7 +575,7 @@ and reasons, not what the code does: a `/** */` on every export whose name does
## Development ## Development
Node 22 is required (on this machine: `PATH=/opt/node22/bin:$PATH`). Node 22 is required (`/usr/bin/node`, in the default PATH).
```bash ```bash
npm install npm install
@@ -585,10 +585,14 @@ npm run typecheck
npm run lint # ESLint, type-aware; --fix for the fixable half npm run lint # ESLint, type-aware; --fix for the fixable half
npm run format # Prettier over src, tests and tools npm run format # Prettier over src, tests and tools
npm run check # typecheck + lint + format:check + tests, what CI would run npm run check # typecheck + lint + format:check + tests, what CI would run
./check.sh # the same, ending in `=== CHECK EXIT n ===`: piped into `tail` npm's exit
# code is lost (tail's is reported), so a red run reads as green
npm run build # writes dist/ and regenerates index.html npm run build # writes dist/ and regenerates index.html
export GM_REMOTE=user@host # optional: run the browser elsewhere; see below export GM_REMOTE=user@host # optional: run the browser elsewhere; see below
node tools/seed-e2e.mjs && node tools/e2e.mjs # 456 editor checks, screenshots in /tmp/gm-e2e-*.png node tools/seed-e2e.mjs && node tools/e2e.mjs # 465 editor checks, screenshots in /tmp/gm-e2e-*.png
GM_STEPS=20 node tools/e2e.mjs # or 1-12, or 6,20 – a subset while working on one step GM_STEPS=20 node tools/e2e.mjs # or 1-12, or 6,20 – a subset while working on one step
./e2e.sh start [GM_STEPS] # the same run detached, see below
./e2e.sh status # live progress, or the last run's result
node tools/seed-jumprun.mjs && node tools/smoke-jumprun.mjs # 24 checks: the example game, played end to end node tools/seed-jumprun.mjs && node tools/smoke-jumprun.mjs # 24 checks: the example game, played end to end
node tools/seed-tetris.mjs && node tools/smoke-tetris.mjs # 19 checks: the Tetris example, played end to end node tools/seed-tetris.mjs && node tools/smoke-tetris.mjs # 19 checks: the Tetris example, played end to end
``` ```
@@ -606,6 +610,17 @@ four cores of this box (60% renderer, 18% GPU process, load ~1.0), which it carr
step is being worked on, `GM_STEPS` keeps a run to seconds – take the whole suite before a commit, step is being worked on, `GM_STEPS` keeps a run to seconds – take the whole suite before a commit,
or when a change touches drawing, the widget toolkit or anything else every step sees. or when a change touches drawing, the widget toolkit or anything else every step sees.
**`./e2e.sh start` runs the suite detached**, in a transient `game-maker-e2e.service`, and returns at
once; output goes to `logs/e2e-<date>_<time>.log` (gitignored) and ends in `=== E2E EXIT n ===`.
Backgrounding it from a shell instead is not enough – four runs in a row died mid-suite with no
closing line, because the whole process group is killed when the calling shell exits (`nohup` only
covers SIGHUP, and `/proc/vmstat` confirmed `oom_kill 0`, so it was never memory). The unit outlives
its caller. It takes the same `GM_STEPS` argument and refuses to start a second run over a live one.
**`./e2e.sh status`** reports the live run (elapsed, PASS/FAIL so far, the check it is on) or, when
nothing is running, the newest log's result plus its FAIL lines. A log without a closing line is
reported as `ABORTED`, not as its PASS count: a run that was cut short is not a result.
**`GM_REMOTE=user@host` moves the browser to another machine** – `tools/lib/browser.mjs` starts it **`GM_REMOTE=user@host` moves the browser to another machine** – `tools/lib/browser.mjs` starts it
over SSH and forwards its CDP port back to `127.0.0.1`, so the harness itself is unchanged. The over SSH and forwards its CDP port back to `127.0.0.1`, so the harness itself is unchanged. The
checks load the public base URL, so the remote side needs only a browser and internet: no repo, no checks load the public base URL, so the remote side needs only a browser and internet: no repo, no
@@ -677,6 +692,16 @@ parallel variables copied back and forth, and each strand packs only the field i
(`mergeStruct` keeps the rest). Reading a cell is `((shape · 4 + rot) · 4 + corner)` into that (`mergeStruct` keeps the rest). Reading a cell is `((shape · 4 + rot) · 4 + corner)` into that
list instead of the same index doubled and read twice. list instead of the same index doubled and read twice.
Where a step needs several variables at once, one `Get variables` / `Set variables` stands for the
lot: the call to `Next move` has **one node at each side** of it rather than five, and the master
graph is **44 nodes / 64 edges** where the same game in single nodes was 69 / 76. Inside `Next
move` each strand writes its whole answer – the candidate, what kind of move it was and the wish
it used up – in one node rather than three to five, which is **30 nodes** against 40. Two things
bound how far such a node may reach: a data node answers once per frame and keeps that answer, so
a `Get variables` must not straddle a write of one of its variables (which is why the board is
read a second time after the lines were cleared), and a `Set variables` reads all its inputs
before it writes any, so none of them may come from a variable it writes.
It is **ten behaviors**, not one graph. `Tetris` owns the state and does none of the work: each It is **ten behaviors**, not one graph. `Tetris` owns the state and does none of the work: each
step of a frame is a behavior of its own, called as a node – `Spawn piece`, `Next move`, `Fits`, step of a frame is a behavior of its own, called as a node – `Spawn piece`, `Next move`, `Fits`,
`Draw piece`, `Lock piece`, `Clear lines`, `Show lines`, `Draw board`, with `Piece cell` (where `Draw piece`, `Lock piece`, `Clear lines`, `Show lines`, `Draw board`, with `Piece cell` (where
+1 -1
View File
@@ -1,3 +1,3 @@
#!/bin/bash #!/bin/bash
set -o pipefail && PATH=/opt/node22/bin:$PATH npm run build set -o pipefail && npm run build
Executable
+11
View File
@@ -0,0 +1,11 @@
#!/bin/bash
# The full gate, ending in a line that survives a pipe: "=== CHECK EXIT n ===".
# `npm run check | tail -20` reports tail's exit code, not npm's, so a red run reads as green –
# and `set -o pipefail` in here does not help, it only covers pipes inside this script (measured).
# Reading the closing line does.
set -o pipefail
npm run check
code=$?
echo "=== CHECK EXIT $code ==="
exit $code
Executable
+109
View File
@@ -0,0 +1,109 @@
#!/bin/bash
# Runs the editor e2e suite in a transient systemd unit, which outlives the calling shell.
# A plain background job is not enough: when the caller exits, its whole process group is
# killed and the run dies mid-suite with no closing line.
#
# Usage: ./e2e.sh start [GM_STEPS] e.g. ./e2e.sh start 1-12
# ./e2e.sh status live progress, or the result of the last run
#
# Logs land in logs/e2e-<date>_<time>.log (gitignored) and end in "=== E2E EXIT n ===".
set -u
UNIT=game-maker-e2e
DIR=$(dirname "$(readlink -f "$0")")
LOGDIR=$DIR/logs
running() { systemctl is-active --quiet "$UNIT.service"; }
rel() { echo "${1#"$DIR"/}"; }
# Newest by name, not by mtime: the timestamped names sort chronologically and a share-mounted
# worktree can hand out surprising mtimes. The glob demands that shape, so a hand-dropped file
# in logs/ cannot outsort the newest run.
latest_log() { ls -1 "$LOGDIR"/e2e-[0-9]*.log 2>/dev/null | tail -1; }
start() {
if running; then
echo "$UNIT.service is still running – ./e2e.sh status, or systemctl stop $UNIT.service" >&2
exit 1
fi
systemctl reset-failed "$UNIT.service" 2>/dev/null
mkdir -p "$LOGDIR"
local log="$LOGDIR/e2e-$(date '+%Y-%m-%d_%H%M%S').log"
local steps=()
if [ $# -gt 0 ]; then steps=(--setenv=GM_STEPS="$1"); fi
systemd-run --unit="$UNIT" --working-directory="$DIR" \
--property=StandardOutput=append:"$log" \
--property=StandardError=append:"$log" \
"${steps[@]}" \
bash -c 'node tools/seed-e2e.mjs && node tools/e2e.mjs; echo "=== E2E EXIT $? ==="' || exit 1
echo "$UNIT.service started${1:+ with GM_STEPS=$1}, logging to $(rel "$log")"
echo " status: ./e2e.sh status"
echo " follow: tail -f $(rel "$log")"
echo " stop: systemctl stop $UNIT.service"
}
# Wall-clock seconds as "2m 14s".
span() { printf '%dm %02ds' $(($1 / 60)) $(($1 % 60)); }
status() {
local log pass fail
log=$(latest_log)
if [ -z "$log" ]; then
running && echo "running – but logs/ is empty, follow: journalctl -u $UNIT.service -f" ||
echo "idle – no run logged yet, start one with ./e2e.sh start"
return
fi
pass=$(grep -c '^PASS' "$log")
fail=$(grep -c '^FAIL' "$log")
if running; then
local since=0 now
now=$(systemctl show "$UNIT.service" -p ActiveEnterTimestamp --value)
[ -n "$now" ] && since=$(($(date +%s) - $(date -d "$now" +%s)))
echo "running – $(span "$since") elapsed"
echo " log: $(rel "$log")"
echo " checks: $pass PASS, $fail FAIL"
local at
at=$(grep -E '^(PASS|FAIL)' "$log" | tail -1)
[ -n "$at" ] && echo " at: $at"
echo " follow: tail -f $(rel "$log")"
return
fi
# Started per the file name, ended when the file was last written to; an unparsable name
# costs the duration, not the line.
local name started epoch took verdict exit_line
name=$(basename "$log" .log)
started=$(echo "${name#e2e-}" | tr '_' ' ' | sed -E 's/(..)(..)(..)$/\1:\2:\3/')
epoch=$(date -d "$started" +%s 2>/dev/null)
took=""
[ -n "$epoch" ] && took=$(($(date -r "$log" +%s) - epoch))
verdict=$(grep -E '^(ALL PASSED|[0-9]+ FAILED|RUN DIED)' "$log" | tail -1)
exit_line=$(grep -E '^=== E2E EXIT' "$log" | tail -1 | sed -E 's/^=== (.*) ===$/\1/')
echo "idle – last run $started$([ -n "$took" ] && [ "$took" -ge 0 ] && echo ", $(span "$took")")"
echo " log: $(rel "$log")"
if [ -z "$exit_line" ]; then
# No closing line means the run was cut short. Reporting its PASS count as a result would
# claim a suite that never finished, so say so instead.
echo " result: ABORTED – no closing line, so this is not a result. Run it again."
else
echo " result: ${verdict:-no summary} ($exit_line)"
fi
echo " checks: $pass PASS, $fail FAIL"
[ "$fail" -gt 0 ] && grep -E '^FAIL' "$log" | head -20 | sed 's/^/ /'
return 0
}
case "${1:-}" in
start) shift; start "$@" ;;
status) status ;;
*)
echo "usage: ./e2e.sh start [GM_STEPS] e.g. ./e2e.sh start 1-12" >&2
echo " ./e2e.sh status" >&2
exit 2
;;
esac
+84
View File
@@ -6,6 +6,7 @@
import { describe, expect, it } from 'vitest'; import { describe, expect, it } from 'vitest';
import { AddPortCommand, SetVariablePortCommand } from '../../src/core/commands/portCommands'; import { AddPortCommand, SetVariablePortCommand } from '../../src/core/commands/portCommands';
import { import {
AddStructFieldCommand,
AddVariableCommand, AddVariableCommand,
RemoveVariableCommand, RemoveVariableCommand,
RenameVariableCommand, RenameVariableCommand,
@@ -111,6 +112,42 @@ describe('a node that reads or writes several variables at once', () => {
expect(run(behavior, fixture)).toEqual({ px: 7, py: 9, sum: 16 }); expect(run(behavior, fixture)).toEqual({ px: 7, py: 9, sum: 16 });
}); });
/**
* The rule that decides how far a `Get variables` may reach: a data node answers once per pulse
* and that answer is kept, so a single node cannot straddle a write of one of its variables – the
* later reader would be served the value from before it. A second node is the way across.
*/
it('answers once per pulse, so a reader past a write needs a node of its own', () => {
const fixture = makeProject();
const behavior = makeBehavior(fixture, 'B', ['px', 'py', 'sum', 'fresh']);
const edit = graphEditor(fixture, behavior);
const start = edit.addNode('OnStart');
const early = edit.addNode(GET_VARIABLES);
const late = edit.addNode(GET_VARIABLES);
const write = edit.addNode(SET_VARIABLES, { px: 7 });
const before = edit.addNode('SetVariable', { variable: 'sum' });
const after = edit.addNode('SetVariable', { variable: 'py' });
const again = edit.addNode('SetVariable', { variable: 'fresh' });
for (const node of [early, late, write])
fixture.stack.execute(new AddPortCommand(behavior.id, node.id, { key: 'px', kind: 'number' }));
edit.wire([
[start, 'exec', before, 'exec'],
[before, 'exec', write, 'exec'],
[write, 'exec', after, 'exec'],
[after, 'exec', again, 'exec'],
[early, 'px', before, 'value'],
[early, 'px', after, 'value'],
[late, 'px', again, 'value'],
]);
// `py` is the point: the same node read past the write still answers 1, while `late` sees 7.
expect(run(behavior, fixture, { px: 1, py: 0, sum: 0, fresh: 0 })).toEqual({
px: 7,
py: 1,
sum: 1,
fresh: 7,
});
});
it('survives a save and load, which is what makes the pick more than a session', () => { it('survives a save and load, which is what makes the pick more than a session', () => {
const { fixture, behavior } = bothWays(); const { fixture, behavior } = bothWays();
const loaded = roundTrip(fixture.project); const loaded = roundTrip(fixture.project);
@@ -119,4 +156,51 @@ describe('a node that reads or writes several variables at once', () => {
expect((set.ports ?? []).map((p) => `${p.key}:${p.kind}`)).toEqual(['px:number', 'py:number']); expect((set.ports ?? []).map((p) => `${p.key}:${p.kind}`)).toEqual(['px:number', 'py:number']);
expect(run(asset, { project: loaded, stack: fixture.stack })).toEqual({ px: 7, py: 9, sum: 16 }); expect(run(asset, { project: loaded, stack: fixture.stack })).toEqual({ px: 7, py: 9, sum: 16 });
}); });
/**
* A struct port writes the way the singular node does: the bundle it is handed is merged in, so a
* `Pack` of one field leaves the rest standing. Tetris leans on this – each strand of `Next move`
* packs only the field it changes and lets the plural node keep the other three.
*/
it('merges a partial bundle into a struct variable instead of replacing it', () => {
const fixture = makeProject();
const behavior = makeBehavior(fixture, 'B');
fixture.stack.execute(new AddVariableCommand(behavior.id, 'piece', 'struct'));
for (const key of ['shape', 'rot'])
fixture.stack.execute(new AddStructFieldCommand(behavior.id, 'piece', { key, kind: 'number' }));
const edit = graphEditor(fixture, behavior);
const start = edit.addNode('OnStart');
const set = edit.addNode(SET_VARIABLES);
const pack = edit.addNode('PackStruct', { rot: 2 });
fixture.stack.execute(new AddPortCommand(behavior.id, set.id, { key: 'piece', kind: 'struct' }));
fixture.stack.execute(new AddPortCommand(behavior.id, pack.id, { key: 'rot', kind: 'number' }));
edit.wire([
[start, 'exec', set, 'exec'],
[pack, 'value', set, 'piece'],
]);
const ctx = fakeContext({ variables: { piece: { shape: 3, rot: 0 } } });
new GraphInterpreter(behavior.graph, behaviorLookup(fixture.project)).fire('OnStart', ctx);
expect(ctx.variables.piece).toEqual({ shape: 3, rot: 2 });
});
/**
* The other half of how far a plural node may reach: it collects every input before it writes any
* of them, so an input fed from a variable the same node writes sees the value from before.
*/
it('reads every input before it writes any, so a plural set cannot feed itself', () => {
const fixture = makeProject();
const behavior = makeBehavior(fixture, 'B', ['px', 'py']);
const edit = graphEditor(fixture, behavior);
const start = edit.addNode('OnStart');
const read = edit.addNode('GetVariable', { variable: 'px' });
const set = edit.addNode(SET_VARIABLES, { px: 7 });
for (const key of ['px', 'py'])
fixture.stack.execute(new AddPortCommand(behavior.id, set.id, { key, kind: 'number' }));
edit.wire([
[start, 'exec', set, 'exec'],
[read, 'value', set, 'py'],
]);
expect(run(behavior, fixture, { px: 1, py: 0 })).toEqual({ px: 7, py: 1 });
});
}); });
@@ -0,0 +1,46 @@
Tetris Game mit Structs neu bauen
...und mit neuen `Get variables` / `Set variables` node
---
**Gebaut (2026-09-10).** Beides, in zwei Durchgängen: erst die Structs, dann die Plural-Knoten.
| | vorher | jetzt |
|---|---|---|
| `shapes` | 224 lose Zahlen | 112 `{x, y}`-Bündel |
| Zellindex | `((shape·4+rot)·8 + corner·2)`, zweimal gelesen | `((shape·4+rot)·4 + corner)`, einmal |
| deklarierte Behavior-Ports | 49 | 35 |
| Variablen im Master | 18 | 13 |
| Master-Graph | 84 Knoten / 94 Kanten | 44 / 64 |
| `next-move` | 44 Knoten | 30 |
| `clear-lines` | 29 Knoten | 23 |
`piece` und der Kandidat `next` sind Structs aus shape/rot/x/y. `Next move` gibt einen ganzen
Kandidaten zurück, `Fits` beurteilt ihn, der Master übernimmt ihn mit *einem* `Set variable`.
Jeder Bewegungsstrang packt nur das Feld, das er ändert (`Pack{rot}`) — „dasselbe Teil, eine Reihe
tiefer" ist dadurch wörtlich ein Feld.
## Zwei Verträge, an denen die Zusammenlegung hängt
Beide gemessen, beide als Unit-Test gepinnt (`tests/core/variable-ports.test.ts`), beide mit
Gegenprobe abgesichert:
- **`Set variables` schreibt je Port mit `writeTyped`**, genau wie der Einzelknoten. Ein
`Pack{rot}` wird deshalb weiterhin per `mergeStruct` eingemischt statt das Bündel zu
überschreiben. Ohne das hätte Drehen die anderen drei Felder gelöscht.
- **Ein Datenknoten wird einmal pro Puls ausgewertet und behält die Antwort** (`dataCache`,
geleert je Schleifenrunde). Ein `Get variables` darf deshalb keine Schreibung einer seiner
Variablen überspannen — sonst liest der spätere Verbraucher den Wert von davor. Deshalb sind
`grid`/`outgrid` in `clear-lines` weiterhin zwei Knoten, und `take`/`keep` bleiben getrennt.
Die Cache-Regel ist im **Spiel** nicht messbar: eine absichtliche Verletzung ließ den Smoke grün,
weil `OnUpdate` jeden Frame neu liest und der Fehler sich einen Frame später selbst heilt. Der
Unit-Test fällt dagegen sauber (`py: 7` statt `1`).
## Was bewusst nicht passiert ist
Das **Brett bleibt eine flache Zahlenliste**. `-1`/Formnummer ist bereits die kompakte Kodierung;
ein `{shape}`-Struct pro Zelle wären 200 Bündel für ein einziges Feld.
Eine **Listen-Variable kann ihre Item-Art im Editor nicht wählen** — eine Liste aus Structs
entsteht heute nur aus einer Projektdatei. Steht in `struct-und-listen-restposten.md`.
+1
View File
@@ -0,0 +1 @@
Möglichkeit Nodes zuklappen können, wichtig: soll auch gespeichert werden.
@@ -1,2 +0,0 @@
Tetris Game mit Structs neu bauen
...und mit neuen `Get variables` / `Set variables` node
+1 -1
View File
@@ -1,7 +1,7 @@
#!/usr/bin/env node #!/usr/bin/env node
/** /**
* Headless end-to-end checks of the editor, driven over the Chrome DevTools Protocol. * Headless end-to-end checks of the editor, driven over the Chrome DevTools Protocol.
* Usage: PATH=/opt/node22/bin:$PATH node tools/seed-e2e.mjs && node tools/e2e.mjs [projectId] [baseUrl] * Usage: node tools/seed-e2e.mjs && node tools/e2e.mjs [projectId] [baseUrl]
* Prints PASS/FAIL per check, writes screenshots to /tmp/gm-e2e-*.png. No dependencies. * Prints PASS/FAIL per check, writes screenshots to /tmp/gm-e2e-*.png. No dependencies.
* *
* Runs against the fixture project written by tools/seed-e2e.mjs and mutates it heavily; the * Runs against the fixture project written by tools/seed-e2e.mjs and mutates it heavily; the
+1 -1
View File
@@ -4,7 +4,7 @@
* child each, a camera, the Mover behavior owning the red crate and a Spawner. The objects are * child each, a camera, the Mover behavior owning the red crate and a Spawner. The objects are
* uniquely named and positioned exactly where the checks expect them. * uniquely named and positioned exactly where the checks expect them.
* *
* Usage: PATH=/opt/node22/bin:$PATH node tools/seed-e2e.mjs [projectId] [baseUrl] * Usage: node tools/seed-e2e.mjs [projectId] [baseUrl]
*/ */
import { edge, node, sceneObject, variable } from './lib/builders.mjs'; import { edge, node, sceneObject, variable } from './lib/builders.mjs';
import { PROJECTS, cliArgs, writeProject } from './lib/projects.mjs'; import { PROJECTS, cliArgs, writeProject } from './lib/projects.mjs';
+1 -1
View File
@@ -12,7 +12,7 @@
* switch the inspector shows for every object. Everything else is nodes: gravity and jumping * switch the inspector shows for every object. Everything else is nodes: gravity and jumping
* on the player, timer, coin counter and screen flow on the "Game" behavior. * on the player, timer, coin counter and screen flow on the "Game" behavior.
* *
* Usage: PATH=/opt/node22/bin:$PATH node tools/seed-jumprun.mjs [projectId] [baseUrl] * Usage: node tools/seed-jumprun.mjs [projectId] [baseUrl]
*/ */
import { edge, node, sceneObject, variable } from './lib/builders.mjs'; import { edge, node, sceneObject, variable } from './lib/builders.mjs';
import { PROJECTS, cliArgs, writeProject } from './lib/projects.mjs'; import { PROJECTS, cliArgs, writeProject } from './lib/projects.mjs';
+151 -170
View File
@@ -12,6 +12,10 @@
* cells. That is what lets `Next move` hand back a whole candidate for `Fits` to judge and the * cells. That is what lets `Next move` hand back a whole candidate for `Fits` to judge and the
* master to accept in a single write, and it halves the index arithmetic in `Piece cell`. * master to accept in a single write, and it halves the index arithmetic in `Piece cell`.
* *
* Where a step needs several variables at once, one `Get variables` / `Set variables` stands for the
* lot – the call to `Next move` has one node at each side of it instead of five. How far such a node
* may reach is bounded by the pulse cache; `graphBuilder` below says how.
*
* The game is ten behaviors rather than one graph. `Tetris` owns the state and does none of the * The game is ten behaviors rather than one graph. `Tetris` owns the state and does none of the
* work: every step is a behavior of its own, called as a node – `Spawn piece`, `Next move`, * work: every step is a behavior of its own, called as a node – `Spawn piece`, `Next move`,
* `Fits`, `Draw piece`, `Lock piece`, `Clear lines`, `Show lines`, `Draw board`, with `Piece cell` * `Fits`, `Draw piece`, `Lock piece`, `Clear lines`, `Show lines`, `Draw board`, with `Piece cell`
@@ -22,7 +26,7 @@
* What the player sees is a pool of objects the graphs address by name: `c0…c199` for the board * What the player sees is a pool of objects the graphs address by name: `c0…c199` for the board
* and `p0…p3` for the piece that is falling. Nothing is spawned or destroyed while playing. * and `p0…p3` for the piece that is falling. Nothing is spawned or destroyed while playing.
* *
* Usage: PATH=/opt/node22/bin:$PATH node tools/seed-tetris.mjs [projectId] [baseUrl] * Usage: node tools/seed-tetris.mjs [projectId] [baseUrl]
*/ */
import { edge, field, node, port, sceneObject, variable } from './lib/builders.mjs'; import { edge, field, node, port, sceneObject, variable } from './lib/builders.mjs';
import { PROJECTS, cliArgs, writeProject } from './lib/projects.mjs'; import { PROJECTS, cliArgs, writeProject } from './lib/projects.mjs';
@@ -167,14 +171,24 @@ const boardTemplate = [
), ),
]; ];
/** A `sets` entry whose value arrives through an edge instead of being typed on the node. */
const LINKED = Symbol('linked');
/** /**
* One graph under construction. Nodes are laid out in bands, one per strand, so the editor shows a * One graph under construction. Nodes are laid out in bands, one per strand, so the editor shows a
* behavior as a stack of readable blocks rather than one cloud. * behavior as a stack of readable blocks rather than one cloud. `variables` is what the plural nodes
* declare their ports from – pass the behavior's own list.
*/ */
function graphBuilder() { function graphBuilder(variables = []) {
const nodes = []; const nodes = [];
const edges = []; const edges = [];
let band = 0; let band = 0;
const kinds = Object.fromEntries(variables.map((v) => [v.key, v.kind]));
const declare = (keys) =>
keys.map((key) => {
if (!kinds[key]) throw new Error(`no variable "${key}" to declare a port for`);
return port(key, kinds[key]);
});
const at = (id, type, column, row, params = {}, ports) => { const at = (id, type, column, row, params = {}, ports) => {
nodes.push(node(id, type, column * 220, band * 1400 + row * 90, params, ports)); nodes.push(node(id, type, column * 220, band * 1400 + row * 90, params, ports));
@@ -196,6 +210,20 @@ function graphBuilder() {
get: (id, key, column, row) => at(id, 'GetVariable', column, row, { variable: key }), get: (id, key, column, row) => at(id, 'GetVariable', column, row, { variable: key }),
set: (id, key, column, row, value) => set: (id, key, column, row, value) =>
at(id, 'SetVariable', column, row, value === undefined ? { variable: key } : { variable: key, value }), at(id, 'SetVariable', column, row, value === undefined ? { variable: key } : { variable: key, value }),
/**
* The plural nodes: one node for a whole cluster, one port per variable. A data node is cached
* for the entire pulse, so a `gets` must not span a write of one of its variables – the second
* read would serve the value from before the write. Split it in two where it does. A `sets`
* reads all its inputs before it writes any, so none of them may come from a variable it writes.
*/
gets: (id, keys, column, row) => at(id, 'GetVariables', column, row, {}, declare(keys)),
sets: (id, spec, column, row) => {
const keys = Array.isArray(spec) ? spec : Object.keys(spec);
const params = Array.isArray(spec)
? {}
: Object.fromEntries(Object.entries(spec).filter(([, v]) => v !== LINKED));
return at(id, 'SetVariables', column, row, params, declare(keys));
},
/** A mistyped id would silently leave an input unconnected, so the seed refuses to write one. */ /** A mistyped id would silently leave an input unconnected, so the seed refuses to write one. */
done() { done() {
const ids = new Set(); const ids = new Set();
@@ -329,36 +357,41 @@ function fitsGraph() {
return g.done(); return g.done();
} }
const CLEAR_LINES_VARS = [
variable('board', 'list', [], 'number'),
variable('cleared', 'number', 0),
variable('row', 'number', 0),
];
/** /**
* Removes every full row and counts it. Walks the board from the bottom up; a cleared row does not * Removes every full row and counts it. Walks the board from the bottom up; a cleared row does not
* move the walk on, because everything above has dropped into it – so the same row is looked at * move the walk on, because everything above has dropped into it – so the same row is looked at
* again, which is how two full rows on top of each other both go. * again, which is how two full rows on top of each other both go.
*/ */
function clearLinesGraph() { function clearLinesGraph() {
const g = graphBuilder(); const g = graphBuilder(CLEAR_LINES_VARS);
g.at('start', 'BehaviorStart', 0, 0, {}, [port('grid', 'list'), port('lines', 'number')]); g.at('start', 'BehaviorStart', 0, 0, {}, [port('grid', 'list'), port('lines', 'number')]);
g.set('start-board', 'board', 1, 0); g.sets('start-vars', { board: LINKED, cleared: LINKED, row: ROWS - 1 }, 1, 0);
g.set('start-count', 'cleared', 2, 0);
g.set('row', 'row', 3, 0, ROWS - 1);
g.at('loop', 'Repeat', 4, 0, { times: ROWS + 4 }); g.at('loop', 'Repeat', 4, 0, { times: ROWS + 4 });
g.link('start', 'grid', 'start-board', 'value'); g.link('start', 'grid', 'start-vars', 'board');
g.link('start', 'lines', 'start-count', 'value'); g.link('start', 'lines', 'start-vars', 'cleared');
g.flow('start', 'start-board', 'start-count', 'row', 'loop'); g.flow('start', 'start-vars', 'loop');
g.get('rowvar', 'row', 5, 3); // Every round reads all three before it writes any of them, and the loop drops the pulse cache
// between rounds – so one node serves the whole round.
g.gets('row-vars', ['row', 'board', 'cleared'], 5, 3);
g.at('left', 'Compare', 6, 3, { op: '>=', b: 0 }); g.at('left', 'Compare', 6, 3, { op: '>=', b: 0 });
g.at('brleft', 'Branch', 6, 0); g.at('brleft', 'Branch', 6, 0);
g.at('rowstart', 'Multiply', 7, 3, { b: COLS }); g.at('rowstart', 'Multiply', 7, 3, { b: COLS });
g.get('grid', 'board', 7, 5);
g.at('cells', 'SubList', 8, 4, { count: COLS }); g.at('cells', 'SubList', 8, 4, { count: COLS });
g.at('gap', 'IndexOf', 9, 4, { value: EMPTY }); g.at('gap', 'IndexOf', 9, 4, { value: EMPTY });
g.at('full', 'Compare', 10, 4, { op: '<', b: 0 }); g.at('full', 'Compare', 10, 4, { op: '<', b: 0 });
g.at('brfull', 'Branch', 8, 0); g.at('brfull', 'Branch', 8, 0);
g.link('loop', 'body', 'brleft', 'exec'); g.link('loop', 'body', 'brleft', 'exec');
g.link('rowvar', 'value', 'left', 'a'); g.link('row-vars', 'row', 'left', 'a');
g.link('left', 'result', 'brleft', 'condition'); g.link('left', 'result', 'brleft', 'condition');
g.link('rowvar', 'value', 'rowstart', 'a'); g.link('row-vars', 'row', 'rowstart', 'a');
g.link('grid', 'value', 'cells', 'list'); g.link('row-vars', 'board', 'cells', 'list');
g.link('rowstart', 'result', 'cells', 'start'); g.link('rowstart', 'result', 'cells', 'start');
g.link('cells', 'result', 'gap', 'list'); g.link('cells', 'result', 'gap', 'list');
g.link('gap', 'index', 'full', 'a'); g.link('gap', 'index', 'full', 'a');
@@ -371,37 +404,33 @@ function clearLinesGraph() {
g.at('blank', 'Fill', 9, 12, { value: EMPTY, count: COLS }); g.at('blank', 'Fill', 9, 12, { value: EMPTY, count: COLS });
g.at('top', 'Concat', 10, 8); g.at('top', 'Concat', 10, 8);
g.at('rest', 'Concat', 11, 9); g.at('rest', 'Concat', 11, 9);
g.set('setgrid', 'board', 10, 0);
g.get('lines', 'cleared', 11, 3);
g.at('onemore', 'Add', 12, 3, { b: 1 }); g.at('onemore', 'Add', 12, 3, { b: 1 });
g.set('setlines', 'cleared', 12, 0); g.sets('cleared-row', ['board', 'cleared'], 10, 0);
g.link('brfull', 'then', 'setgrid', 'exec'); g.link('brfull', 'then', 'cleared-row', 'exec');
g.link('grid', 'value', 'above', 'list'); g.link('row-vars', 'board', 'above', 'list');
g.link('rowstart', 'result', 'above', 'count'); g.link('rowstart', 'result', 'above', 'count');
g.link('rowstart', 'result', 'past', 'a'); g.link('rowstart', 'result', 'past', 'a');
g.link('grid', 'value', 'below', 'list'); g.link('row-vars', 'board', 'below', 'list');
g.link('past', 'result', 'below', 'start'); g.link('past', 'result', 'below', 'start');
g.link('blank', 'result', 'top', 'a'); g.link('blank', 'result', 'top', 'a');
g.link('above', 'result', 'top', 'b'); g.link('above', 'result', 'top', 'b');
g.link('top', 'result', 'rest', 'a'); g.link('top', 'result', 'rest', 'a');
g.link('below', 'result', 'rest', 'b'); g.link('below', 'result', 'rest', 'b');
g.link('rest', 'result', 'setgrid', 'value'); g.link('rest', 'result', 'cleared-row', 'board');
g.link('lines', 'value', 'onemore', 'a'); g.link('row-vars', 'cleared', 'onemore', 'a');
g.link('onemore', 'result', 'setlines', 'value'); g.link('onemore', 'result', 'cleared-row', 'cleared');
g.flow('setgrid', 'setlines');
g.at('back', 'Subtract', 9, 14, { b: 1 }); g.at('back', 'Subtract', 9, 14, { b: 1 });
g.set('setrow', 'row', 10, 13); g.set('setrow', 'row', 10, 13);
g.link('brfull', 'else', 'setrow', 'exec'); g.link('brfull', 'else', 'setrow', 'exec');
g.link('rowvar', 'value', 'back', 'a'); g.link('row-vars', 'row', 'back', 'a');
g.link('back', 'result', 'setrow', 'value'); g.link('back', 'result', 'setrow', 'value');
g.at('end', 'BehaviorEnd', 13, 0, {}, [port('grid', 'list'), port('lines', 'number')]); g.at('end', 'BehaviorEnd', 13, 0, {}, [port('grid', 'list'), port('lines', 'number')]);
g.get('outgrid', 'board', 12, 6); g.gets('out-vars', ['board', 'cleared'], 12, 6);
g.get('outlines', 'cleared', 12, 8);
g.link('loop', 'done', 'end', 'exec'); g.link('loop', 'done', 'end', 'exec');
g.link('outgrid', 'value', 'end', 'grid'); g.link('out-vars', 'board', 'end', 'grid');
g.link('outlines', 'value', 'end', 'lines'); g.link('out-vars', 'cleared', 'end', 'lines');
return g.done(); return g.done();
} }
@@ -459,14 +488,22 @@ function spawnPieceGraph() {
return g.done(); return g.done();
} }
const NEXT_MOVE_VARS = [
variable('next', 'struct', NO_PIECE, undefined, PIECE_FIELDS),
variable('kind', 'text', ''),
variable('outFall', 'number', 0),
variable('outDx', 'number', 0),
variable('outRot', 'number', 0),
];
/** /**
* The one move a frame attempts, and nothing else: turning beats moving beats falling. It only * The one move a frame attempts, and nothing else: turning beats moving beats falling. It only
* proposes – whether the piece may go there is `Fits`, one step later. A wish that was granted * proposes – whether the piece may go there is `Fits`, one step later. A wish that was granted
* comes back as 0, which is how a key press is spent exactly once. * comes back as 0, which is how a key press is spent exactly once.
*/ */
function nextMoveGraph() { function nextMoveGraph() {
const g = graphBuilder(); const g = graphBuilder(NEXT_MOVE_VARS);
const { at, link, flow, get, set } = g; const { at, link, flow, get, set, sets } = g;
at('start', 'BehaviorStart', 0, 0, {}, [ at('start', 'BehaviorStart', 0, 0, {}, [
port('piece', 'struct'), port('piece', 'struct'),
@@ -477,21 +514,17 @@ function nextMoveGraph() {
port('stepTime', 'number'), port('stepTime', 'number'),
]); ]);
// The candidate starts out as the piece itself – one write, because it is one value. Each // The frame starts out having decided nothing: the candidate is the piece itself and no wish has
// strand below then packs only the field it changes: writing a bundle keeps the fields it // been used up. Each strand below then packs only the field it changes, because writing a bundle
// does not name, so "the same piece, one row lower" needs no copying. // keeps the fields it does not name – "the same piece, one row lower" needs no copying.
at('of', 'ExpandStruct', 1, 3, {}, PIECE_FIELDS); at('of', 'ExpandStruct', 1, 3, {}, PIECE_FIELDS);
link('start', 'piece', 'of', 'value'); link('start', 'piece', 'of', 'value');
set('i-next', 'next', 1, 0); sets('i-vars', { next: LINKED, kind: '', outFall: LINKED, outDx: LINKED, outRot: LINKED }, 1, 0);
set('i-kind', 'kind', 2, 0, ''); link('start', 'piece', 'i-vars', 'next');
set('i-fall', 'outFall', 3, 0); link('start', 'fall', 'i-vars', 'outFall');
set('i-dx', 'outDx', 4, 0); link('start', 'wantDx', 'i-vars', 'outDx');
set('i-rot', 'outRot', 5, 0); link('start', 'wantRot', 'i-vars', 'outRot');
link('start', 'piece', 'i-next', 'value'); flow('start', 'i-vars', 't-br');
link('start', 'fall', 'i-fall', 'value');
link('start', 'wantDx', 'i-dx', 'value');
link('start', 'wantRot', 'i-rot', 'value');
flow('start', 'i-next', 'i-kind', 'i-fall', 'i-dx', 'i-rot', 't-br');
// Turning wins: a quarter turn clockwise is one step around the four rotations of the shape. // Turning wins: a quarter turn clockwise is one step around the four rotations of the shape.
g.strand(); g.strand();
@@ -500,17 +533,15 @@ function nextMoveGraph() {
at('t-plus1', 'Add', 1, 3, { b: 1 }); at('t-plus1', 'Add', 1, 3, { b: 1 });
at('t-wrap', 'Modulo', 2, 3, { b: 4 }); at('t-wrap', 'Modulo', 2, 3, { b: 4 });
at('t-pack', 'PackStruct', 3, 3, {}, [field('rot')]); at('t-pack', 'PackStruct', 3, 3, {}, [field('rot')]);
set('t-set', 'next', 1, 0); sets('t-vars', { next: LINKED, kind: 'turn', outRot: 0 }, 1, 0);
set('t-kind', 'kind', 2, 0, 'turn');
set('t-used', 'outRot', 3, 0, 0);
link('start', 'wantRot', 't-want', 'a'); link('start', 'wantRot', 't-want', 'a');
link('t-want', 'result', 't-br', 'condition'); link('t-want', 'result', 't-br', 'condition');
link('t-br', 'then', 't-set', 'exec'); link('t-br', 'then', 't-vars', 'exec');
link('of', 'rot', 't-plus1', 'a'); link('of', 'rot', 't-plus1', 'a');
link('t-plus1', 'result', 't-wrap', 'a'); link('t-plus1', 'result', 't-wrap', 'a');
link('t-wrap', 'result', 't-pack', 'rot'); link('t-wrap', 'result', 't-pack', 'rot');
link('t-pack', 'value', 't-set', 'value'); link('t-pack', 'value', 't-vars', 'next');
flow('t-set', 't-kind', 't-used', 'end'); flow('t-vars', 'end');
// Then sideways, one column per key press. // Then sideways, one column per key press.
at('m-still', 'Equals', 4, 3, { b: 0 }); at('m-still', 'Equals', 4, 3, { b: 0 });
@@ -518,19 +549,17 @@ function nextMoveGraph() {
at('m-br', 'Branch', 5, 0); at('m-br', 'Branch', 5, 0);
at('m-shift', 'Add', 6, 3); at('m-shift', 'Add', 6, 3);
at('m-pack', 'PackStruct', 7, 3, {}, [field('x')]); at('m-pack', 'PackStruct', 7, 3, {}, [field('x')]);
set('m-set', 'next', 6, 0); sets('m-vars', { next: LINKED, kind: 'move', outDx: 0 }, 6, 0);
set('m-kind', 'kind', 7, 0, 'move');
set('m-used', 'outDx', 8, 0, 0);
link('t-br', 'else', 'm-br', 'exec'); link('t-br', 'else', 'm-br', 'exec');
link('start', 'wantDx', 'm-still', 'a'); link('start', 'wantDx', 'm-still', 'a');
link('m-still', 'result', 'm-moving', 'value'); link('m-still', 'result', 'm-moving', 'value');
link('m-moving', 'result', 'm-br', 'condition'); link('m-moving', 'result', 'm-br', 'condition');
link('m-br', 'then', 'm-set', 'exec'); link('m-br', 'then', 'm-vars', 'exec');
link('of', 'x', 'm-shift', 'a'); link('of', 'x', 'm-shift', 'a');
link('start', 'wantDx', 'm-shift', 'b'); link('start', 'wantDx', 'm-shift', 'b');
link('m-shift', 'result', 'm-pack', 'x'); link('m-shift', 'result', 'm-pack', 'x');
link('m-pack', 'value', 'm-set', 'value'); link('m-pack', 'value', 'm-vars', 'next');
flow('m-set', 'm-kind', 'm-used', 'end'); flow('m-vars', 'end');
// And otherwise the clock: holding the down key lets it run ten times as fast. // And otherwise the clock: holding the down key lets it run ten times as fast.
g.strand(); g.strand();
@@ -553,23 +582,21 @@ function nextMoveGraph() {
at('f-due', 'Compare', 6, 3, { op: '>=' }); at('f-due', 'Compare', 6, 3, { op: '>=' });
at('f-br', 'Branch', 5, 0); at('f-br', 'Branch', 5, 0);
at('f-minus', 'Subtract', 7, 3); at('f-minus', 'Subtract', 7, 3);
set('f-rest', 'outFall', 6, 0);
at('f-down1', 'Add', 7, 5, { b: 1 }); at('f-down1', 'Add', 7, 5, { b: 1 });
at('f-pack', 'PackStruct', 8, 5, {}, [field('y')]); at('f-pack', 'PackStruct', 8, 5, {}, [field('y')]);
set('f-ny', 'next', 7, 0); sets('f-vars', { outFall: LINKED, next: LINKED, kind: 'fall' }, 6, 0);
set('f-kind', 'kind', 8, 0, 'fall');
flow('f-set', 'f-br'); flow('f-set', 'f-br');
link('f-fall', 'value', 'f-due', 'a'); link('f-fall', 'value', 'f-due', 'a');
link('start', 'stepTime', 'f-due', 'b'); link('start', 'stepTime', 'f-due', 'b');
link('f-due', 'result', 'f-br', 'condition'); link('f-due', 'result', 'f-br', 'condition');
link('f-br', 'then', 'f-rest', 'exec'); link('f-br', 'then', 'f-vars', 'exec');
link('f-fall', 'value', 'f-minus', 'a'); link('f-fall', 'value', 'f-minus', 'a');
link('start', 'stepTime', 'f-minus', 'b'); link('start', 'stepTime', 'f-minus', 'b');
link('f-minus', 'result', 'f-rest', 'value'); link('f-minus', 'result', 'f-vars', 'outFall');
link('of', 'y', 'f-down1', 'a'); link('of', 'y', 'f-down1', 'a');
link('f-down1', 'result', 'f-pack', 'y'); link('f-down1', 'result', 'f-pack', 'y');
link('f-pack', 'value', 'f-ny', 'value'); link('f-pack', 'value', 'f-vars', 'next');
flow('f-rest', 'f-ny', 'f-kind', 'end'); flow('f-vars', 'end');
// Not due yet: nothing moves, and "kind" stays empty so the fit test below changes nothing. // Not due yet: nothing moves, and "kind" stays empty so the fit test below changes nothing.
link('f-br', 'else', 'end', 'exec'); link('f-br', 'else', 'end', 'exec');
@@ -583,16 +610,19 @@ function nextMoveGraph() {
port('wantRot', 'number'), port('wantRot', 'number'),
]); ]);
const back = [ const back = [
['e-next', 'next', 'next'], ['next', 'next'],
['e-kind', 'kind', 'kind'], ['kind', 'kind'],
['e-fall', 'outFall', 'fall'], ['outFall', 'fall'],
['e-dx', 'outDx', 'wantDx'], ['outDx', 'wantDx'],
['e-rot', 'outRot', 'wantRot'], ['outRot', 'wantRot'],
]; ];
back.forEach(([id, key, portKey], i) => { g.gets(
get(id, key, 0, i * 2 + 2); 'e-vars',
link(id, 'value', 'end', portKey); back.map(([key]) => key),
}); 0,
2,
);
for (const [key, portKey] of back) link('e-vars', key, 'end', portKey);
return g.done(); return g.done();
} }
@@ -715,23 +745,18 @@ function showLinesGraph() {
* each answer lands in. * each answer lands in.
*/ */
function tetrisGraph() { function tetrisGraph() {
const g = graphBuilder(); const g = graphBuilder(tetrisVariables);
const { at, link, flow, get, set, strand } = g; const { at, link, flow, get, set, gets, sets, strand } = g;
// 1) On start: an empty board, no piece, nothing pressed. The world is rebuilt for every run, so // 1) On start: an empty board, no piece, nothing pressed. The world is rebuilt for every run, so
// this only matters when the behavior was left running in the editor. // this only matters when the behavior was left running in the editor.
strand(); strand();
at('s-start', 'OnStart', 0, 0); at('s-start', 'OnStart', 0, 0);
at('s-empty', 'Fill', 0, 3, { value: EMPTY, count: COLS * ROWS }); at('s-empty', 'Fill', 0, 3, { value: EMPTY, count: COLS * ROWS });
set('s-grid', 'grid', 1, 0); sets('s-vars', { grid: LINKED, lines: 0, hasPiece: false, fall: 0, wantDx: 0, wantRot: 0 }, 1, 0);
set('s-lines', 'lines', 2, 0, 0); at('s-show', 'RunBehavior', 2, 0, { behavior: 'show-lines', lines: 0 });
set('s-piece', 'hasPiece', 3, 0, false); link('s-empty', 'result', 's-vars', 'grid');
set('s-fall', 'fall', 4, 0, 0); flow('s-start', 's-vars', 's-show');
set('s-dx', 'wantDx', 5, 0, 0);
set('s-turn', 'wantRot', 6, 0, 0);
at('s-show', 'RunBehavior', 7, 0, { behavior: 'show-lines', lines: 0 });
link('s-empty', 'result', 's-grid', 'value');
flow('s-start', 's-grid', 's-lines', 's-piece', 's-fall', 's-turn', 's-dx', 's-show');
// 2) The keys do not move anything themselves – they leave a wish that the next frame grants. That // 2) The keys do not move anything themselves – they leave a wish that the next frame grants. That
// way every attempt to move goes through the one test below, whatever asked for it. // way every attempt to move goes through the one test below, whatever asked for it.
@@ -757,66 +782,47 @@ function tetrisGraph() {
// 3a) No piece: ask for one and write down what came back. // 3a) No piece: ask for one and write down what came back.
at('n-spawn', 'RunBehavior', 2, 5, { behavior: 'spawn-piece' }); at('n-spawn', 'RunBehavior', 2, 5, { behavior: 'spawn-piece' });
get('n-columns', 'spawnX', 2, 8); get('n-columns', 'spawnX', 2, 8);
set('n-piece', 'piece', 3, 5); sets('n-vars', { piece: LINKED, fall: 0, hasPiece: true }, 3, 5);
set('n-fall', 'fall', 4, 5, 0);
set('n-has', 'hasPiece', 5, 5, true);
link('u-br', 'else', 'n-spawn', 'exec'); link('u-br', 'else', 'n-spawn', 'exec');
link('n-columns', 'value', 'n-spawn', 'spawnX'); link('n-columns', 'value', 'n-spawn', 'spawnX');
link('n-spawn', 'piece', 'n-piece', 'value'); link('n-spawn', 'piece', 'n-vars', 'piece');
flow('n-spawn', 'n-piece', 'n-fall', 'n-has'); flow('n-spawn', 'n-vars');
// 3b) A piece is falling: one behavior decides which single move this frame attempts. // 3b) A piece is falling: one behavior decides which single move this frame attempts.
strand(); strand();
at('c-move', 'RunBehavior', 0, 0, { behavior: 'next-move' }); at('c-move', 'RunBehavior', 0, 0, { behavior: 'next-move' });
link('u-br', 'then', 'c-move', 'exec'); link('u-br', 'then', 'c-move', 'exec');
const moveIn = [ // Every port of the call is a variable of the same name, so one node stands at each side of it.
['piece', 'piece'], const moveIn = ['piece', 'wantDx', 'wantRot', 'fall', 'stepTime'];
['wantDx', 'wantDx'], gets('c-in', moveIn, 0, 3);
['wantRot', 'wantRot'], for (const key of moveIn) link('c-in', key, 'c-move', key);
['fall', 'fall'],
['stepTime', 'stepTime'],
];
moveIn.forEach(([key, variableKey], i) => {
get(`c-in-${key}`, variableKey, 0, i * 2 + 3);
link(`c-in-${key}`, 'value', 'c-move', key);
});
link('u-update', 'dt', 'c-move', 'dt'); link('u-update', 'dt', 'c-move', 'dt');
const moveOut = [ const moveOut = ['next', 'kind', 'fall', 'wantDx', 'wantRot'];
['next', 'next'], sets('c-out', moveOut, 1, 0);
['kind', 'kind'], for (const key of moveOut) link('c-move', key, 'c-out', key);
['fall', 'fall'], flow('c-move', 'c-out');
['wantDx', 'wantDx'],
['wantRot', 'wantRot'],
];
moveOut.forEach(([key, variableKey], i) => {
set(`c-out-${key}`, variableKey, i + 1, 0);
link('c-move', key, `c-out-${key}`, 'value');
});
flow('c-move', ...moveOut.map(([key]) => `c-out-${key}`));
// 4) The one test, and it is one node: does the candidate fit? Every attempt above ends here. // 4) The one test, and it is one node: does the candidate fit? Every attempt above ends here.
strand(); strand();
at('t-fits', 'RunBehavior', 0, 0, { behavior: 'fits' }); at('t-fits', 'RunBehavior', 0, 0, { behavior: 'fits' });
get('t-shapes', 'shapes', 0, 3); gets('t-in', ['shapes', 'grid', 'next'], 0, 3);
get('t-grid', 'grid', 0, 5); flow('c-out', 't-fits');
get('t-next', 'next', 0, 7); link('t-in', 'shapes', 't-fits', 'shapes');
flow('c-out-wantRot', 't-fits'); link('t-in', 'grid', 't-fits', 'grid');
link('t-shapes', 'value', 't-fits', 'shapes'); link('t-in', 'next', 't-fits', 'piece');
link('t-grid', 'value', 't-fits', 'grid');
link('t-next', 'value', 't-fits', 'piece');
// 5) It fits: the candidate becomes the piece. It does not: only a fall means anything – the // 5) It fits: the candidate becomes the piece. It does not: only a fall means anything – the
// piece has landed (or, still at the very top, the well is full). // piece has landed (or, still at the very top, the well is full).
at('m-br', 'Branch', 1, 0); at('m-br', 'Branch', 1, 0);
get('m-next', 'next', 2, 3); // `piece` is not in here: it is written a line below, and `m-py2` has to see that write.
gets('m-in', ['next', 'kind'], 2, 3);
set('m-piece', 'piece', 2, 0); set('m-piece', 'piece', 2, 0);
flow('t-fits', 'm-br'); flow('t-fits', 'm-br');
link('t-fits', 'ok', 'm-br', 'condition'); link('t-fits', 'ok', 'm-br', 'condition');
link('m-br', 'then', 'm-piece', 'exec'); link('m-br', 'then', 'm-piece', 'exec');
link('m-next', 'value', 'm-piece', 'value'); link('m-in', 'next', 'm-piece', 'value');
get('m-kind', 'kind', 2, 7);
at('m-landed', 'Equals', 3, 7, { b: 'fall' }); at('m-landed', 'Equals', 3, 7, { b: 'fall' });
at('m-brland', 'Branch', 3, 5); at('m-brland', 'Branch', 3, 5);
get('m-py2', 'piece', 4, 8); get('m-py2', 'piece', 4, 8);
@@ -825,7 +831,7 @@ function tetrisGraph() {
at('m-brover', 'Branch', 5, 5); at('m-brover', 'Branch', 5, 5);
at('m-stop', 'StopGame', 6, 5); at('m-stop', 'StopGame', 6, 5);
link('m-br', 'else', 'm-brland', 'exec'); link('m-br', 'else', 'm-brland', 'exec');
link('m-kind', 'value', 'm-landed', 'a'); link('m-in', 'kind', 'm-landed', 'a');
link('m-landed', 'result', 'm-brland', 'condition'); link('m-landed', 'result', 'm-brland', 'condition');
link('m-brland', 'then', 'm-brover', 'exec'); link('m-brland', 'then', 'm-brover', 'exec');
link('m-py2', 'value', 'm-pieceY', 'value'); link('m-py2', 'value', 'm-pieceY', 'value');
@@ -837,48 +843,43 @@ function tetrisGraph() {
strand(); strand();
at('d-draw', 'RunBehavior', 0, 0, { behavior: 'draw-piece' }); at('d-draw', 'RunBehavior', 0, 0, { behavior: 'draw-piece' });
flow('m-piece', 'd-draw'); flow('m-piece', 'd-draw');
flow('n-has', 'd-draw'); flow('n-vars', 'd-draw');
['shapes', 'piece', 'colors'].forEach((key, i) => { const drawIn = ['shapes', 'piece', 'colors'];
get(`d-${key}`, key, 0, i * 2 + 3); gets('d-in', drawIn, 0, 3);
link(`d-${key}`, 'value', 'd-draw', key); for (const key of drawIn) link('d-in', key, 'd-draw', key);
});
// 7) It landed: it becomes part of the board, full lines go, the counter and the board follow. // 7) It landed: it becomes part of the board, full lines go, the counter and the board follow.
strand(); strand();
at('l-lock', 'RunBehavior', 0, 0, { behavior: 'lock-piece' }); at('l-lock', 'RunBehavior', 0, 0, { behavior: 'lock-piece' });
get('l-grid', 'grid', 0, 3); gets('l-in', ['grid', 'piece'], 0, 3);
get('l-piece', 'piece', 0, 5);
at('l-shape', 'ExpandStruct', 0, 7, {}, [field('shape')]); at('l-shape', 'ExpandStruct', 0, 7, {}, [field('shape')]);
set('l-setgrid', 'grid', 1, 0); set('l-setgrid', 'grid', 1, 0);
link('m-brover', 'else', 'l-lock', 'exec'); link('m-brover', 'else', 'l-lock', 'exec');
link('l-grid', 'value', 'l-lock', 'grid'); link('l-in', 'grid', 'l-lock', 'grid');
link('l-piece', 'value', 'l-shape', 'value'); link('l-in', 'piece', 'l-shape', 'value');
link('l-shape', 'shape', 'l-lock', 'shape'); link('l-shape', 'shape', 'l-lock', 'shape');
link('l-lock', 'grid', 'l-setgrid', 'value'); link('l-lock', 'grid', 'l-setgrid', 'value');
flow('l-lock', 'l-setgrid'); flow('l-lock', 'l-setgrid');
at('r-clear', 'RunBehavior', 2, 0, { behavior: 'clear-lines' }); at('r-clear', 'RunBehavior', 2, 0, { behavior: 'clear-lines' });
get('r-grid', 'grid', 2, 3); gets('r-in', ['grid', 'lines'], 2, 3);
get('r-lines', 'lines', 2, 5); sets('r-out', ['grid', 'lines'], 3, 0);
set('r-setgrid', 'grid', 3, 0); link('r-in', 'grid', 'r-clear', 'grid');
set('r-setlines', 'lines', 4, 0); link('r-in', 'lines', 'r-clear', 'lines');
link('r-grid', 'value', 'r-clear', 'grid'); link('r-clear', 'grid', 'r-out', 'grid');
link('r-lines', 'value', 'r-clear', 'lines'); link('r-clear', 'lines', 'r-out', 'lines');
link('r-clear', 'grid', 'r-setgrid', 'value'); flow('l-setgrid', 'r-clear', 'r-out');
link('r-clear', 'lines', 'r-setlines', 'value');
flow('l-setgrid', 'r-clear', 'r-setgrid', 'r-setlines');
at('b-show', 'RunBehavior', 5, 0, { behavior: 'show-lines' }); at('b-show', 'RunBehavior', 5, 0, { behavior: 'show-lines' });
get('b-lines', 'lines', 5, 3);
at('b-draw', 'RunBehavior', 6, 0, { behavior: 'draw-board' }); at('b-draw', 'RunBehavior', 6, 0, { behavior: 'draw-board' });
get('b-grid', 'grid', 6, 3); // A second reader of `grid` and `lines`, because `r-out` wrote them since `r-in` was asked.
get('b-colors', 'colors', 6, 5); gets('b-in', ['lines', 'grid', 'colors'], 5, 3);
set('b-nopiece', 'hasPiece', 7, 0, false); set('b-nopiece', 'hasPiece', 7, 0, false);
link('b-lines', 'value', 'b-show', 'lines'); link('b-in', 'lines', 'b-show', 'lines');
link('b-grid', 'value', 'b-draw', 'grid'); link('b-in', 'grid', 'b-draw', 'grid');
link('b-colors', 'value', 'b-draw', 'colors'); link('b-in', 'colors', 'b-draw', 'colors');
// The board is drawn and the piece is part of it now: the next frame draws a new one. // The board is drawn and the piece is part of it now: the next frame draws a new one.
flow('r-setlines', 'b-show', 'b-draw', 'b-nopiece'); flow('r-out', 'b-show', 'b-draw', 'b-nopiece');
return g.done(); return g.done();
} }
@@ -948,30 +949,10 @@ const project = {
{ id: 'board', type: 'container', name: 'Board', props: {}, objects: boardTemplate }, { id: 'board', type: 'container', name: 'Board', props: {}, objects: boardTemplate },
behavior('piece-cell', 'Piece cell', [], pieceCellGraph()), behavior('piece-cell', 'Piece cell', [], pieceCellGraph()),
behavior('fits', 'Fits', [variable('ok', 'boolean', true)], fitsGraph()), behavior('fits', 'Fits', [variable('ok', 'boolean', true)], fitsGraph()),
behavior( behavior('clear-lines', 'Clear lines', CLEAR_LINES_VARS, clearLinesGraph()),
'clear-lines',
'Clear lines',
[
variable('board', 'list', [], 'number'),
variable('cleared', 'number', 0),
variable('row', 'number', 0),
],
clearLinesGraph(),
),
behavior('draw-board', 'Draw board', [], drawBoardGraph()), behavior('draw-board', 'Draw board', [], drawBoardGraph()),
behavior('spawn-piece', 'Spawn piece', [], spawnPieceGraph()), behavior('spawn-piece', 'Spawn piece', [], spawnPieceGraph()),
behavior( behavior('next-move', 'Next move', NEXT_MOVE_VARS, nextMoveGraph()),
'next-move',
'Next move',
[
variable('next', 'struct', NO_PIECE, undefined, PIECE_FIELDS),
variable('kind', 'text', ''),
variable('outFall', 'number', 0),
variable('outDx', 'number', 0),
variable('outRot', 'number', 0),
],
nextMoveGraph(),
),
behavior('draw-piece', 'Draw piece', [], drawPieceGraph()), behavior('draw-piece', 'Draw piece', [], drawPieceGraph()),
behavior('lock-piece', 'Lock piece', [variable('board', 'list', [], 'number')], lockPieceGraph()), behavior('lock-piece', 'Lock piece', [variable('board', 'list', [], 'number')], lockPieceGraph()),
behavior('show-lines', 'Show lines', [], showLinesGraph()), behavior('show-lines', 'Show lines', [], showLinesGraph()),
+1 -1
View File
@@ -4,7 +4,7 @@
* button, running, jumping, collecting a coin, the win screen, restart and the lose screen. * button, running, jumping, collecting a coin, the win screen, restart and the lose screen.
* It is the proof that the engine features this game needs actually work together. * It is the proof that the engine features this game needs actually work together.
* *
* Usage: PATH=/opt/node22/bin:$PATH node tools/seed-jumprun.mjs && node tools/smoke-jumprun.mjs * Usage: node tools/seed-jumprun.mjs && node tools/smoke-jumprun.mjs
* Screenshots land in /tmp/gm-jr-*.png. * Screenshots land in /tmp/gm-jr-*.png.
*/ */
import { instanceChild } from './lib/builders.mjs'; import { instanceChild } from './lib/builders.mjs';
+4 -4
View File
@@ -7,7 +7,7 @@
* It is not part of the e2e suite – like smoke-jumprun.mjs it proves one seeded project, and is * It is not part of the e2e suite – like smoke-jumprun.mjs it proves one seeded project, and is
* run when that project or the engine underneath it changes. * run when that project or the engine underneath it changes.
* *
* Usage: PATH=/opt/node22/bin:$PATH node tools/seed-tetris.mjs && node tools/smoke-tetris.mjs * Usage: node tools/seed-tetris.mjs && node tools/smoke-tetris.mjs
* Screenshots land in /tmp/gm-tetris-*.png. * Screenshots land in /tmp/gm-tetris-*.png.
*/ */
import { instanceChild } from './lib/builders.mjs'; import { instanceChild } from './lib/builders.mjs';
@@ -27,13 +27,13 @@ const OBJECT_COUNT = STORED_COUNT + 1 + COLS * ROWS + 4;
/** The master graph and the nine behaviors the game is split into. */ /** The master graph and the nine behaviors the game is split into. */
const GRAPHS = { const GRAPHS = {
tetris: { nodes: 69, edges: 76 }, tetris: { nodes: 44, edges: 64 },
'spawn-piece': { nodes: 6, edges: 7 }, 'spawn-piece': { nodes: 6, edges: 7 },
'next-move': { nodes: 44, edges: 61 }, 'next-move': { nodes: 30, edges: 51 },
fits: { nodes: 18, edges: 26 }, fits: { nodes: 18, edges: 26 },
'draw-piece': { nodes: 15, edges: 24 }, 'draw-piece': { nodes: 15, edges: 24 },
'lock-piece': { nodes: 20, edges: 23 }, 'lock-piece': { nodes: 20, edges: 23 },
'clear-lines': { nodes: 29, edges: 36 }, 'clear-lines': { nodes: 23, edges: 33 },
'show-lines': { nodes: 5, edges: 5 }, 'show-lines': { nodes: 5, edges: 5 },
'draw-board': { nodes: 11, edges: 17 }, 'draw-board': { nodes: 11, edges: 17 },
'piece-cell': { nodes: 11, edges: 17 }, 'piece-cell': { nodes: 11, edges: 17 },