diff --git a/README.md b/README.md index 9a3afb6..df4411f 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ links to the package's own README. | --- | --- | | [hello-world](challenges/hello-world/README.md) | Meet your AI guide and make your first file. | | [vibe-coding-mad-libs](challenges/vibe-coding-mad-libs/README.md) | Fill four blanks, and Pi builds the web app you described. | -| [birdsbirdbirds](challenges/birdsbirdbirds/README.md) | Draw a bird and watch it fly across the sky. | +| [birdsbirdbirds](challenges/birdsbirdbirds/README.md) | Draw a bird, then direct its flight by talking to Pi. | | [glitch-invaders](challenges/glitch-invaders/README.md) | Shoot the bugs, then debug the three that fight back using Sentry evidence. | Challenges follow the contract in boothstrap.sh's challenge spec and diff --git a/challenges/birdsbirdbirds/README.md b/challenges/birdsbirdbirds/README.md index f57d96a..5d259bd 100644 --- a/challenges/birdsbirdbirds/README.md +++ b/challenges/birdsbirdbirds/README.md @@ -1,9 +1,11 @@ # BirdsBirdBirds -Draw a bird in the browser, tell Pi how it should fly, and watch it cross the -sky. Pi writes the flight as a CSS keyframe animation in `bird.json`, and a -checker lets only safe animations through to the page. The bird can then be -published to the booth screens as a `birds-v1` gallery item. +Draw a bird in the browser, describe how it should fly, and direct the +flight by talking to Pi. Pi writes the flight as a CSS keyframe animation in +`bird.json`, and a checker lets only safe animations through. The sky page +draws each flight as a numbered path, then flies the bird along it, so the +participant can point at waypoints ("add a loop at 3"). When they're happy, +Pi publishes the bird to the booth screens as a `birds-v1` gallery item. ## Files @@ -15,10 +17,14 @@ 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/` | +| `flock.mjs` | Fetches the booth flock for the sky, or falls back to the practice flock | +| `flock/` | The practice flock: SVG birds and their flights in the feed's shape | | `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. @@ -32,11 +38,109 @@ It starts a turn when Pi is idle and follows the current turn otherwise. The skill then reads the drawing and reports `draw-bird`, so nobody has to tell Pi they saved. A `bird.png` already in the workspace when Pi starts, and a save with the same content, send nothing. Pi writes `bird.json` and runs the -checker itself, so `animate` needs no message. +checker itself, so `animate` needs no message. Pi counts the files in +`.flights/` (see Flight history) to report `direct-flight`, and reports +`join-flock` once `publish_gallery_item` answers, without asking first: the +participant can ask Pi to take the bird down, which it does with +`withdraw_gallery_item`. 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. + +Drawings are often thin strokes in a corner of the canvas, so the page crops +`bird.png` to its ink plus 6% padding before saving, and the sky crops every +PNG it shows the same way, cached per URL, so birds published before that +still fill their box. The participant's bird flies 1.6 times the size of a +flock bird, centred on the same box so it stays on its path, with a white +halo and above the path and markers; the flock is dimmed to 55% opacity. + +## 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` | `animation.duration` in seconds, unslowed; the page plays it 3 times slower, as the gallery does | +| `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 matches no waypoint of the previous flight, whatever its number, +so inserting waypoints doesn't mark the unchanged ones after them. + +## The flock + +The other attendees' published birds fly dimmed behind the participant's. +The page server reads the gallery's URL from `BOOTHSTRAP_GALLERY_URL`, or +else from `BIRDSBIRDBIRDS_GALLERY_URL`, which an event can set in its +`challenge_env`. It fetches `/birds.json`: + +```json +{ + "items": [{ + "id": "…", "bird_name": "…", "origin": "…", "display_name": "…", + "stored_at": "…", "image_url": "/gallery/{slug}/items/{id}/files/bird.png", + "keyframes": [{ "offset": 0, "transform": "translate(120px, 300px) rotate(5deg)", "opacity": 1, "easing": "ease-in" }], + "duration_s": 14, "timing_function": "ease-in-out" + }] +} +``` + +`image_url` is a path on the gallery's origin, transforms are in stage +pixels, and `duration_s` is the bird's own stated duration (2 to 60 s, +unslowed); the page slows it 3 times at playback, as the gallery does. Each +transform and timing function must pass the checker's grammar and is written +out again; a bird that fails any check, or whose image is on another origin, +is dropped. The booth API serves its files same-origin only, so the page +server proxies the feed at `GET /flock.json` and each PNG at +`GET /flock/images/.png`, keeping copies in +`$TMPDIR/birdsbirdbirds-flock/` and asking the gallery for the feed again +after 30 s. + +With no URL, or when the gallery is offline, errs, answers 404, or sends no +valid feed, `/flock.json` sends the practice flock in `flock/` instead, its +SVGs served at `GET /flock/demo/.svg`. Its `source` field is `live` or +`demo`, and the page labels the flock to match. + +## Preview the page + +To see the sky open on a revision, with a ghost path and highlighted +changes, seed a workspace with a sample drawing and two flights, then run the +command the script prints, from the repository root: + +```sh +node tools/seed-birds-workspace.mjs /tmp/bird-preview +``` + ## Run without boothstrap Needs Node 26 and a `GEMINI_API_KEY`. Follow the standalone steps in @@ -65,7 +169,7 @@ When done: ``` `cleanup` stops the server and removes `.birdsbirdbirds/`; it leaves -`bird.png` and `bird.json` alone. Without boothstrap there is no +`bird.png`, `bird.json`, and `.flights/` alone. Without boothstrap there is no `report_step` or `publish_gallery_item`, so the skill names each finished step and skips the gallery. @@ -96,15 +200,14 @@ need them. The `fly_` prefix keeps a bird's keyframes from colliding with CSS keywords or with the sky page's own `flap` keyframes. -The keyframes alone place the bird. In the sky page the bird's wrapper sits -at `top: 0; left: 0`, so `translate(x, y)` is measured from the top-left -corner of the sky with no extra offset. A gallery renderer should place the +The keyframes alone place the bird. In the sky page the bird's box sits at +the top-left corner of the 1000 by 850 stage, so `translate(x, y)` is +measured from there with no extra offset. A gallery renderer should place the bird the same way. -Payloads come from `check-bird.mjs`, but a display should still load -`css_keyframes` only as CSS text, as the sky page does with a constructed -`CSSStyleSheet` (`replaceSync`), and set names with `textContent`, never as -HTML. +Payloads come from `check-bird.mjs`, but a display should still never insert +`css_keyframes` as HTML, and should set names with `textContent`. The sky +page takes neither: it gets waypoint numbers from `/flight.json`. ## What the checker allows @@ -128,4 +231,7 @@ to 100%. - Rejected: `url(`, `@import`, a second at-rule, backslash escapes, quotes, `<`, comments, `!important`, unbalanced braces, text after the rule, and a name without the `fly_` prefix. +- The flight starts and ends off screen: the first and last waypoints must + hold the whole bird box, turned and scaled, off the 1000 by 850 stage, so + the bird flies in and out of the sky. - The whole `bird.json` is at most 8192 bytes. diff --git a/challenges/birdsbirdbirds/challenge.json b/challenges/birdsbirdbirds/challenge.json index 2d4703d..7ef91ca 100644 --- a/challenges/birdsbirdbirds/challenge.json +++ b/challenges/birdsbirdbirds/challenge.json @@ -1,7 +1,7 @@ { "id": "birdsbirdbirds", "title": "BirdsBirdBirds", - "tagline": "Draw a bird and watch it fly across the sky.", + "tagline": "Draw a bird, then direct its flight by talking to Pi.", "icon": "icon.txt", "type": "terminal", "agent": "pi", @@ -11,6 +11,7 @@ "estimated_minutes": 10, "learning_objectives": [ "describing a task so an agent can do it", + "refining an agent's work one prompt at a time", "checking an agent's work with a script" ], "featured_tech": ["Gemini", "Pi", "CSS animation"], @@ -24,17 +25,24 @@ }, { "id": "animate", - "title": "Give it a flight", - "hint": "Tell Pi how your bird should fly.", - "done_when": "bird.json exists in the workspace and passes check-bird.mjs." + "title": "Teach it to fly", + "hint": "Tell Pi your bird's name, where it's from, and how it should fly.", + "done_when": "Pi wrote bird.json from the participant's description and it passes check-bird.mjs." }, { - "id": "fly", - "title": "Watch it fly", - "hint": "Watch your bird cross the sky and tell Pi how it looks.", - "done_when": "The participant watched the bird fly and said so." + "id": "direct-flight", + "title": "Direct the flight", + "hint": "Tell Pi what to change. Name a waypoint number to change just that part.", + "done_when": "Two or more distinct flights have passed check-bird.mjs (.flights holds 2 or more files), or the participant says they're happy with the flight." + }, + { + "id": "join-flock", + "title": "Join the flock", + "hint": "Say you're happy with the flight, and Pi sends your bird to the booth screens.", + "done_when": "Pi called publish_gallery_item with the bird and got an answer, whatever its outcome." } ], + "skip_dialog": ["gallery"], "gallery": { "kinds": ["bird"] } diff --git a/challenges/birdsbirdbirds/check-bird.mjs b/challenges/birdsbirdbirds/check-bird.mjs index b17affb..2930b3e 100644 --- a/challenges/birdsbirdbirds/check-bird.mjs +++ b/challenges/birdsbirdbirds/check-bird.mjs @@ -5,11 +5,14 @@ // 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' +import { offScreenErrors } from './flight-path.mjs' import { readWorkspaceFile, RefusedFileError } from './workspace-file.mjs' export const MAX_BIRD_FILE_BYTES = 8192 @@ -109,8 +112,9 @@ const WHITESPACE = new Set([' ', '\t', '\n', '\r', '\f']) class CheckError extends Error {} /** - * Checks a parsed bird.json value against the birds-v1 shape. - * Returns an array of actionable error strings; an empty array means valid. + * Checks a parsed bird.json value against the birds-v1 shape, and that its + * flight starts and ends off screen. Returns an array of actionable error + * strings; an empty array means valid. */ export function checkBird(entry) { if (!isPlainObject(entry)) { @@ -125,7 +129,10 @@ export function checkBird(entry) { if (!Object.hasOwn(entry, 'animation')) return [...errors, 'animation is missing; add an "animation" object with css_keyframes, animation_name, duration, timing_function'] if (!isPlainObject(entry.animation)) return [...errors, 'animation must be an object with css_keyframes, animation_name, duration, timing_function'] - return [...errors, ...animationErrors(entry.animation)] + const animationProblems = animationErrors(entry.animation) + if (animationProblems.length > 0) return [...errors, ...animationProblems] + + return [...errors, ...offScreenErrors(entry)] } /** @@ -292,7 +299,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 +308,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 +488,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 +522,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 +547,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 +568,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 +606,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 +640,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 +680,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 +720,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..791621a --- /dev/null +++ b/challenges/birdsbirdbirds/flight-path.mjs @@ -0,0 +1,247 @@ +// 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 + +/** 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 the flight's own duration in seconds, as bird.json states + * it; players slow it down as the gallery does. 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), + timing_function: timingFunction, + waypoints: waypointsOf(readKeyframes(css, animationName)), + } +} + +/** + * What's wrong with where a checked bird.json `entry` starts and ends: a bird + * flies in from off the sky and out again, so its first and last waypoints + * must hold all of its box, turned and scaled, off the stage. Empty when both + * do. + */ +export function offScreenErrors(entry) { + const { waypoints } = flightOf(entry, 0) + const first = waypoints[0] + const last = waypoints.at(-1) + const errors = [] + if (isInSky(first)) { + errors.push('waypoint 1 puts the bird in the sky; start it fully off screen, such as translate(-20vw, 50vh)') + } + if (isInSky(last)) { + errors.push(`waypoint ${last.n}, the last, puts the bird in the sky; end it fully off screen, such as translate(120vw, 40vh)`) + } + + return errors +} + +/** True when any of the bird box `waypoint` places overlaps the stage. */ +function isInSky({ x, y, rotate = 0, scale = 1 }) { + const radians = (rotate * Math.PI) / 180 + const reach = (BIRD_BOX_PIXELS / 2) * Math.abs(scale) * (Math.abs(Math.cos(radians)) + Math.abs(Math.sin(radians))) + const centreX = x + BIRD_BOX_PIXELS / 2 + const centreY = y + BIRD_BOX_PIXELS / 2 + + return centreX + reach > 0 && centreX - reach < STAGE_WIDTH && centreY + reach > 0 && centreY - reach < STAGE_HEIGHT +} + +/** + * The numbers of the waypoints in `current` that no waypoint of `previous` + * matches in position and transform, whatever its number: inserting a loop + * renumbers every waypoint after it, but those that didn't move stay + * unchanged. Empty when there is no previous flight, since nothing has been + * revised yet. + */ +export function changedWaypoints(current, previous) { + if (previous === undefined) return [] + + return current.waypoints + .filter((waypoint) => !previous.waypoints.some((before) => isSamePlacement(waypoint, before))) + .map((waypoint) => waypoint.n) +} + +/** True when `waypoint` and `before` place the bird alike, within CHANGE_TOLERANCE. */ +function isSamePlacement(waypoint, before) { + 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 `number`, from the merged frame at `offset`. */ +function waypointOf(number, offset, frame) { + const { x, y, rotate, scale } = placementOf(frame.transform) + const waypoint = { n: number, 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 + + switch (name) { + case 'translate': + case 'translate3d': + return [1, 0, 0, 1, pixelsOf(first), second === undefined ? 0 : pixelsOf(second)] + 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 = radiansOf(first) + return [Math.cos(angle), Math.sin(angle), -Math.sin(angle), Math.cos(angle), 0, 0] + } + case 'scale': { + const scaleX = clampedScale(first) + return [scaleX, 0, 0, second === undefined ? scaleX : clampedScale(second), 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(radiansOf(second)), Math.tan(radiansOf(first)), 1, 0, 0] + case 'skewX': + return [1, 0, Math.tan(radiansOf(first)), 1, 0, 0] + case 'skewY': + return [1, Math.tan(radiansOf(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 checked angle in radians; a missing optional angle is 0. */ +function radiansOf(argument) { + return argument === undefined ? 0 : (degreesOf(argument) * Math.PI) / 180 +} + +/** 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/flock.mjs b/challenges/birdsbirdbirds/flock.mjs new file mode 100644 index 0000000..ca1bccb --- /dev/null +++ b/challenges/birdsbirdbirds/flock.mjs @@ -0,0 +1,298 @@ +// Brings the booth screens' flock to the sky page, so the participant's bird +// flies among the birds already published. The booth API serves its feed and +// images same-origin only, so the page server fetches them and serves them +// from its own origin, keeping copies in TMPDIR. Without a gallery URL, or +// when the feed can't be fetched or fails its checks, the page gets the +// bundled practice flock in flock/ instead. +// +// The feed is `/birds.json`: +// `{ "items": [{ id, bird_name, origin, display_name, stored_at, image_url, +// keyframes: [{ offset, transform, opacity?, easing? }], duration_s, +// timing_function }] }`, with `image_url` a path on the gallery's origin, +// every transform in stage pixels, and `duration_s` the bird's own stated +// duration, which the page slows down as the gallery does. Each transform and timing function is +// read with check-bird.mjs's grammar and written out again, so nothing else +// reaches the page. + +import { createHash, randomBytes } from 'node:crypto' +import { mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs' +import path from 'node:path' +import { fileURLToPath } from 'node:url' + +import { readTimingFunction, readTransform } from './check-bird.mjs' +import { readWorkspaceFile } from './workspace-file.mjs' + +export const FLOCK_FEED_FILE = 'birds.json' + +/** How long a fetched feed is reused before the gallery is asked again. */ +export const FEED_FRESH_MS = 30_000 + +const FETCH_TIMEOUT_MS = 3000 +const MAX_FEED_BYTES = 1024 * 1024 +const MAX_IMAGE_BYTES = 2 * 1024 * 1024 +const MAX_FLOCK_BIRDS = 40 +const MAX_KEYFRAMES = 40 +const MAX_TRANSFORM_LENGTH = 400 +const MAX_ID_LENGTH = 100 +const MAX_LABEL_LENGTH = { bird_name: 40, origin: 60, display_name: 40 } +const MIN_DURATION_SECONDS = 2 +const MAX_DURATION_SECONDS = 60 + +const DEMO_DIRECTORY = path.join(path.dirname(fileURLToPath(import.meta.url)), 'flock') +const DEMO_FEED_FILE = path.join(DEMO_DIRECTORY, 'flock.json') +const DEMO_IMAGE_PATTERN = /^demo\/([a-z0-9-]{1,40}\.svg)$/ +const IMAGE_KEY_PATTERN = /^[0-9a-f]{32}$/ +const PNG_SIGNATURE = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]) + +/** + * Reads the gallery URL from BOOTHSTRAP_GALLERY_URL, else from the event's + * BIRDSBIRDBIRDS_GALLERY_URL. Returns undefined when neither is set, and + * throws an Error fit to show as is when the one set isn't an http(s) URL. + */ +export function galleryUrlFromEnvironment(environment) { + const text = environment.BOOTHSTRAP_GALLERY_URL || environment.BIRDSBIRDBIRDS_GALLERY_URL + if (!text) return undefined + + const url = URL.parse(text) + if (url === null || !['http:', 'https:'].includes(url.protocol)) { + throw new Error(`the gallery URL must be an http or https URL, got ${JSON.stringify(text)}`) + } + + return url.href +} + +/** + * The flock for the page, from `galleryUrl` (or none) with copies kept in + * `cacheDirectory`. Returns `{ feed, liveImage, demoImage }`: + * + * - `feed()` resolves `{ source: 'live' | 'demo', items }`, each item with an + * `image_url` on the page server's own origin. + * - `liveImage(key)` resolves a gallery bird's PNG bytes, or undefined when + * `key` names no bird in the feed or its image can't be fetched. + * - `demoImage(fileName)` returns a practice bird's SVG bytes, or undefined. + */ +export function createFlock({ galleryUrl, cacheDirectory }) { + const feedCacheFile = path.join(cacheDirectory, 'feed.json') + const imageDirectory = path.join(cacheDirectory, 'images') + const demoFeed = readDemoFeed() + const demoFileNames = new Set(demoFeed.items.map((item) => path.posix.basename(item.image_url))) + + /** The live feed: a fresh cached copy, or a new fetch, or undefined when the gallery can't give one. */ + const liveFeed = async () => { + const cached = readCachedFeed(feedCacheFile) + if (cached !== undefined && Date.now() - cached.fetchedAt < FEED_FRESH_MS) return cached + + const fetched = await fetchFeed(galleryUrl) + if (fetched !== undefined) writeFileAtomically(feedCacheFile, JSON.stringify(fetched)) + + return fetched + } + + return { + /** The flock for /flock.json: the live feed when the gallery gives one, else the practice flock. */ + async feed() { + const live = galleryUrl === undefined ? undefined : await liveFeed() + if (live === undefined) return { source: 'demo', items: demoFeed.items } + + return { source: 'live', items: live.items.map(({ image_source: _, ...item }) => item) } + }, + + /** A gallery bird's PNG by its cache key, from the cache or fetched once. */ + async liveImage(key) { + if (!IMAGE_KEY_PATTERN.test(key)) return undefined + const cachedImage = readCachedImage(path.join(imageDirectory, `${key}.png`)) + if (cachedImage !== undefined) return cachedImage + + const source = readCachedFeed(feedCacheFile)?.items.find((item) => imageKeyOf(item.image_source) === key)?.image_source + if (source === undefined) return undefined + const image = await fetchCapped(source, MAX_IMAGE_BYTES) + if (image === undefined || !image.subarray(0, PNG_SIGNATURE.length).equals(PNG_SIGNATURE)) return undefined + writeFileAtomically(path.join(imageDirectory, `${key}.png`), image) + + return image + }, + + /** A practice bird's SVG, when `fileName` is one the practice flock lists. */ + demoImage(fileName) { + return demoFileNames.has(fileName) ? readFileSync(path.join(DEMO_DIRECTORY, fileName)) : undefined + }, + } +} + +/** Reads the bundled practice flock, which must pass the same checks as a live feed. */ +function readDemoFeed() { + const items = birdsOf(JSON.parse(readFileSync(DEMO_FEED_FILE, 'utf8')), (imageUrl) => { + const fileName = DEMO_IMAGE_PATTERN.exec(imageUrl)?.[1] + + return fileName === undefined ? undefined : { image_url: `/flock/demo/${fileName}` } + }) + if (items.length === 0) throw new Error(`${DEMO_FEED_FILE} holds no bird that passes the feed checks`) + + return { items } +} + +/** + * Fetches and checks the gallery's feed. Resolves `{ fetchedAt, items }`, + * each item also carrying its absolute `image_source`, or undefined when the + * gallery doesn't answer, answers with an error, or sends no valid feed. + */ +async function fetchFeed(galleryUrl) { + const feedUrl = new URL(FLOCK_FEED_FILE, galleryUrl.endsWith('/') ? galleryUrl : `${galleryUrl}/`) + const body = await fetchCapped(feedUrl, MAX_FEED_BYTES) + if (body === undefined) return undefined + + let feed + try { + feed = JSON.parse(body.toString('utf8')) + } catch { + return undefined + } + + const galleryOrigin = new URL(galleryUrl).origin + const items = birdsOf(feed, (imageUrl) => { + const source = imageUrl.startsWith('/') ? URL.parse(imageUrl, galleryOrigin) : null + if (source === null || source.origin !== galleryOrigin) return undefined + + return { image_url: `/flock/images/${imageKeyOf(source.href)}.png`, image_source: source.href } + }) + + return { fetchedAt: Date.now(), items } +} + +/** + * The feed's birds that pass every check, newest first as the feed lists + * them, at most MAX_FLOCK_BIRDS. `imageOf(image_url)` gives the fields that + * locate a bird's image on the page server, or undefined to drop the bird. + */ +function birdsOf(feed, imageOf) { + if (typeof feed !== 'object' || feed === null || !Array.isArray(feed.items)) return [] + + return feed.items + .flatMap((item) => { + const bird = birdOf(item) + const image = bird === undefined || typeof item.image_url !== 'string' ? undefined : imageOf(item.image_url) + + return image === undefined ? [] : [{ ...bird, ...image }] + }) + .slice(0, MAX_FLOCK_BIRDS) +} + +/** One feed item's checked fields, without its image, or undefined when any fails its rule. */ +function birdOf(item) { + if (typeof item !== 'object' || item === null) return undefined + + const { id, duration_s: durationSeconds } = item + const labels = Object.fromEntries(Object.keys(MAX_LABEL_LENGTH).map((key) => [key, labelOf(item[key], MAX_LABEL_LENGTH[key])])) + const timingFunction = typeof item.timing_function === 'string' ? readTimingFunction(item.timing_function) : undefined + const keyframes = keyframesOf(item.keyframes) + const validDuration = Number.isFinite(durationSeconds) && durationSeconds >= MIN_DURATION_SECONDS && durationSeconds <= MAX_DURATION_SECONDS + const validId = typeof id === 'string' && id.length > 0 && id.length <= MAX_ID_LENGTH + if (!validId || labels.bird_name === '' || labels.origin === '' || !validDuration || timingFunction === undefined || keyframes === undefined) return undefined + + return { id, ...labels, keyframes, duration_s: durationSeconds, timing_function: timingFunction } +} + +/** A label cut to `maxLength` characters; '' when it is missing or not a string. The page shows it as text only. */ +function labelOf(value, maxLength) { + return typeof value === 'string' ? [...value.trim()].slice(0, maxLength).join('') : '' +} + +/** + * The feed's keyframes, each `{ offset, transform }` plus `opacity` and + * `easing` when given, with transforms and easings written out again; or + * undefined when any keyframe fails its rule or the offsets go backwards. + */ +function keyframesOf(keyframes) { + if (!Array.isArray(keyframes) || keyframes.length === 0 || keyframes.length > MAX_KEYFRAMES) return undefined + + const checked = [] + for (const keyframe of keyframes) { + const offset = keyframe?.offset + const transform = typeof keyframe?.transform === 'string' && keyframe.transform.length <= MAX_TRANSFORM_LENGTH ? readTransform(keyframe.transform) : undefined + const previousOffset = checked.at(-1)?.offset ?? 0 + if (!Number.isFinite(offset) || offset < previousOffset || offset > 1 || transform === undefined) return undefined + + const written = { offset, transform: transform.length === 0 ? 'none' : transform.map(writtenFunction).join(' ') } + if (keyframe.opacity !== undefined) { + if (!Number.isFinite(keyframe.opacity) || keyframe.opacity < 0 || keyframe.opacity > 1) return undefined + written.opacity = keyframe.opacity + } + if (keyframe.easing !== undefined) { + written.easing = typeof keyframe.easing === 'string' ? readTimingFunction(keyframe.easing) : undefined + if (written.easing === undefined) return undefined + } + checked.push(written) + } + + return checked +} + +/** A parsed transform function written out again, such as `translate(120px, 300px)`. */ +function writtenFunction({ name, arguments: transformArguments }) { + return `${name}(${transformArguments.map(({ value, unit }) => `${value}${unit}`).join(', ')})` +} + +/** The cache key of an image URL: hex, so it is safe as a file name and a URL path. */ +function imageKeyOf(imageUrl) { + return createHash('sha256').update(imageUrl).digest('hex').slice(0, 32) +} + +/** + * GETs `url` without following redirects and resolves its body, or undefined + * when the request fails, times out, answers anything but 200, or sends more + * than `maxBytes`. + */ +async function fetchCapped(url, maxBytes) { + try { + const response = await fetch(url, { redirect: 'error', signal: AbortSignal.timeout(FETCH_TIMEOUT_MS) }) + if (response.status !== 200 || Number(response.headers.get('content-length')) > maxBytes) { + await response.body?.cancel() + return undefined + } + + const chunks = [] + let totalBytes = 0 + for await (const chunk of response.body) { + totalBytes += chunk.length + if (totalBytes > maxBytes) return undefined + chunks.push(chunk) + } + + return Buffer.concat(chunks) + } catch { + return undefined + } +} + +/** The cached feed `{ fetchedAt, items }`, or undefined when there is none or it can't be read. */ +function readCachedFeed(feedCacheFile) { + try { + const cached = JSON.parse(readWorkspaceFile(feedCacheFile, MAX_FEED_BYTES * 2).toString('utf8')) + + return Number.isFinite(cached.fetchedAt) && Array.isArray(cached.items) ? cached : undefined + } catch { + return undefined + } +} + +/** A cached image's bytes, or undefined when it isn't cached. */ +function readCachedImage(imageFile) { + try { + return readWorkspaceFile(imageFile, MAX_IMAGE_BYTES) + } catch { + return undefined + } +} + +/** Writes `body` to a fresh temp file beside `destination`, then renames it over, so readers never see half a file. */ +function writeFileAtomically(destination, body) { + mkdirSync(path.dirname(destination), { recursive: true, mode: 0o700 }) + const temporaryFile = `${destination}.${randomBytes(6).toString('hex')}.tmp` + try { + writeFileSync(temporaryFile, body, { flag: 'wx' }) + renameSync(temporaryFile, destination) + } catch (error) { + rmSync(temporaryFile, { force: true }) + throw error + } +} diff --git a/challenges/birdsbirdbirds/flock/duck.svg b/challenges/birdsbirdbirds/flock/duck.svg new file mode 100644 index 0000000..8f0c62e --- /dev/null +++ b/challenges/birdsbirdbirds/flock/duck.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/challenges/birdsbirdbirds/flock/flock.json b/challenges/birdsbirdbirds/flock/flock.json new file mode 100644 index 0000000..d2360e2 --- /dev/null +++ b/challenges/birdsbirdbirds/flock/flock.json @@ -0,0 +1,228 @@ +{ + "items": [ + { + "id": "demo-pigeon", + "bird_name": "Pigeon Pete", + "origin": "Brooklyn", + "display_name": "MLH", + "stored_at": "2026-11-01T12:00:00Z", + "image_url": "demo/pigeon.svg", + "keyframes": [ + { + "offset": 0.0, + "transform": "translate(-140px, 120px) rotate(-4deg)" + }, + { + "offset": 0.25, + "transform": "translate(155px, 80px) rotate(3deg)" + }, + { + "offset": 0.5, + "transform": "translate(450px, 140px) rotate(-3deg)" + }, + { + "offset": 0.75, + "transform": "translate(745px, 95px) rotate(4deg)" + }, + { + "offset": 1.0, + "transform": "translate(1040px, 125px) rotate(0deg)" + } + ], + "duration_s": 14, + "timing_function": "ease-in-out" + }, + { + "id": "demo-heron", + "bird_name": "Hazel", + "origin": "Edinburgh", + "display_name": "MLH", + "stored_at": "2026-11-01T12:00:00Z", + "image_url": "demo/heron.svg", + "keyframes": [ + { + "offset": 0.0, + "transform": "translate(-140px, 50px) rotate(-2deg)" + }, + { + "offset": 0.25, + "transform": "translate(155px, 70px) rotate(2deg)" + }, + { + "offset": 0.5, + "transform": "translate(450px, 40px) rotate(-2deg)" + }, + { + "offset": 0.75, + "transform": "translate(745px, 75px) rotate(2deg)" + }, + { + "offset": 1.0, + "transform": "translate(1040px, 55px) rotate(0deg)" + } + ], + "duration_s": 18, + "timing_function": "linear" + }, + { + "id": "demo-sparrow", + "bird_name": "Pip", + "origin": "Lisbon", + "display_name": "MLH", + "stored_at": "2026-11-01T12:00:00Z", + "image_url": "demo/sparrow.svg", + "keyframes": [ + { + "offset": 0.0, + "transform": "translate(-140px, 300px) rotate(-8deg)" + }, + { + "offset": 0.25, + "transform": "translate(155px, 250px) rotate(6deg)" + }, + { + "offset": 0.5, + "transform": "translate(450px, 320px) rotate(-6deg)" + }, + { + "offset": 0.75, + "transform": "translate(745px, 260px) rotate(8deg)" + }, + { + "offset": 1.0, + "transform": "translate(1040px, 300px) rotate(0deg)" + } + ], + "duration_s": 12, + "timing_function": "ease-in-out" + }, + { + "id": "demo-duck", + "bird_name": "Dolores", + "origin": "Austin", + "display_name": "MLH", + "stored_at": "2026-11-01T12:00:00Z", + "image_url": "demo/duck.svg", + "keyframes": [ + { + "offset": 0.0, + "transform": "translate(-140px, 560px) rotate(-3deg)" + }, + { + "offset": 0.25, + "transform": "translate(155px, 530px) rotate(3deg)" + }, + { + "offset": 0.5, + "transform": "translate(450px, 580px) rotate(-3deg)" + }, + { + "offset": 0.75, + "transform": "translate(745px, 540px) rotate(3deg)" + }, + { + "offset": 1.0, + "transform": "translate(1040px, 560px) rotate(0deg)" + } + ], + "duration_s": 16, + "timing_function": "linear" + }, + { + "id": "demo-toucan", + "bird_name": "Tico", + "origin": "San José", + "display_name": "MLH", + "stored_at": "2026-11-01T12:00:00Z", + "image_url": "demo/toucan.svg", + "keyframes": [ + { + "offset": 0.0, + "transform": "translate(-140px, 420px) rotate(-5deg)" + }, + { + "offset": 0.25, + "transform": "translate(155px, 370px) rotate(5deg)" + }, + { + "offset": 0.5, + "transform": "translate(450px, 450px) rotate(-5deg)" + }, + { + "offset": 0.75, + "transform": "translate(745px, 380px) rotate(5deg)" + }, + { + "offset": 1.0, + "transform": "translate(1040px, 430px) rotate(0deg)" + } + ], + "duration_s": 15, + "timing_function": "ease-in-out" + }, + { + "id": "demo-owl", + "bird_name": "Professor Hoot", + "origin": "Oslo", + "display_name": "MLH", + "stored_at": "2026-11-01T12:00:00Z", + "image_url": "demo/owl.svg", + "keyframes": [ + { + "offset": 0.0, + "transform": "translate(-140px, 210px) rotate(-2deg)" + }, + { + "offset": 0.25, + "transform": "translate(155px, 190px) rotate(2deg)" + }, + { + "offset": 0.5, + "transform": "translate(450px, 230px) rotate(-2deg)" + }, + { + "offset": 0.75, + "transform": "translate(745px, 200px) rotate(2deg)" + }, + { + "offset": 1.0, + "transform": "translate(1040px, 215px) rotate(0deg)" + } + ], + "duration_s": 20, + "timing_function": "linear" + }, + { + "id": "demo-parrot", + "bird_name": "Rio", + "origin": "Taipei", + "display_name": "MLH", + "stored_at": "2026-11-01T12:00:00Z", + "image_url": "demo/parrot.svg", + "keyframes": [ + { + "offset": 0.0, + "transform": "translate(-140px, 480px) rotate(-12deg)" + }, + { + "offset": 0.25, + "transform": "translate(155px, 330px) rotate(14deg)" + }, + { + "offset": 0.5, + "transform": "translate(450px, 520px) rotate(-10deg)" + }, + { + "offset": 0.75, + "transform": "translate(745px, 350px) rotate(12deg)" + }, + { + "offset": 1.0, + "transform": "translate(1040px, 470px) rotate(0deg)" + } + ], + "duration_s": 13, + "timing_function": "ease-in-out" + } + ] +} diff --git a/challenges/birdsbirdbirds/flock/heron.svg b/challenges/birdsbirdbirds/flock/heron.svg new file mode 100644 index 0000000..96fd984 --- /dev/null +++ b/challenges/birdsbirdbirds/flock/heron.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/challenges/birdsbirdbirds/flock/owl.svg b/challenges/birdsbirdbirds/flock/owl.svg new file mode 100644 index 0000000..0ee57af --- /dev/null +++ b/challenges/birdsbirdbirds/flock/owl.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/challenges/birdsbirdbirds/flock/parrot.svg b/challenges/birdsbirdbirds/flock/parrot.svg new file mode 100644 index 0000000..c9900e5 --- /dev/null +++ b/challenges/birdsbirdbirds/flock/parrot.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/challenges/birdsbirdbirds/flock/pigeon.svg b/challenges/birdsbirdbirds/flock/pigeon.svg new file mode 100644 index 0000000..6ba4a6e --- /dev/null +++ b/challenges/birdsbirdbirds/flock/pigeon.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/challenges/birdsbirdbirds/flock/sparrow.svg b/challenges/birdsbirdbirds/flock/sparrow.svg new file mode 100644 index 0000000..bd61ec8 --- /dev/null +++ b/challenges/birdsbirdbirds/flock/sparrow.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/challenges/birdsbirdbirds/flock/toucan.svg b/challenges/birdsbirdbirds/flock/toucan.svg new file mode 100644 index 0000000..2b85823 --- /dev/null +++ b/challenges/birdsbirdbirds/flock/toucan.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/challenges/birdsbirdbirds/intro.md b/challenges/birdsbirdbirds/intro.md index c407b5b..cd1b760 100644 --- a/challenges/birdsbirdbirds/intro.md +++ b/challenges/birdsbirdbirds/intro.md @@ -3,13 +3,12 @@ Every bird in the sky started as a doodle. Yours is next. You will: - Draw a bird on a small canvas in your browser. -- Tell Pi who your bird is, where it flies in from, and how it moves. -- Pi writes the flight as a CSS animation, and a checker makes sure - it is safe before your bird takes off. -- Watch your bird fly across the sky. +- Tell Pi who your bird is, where it flies in from, and how it + should fly, in your own words. +- Watch Pi sketch the flight on the sky as a numbered path, then + direct it: "add a loop at 3", "slower at the end". +- Send your finished bird to join the flock on the booth screens. Any bird works: realistic, abstract, silly, or a scribble with wings. -Keep the name and origin family-friendly, because birds can appear on -the booth screens. - -At the end, the station asks if you want your bird shown there. +Keep the name and origin family-friendly, because your bird flies on +the booth screens. Ask Pi to take it down anytime. diff --git a/challenges/birdsbirdbirds/page/app.mjs b/challenges/birdsbirdbirds/page/app.mjs index df8dc41..4c82312 100644 --- a/challenges/birdsbirdbirds/page/app.mjs +++ b/challenges/birdsbirdbirds/page/app.mjs @@ -1,10 +1,11 @@ // 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