From ceeaef7d21607ba24f171d20c15134a4638adeae Mon Sep 17 00:00:00 2001 From: Swift Date: Tue, 3 Nov 2026 15:17:03 -0500 Subject: [PATCH 01/11] feat: record each passing bird flight and serve it as stage waypoints The page server copies every new bird.json that passes check-bird.mjs to .flights/NNN.json and serves the latest two as waypoints on the gallery's 1000x850 stage at /flight.json, with the waypoints a revision changed. --- challenges/birdsbirdbirds/README.md | 28 +++ challenges/birdsbirdbirds/check-bird.mjs | 118 +++++++--- challenges/birdsbirdbirds/flight-history.mjs | 135 +++++++++++ challenges/birdsbirdbirds/flight-path.mjs | 221 +++++++++++++++++++ challenges/birdsbirdbirds/server.mjs | 48 ++-- test/birdsbirdbirds.test.mjs | 178 ++++++++++++--- 6 files changed, 648 insertions(+), 80 deletions(-) create mode 100644 challenges/birdsbirdbirds/flight-history.mjs create mode 100644 challenges/birdsbirdbirds/flight-path.mjs diff --git a/challenges/birdsbirdbirds/README.md b/challenges/birdsbirdbirds/README.md index f57d96a..5777e9d 100644 --- a/challenges/birdsbirdbirds/README.md +++ b/challenges/birdsbirdbirds/README.md @@ -15,6 +15,8 @@ published to the booth screens as a `birds-v1` gallery item. | `extension.mjs` | Pi extension: tells Pi about each saved drawing (see below) | | `bird-watcher.mjs` | Watches the workspace for a new `bird.png` | | `check-bird.mjs` | Checks `bird.json`; `node check-bird.mjs [path]` | +| `flight-path.mjs` | Turns a checked flight into waypoints on the gallery's stage | +| `flight-history.mjs` | Records each new passing flight in `.flights/` | | `server.mjs` | Local page server on `127.0.0.1:4322` | | `workspace-file.mjs` | Reads `bird.json` and `bird.png` only as capped regular files | | `setup`, `cleanup` | Start and stop the server (hooks in `hooks/`) | @@ -37,6 +39,32 @@ checker itself, so `animate` needs no message. The server listens on port 4322. Set `BIRDSBIRDBIRDS_PORT` to use another port, though the skill and the challenge expect 4322. +## Flight history + +While the page server runs, each `bird.json` that passes the checker and +differs from the last recorded flight is copied to +`/.flights/NNN.json`, numbered from `001`. The server checks on +any change in the workspace, debounced, and again whenever the page asks, so +it never depends on what `fs.watch` reports. + +`GET /flight.json` answers `{ current, previous, changed }`. `current` and +`previous` are the two latest recorded flights, or `null`: + +| Field | Meaning | +| --- | --- | +| `version` | The flight's number in `.flights/` | +| `bird_name`, `origin` | From `bird.json` | +| `duration_s` | One flight's playback time: `animation.duration` slowed 3 times, as the gallery plays it | +| `timing_function` | `animation.timing_function` | +| `waypoints` | `{ n, offset, x, y }`, plus `rotate` (degrees), `scale`, `opacity`, and `easing` when set | + +Waypoints are numbered from 1, one per keyframe offset that sets a +`transform`, in offset order. `x` and `y` are the bird box's top-left corner +in pixels on the gallery's 1000 by 850 stage: 1vw is 10px and 1vh is 8.5px, +as the gallery converts them, and a translate percentage is a share of the +120px bird box. `changed` lists the current waypoints whose position or +transform differs from the previous flight's waypoint of the same number. + ## Run without boothstrap Needs Node 26 and a `GEMINI_API_KEY`. Follow the standalone steps in diff --git a/challenges/birdsbirdbirds/check-bird.mjs b/challenges/birdsbirdbirds/check-bird.mjs index b17affb..dc10a0c 100644 --- a/challenges/birdsbirdbirds/check-bird.mjs +++ b/challenges/birdsbirdbirds/check-bird.mjs @@ -5,7 +5,9 @@ // css_keyframes goes through a small strict tokenizer and parser that accepts // exactly one `@keyframes { ... }` rule built from an // allowlist of properties, transform functions, and units, so nothing else -// can reach the page's stylesheet. +// can reach the page's stylesheet. The parser also returns what it read, so +// the page server can turn a checked flight into waypoints and check the +// booth flock's transforms with the same grammar. import { realpathSync } from 'node:fs' import { pathToFileURL } from 'node:url' @@ -292,7 +294,7 @@ function keyframesErrors(css, animationName) { if (forbidden.length > 0) return forbidden try { - parseKeyframes(tokenize(css, KEYFRAMES_FIELD), animationName) + readKeyframes(css, animationName) } catch (error) { if (!(error instanceof CheckError)) throw error return [error.message] @@ -301,6 +303,48 @@ function keyframesErrors(css, animationName) { return [] } +/** + * One keyframe as parsed: `offsets` from 0 to 1 (from is 0, to is 1, 50% is + * 0.5), and `declarations`, each `{ property, value }`. A transform's value + * is its functions in order, each `{ name, arguments }` with every argument + * `{ value, unit }` (`none` is no functions); an opacity's is a number; an + * animation-timing-function's is its text, written out again. + */ + +/** + * Parses checked css_keyframes named `animationName` into keyframes in the + * order written. Throws an Error naming the first problem when it fails the + * grammar, so call it on keyframes checkBird has passed. + */ +export function readKeyframes(css, animationName) { + return parseKeyframes(tokenize(css, KEYFRAMES_FIELD), animationName) +} + +/** + * Parses a transform value such as `translate(120px, 300px) rotate(5deg)` with + * the keyframes grammar. Returns its functions as a keyframe holds them, or + * undefined when the grammar refuses it. + */ +export function readTransform(text) { + try { + return checkTransformValue(tokenize(text, 'transform')) + } catch (error) { + if (!(error instanceof CheckError)) throw error + return undefined + } +} + +/** Returns a timing function written out again, or undefined when it isn't a keyword or allowed cubic-bezier(). */ +export function readTimingFunction(text) { + if (text.length > MAX_TIMING_FUNCTION_LENGTH) return undefined + try { + return checkTimingValue(tokenize(text, 'timing function'), 'timing function') + } catch (error) { + if (!(error instanceof CheckError)) throw error + return undefined + } +} + /** Builds a CheckError for css_keyframes with the given detail. */ function keyframesError(detail) { return new CheckError(`${KEYFRAMES_FIELD}: ${detail}`) @@ -439,7 +483,8 @@ function describe(token) { /** * Parses tokens as exactly `@keyframes { + }` and - * nothing else. Throws CheckError naming the first problem and its fix. + * nothing else, returning the frames. Throws CheckError naming the first + * problem and its fix. */ function parseKeyframes(tokens, animationName) { checkBraceBalance(tokens) @@ -472,17 +517,20 @@ function parseKeyframes(tokens, animationName) { } expect('{', `after "@keyframes ${animationName}"`) - let frameCount = 0 + const frames = [] while (peek()?.type !== '}') { - position = parseFrame(tokens, position) - frameCount += 1 + const { frame, next: afterFrame } = parseFrame(tokens, position) + frames.push(frame) + position = afterFrame } next() - if (frameCount === 0) throw keyframesError('the @keyframes block has no frames; add frames such as "0% { ... } 100% { ... }"') + if (frames.length === 0) throw keyframesError('the @keyframes block has no frames; add frames such as "0% { ... } 100% { ... }"') if (position < tokens.length) { throw keyframesError(`found ${describe(peek())} after the closing "}" of @keyframes; every frame must sit inside the one @keyframes block, so remove or move anything after its final "}"`) } + + return frames } /** Fails unless every "{" has a matching "}" and none closes early below depth zero. */ @@ -494,11 +542,13 @@ function checkBraceBalance(tokens) { } } -/** Parses one frame starting at `position`: selectors, then a declaration block. Returns the next position. */ +/** Parses one frame starting at `position`: selectors, then a declaration block. Returns `{ frame, next }`. */ function parseFrame(tokens, position) { + const offsets = [] + const declarations = [] let cursor = position for (;;) { - checkFrameSelector(tokens[cursor]) + offsets.push(frameOffset(tokens[cursor])) cursor += 1 if (tokens[cursor]?.type !== ',') break cursor += 1 @@ -513,21 +563,24 @@ function parseFrame(tokens, position) { cursor += 1 continue } - cursor = parseDeclaration(tokens, cursor) + const { declaration, next } = parseDeclaration(tokens, cursor) + declarations.push(declaration) + cursor = next } - return cursor + 1 + return { frame: { offsets, declarations }, next: cursor + 1 } } -/** Fails unless the token is `from`, `to`, or a percentage from 0% to 100%. */ -function checkFrameSelector(token) { - if (token?.type === 'ident' && (token.value === 'from' || token.value === 'to')) return - if (token?.type === 'number' && token.unit === '%' && token.value >= 0 && token.value <= 100) return +/** The offset from 0 to 1 a frame selector stands for; fails unless it is `from`, `to`, or a percentage from 0% to 100%. */ +function frameOffset(token) { + if (token?.type === 'ident' && token.value === 'from') return 0 + if (token?.type === 'ident' && token.value === 'to') return 1 + if (token?.type === 'number' && token.unit === '%' && token.value >= 0 && token.value <= 100) return token.value / 100 throw keyframesError(`frame selector ${describe(token)} is not allowed; use from, to, or a percentage from 0% to 100%`) } -/** Parses `property: value` starting at `position`; returns the position of the ";" or "}" after it. */ +/** Parses `property: value` starting at `position`; returns `{ declaration, next }`, `next` being the position of the ";" or "}" after it. */ function parseDeclaration(tokens, position) { const property = tokens[position] if (property?.type !== 'ident') { @@ -548,20 +601,25 @@ function parseDeclaration(tokens, position) { const value = tokens.slice(position + 2, end) if (value.length === 0) throw keyframesError(`${quote(property.value)} has no value; give it one or remove it`) - if (property.value === 'transform') checkTransformValue(value) - if (property.value === 'opacity') checkOpacityValue(value) - if (property.value === 'animation-timing-function') checkTimingValue(value, `${KEYFRAMES_FIELD}: animation-timing-function`) + const readers = { + transform: () => checkTransformValue(value), + opacity: () => checkOpacityValue(value), + 'animation-timing-function': () => checkTimingValue(value, `${KEYFRAMES_FIELD}: animation-timing-function`), + } - return end + return { declaration: { property: property.value, value: readers[property.value]() }, next: end } } /** * Fails unless the value is `none` or allowed transform functions, each with - * the right number of arguments in the units its kind takes. + * the right number of arguments in the units its kind takes. Returns the + * functions, each `{ name, arguments }`; `none` is none. */ function checkTransformValue(value) { - if (value.length === 1 && value[0].type === 'ident' && value[0].value === 'none') return + if (value.length === 1 && value[0].type === 'ident' && value[0].value === 'none') return [] + if (value.length === 0) throw keyframesError('transform has no value; give it one or remove it') + const functions = [] let position = 0 while (position < value.length) { const transformFunction = value[position] @@ -577,8 +635,11 @@ function checkTransformValue(value) { } const call = `${transformFunction.value}(${argumentTokens.map((argument) => argument.text).join(', ')})` argumentTokens.forEach((argument, index) => checkArgumentKind(call, argument, kinds[index])) + functions.push({ name: transformFunction.value, arguments: argumentTokens.map(({ value: number, unit }) => ({ value: number, unit })) }) position = next } + + return functions } /** @@ -614,20 +675,25 @@ function readArguments(tokens, position, context) { } } -/** Fails unless the value is a single unitless number from 0 to 1. */ +/** Fails unless the value is a single unitless number from 0 to 1; returns the number. */ function checkOpacityValue(value) { const [number] = value if (value.length !== 1 || number.type !== 'number' || number.unit !== '' || number.value < 0 || number.value > 1) { throw keyframesError(`opacity must be one plain number from 0 to 1, like "opacity: 0.5"; found ${value.map(describe).join(' ')}`) } + + return number.value } -/** Fails unless the tokens are a timing keyword or cubic-bezier with four plain numbers, x values in 0–1. */ +/** + * Fails unless the tokens are a timing keyword or cubic-bezier with four + * plain numbers, x values in 0–1. Returns the timing function written out again. + */ function checkTimingValue(value, field) { const allowed = `use ${TIMING_KEYWORDS.join(', ')}, or cubic-bezier(x1, y1, x2, y2) with plain numbers` const [first] = value if (first?.type === 'ident' && TIMING_KEYWORDS.includes(first.value)) { - if (value.length === 1) return + if (value.length === 1) return first.value throw new CheckError(`${field} has extra text after ${describe(first)}; use one timing function on its own`) } if (first?.type !== 'function' || first.value !== 'cubic-bezier') { @@ -649,6 +715,8 @@ function checkTimingValue(value, field) { if ([argumentTokens[0], argumentTokens[2]].some((argument) => argument.value < 0 || argument.value > 1)) { throw new CheckError(`${field} cubic-bezier() x values (the 1st and 3rd numbers) must be from 0 to 1`) } + + return `cubic-bezier(${argumentTokens.map((argument) => argument.value).join(', ')})` } /** diff --git a/challenges/birdsbirdbirds/flight-history.mjs b/challenges/birdsbirdbirds/flight-history.mjs new file mode 100644 index 0000000..eab1ed2 --- /dev/null +++ b/challenges/birdsbirdbirds/flight-history.mjs @@ -0,0 +1,135 @@ +// Keeps every distinct flight that passed the checker, so the sky page can +// show what the last change did and Pi can count how many flights the +// participant directed. Each new passing bird.json is copied to +// /.flights/NNN.json, numbered from 001. +// +// Recording runs when the workspace changes and whenever the page asks. It +// never relies on what fs.watch says changed, since Linux and macOS report +// different events and names: any event schedules one debounced check, and a +// check that finds nothing new does nothing. + +import { lstatSync, mkdirSync, readdirSync, watch, writeFileSync } from 'node:fs' +import path from 'node:path' + +import { loadBirdFile } from './check-bird.mjs' +import { changedWaypoints, flightOf } from './flight-path.mjs' + +export const FLIGHTS_DIRECTORY = '.flights' +export const DEFAULT_FLIGHT_DEBOUNCE_MS = 200 + +const FLIGHT_FILE_PATTERN = /^(\d{3,})\.json$/ + +/** + * Copies /bird.json into .flights/ as the next number when it + * passes the checker and differs from the latest recorded flight. Returns + * the new flight's number, or undefined when nothing was recorded. + */ +export function recordFlight(workspace) { + const { entry, errors } = loadBirdFile(path.join(workspace, 'bird.json')) + if (errors.length > 0) return undefined + + const directory = flightsDirectory(workspace) + const recorded = recordedFlights(directory) + const latest = recorded.at(-1) + if (latest !== undefined && canonicalJson(latest.entry) === canonicalJson(entry)) return undefined + + const number = (recorded.length === 0 ? 0 : Math.max(...recorded.map((flight) => flight.number))) + 1 + mkdirSync(directory, { recursive: true }) + writeFileSync(path.join(directory, `${String(number).padStart(3, '0')}.json`), `${JSON.stringify(entry, null, 2)}\n`, { flag: 'wx' }) + + return number +} + +/** + * The latest recorded flight and the one before it, as the page shows them: + * `{ current, previous, changed }`. Each flight is flightOf's shape or null, + * and `changed` lists the current waypoints that differ from the previous. + */ +export function readFlightHistory(workspace) { + const latestFlights = recordedFlights(flightsDirectory(workspace)) + .slice(-2) + .map(({ entry, number }) => flightOf(entry, number)) + const current = latestFlights.at(-1) + const previous = latestFlights.length === 2 ? latestFlights[0] : undefined + + return { + current: current ?? null, + previous: previous ?? null, + changed: current === undefined ? [] : changedWaypoints(current, previous), + } +} + +/** + * Records flights as the workspace changes until `stop()` is called. Any + * event schedules one check `debounceMs` after events go quiet; errors go to + * `onError`. The watcher never keeps the process alive. Returns `{ stop }`. + */ +export function watchFlights({ workspace, onError, debounceMs = DEFAULT_FLIGHT_DEBOUNCE_MS }) { + let timer + let stopped = false + + /** Checks for a new flight once events have been quiet for `debounceMs`. */ + const scheduleCheck = () => { + clearTimeout(timer) + timer = setTimeout(() => { + if (stopped) return + try { + recordFlight(workspace) + } catch (error) { + onError(error) + } + }, debounceMs) + timer.unref() + } + + const watcher = watch(workspace, { persistent: false }, scheduleCheck) + watcher.on('error', onError) + + return { + /** Stops watching and cancels a pending check; safe to call more than once. */ + stop() { + stopped = true + watcher.close() + clearTimeout(timer) + }, + } +} + +/** The .flights directory of `workspace`. */ +function flightsDirectory(workspace) { + return path.join(workspace, FLIGHTS_DIRECTORY) +} + +/** + * The flights in `directory` that still pass the checker, oldest first, each + * `{ number, entry }`. Empty when the directory doesn't exist yet. Throws when + * it is anything but a real directory, so a symlink never redirects writes. + */ +function recordedFlights(directory) { + try { + if (!lstatSync(directory).isDirectory()) throw new Error(`${directory} is not a directory; remove it so flights can be recorded`) + } catch (error) { + if (error.code === 'ENOENT') return [] + throw error + } + + return readdirSync(directory) + .flatMap((name) => { + const number = FLIGHT_FILE_PATTERN.exec(name)?.[1] + if (number === undefined) return [] + const { entry, errors } = loadBirdFile(path.join(directory, name)) + + return errors.length === 0 ? [{ number: Number(number), entry }] : [] + }) + .sort((left, right) => left.number - right.number) +} + +/** JSON text of `value` with object keys sorted, so key order never makes two flights distinct. */ +function canonicalJson(value) { + if (Array.isArray(value)) return `[${value.map(canonicalJson).join(',')}]` + if (typeof value === 'object' && value !== null) { + return `{${Object.keys(value).sort().map((key) => `${JSON.stringify(key)}:${canonicalJson(value[key])}`).join(',')}}` + } + + return JSON.stringify(value) +} diff --git a/challenges/birdsbirdbirds/flight-path.mjs b/challenges/birdsbirdbirds/flight-path.mjs new file mode 100644 index 0000000..3cbf677 --- /dev/null +++ b/challenges/birdsbirdbirds/flight-path.mjs @@ -0,0 +1,221 @@ +// Turns a checked bird.json into the waypoints the sky page draws and flies, +// on the booth gallery's 1000 by 850 sky stage. Lengths convert to stage +// pixels exactly as the gallery converts them (1vw is 10px, 1vh is 8.5px), +// since the gallery plays the same flight on that stage, so the path the +// participant directs is the path the booth screens show. + +import { readKeyframes } from './check-bird.mjs' + +/** The gallery's sky stage, in pixels. */ +export const STAGE_WIDTH = 1000 +export const STAGE_HEIGHT = 850 + +/** How much slower than bird.json says a bird flies, as the gallery plays it. */ +export const FLIGHT_SLOWDOWN = 3 + +/** The bird's box on the gallery stage, in pixels; a translate percentage is a share of it. */ +export const BIRD_BOX_PIXELS = 120 + +/** Stage pixels per length unit, as the gallery converts them. */ +const STAGE_PIXELS_PER_UNIT = { px: 1, vw: STAGE_WIDTH / 100, vh: STAGE_HEIGHT / 100, em: 16, rem: 16 } + +/** Degrees per angle unit. */ +const DEGREES_PER_UNIT = { deg: 1, turn: 360 } + +/** The largest scale the gallery draws a bird at either way. */ +const MAX_SCALE = 5 + +/** How far a waypoint may move or turn before it counts as changed: pixels, degrees, and scale. */ +const CHANGE_TOLERANCE = { position: 0.5, rotate: 0.5, scale: 0.005 } + +/** The identity 2D matrix, as `[a, b, c, d, e, f]`. */ +const IDENTITY = [1, 0, 0, 1, 0, 0] + +/** + * The flight a checked bird.json `entry` describes, numbered `version`: + * `{ version, bird_name, origin, duration_s, timing_function, waypoints }`. + * `duration_s` is one flight's playback time on the stage, already slowed + * down as the gallery slows it. Each waypoint is `{ n, offset, x, y }` plus + * `rotate` (degrees) and `scale` when its transform sets them, `opacity` when + * its frame sets it, and `easing` when its frame sets a timing function. + * `x` and `y` place the bird box's top-left corner, as translate does. + */ +export function flightOf(entry, version) { + const { css_keyframes: css, animation_name: animationName, duration, timing_function: timingFunction } = entry.animation + + return { + version, + bird_name: entry.bird_name, + origin: entry.origin, + duration_s: Number.parseFloat(duration) * FLIGHT_SLOWDOWN, + timing_function: timingFunction, + waypoints: waypointsOf(readKeyframes(css, animationName)), + } +} + +/** + * The numbers of the waypoints in `current` that moved, turned, or resized + * since `previous`, or that `previous` lacks. Empty when there is no + * previous flight, since nothing has been revised yet. + */ +export function changedWaypoints(current, previous) { + if (previous === undefined) return [] + + const previousByNumber = new Map(previous.waypoints.map((waypoint) => [waypoint.n, waypoint])) + + return current.waypoints + .filter((waypoint) => isChanged(waypoint, previousByNumber.get(waypoint.n))) + .map((waypoint) => waypoint.n) +} + +/** True when `waypoint` differs in position or transform from `before`, or `before` is missing. */ +function isChanged(waypoint, before) { + if (before === undefined) return true + + return Math.abs(waypoint.x - before.x) > CHANGE_TOLERANCE.position + || Math.abs(waypoint.y - before.y) > CHANGE_TOLERANCE.position + || Math.abs((waypoint.rotate ?? 0) - (before.rotate ?? 0)) > CHANGE_TOLERANCE.rotate + || Math.abs((waypoint.scale ?? 1) - (before.scale ?? 1)) > CHANGE_TOLERANCE.scale +} + +/** + * The waypoints of parsed keyframes: one per offset that sets a transform, in + * offset order and numbered from 1. Frames sharing an offset merge, later + * declarations winning, as CSS merges them. A flight that sets no transform + * at its start or end gets an untransformed waypoint there, which is where + * CSS puts the bird. + */ +function waypointsOf(frames) { + const framesByOffset = new Map() + for (const { offsets, declarations } of frames) { + for (const offset of offsets) { + const merged = framesByOffset.get(offset) ?? {} + for (const { property, value } of declarations) merged[property] = value + framesByOffset.set(offset, merged) + } + } + for (const edge of [0, 1]) { + const frame = framesByOffset.get(edge) ?? {} + frame.transform ??= [] + framesByOffset.set(edge, frame) + } + + return [...framesByOffset] + .filter(([, frame]) => frame.transform !== undefined) + .sort(([left], [right]) => left - right) + .map(([offset, frame], index) => waypointOf(index + 1, offset, frame)) +} + +/** One waypoint, numbered `n`, from the merged frame at `offset`. */ +function waypointOf(n, offset, frame) { + const { x, y, rotate, scale } = placementOf(frame.transform) + const waypoint = { n, offset: rounded(offset, 4), x: rounded(x, 1), y: rounded(y, 1) } + if (rotate !== undefined) waypoint.rotate = rounded(rotate, 1) + if (scale !== undefined) waypoint.scale = rounded(scale, 3) + if (frame.opacity !== undefined) waypoint.opacity = frame.opacity + if (frame['animation-timing-function'] !== undefined) waypoint.easing = frame['animation-timing-function'] + + return waypoint +} + +/** + * Where a transform puts the bird box and how it turns and sizes it. The + * transform origin is the box's centre, so the box moves by the translation + * part of the composed matrix whatever turns or scales come first. `rotate` + * adds up every rotation, so a full turn stays a full turn, and `scale` is + * the product of the horizontal scales; each is undefined when the transform + * has no function of its kind. + */ +function placementOf(transformFunctions) { + let matrix = IDENTITY + let rotate + let scale + for (const { name, arguments: transformArguments } of transformFunctions) { + matrix = multiply(matrix, matrixOf(name, transformArguments)) + if (name === 'rotate' || name === 'rotateZ') rotate = (rotate ?? 0) + degreesOf(transformArguments[0]) + if (name === 'scale' || name === 'scaleX') scale = (scale ?? 1) * clampedScale(transformArguments[0]) + } + + return { x: matrix[4], y: matrix[5], rotate, scale } +} + +/** The 2D matrix of one checked transform function. */ +function matrixOf(name, transformArguments) { + const [first, second] = transformArguments + const lengths = () => [pixelsOf(first), second === undefined ? 0 : pixelsOf(second)] + const scales = () => [clampedScale(first), second === undefined ? clampedScale(first) : clampedScale(second)] + const radians = (argument) => (argument === undefined ? 0 : (degreesOf(argument) * Math.PI) / 180) + + switch (name) { + case 'translate': + case 'translate3d': { + const [x, y] = lengths() + return [1, 0, 0, 1, x, y] + } + case 'translateX': + return [1, 0, 0, 1, pixelsOf(first), 0] + case 'translateY': + return [1, 0, 0, 1, 0, pixelsOf(first)] + case 'rotate': + case 'rotateZ': { + const angle = radians(first) + return [Math.cos(angle), Math.sin(angle), -Math.sin(angle), Math.cos(angle), 0, 0] + } + case 'scale': { + const [scaleX, scaleY] = scales() + return [scaleX, 0, 0, scaleY, 0, 0] + } + case 'scaleX': + return [clampedScale(first), 0, 0, 1, 0, 0] + case 'scaleY': + return [1, 0, 0, clampedScale(first), 0, 0] + case 'skew': + return [1, Math.tan(radians(second)), Math.tan(radians(first)), 1, 0, 0] + case 'skewX': + return [1, 0, Math.tan(radians(first)), 1, 0, 0] + case 'skewY': + return [1, Math.tan(radians(first)), 0, 1, 0, 0] + default: + throw new Error(`transform ${name}() has no matrix; check the bird with check-bird.mjs first`) + } +} + +/** The product `left × right` of two 2D matrices, each `[a, b, c, d, e, f]`. */ +function multiply(left, right) { + const [a, b, c, d, e, f] = left + const [a2, b2, c2, d2, e2, f2] = right + + return [ + a * a2 + c * b2, + b * a2 + d * b2, + a * c2 + c * d2, + b * c2 + d * d2, + a * e2 + c * f2 + e, + b * e2 + d * f2 + f, + ] +} + +/** A checked length in stage pixels; a percentage is a share of the bird box. */ +function pixelsOf({ value, unit }) { + if (unit === '') return 0 + if (unit === '%') return (value / 100) * BIRD_BOX_PIXELS + + return value * STAGE_PIXELS_PER_UNIT[unit] +} + +/** A checked angle in degrees. */ +function degreesOf({ value, unit }) { + return unit === '' ? 0 : value * DEGREES_PER_UNIT[unit] +} + +/** A scale factor held to the gallery's limit either way. */ +function clampedScale({ value }) { + return Math.min(Math.max(value, -MAX_SCALE), MAX_SCALE) +} + +/** `value` rounded to `decimals` places, with -0 as 0. */ +function rounded(value, decimals) { + const result = Number(value.toFixed(decimals)) + + return result === 0 ? 0 : result +} diff --git a/challenges/birdsbirdbirds/server.mjs b/challenges/birdsbirdbirds/server.mjs index 52404c4..77fcf2a 100644 --- a/challenges/birdsbirdbirds/server.mjs +++ b/challenges/birdsbirdbirds/server.mjs @@ -1,19 +1,21 @@ // Serves the drawing page and the sky on 127.0.0.1. The page saves the -// participant's drawing as /bird.png and polls bird.json, which is -// served only once check-bird.mjs passes it, so nothing unchecked reaches the -// page's keyframes stylesheet. +// participant's drawing as /bird.png and polls /flight.json: the +// latest flight that passed check-bird.mjs and the one before it, as +// waypoints on the gallery's sky stage. The page gets numbers only, never +// bird.json's CSS text. // // Run as `node server.mjs` with BIRDSBIRDBIRDS_WORKSPACE (absolute), // BIRDSBIRDBIRDS_TOKEN, and optionally BIRDSBIRDBIRDS_PORT (default 4322). // Exits with code 3 when the port is busy. import { createHash, randomBytes, timingSafeEqual } from 'node:crypto' -import { lstat, readFile, rename, rm, writeFile } from 'node:fs/promises' +import { readFile, rename, rm, writeFile } from 'node:fs/promises' import http from 'node:http' import path from 'node:path' import { fileURLToPath } from 'node:url' -import { isRunDirectly, loadBirdFile } from './check-bird.mjs' +import { isRunDirectly } from './check-bird.mjs' +import { readFlightHistory, recordFlight, watchFlights } from './flight-history.mjs' import { readWorkspaceFile, RefusedFileError } from './workspace-file.mjs' export const DEFAULT_PORT = 4322 @@ -53,6 +55,8 @@ export function sha256Hex(text) { * Creates the page server for `workspace` (absolute). `token` authorises * POST /shutdown. The server is not listening yet; the Host and Origin checks * use whatever port it is later bound to, so tests can listen on port 0. + * While it listens, it records each new passing flight as the workspace + * changes. */ export function createBirdServer({ workspace, token }) { if (!path.isAbsolute(workspace)) throw new Error(`workspace must be an absolute path, got ${JSON.stringify(workspace)}`) @@ -65,10 +69,23 @@ export function createBirdServer({ workspace, token }) { sendText(response, 500, 'internal error') }) }) + recordFlightsWhileListening(server, workspace) return server } +/** Watches `workspace` for new flights from when `server` listens until it closes. */ +function recordFlightsWhileListening(server, workspace) { + let flightWatcher + server.on('listening', () => { + flightWatcher = watchFlights({ + workspace, + onError: (error) => console.error(`birdsbirdbirds server: recording a flight failed: ${error.message}`), + }) + }) + server.on('close', () => flightWatcher?.stop()) +} + /** Routes one request after the Host check; paths match exactly, with no normalisation. */ async function handleRequest({ server, workspace, token, request, response }) { const port = server.address().port @@ -81,7 +98,7 @@ async function handleRequest({ server, workspace, token, request, response }) { if (route === 'GET /health') return sendJson(response, 200, { instance: sha256Hex(token) }) if (route === 'GET /bird.png') return sendSavedBird(response, workspace) if (route === 'POST /bird.png') return saveBird({ request, response, workspace, port }) - if (route === 'GET /bird.json') return sendBirdEntry(response, workspace) + if (route === 'GET /flight.json') return sendFlights(response, workspace) if (route === 'POST /shutdown') return shutDown({ server, token, request, response }) sendText(response, 404, 'not found') @@ -124,22 +141,13 @@ function sendSavedBird(response, workspace) { } /** - * Serves /bird.json only when check-bird.mjs passes it: 404 when - * missing, 422 with the checker's errors (and never the entry) otherwise. + * Records bird.json if it is a new passing flight, so the page never waits + * on the watcher, then sends `{ current, previous, changed }`. */ -async function sendBirdEntry(response, workspace) { - const birdFile = path.join(workspace, 'bird.json') - try { - await lstat(birdFile) - } catch (error) { - if (error.code === 'ENOENT') return sendText(response, 404, 'no bird.json yet') - throw error - } - - const { entry, errors } = loadBirdFile(birdFile) - if (errors.length > 0) return sendJson(response, 422, { errors }) +function sendFlights(response, workspace) { + recordFlight(workspace) - sendJson(response, 200, entry) + sendJson(response, 200, readFlightHistory(workspace)) } /** diff --git a/test/birdsbirdbirds.test.mjs b/test/birdsbirdbirds.test.mjs index 7141bee..b53d5f5 100644 --- a/test/birdsbirdbirds.test.mjs +++ b/test/birdsbirdbirds.test.mjs @@ -1,8 +1,9 @@ // Proves the birdsbirdbirds package: check-bird.mjs accepts real gallery // birds and rejects CSS that could escape the page's keyframes stylesheet; the -// page server saves only PNGs to bird.png and serves only checked birds; the -// setup and cleanup hooks start and stop it; the label never parses HTML; the -// workspace watcher and its Pi extension report each new drawing once. +// page server saves only PNGs to bird.png, records each new checked flight, +// and sends it as waypoints on the gallery's stage; the setup and cleanup +// hooks start and stop it; the label never parses HTML; the workspace watcher +// and its Pi extension report each new drawing once. import assert from 'node:assert/strict' import { execFile, spawn, spawnSync } from 'node:child_process' @@ -14,12 +15,15 @@ import net from 'node:net' import { tmpdir } from 'node:os' import path from 'node:path' import { test } from 'node:test' +import { setTimeout as sleep } from 'node:timers/promises' import { fileURLToPath } from 'node:url' import { crc32, deflateSync } from 'node:zlib' import { DEFAULT_DEBOUNCE_MS, watchWorkspace } from '../challenges/birdsbirdbirds/bird-watcher.mjs' import { checkBird, loadBirdFile, MAX_BIRD_FILE_BYTES, MAX_KEYFRAMES_BYTES } from '../challenges/birdsbirdbirds/check-bird.mjs' import birdsbirdbirdsExtension from '../challenges/birdsbirdbirds/extension.mjs' +import { FLIGHTS_DIRECTORY } from '../challenges/birdsbirdbirds/flight-history.mjs' +import { changedWaypoints, flightOf } from '../challenges/birdsbirdbirds/flight-path.mjs' import { createBirdLabel } from '../challenges/birdsbirdbirds/page/bird-label.mjs' import { createBirdServer, MAX_PNG_BYTES } from '../challenges/birdsbirdbirds/server.mjs' @@ -611,55 +615,147 @@ test('the server sends the page with a CSP that admits no inline styles and no b assert.doesNotMatch(response.body.toString(), / withWorkspace(async ({ workspace }) => { +/** GETs /flight.json and resolves its status and parsed body. */ +const getFlights = async (port) => { + const response = await sendRequest({ port, requestPath: '/flight.json', timeoutMs: 5000 }) + + return { status: response.status, text: response.body.toString(), flights: JSON.parse(response.body) } +} + +/** Writes `entry` to the workspace's bird.json, as Pi's write tool does. */ +const writeBird = (workspace, entry) => writeFile(path.join(workspace, 'bird.json'), JSON.stringify(entry)) + +/** Builds a bird whose css_keyframes is one frame per `transforms` entry, spread evenly from 0% to 100%. */ +const birdFlying = (transforms) => birdWithKeyframes(`@keyframes fly_test { ${transforms.map((transform, index) => `${(index * 100) / (transforms.length - 1)}% { transform: ${transform}; }`).join(' ')} }`) + +const FLIGHT_PATHS = [ + { + behavior: 'converts vw, vh, px, em, and % to stage pixels', + keyframes: '0% { transform: translate(10vw, 10vh); } 50% { transform: translate(100px, 2em); } 100% { transform: translate(50%, 0); }', + waypoints: [{ n: 1, offset: 0, x: 100, y: 85 }, { n: 2, offset: 0.5, x: 100, y: 32 }, { n: 3, offset: 1, x: 60, y: 0 }], + }, + { + behavior: 'adds untransformed waypoints where CSS starts and ends a flight that sets none there', + keyframes: '50% { transform: translateY(20vh); }', + waypoints: [{ n: 1, offset: 0, x: 0, y: 0 }, { n: 2, offset: 0.5, x: 0, y: 170 }, { n: 3, offset: 1, x: 0, y: 0 }], + }, + { + behavior: 'merges frames that share an offset, the later declaration winning, and keeps opacity and easing', + keyframes: 'from, to { transform: translateX(10vw); } to { transform: translateX(20vw); opacity: 0.5; animation-timing-function: ease-out; }', + waypoints: [{ n: 1, offset: 0, x: 100, y: 0 }, { n: 2, offset: 1, x: 200, y: 0, opacity: 0.5, easing: 'ease-out' }], + }, + { + behavior: 'adds up full turns and moves a box rotated before it is translated along the turned axis', + keyframes: 'from { transform: rotate(1turn); } to { transform: rotate(90deg) translateX(10vw) scale(9); }', + waypoints: [{ n: 1, offset: 0, x: 0, y: 0, rotate: 360 }, { n: 2, offset: 1, x: 0, y: 100, rotate: 90, scale: 5 }], + }, +] + +for (const { behavior, keyframes, waypoints } of FLIGHT_PATHS) { + test(`the flight path ${behavior}`, () => { + const entry = birdWithKeyframes(`@keyframes fly_test { ${keyframes} }`) + + const flight = flightOf(entry, 1) + + assert.deepEqual(flight.waypoints, waypoints) + }) +} + +test('a first flight has no changed waypoints, since nothing was revised', () => { + const flight = flightOf(birdEntry(), 1) + + const changed = changedWaypoints(flight, undefined) + + assert.deepEqual(changed, []) +}) + +test('the server sends no flight before Pi writes bird.json', (testContext) => withWorkspace(async ({ workspace }) => { const port = await startBirdServer(testContext, workspace) - const response = await sendRequest({ port, requestPath: '/bird.json' }) + const { status, flights } = await getFlights(port) - assert.equal(response.status, 404) + assert.equal(status, 200) + assert.deepEqual(flights, { current: null, previous: null, changed: [] }) })) -test('the server serves a bird.json that passes the checker', (testContext) => withWorkspace(async ({ workspace }) => { +test('the server sends a passing bird.json as waypoints in stage pixels and records it as flight 001', (testContext) => withWorkspace(async ({ workspace }) => { const port = await startBirdServer(testContext, workspace) - await writeFile(path.join(workspace, 'bird.json'), JSON.stringify(GALLERY_BIRDS[0])) + await writeBird(workspace, birdFlying(['translate(10vw, 20vh)', 'translate(50vw, 40vh) rotate(-10deg)', 'translate(80vw, 60vh)'])) - const response = await sendRequest({ port, requestPath: '/bird.json' }) + const { flights } = await getFlights(port) - assert.equal(response.status, 200) - assert.deepEqual(JSON.parse(response.body), GALLERY_BIRDS[0]) + assert.deepEqual(flights.current, { + version: 1, + bird_name: 'Robin', + origin: 'Durham, NC', + duration_s: 36, + timing_function: 'linear', + waypoints: [ + { n: 1, offset: 0, x: 100, y: 170 }, + { n: 2, offset: 0.5, x: 500, y: 340, rotate: -10 }, + { n: 3, offset: 1, x: 800, y: 510 }, + ], + }) + assert.equal(flights.previous, null) + assert.deepEqual(await readdir(path.join(workspace, FLIGHTS_DIRECTORY)), ['001.json']) })) -test('the server answers 422 for a failing bird.json and never serves its keyframes', (testContext) => withWorkspace(async ({ workspace }) => { +test('the server marks the waypoints a revision moved and keeps the flight before it', (testContext) => withWorkspace(async ({ workspace }) => { const port = await startBirdServer(testContext, workspace) - const keyframes = '@keyframes fly_test { 0% { transform: url(https://evil.example/x.svg); } }' - await writeFile(path.join(workspace, 'bird.json'), JSON.stringify(birdWithKeyframes(keyframes))) + await writeBird(workspace, birdFlying(['translate(10vw, 20vh)', 'translate(50vw, 40vh)', 'translate(80vw, 60vh)'])) + await getFlights(port) + await writeBird(workspace, birdFlying(['translate(10vw, 20vh)', 'translate(50vw, 10vh)', 'translate(80vw, 60vh)', 'translate(85vw, 70vh)'])) - const response = await sendRequest({ port, requestPath: '/bird.json' }) + const { flights } = await getFlights(port) - assert.equal(response.status, 422) - assert.ok(!response.body.toString().includes('evil.example')) - assert.match(JSON.parse(response.body).errors.join('\n'), /"url\(" is not allowed/) + assert.equal(flights.current.version, 2) + assert.equal(flights.previous.version, 1) + assert.deepEqual(flights.changed, [2, 4]) + assert.deepEqual(await readdir(path.join(workspace, FLIGHTS_DIRECTORY)), ['001.json', '002.json']) })) -test('the server answers 422 for a bird.json over the byte cap', (testContext) => withWorkspace(async ({ workspace }) => { +test('the server records a rewrite of the same flight only once, whatever its key order', (testContext) => withWorkspace(async ({ workspace }) => { const port = await startBirdServer(testContext, workspace) - await writeFile(path.join(workspace, 'bird.json'), ' '.repeat(MAX_BIRD_FILE_BYTES + 1)) + const { bird_name: birdName, origin, animation } = birdEntry() + await writeBird(workspace, { bird_name: birdName, origin, animation }) + await getFlights(port) + await writeBird(workspace, { animation, origin, bird_name: birdName }) - const response = await sendRequest({ port, requestPath: '/bird.json' }) + const { flights } = await getFlights(port) - assert.equal(response.status, 422) + assert.equal(flights.current.version, 1) + assert.deepEqual(await readdir(path.join(workspace, FLIGHTS_DIRECTORY)), ['001.json']) +})) + +test('the server records a new passing flight as the workspace changes, without the page asking', (testContext) => withWorkspace(async ({ workspace }) => { + await startBirdServer(testContext, workspace) + const flightFile = path.join(workspace, FLIGHTS_DIRECTORY, '001.json') + + await writeBird(workspace, birdEntry()) + + const deadline = Date.now() + 3_000 + while (!fs.existsSync(flightFile) && Date.now() < deadline) await sleep(50) + assert.deepEqual(JSON.parse(await readFile(flightFile, 'utf8')), birdEntry()) })) -test('the server refuses a symlinked bird.json without echoing its target', (testContext) => withWorkspace(async ({ directory, workspace }) => { +test('the server never records or sends a failing bird.json, nor any of its text', (testContext) => withWorkspace(async ({ workspace }) => { const port = await startBirdServer(testContext, workspace) - const target = path.join(directory, 'secret.txt') - await writeFile(target, 'top-secret-line') - await symlink(target, path.join(workspace, 'bird.json')) + await writeBird(workspace, birdWithKeyframes('@keyframes fly_test { 0% { transform: url(https://evil.example/x.svg); } }')) - const response = await sendRequest({ port, requestPath: '/bird.json' }) + const { flights, text } = await getFlights(port) - assert.equal(response.status, 422) - assert.ok(!response.body.toString().includes('top-secret'), response.body.toString()) + assert.equal(flights.current, null) + assert.ok(!text.includes('evil.example')) + assert.equal(fs.existsSync(path.join(workspace, FLIGHTS_DIRECTORY)), false) +})) + +test('the server sends no flight for a bird.json over the byte cap', (testContext) => withWorkspace(async ({ workspace }) => { + const port = await startBirdServer(testContext, workspace) + await writeFile(path.join(workspace, 'bird.json'), ' '.repeat(MAX_BIRD_FILE_BYTES + 1)) + + const { flights } = await getFlights(port) + + assert.equal(flights.current, null) })) test('the server refuses a symlinked bird.json even when its target is a valid bird', (testContext) => withWorkspace(async ({ directory, workspace }) => { @@ -668,10 +764,22 @@ test('the server refuses a symlinked bird.json even when its target is a valid b await writeFile(target, JSON.stringify(GALLERY_BIRDS[0])) await symlink(target, path.join(workspace, 'bird.json')) - const response = await sendRequest({ port, requestPath: '/bird.json' }) + const { flights } = await getFlights(port) - assert.equal(response.status, 422) - assert.match(JSON.parse(response.body).errors.join('\n'), /bird.json is not a regular file/) + assert.equal(flights.current, null) +})) + +test('the server refuses to record flights into a symlinked .flights directory', (testContext) => withWorkspace(async ({ directory, workspace }) => { + const port = await startBirdServer(testContext, workspace) + const elsewhere = path.join(directory, 'elsewhere') + await mkdir(elsewhere) + await symlink(elsewhere, path.join(workspace, FLIGHTS_DIRECTORY)) + await writeBird(workspace, birdEntry()) + + const response = await sendRequest({ port, requestPath: '/flight.json' }) + + assert.equal(response.status, 500) + assert.deepEqual(await readdir(elsewhere), []) })) /** @@ -765,13 +873,13 @@ const startServerProcess = async (testContext, workspace) => { return port } -test('the server answers 422 for a named-pipe bird.json and keeps answering', (testContext) => withWorkspace(async ({ workspace }) => { +test('the server sends no flight for a named-pipe bird.json and keeps answering', (testContext) => withWorkspace(async ({ workspace }) => { const port = await startServerProcess(testContext, workspace) makeFifo(path.join(workspace, 'bird.json')) - const response = await sendRequest({ port, requestPath: '/bird.json', timeoutMs: 5000 }) + const { flights } = await getFlights(port) - assert.equal(response.status, 422) + assert.equal(flights.current, null) assert.equal((await sendRequest({ port, requestPath: '/health', timeoutMs: 5000 })).status, 200) })) From eb667edf96e75c6829de98609e62032607b78f86 Mon Sep 17 00:00:00 2001 From: Swift Date: Tue, 3 Nov 2026 15:22:09 -0500 Subject: [PATCH 02/11] feat: turn the sky into a display-only stage that draws each flight The sky renders on the gallery's 1000x850 stage with its bird size, slowdown, and stagger. Each new flight draws its path on, pops in numbered waypoints, then flies; a revision ghosts the previous path and highlights what changed. The Draw again button is gone: Pi reopens the canvas at /?draw. --- challenges/birdsbirdbirds/README.md | 18 +- challenges/birdsbirdbirds/page/app.mjs | 145 +++---- .../birdsbirdbirds/page/flight-motion.mjs | 83 ++++ challenges/birdsbirdbirds/page/index.html | 54 ++- challenges/birdsbirdbirds/page/sky.mjs | 250 +++++++++++ challenges/birdsbirdbirds/page/style.css | 387 +++++++++++++++--- challenges/birdsbirdbirds/server.mjs | 2 + test/birdsbirdbirds.test.mjs | 27 ++ 8 files changed, 820 insertions(+), 146 deletions(-) create mode 100644 challenges/birdsbirdbirds/page/flight-motion.mjs create mode 100644 challenges/birdsbirdbirds/page/sky.mjs diff --git a/challenges/birdsbirdbirds/README.md b/challenges/birdsbirdbirds/README.md index 5777e9d..b290a70 100644 --- a/challenges/birdsbirdbirds/README.md +++ b/challenges/birdsbirdbirds/README.md @@ -20,7 +20,7 @@ published to the booth screens as a `birds-v1` gallery item. | `server.mjs` | Local page server on `127.0.0.1:4322` | | `workspace-file.mjs` | Reads `bird.json` and `bird.png` only as capped regular files | | `setup`, `cleanup` | Start and stop the server (hooks in `hooks/`) | -| `page/` | The drawing canvas and the sky | +| `page/` | The drawing canvas and the display-only sky | There is no `install` hook: the package, extension included, has no dependencies beyond Node. @@ -39,6 +39,22 @@ checker itself, so `animate` needs no message. The server listens on port 4322. Set `BIRDSBIRDBIRDS_PORT` to use another port, though the skill and the challenge expect 4322. +## The page + +The page opens on the drawing canvas until a drawing is saved, and on the sky +after. A save switches to the sky by itself. For a redraw, Pi opens +`http://127.0.0.1:4322/?draw`, which shows the canvas again. + +The sky takes no input: the participant directs the flight by talking to Pi. +It is the gallery's 1000 by 850 stage, scaled to fit the window, with each +bird in the gallery's 120px box, flights slowed 3 times, and flock birds +started 2.3 s apart, so the flight looks as it will on the booth screens. +When a new flight arrives, its path draws itself on as a dotted line of +straight segments, as CSS moves the bird between keyframes, then its numbered +waypoints pop in, then the bird flies it. After a revision the previous path +stays as a faint ghost, with the changed waypoints and the segments touching +them highlighted. A chip names the bird and its origin. + ## Flight history While the page server runs, each `bird.json` that passes the checker and diff --git a/challenges/birdsbirdbirds/page/app.mjs b/challenges/birdsbirdbirds/page/app.mjs index df8dc41..1b220dd 100644 --- a/challenges/birdsbirdbirds/page/app.mjs +++ b/challenges/birdsbirdbirds/page/app.mjs @@ -1,10 +1,10 @@ // The BirdsBirdBirds page: a canvas to draw the bird and save it as -// bird.png, then a sky where it flies once Pi's bird.json passes the checker. -// The server only ever serves a checked bird.json, so its keyframes are safe -// to adopt as a constructed stylesheet; the page needs no inline