From f3b3e7c9f31ca1edea7abebf7cc35861411129dd Mon Sep 17 00:00:00 2001 From: prompt.ac/@jeffrey Date: Mon, 10 Aug 2026 23:52:03 +0000 Subject: [PATCH] Give Build and Banner their own pieces That is the last of them. Build walks a blockSize grid — choose(4, 8, 16, 32, 64, 128), blockIndexMax = 256 / blockSize - 1 — stepping ±1 on one axis at a time and laying a brick at random(50,100) opacity. Banner zips a two-colour ribbon on a shared tenth-second Draw and Turn beat, turning by choose(-45, 45, -15, 15); its size list really is choose(4, 8, 16), and its theme's rate and volume are both functions of the score. Both accumulate, so both take the layer treatment. A test caught the builder laying no brick on its first frame. nopaint-construct-catalog.mjs is down from fourteen fallbacks to two, and both are already owned by their pieces through import order. Co-Authored-By: Claude Opus 5 (1M context) --- docs/nopaint-construct-migration-ledger.md | 25 +++++++++++++++++++------ system/public/aesthetic.computer/disks/banner.mjs | 14 ++++++++++++++ system/public/aesthetic.computer/disks/build.mjs | 14 ++++++++++++++ system/public/aesthetic.computer/disks/nopaint.mjs | 16 ++++------------ system/public/aesthetic.computer/lib/nopaint-construct-build.mjs | 206 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ system/public/aesthetic.computer/lib/nopaint-construct-catalog.mjs | 12 +----------- system/tests/nopaint-construct-build.test.mjs | 125 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ system/tests/nopaint-construct-catalog.test.mjs | 2 +- 8 file(s) changed, 384 insertion(s)(+), 30 deletion(s)(-) diff --git a/docs/nopaint-construct-migration-ledger.md b/docs/nopaint-construct-migration-ledger.md --- a/docs/nopaint-construct-migration-ledger.md +++ b/docs/nopaint-construct-migration-ledger.md @@ -46,13 +46,26 @@ `nopaintProposal` beside its own brush, the way `box` already had. ## Pieces that own their slug -`bubbles`, `walker`, `dark-window`, `line`, `box`, `stamp`, `frame`, -`caterpillar`, `softy`, and `wafer` are real AC pieces that export their own -`nopaintProposal`; +Every recovered brush now has one: `bubbles`, `walker`, `dark-window`, `line`, +`box`, `stamp`, `frame`, `caterpillar`, `softy`, `wafer`, `grid-worm`, +`triangle`, `ellipse`, `rainbow`, `breathe`, `vignette`, `aura`, `build`, and +`banner` are real AC pieces that export their own `nopaintProposal`; `disks/nopaint.mjs` imports each one and registers it after the fallback -catalog, so the piece's contract wins by slug. A name leaves -`nopaint-construct-catalog.mjs` on the day a piece takes it over — that catalog -shrinking to nothing is the finish line. +catalog, so the piece's contract wins by slug. + +`nopaint-construct-catalog.mjs` is down from fourteen fallbacks to two, and the +two left — `bubbles` and `walker` — are already owned by their pieces through +import order. It is an inventory now, not an implementation. + +## Three of them were never brushes + +`rainbow` and `breathe` draw nothing. Their sheets put a Construct effect over +the whole painting — AdjustHSL and Bulge — and walk its parameter on a timer, so +both belong with the pixel transforms and rewrite the accepted painting instead +of proposing into the buffer. `vignette` and `aura` lay a field rather than a +shape. Reading a name as "a brush" because it sits in the brush picker is the +third kind of mistake this migration kept making, after misreading expression +indices as literals and missing function blocks. ## Corrections diff --git a/system/public/aesthetic.computer/disks/banner.mjs b/system/public/aesthetic.computer/disks/banner.mjs new file mode 100644 --- /dev/null +++ b/system/public/aesthetic.computer/disks/banner.mjs @@ -0,0 +1,14 @@ +// Banner, 26.08.10 +// The original No Paint banner as a standalone AC piece. + +import { createNoPaintBrushPiece } from "../lib/nopaint-brush-piece.mjs"; +import { bannerProposal as nopaintProposal } from "../lib/nopaint-construct-build.mjs"; + +const system = "nopaint"; +const piece = createNoPaintBrushPiece(nopaintProposal, { + title: "Banner", + desc: "Paint with the recovered ribbon as it zips and turns.", +}); + +export const { boot, sim, paint, bake, meta } = piece; +export { nopaintProposal, system }; diff --git a/system/public/aesthetic.computer/disks/build.mjs b/system/public/aesthetic.computer/disks/build.mjs new file mode 100644 --- /dev/null +++ b/system/public/aesthetic.computer/disks/build.mjs @@ -0,0 +1,14 @@ +// Build, 26.08.10 +// The original No Paint build as a standalone AC piece. + +import { createNoPaintBrushPiece } from "../lib/nopaint-brush-piece.mjs"; +import { buildProposal as nopaintProposal } from "../lib/nopaint-construct-build.mjs"; + +const system = "nopaint"; +const piece = createNoPaintBrushPiece(nopaintProposal, { + title: "Build", + desc: "Paint with the recovered builder as it walks its grid laying bricks.", +}); + +export const { boot, sim, paint, bake, meta } = piece; +export { nopaintProposal, system }; diff --git a/system/public/aesthetic.computer/disks/nopaint.mjs b/system/public/aesthetic.computer/disks/nopaint.mjs --- a/system/public/aesthetic.computer/disks/nopaint.mjs +++ b/system/public/aesthetic.computer/disks/nopaint.mjs @@ -27,6 +27,8 @@ import { nopaintProposal as rainbowPieceProposal } from "./rainbow.mjs"; import { nopaintProposal as breathePieceProposal } from "./breathe.mjs"; import { nopaintProposal as vignettePieceProposal } from "./vignette.mjs"; import { nopaintProposal as auraPieceProposal } from "./aura.mjs"; +import { nopaintProposal as buildPieceProposal } from "./build.mjs"; +import { nopaintProposal as bannerPieceProposal } from "./banner.mjs"; import { gridWormProposal } from "../lib/nopaint-construct-brushes.mjs"; import { nonConflictingConstructProposals } from "../lib/nopaint-construct-catalog.mjs"; import { recoveredConstructTransforms } from "../lib/nopaint-construct-transforms.mjs"; @@ -62,6 +64,8 @@ [rainbowPieceProposal.slug, rainbowPieceProposal], [breathePieceProposal.slug, breathePieceProposal], [vignettePieceProposal.slug, vignettePieceProposal], [auraPieceProposal.slug, auraPieceProposal], + [buildPieceProposal.slug, buildPieceProposal], + [bannerPieceProposal.slug, bannerPieceProposal], ])); let loopState = "choosing"; @@ -1195,18 +1199,6 @@ ink(color).line(x + drift, y, nextX + drift, nextY, proposal.thickness); x = nextX; y = nextY; }); - } else if (proposal.kind === "banner") { - const stripes = 9; - for (let index = 0; index < stripes; index += 1) { - const y = proposal.y + (proposal.h / stripes) * index; - const sway = Math.sin(phase + index * 0.55) * proposal.drift; - ink(color[0], color[1], color[2], 80 + index * 14).box( - proposal.x + sway, - y, - proposal.w, - Math.max(2, proposal.h / stripes - 1), - ); - } } else if (proposal.kind === "wipe") { // A recovered "wipe" is a translucent wash in No Paint 3.0. Random // proposals must accumulate history; they may never replace it opaquely. diff --git a/system/public/aesthetic.computer/lib/nopaint-construct-build.mjs b/system/public/aesthetic.computer/lib/nopaint-construct-build.mjs new file mode 100644 --- /dev/null +++ b/system/public/aesthetic.computer/lib/nopaint-construct-build.mjs @@ -0,0 +1,206 @@ +// Build and Banner, recovered from the Construct sheets of the same names. +// They are together because they are the same idea twice: something walks and +// leaves a trail behind it, so both accumulate into a layer rather than being +// redrawn from the frame number. Constants read out of the compiled expression +// table (toolchain/nopaint/expressions.mjs). + +const frozen = (value) => Object.freeze(value); +const choose = (random, values) => values[Math.floor(random() * values.length)]; +const between = (random, [low, high]) => low + random() * (high - low); + +export const BUILD = frozen({ + canvas: 256, + blockSizes: frozen([4, 8, 16, 32, 64, 128]), + opacity: frozen([50, 100]), // random(50, 100) + stepSeconds: .1, // the builder's beat + cues: frozen({ + theme: "build - builder's beat", + scrape: "build - brick scrape", + click: "build - brick click", + clop: "build - brick clop", + blowDown: "build - blow down", + }), + cueVolume: frozen([-20, -10]), // -20 + random(0,10) and -10 + random(0,10) + cueRate: frozen([.9, 1.1]), // 1 + random(-.1, .1) +}); + +export const BANNER = frozen({ + canvas: 256, + sizes: frozen([4, 8, 16]), // choose(4, 8, 16) + speeds: frozen([1, 2, 3, 4]), // choose(1, 2, 3, 4) + depths: frozen([1, 2, 5]), // choose(1, 2, 5) + turns: frozen([-45, 45, -15, 15]), // choose(-45, 45, -15, 15) + drawSeconds: .1, // Timer "Draw" + turnSeconds: .1, // Timer "Turn" + // hslaToRgba(random(1), random(.2,.6), random(.1,.5), 1) and + // hslaToRgba(random(1), random(.7,1), random(.5,.95), 1) + dark: frozen({ saturation: frozen([.2, .6]), lightness: frozen([.1, .5]) }), + light: frozen({ saturation: frozen([.7, 1]), lightness: frozen([.5, .95]) }), + cue: "banner - theme", + cueTag: "Zipper", + cueRate: (speed) => .9 + speed / 4 * .2, + cueVolume: (size) => -5 - (10 - size / 16 * 10), +}); + +function hslaToRgba(hue, saturation, lightness) { + const chroma = (1 - Math.abs(2 * lightness - 1)) * saturation; + const sector = ((hue % 1) + 1) % 1 * 6; + const second = chroma * (1 - Math.abs(sector % 2 - 1)); + const [r, g, b] = [[chroma, second, 0], [second, chroma, 0], [0, chroma, second], + [0, second, chroma], [second, 0, chroma], [chroma, 0, second]][Math.floor(sector) % 6]; + const base = lightness - chroma / 2; + return frozen([r, g, b].map((channel) => Math.round((channel + base) * 255))); +} + +function seededWalk(seed) { + let state = seed >>> 0 || 1; + return () => { + state += 0x6d2b79f5; + let value = state; + value = Math.imul(value ^ (value >>> 15), value | 1); + value ^= value + Math.imul(value ^ (value >>> 7), value | 61); + return ((value ^ (value >>> 14)) >>> 0) / 4294967296; + }; +} + +function fill(layer, x, y, w, h, color, alpha) { + const left = Math.max(0, Math.floor(x)); + const top = Math.max(0, Math.floor(y)); + const right = Math.min(layer.width, Math.ceil(x + w)); + const bottom = Math.min(layer.height, Math.ceil(y + h)); + for (let py = top; py < bottom; py += 1) { + for (let px = left; px < right; px += 1) { + const at = (py * layer.width + px) * 4; + const source = alpha; + const under = layer.pixels[at + 3] * (255 - source) / 255; + const total = source + under || 1; + layer.pixels[at] = (color[0] * source + layer.pixels[at] * under) / total; + layer.pixels[at + 1] = (color[1] * source + layer.pixels[at + 1] * under) / total; + layer.pixels[at + 2] = (color[2] * source + layer.pixels[at + 2] * under) / total; + layer.pixels[at + 3] = Math.min(255, total); + } + } +} + +const layers = new WeakMap(); +function layerFor(score) { + let state = layers.get(score); + if (!state) { + const width = Math.max(1, Math.round(score.width)); + const height = Math.max(1, Math.round(score.height)); + state = { + random: seededWalk(score.seed), placed: 0, + layer: { width, height, pixels: new Uint8ClampedArray(width * height * 4) }, + }; + layers.set(score, state); + } + return state; +} + +export const buildProposal = frozen({ + version: 1, + slug: "build", + label: "Build", + compatible: true, + source: frozen({ ...BUILD, actionSheet: "Build", + grid: "blockIndexMax = 256 / blockSize - 1; the builder steps ±1 and clamps", + // The builder's own sprite and the blow-down lifecycle are not modeled; + // what is here is where it walks and what it leaves. + reconstructed: frozen(["the builder sprite", "the blow-down lifecycle"]) }), + generate({ random, width, height, base }) { + const blockSize = choose(random, BUILD.blockSizes); + const scale = Math.min(width, height) / BUILD.canvas; + const opacity = Math.round(between(random, BUILD.opacity)); + return frozen({ ...base, kind: "build", + seed: Math.floor(random() * 0xffffffff), + blockSize, opacity, scale, + block: Math.max(1, blockSize * scale), + columns: Math.max(1, Math.ceil(width / Math.max(1, blockSize * scale))), + rows: Math.max(1, Math.ceil(height / Math.max(1, blockSize * scale))), + color: hslaToRgba(random(), random(), random()), + width, height, + brush: frozen({ slug: "build", params: frozen([String(blockSize)]), + colon: frozen([]), + parameters: frozen({ blockSize, opacity, + blockIndexMax: BUILD.canvas / blockSize - 1, cue: BUILD.cues.theme }) }) }); + }, + render({ paste }, score, tick) { + const state = layerFor(score); + // The builder lays its first brick on the first frame, not a beat later. + const due = 1 + Math.floor(tick / 60 / BUILD.stepSeconds); + if (state.placed === 0) { + state.column = Math.floor(state.random() * score.columns); + state.row = Math.floor(state.random() * score.rows); + } + // The builder walks ±1 on one axis at a time, clamped to the grid, laying + // a brick wherever it lands. + const bricks = Math.min(due, score.columns * score.rows * 4); + while (state.placed < bricks) { + state.placed += 1; + fill(state.layer, state.column * score.block, state.row * score.block, + score.block, score.block, score.color, + Math.round(score.opacity / 100 * 255)); + const axis = state.random() < .5; + const step = state.random() < .5 ? -1 : 1; + if (axis) state.column = Math.max(0, Math.min(score.columns - 1, state.column + step)); + else state.row = Math.max(0, Math.min(score.rows - 1, state.row + step)); + } + paste(state.layer, 0, 0); + }, +}); + +export const bannerProposal = frozen({ + version: 1, + slug: "banner", + label: "Banner", + compatible: true, + source: frozen({ ...BANNER, actionSheet: "Banner", + imagePoints: frozen(["BaseLeft", "BaseRight", "TopLeft", "TopRight", + "BottomLeft", "BottomRight"]), + reconstructed: frozen(["the ribbon's quad geometry"]) }), + generate({ random, width, height, base }) { + const size = choose(random, BANNER.sizes); + const speed = choose(random, BANNER.speeds); + const depth = choose(random, BANNER.depths); + const scale = Math.min(width, height) / BANNER.canvas; + return frozen({ ...base, kind: "banner", + seed: Math.floor(random() * 0xffffffff), + size, speed, depth, scale, + band: Math.max(2, size * 2 * scale), + startAngle: Math.floor(random() * 360), + dark: hslaToRgba(random(), between(random, BANNER.dark.saturation), + between(random, BANNER.dark.lightness)), + light: hslaToRgba(random(), between(random, BANNER.light.saturation), + between(random, BANNER.light.lightness)), + x: Math.floor(random() * width), y: Math.floor(random() * height), + width, height, + brush: frozen({ slug: "banner", + params: frozen([String(size), String(speed)]), + colon: frozen([]), + parameters: frozen({ size, speed, depth, cue: BANNER.cue, + cueRate: BANNER.cueRate(speed), cueVolume: BANNER.cueVolume(size) }) }) }); + }, + render({ paste }, score, tick) { + const state = layerFor(score); + const due = 1 + Math.floor(tick / 60 / BANNER.drawSeconds); + if (state.placed === 0) { + state.angle = score.startAngle; + state.x = score.x; + state.y = score.y; + } + const steps = Math.min(due, 600); + while (state.placed < steps) { + state.placed += 1; + // Draw and Turn share a tenth-second beat, so every laid segment also + // turns by one of choose(-45, 45, -15, 15). + const radians = state.angle * Math.PI / 180; + state.x += Math.cos(radians) * score.speed * score.depth; + state.y += Math.sin(radians) * score.speed * score.depth; + fill(state.layer, state.x - score.band / 2, state.y - score.band / 2, + score.band, score.band, + state.placed % 2 ? score.dark : score.light, 255); + state.angle += choose(state.random, BANNER.turns); + } + paste(state.layer, 0, 0); + }, +}); diff --git a/system/public/aesthetic.computer/lib/nopaint-construct-catalog.mjs b/system/public/aesthetic.computer/lib/nopaint-construct-catalog.mjs --- a/system/public/aesthetic.computer/lib/nopaint-construct-catalog.mjs +++ b/system/public/aesthetic.computer/lib/nopaint-construct-catalog.mjs @@ -19,21 +19,11 @@ parameters: frozen({ source, ...(score.parameters || {}) }) }) }); }, render }); } -export const buildProposal = contract("build", "Build", - { brickSizes: [4, 8, 16, 32, 64, 128], colorAlpha: 1 }, - ({ random, width, height, base }) => ({ brickSize: [4, 8, 16, 32, 64, 128][Math.floor(random() * 6)], points: points(random, width, height, 24), color: base.color }), - ({ ink }, s, frame) => s.points.slice(0, Math.max(1, Math.min(s.points.length, Math.ceil(frame / 4)))).forEach(p => ink(s.color).box(Math.floor(p.x / s.brickSize) * s.brickSize, Math.floor(p.y / s.brickSize) * s.brickSize, s.brickSize, s.brickSize))); - export const bubblesProposal = contract("bubbles", "Bubbles", { spawnRates: [.025, .05, .1], renderStep: 1 / 60, scale: [.8, 1.2], rise: [-5, -4, -3] }, ({ random, width, height, base }) => ({ rate: [.025, .05, .1][Math.floor(random() * 3)], points: points(random, width, height, 18), color: base.color, height }), ({ ink }, s, frame) => s.points.forEach((p, i) => ink(s.color).oval(p.x, (p.y - frame * (3 + i % 3) + s.height) % s.height, p.size, p.size, false, 1))); -export const bannerProposal = contract("banner", "Banner", - { hueChannels: 3, segmentCounts: [4, 8, 16], zipperChoices: [1, 2, 3, 4], depthChoices: [1, 2, 5] }, - ({ random, base }) => ({ segments: [4, 8, 16][Math.floor(random() * 3)], color: base.color }), - ({ ink }, s, frame) => { for (let i = 0; i < s.segments; i++) ink(s.color[0], s.color[1], s.color[2], 60 + i * 8).box(s.x + Math.sin(frame / 24 + i * .5) * s.drift, s.y + i * s.h / s.segments, s.w, Math.max(1, s.h / s.segments - 1)); }); - export const walkerProposal = contract("walker", "Walker", { blip: true, noiseShift: 1 / 30, alphaLevels: [60, 80] }, ({ random, width, height, base }) => ({ points: points(random, width, height, 24), color: base.color }), @@ -42,5 +32,5 @@ // A name only stays in this fallback catalog until a real piece owns it; what is // left here is the last of the procedural brushes. export const nonConflictingConstructProposals = frozen([ - buildProposal, bubblesProposal, bannerProposal, walkerProposal, + bubblesProposal, walkerProposal, ]); diff --git a/system/tests/nopaint-construct-build.test.mjs b/system/tests/nopaint-construct-build.test.mjs new file mode 100644 --- /dev/null +++ b/system/tests/nopaint-construct-build.test.mjs @@ -0,0 +1,125 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { seededRandom } from "../public/aesthetic.computer/lib/nopaint-proposals.mjs"; +import { + BANNER, + BUILD, + bannerProposal, + buildProposal, +} from "../public/aesthetic.computer/lib/nopaint-construct-build.mjs"; + +const base = Object.freeze({ + color: Object.freeze([20, 40, 60, 128]), x: 10, y: 20, w: 120, h: 80, + drift: 4, thickness: 2, points: Object.freeze([]), phase: 0, +}); +const make = (contract, seed, width = 256, height = 256) => + contract.generate({ random: seededRandom(seed), width, height, base }); +const render = (contract, score, tick) => { + const pasted = []; + contract.render({ paste: (...args) => pasted.push(args) }, score, tick); + assert.equal(pasted.length, 1, `${contract.slug} pastes one layer`); + return pasted[0][0]; +}; +const painted = (layer) => layer.pixels.reduce( + (total, value, index) => index % 4 === 3 && value > 0 ? total + 1 : total, 0); + +test("Build keeps its recovered block sizes, opacity, and cues", () => { + assert.deepEqual(BUILD.blockSizes, [4, 8, 16, 32, 64, 128]); + assert.deepEqual(BUILD.opacity, [50, 100]); + assert.deepEqual(Object.values(BUILD.cues).sort(), [ + "build - blow down", "build - brick click", "build - brick clop", + "build - brick scrape", "build - builder's beat"].sort()); + const sizes = new Set(); + for (let seed = 0; seed < 200; seed += 1) { + const score = make(buildProposal, `build:${seed}`); + sizes.add(score.blockSize); + assert.ok(BUILD.blockSizes.includes(score.blockSize)); + assert.ok(score.opacity >= 50 && score.opacity <= 100); + // blockIndexMax = 256 / blockSize - 1, straight off the sheet. + assert.equal(score.brush.parameters.blockIndexMax, 256 / score.blockSize - 1); + } + assert.deepEqual([...sizes].sort((a, b) => a - b), BUILD.blockSizes); +}); + +test("the builder lays bricks on its grid and stays inside the painting", () => { + const score = make(buildProposal, "build:walk", 128, 128); + assert.deepEqual(score, make(buildProposal, "build:walk", 128, 128)); + const layer = render(buildProposal, score, 0); + const early = painted(layer); + assert.ok(early > 0, "the first brick lands"); + render(buildProposal, score, 60 * 5); + assert.ok(painted(layer) > early, "more bricks arrive as the clock runs"); + + // The walk is capped, so once it has run out the layer stops changing. + render(buildProposal, score, 60 * 100000); + const settled = painted(layer); + render(buildProposal, score, 60 * 200000); + assert.equal(painted(layer), settled, "the build finishes rather than running on"); + assert.ok(settled <= layer.width * layer.height); +}); + +test("Banner keeps its recovered size, speed, depth, and turn lists", () => { + assert.deepEqual(BANNER.sizes, [4, 8, 16]); + assert.deepEqual(BANNER.speeds, [1, 2, 3, 4]); + assert.deepEqual(BANNER.depths, [1, 2, 5]); + assert.deepEqual(BANNER.turns, [-45, 45, -15, 15]); + assert.equal(BANNER.drawSeconds, .1); + assert.equal(BANNER.turnSeconds, .1); + assert.equal(BANNER.cue, "banner - theme"); + // rate = .9 + speed / 4 * .2, volume = -5 - (10 - size / 16 * 10) + assert.equal(BANNER.cueRate(4), 1.1); + assert.equal(BANNER.cueVolume(16), -5); + assert.ok(BANNER.cueVolume(4) < BANNER.cueVolume(16), "a smaller banner is quieter"); + + for (let seed = 0; seed < 200; seed += 1) { + const score = make(bannerProposal, `banner:${seed}`); + assert.ok(BANNER.sizes.includes(score.size)); + assert.ok(BANNER.speeds.includes(score.speed)); + assert.ok(BANNER.depths.includes(score.depth)); + // Dark is mixed from .1–.5 lightness, light from .5–.95, so the pair must + // differ in brightness the way the two hslaToRgba calls do. + const brightness = (color) => color[0] + color[1] + color[2]; + assert.ok(brightness(score.light) >= brightness(score.dark) - 60, + "the light band is not darker than the dark one"); + } +}); + +test("the banner lays a two-colour trail that turns as it goes", () => { + const score = make(bannerProposal, "banner:zip", 128, 128); + assert.deepEqual(score, make(bannerProposal, "banner:zip", 128, 128)); + const layer = render(bannerProposal, score, 0); + const early = painted(layer); + render(bannerProposal, score, 60 * 3); + assert.ok(painted(layer) > early, "the ribbon advances"); + + // Both bands are laid, alternating. + const seen = new Set(); + for (let index = 0; index < layer.pixels.length; index += 4) { + if (layer.pixels[index + 3] === 0) continue; + seen.add(`${layer.pixels[index]},${layer.pixels[index + 1]},${layer.pixels[index + 2]}`); + } + assert.ok(seen.size >= 2, "the zipper alternates its two colours"); + render(bannerProposal, score, 60 * 100000); + const settled = painted(layer); + render(bannerProposal, score, 60 * 200000); + assert.equal(painted(layer), settled, "the ribbon ends rather than running on"); +}); + +test("No Paint 3 resolves build and banner to their own pieces", async () => { + const { COMPATIBLE_BRUSHES } = await import( + "../public/aesthetic.computer/disks/nopaint.mjs"); + for (const slug of ["build", "banner"]) { + const piece = await import(`../public/aesthetic.computer/disks/${slug}.mjs`); + assert.equal(piece.system, "nopaint"); + assert.equal(piece.nopaintProposal.slug, slug); + assert.equal(COMPATIBLE_BRUSHES.get(slug), piece.nopaintProposal); + } +}); + +test("the fallback catalog is down to the two names still waiting", async () => { + const { nonConflictingConstructProposals } = await import( + "../public/aesthetic.computer/lib/nopaint-construct-catalog.mjs"); + assert.deepEqual(nonConflictingConstructProposals.map(({ slug }) => slug).sort(), + ["bubbles", "walker"], + "only the two slugs whose pieces already own them by import order remain"); +}); diff --git a/system/tests/nopaint-construct-catalog.test.mjs b/system/tests/nopaint-construct-catalog.test.mjs --- a/system/tests/nopaint-construct-catalog.test.mjs +++ b/system/tests/nopaint-construct-catalog.test.mjs @@ -3,7 +3,7 @@ import assert from "node:assert/strict"; import { seededRandom } from "../public/aesthetic.computer/lib/nopaint-proposals.mjs"; import { nonConflictingConstructProposals } from "../public/aesthetic.computer/lib/nopaint-construct-catalog.mjs"; -const expected = ["banner", "bubbles", "build", "walker"]; +const expected = ["bubbles", "walker"]; const base = Object.freeze({ color: Object.freeze([20, 40, 60, 128]), x: 20, y: 30, w: 180, h: 140, drift: 8, thickness: 3, points: Object.freeze([]), phase: 0 }); test("all non-conflicting recovered brush names have deterministic contracts", () => { -- tangled.sh