diff --git a/AGENTS.md b/AGENTS.md index 9d3fdb9..5446764 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -12,6 +12,7 @@ renders pixel cottages; a tiny local HTTP process serves the page and npm start # http://127.0.0.1:8787 npm test # Node test runner, no install step npm run test:browser # Real Chrome journey; requires Browser Hand +npm run test:ambience # Music, light, and postcards in real Chrome node src/feed.mjs --once # print one /agents snapshot as JSON node src/feed.mjs --host 0.0.0.0 --port 8787 ``` @@ -40,6 +41,10 @@ duck. Check direct inspector controls, the connect box, and stale-feed recovery. | `src/pr.mjs` / `src/github.mjs` | Shared PR classifier/counts and cached read-only GitHub observations | | `src/occupancy.mjs` | live / recent / settled, letters, conservative PR identity inference | | `src/history.mjs` / `src/sound.mjs` | Bounded browser-local milestones/replay and opt-in Web Audio | +| `src/village-extras.mjs` / `src/atmosphere.mjs` | Gramophone controls, local-clock light, fireflies and postcard dialog | +| `src/music.mjs` / `src/music-output.mjs` | Opt-in music lifecycle, crossfades and quiet native media output | +| `src/music-tracks.mjs` / `src/audio/` | Static soundtrack catalog and bundled MP3s | +| `src/postcard.mjs` | Local PNG rendering with one deliberately selected milestone | | `src/towns.mjs` | `{Stem}Town` naming | Public contract: [`docs/COTTAGE_FEED.md`](docs/COTTAGE_FEED.md). Prefer documenting @@ -62,6 +67,12 @@ handoffs create paths and couriers. Sound starts off. Scrapbook replay is limite to locally observed history. Walk into doors to enter/leave; E talks to a nearby resident or inspects an object. Direct buttons and Escape remain available. +Music is independent of sound effects and starts off on every load. The village +clock uses device-local time; musical location follows Jack, not the selected +inspector. Generation is a manual authoring step, never a browser/build request. +The public sample ships only static assets; keep its live API connections +disabled. Postcards default to scenery, with explicit opt-in for a recorded note. + Naming lock (also in `.cursor/rules/naming.mdc`): - Product / tab / package: **CottageCode** diff --git a/README.md b/README.md index 706fa17..99b7e6e 100644 --- a/README.md +++ b/README.md @@ -40,7 +40,7 @@ Click the map to give it keyboard focus. |---|---| | Arrow keys or WASD | Walk around town or inside a cottage | | Walk into a doorway | Enter the cottage or walk back outside; no interaction key needed | -| E or Enter | Talk to a nearby agent, or use a bench, noticeboard, or room object | +| E or Enter | Talk to a nearby agent, or use a bench, noticeboard, gramophone, or room object | | Escape | Leave the cottage or bench while the map has focus | | Click a cottage or its roster button | Open its inspector without walking there | | Enter cottage / Leave cottage | Visit or exit using buttons | @@ -52,6 +52,19 @@ Rooms and residents are generated from cottage and task identity. Return visits Walk near a duck and it'll head for the pond. Cats and a goose roam too. **Sound starts off.** Enable it for footsteps, doors, a little wordless murmur when you talk to a resident, and status alerts, with separate ambience and alert switches. Reduced-motion preferences are respected. +The **gramophone** plays seven original, quiet low-bit instrumental pieces made +with ElevenLabs. Music starts off independently of sound effects. It follows +your local clock and Jack's position in the village, with daytime accents for +AppTown, MemTown, and VaultTown. Interiors soften it; conversations hush it; +leaving the tab pauses it. Open **Gramophone & light** to set the volume or +preview morning, day, dusk, and night. Warm windows and fireflies follow the +same clock. No geographic location is requested. [Soundtrack and credits](docs/MUSIC.md). + +**Make a postcard** in any cottage's inspector previews a PNG of its seeded +interior and resident. It starts with no work details. You can choose one +recorded milestone, review the picture, and save it. The image is made locally +in the browser and is never uploaded by CottageCode. + The PR desk has a changing status light, icon, and label. The **To-do** tab displays explicit task checklists when available; it never guesses a plan from progress prose. **Talk to agent** shows emitted updates alongside a message composer. Drafts and journal position survive feed refreshes. Messages require a supported connection and an explicit **Send**; unsupported sessions explain why sending is unavailable. When an agent needs input, talking reveals the actual question, choices, or diff --git a/docs/MUSIC.md b/docs/MUSIC.md new file mode 100644 index 0000000..f4e697d --- /dev/null +++ b/docs/MUSIC.md @@ -0,0 +1,62 @@ +# The village gramophone + +Seven instrumental pieces were made for CottageCode with **ElevenLabs Music +v2**, using original prompts for sparse, rounded chip tones, warm pads, and +quiet music-box melodies. No artist or existing track was used as a reference. + +| Piece | When it plays | +|---|---| +| Mosslight Morning | Morning, 05:00–09:00 | +| Little Roads | Day, 09:00–17:00 | +| Lamplight | Dusk, 17:00–21:00 | +| After the Fireflies | Night, 21:00–05:00 | +| Pocket Signals | AppTown, morning/day | +| Between the Pages | MemTown, morning/day | +| The Clockmaker | VaultTown, morning/day | + +Times use the device's local clock. The preview menu changes both light and +music. Location means Jack's feet in a town or the cottage he has entered; +selecting a distant inspector does not move the soundtrack. Unknown towns use +the shared time-of-day pieces. No geolocation or weather services are called. + +Each piece is about 65 seconds. Two native audio elements overlap loops and +crossfade between tracks, with a four-second delay to avoid changing songs at +every step along a town boundary. Playback starts off on every page load. The +default volume is 16%, reduced to 65% of that indoors and a further 25% while +the conversation panel is open. Hidden tabs release playback; returning resumes +only music explicitly enabled in that visit. Playback errors stop the music and +offer an explicit retry. Sound effects have their own switch. + +When a browser ignores the media element's volume setting, a small Web Audio +gain stage preserves the same quiet level, crossfades, and conversation hush. +It still streams the bundled media directly; there is no fetch/decode path. +This addresses [Safari's media-volume limitation](https://developer.apple.com/library/archive/documentation/AudioVideo/Conceptual/Using_HTML5_Audio_Video/Device-SpecificConsiderations/Device-SpecificConsiderations.html). + +The bundled MP3s use 96 kbps stereo. A low-pass filter at 5.5 kHz softens their +top end, loudness normalization targets −20 LUFS, and short edge fades remove +clicks. The generated sources and private attempt receipts remain in ignored +`tmp/music-generation/`. Public prompts, generation dates, source IDs, processing +settings, and final file hashes are in [music-provenance.json](music-provenance.json). + +## Authoring new music + +Generation is a separate, manual paid operation. The app, tests, and deployment +never call ElevenLabs. With an existing `ELEVENLABS_API_KEY` in the environment: + +```bash +node scripts/generate-music.mjs --generate morning +node scripts/prepare-music.mjs +``` + +The generator uses the [official compose endpoint](https://elevenlabs.io/docs/api-reference/music/compose) +with `model_id: music_v2` and `force_instrumental: true`. It records an attempt +before sending and refuses a duplicate ID. Inspect an existing receipt before +any deliberate replacement; an unconfirmed request may have consumed credits. +The preparation step requires `ffmpeg` and `ffprobe`, works only on confirmed +local generations, and performs no network requests. These are authoring tools, +not runtime dependencies. + +`npm run test:ambience` checks the gramophone and postcards in real Chrome using +a temporary fictional feed. To verify a deployed sample village, pass +`-- --url https://autojack.ai/cottagecode/`. `npm test` covers lifecycle races, +loop overlap, visibility, volume, clocks, seeded scenery, and PNG boundaries. diff --git a/docs/PUBLIC_DEMO.md b/docs/PUBLIC_DEMO.md index 8eee571..d66edd0 100644 --- a/docs/PUBLIC_DEMO.md +++ b/docs/PUBLIC_DEMO.md @@ -19,11 +19,16 @@ configuration is in `wrangler.demo.jsonc`; the deployment manifest is The build copies only the browser module graph and generated HTML into `dist/`. It locks the page to fictional demo agents, removes the connection -controls, and ignores feed URL parameters. Browser network connections are -disabled by Content Security Policy. Practice replies affect only the sample +controls, and ignores feed URL parameters. API connections are disabled by +Content Security Policy; same-origin bundled audio and local postcard blob +images are allowed. No audio is requested before explicit play. Practice replies affect only the sample resident in that browser. Live transcripts, activity endpoints, message routes, receipt ledgers, local history, and backend modules are not shipped. +The static bundle includes seven generated MP3s (about 5.2 MiB total), loaded +only as the gramophone needs them. Visitors need no API account or credentials +and cannot trigger paid music generation. PNG postcards stay in their browser. + To build for a different host's root, use: ```bash diff --git a/docs/music-provenance.json b/docs/music-provenance.json new file mode 100644 index 0000000..659eb5d --- /dev/null +++ b/docs/music-provenance.json @@ -0,0 +1,119 @@ +[ + { + "id": "morning", + "title": "Mosslight Morning", + "phase": "morning", + "provider": "ElevenLabs", + "model": "music_v2", + "generatedAt": "2026-09-14T23:53:26.905Z", + "songId": "sCkcL6m3zMy42Iyh3bky", + "durationSeconds": 64.992, + "bytes": 780649, + "sha256": "00113716a94dd503911a2829dc15964a4da77bb374b72338eb2a563487c85c58", + "prompt": "Original instrumental background music for a quiet pixel cottage village. Very restrained low-bit ambient game music, rounded triangle-wave bass, soft filtered pulse-wave plucks, tiny sine-wave music-box notes, a warm faint pad. Slow 64 BPM, gentle G major and E minor colors. Only two or three soft notes at a time, generous rests, delicate dynamics, simple unhurried motifs. Calm and cozy, unobtrusive enough to read and concentrate. No vocals, no speech, no drums, no snare, no sharp high frequencies, no harsh bitcrushing, no busy arpeggios, no loud lead, no risers or drops. Keep an even texture from start to finish suitable for a gentle crossfade loop; no big intro, climax, or final cadence. The music should feel like a small mechanical music box heard from the next room. Early morning sunlight on a wooden floor. A gently curious three-note plucked motif above soft sustained harmony, a little hopeful movement, very sparse and tender.", + "processing": "lowpass=f=5500,loudnorm=I=-20:TP=-3:LRA=7,afade=t=in:d=0.08,afade=t=out:st=64.692:d=0.3", + "format": "MP3 · 44.1 kHz stereo · 96 kbps" + }, + { + "id": "day", + "title": "Little Roads", + "phase": "day", + "provider": "ElevenLabs", + "model": "music_v2", + "generatedAt": "2026-09-14T23:57:29.344Z", + "songId": "xNq74Cd9XoOte9dWU4T6", + "durationSeconds": 65.04, + "bytes": 781271, + "sha256": "4861b39cd7c20f892e290907ccbbe3c51e26bef193aaad9149ec74182aa1ba66", + "prompt": "Original instrumental background music for a quiet pixel cottage village. Very restrained low-bit ambient game music, rounded triangle-wave bass, soft filtered pulse-wave plucks, tiny sine-wave music-box notes, a warm faint pad. Slow 64 BPM, gentle G major and E minor colors. Only two or three soft notes at a time, generous rests, delicate dynamics, simple unhurried motifs. Calm and cozy, unobtrusive enough to read and concentrate. No vocals, no speech, no drums, no snare, no sharp high frequencies, no harsh bitcrushing, no busy arpeggios, no loud lead, no risers or drops. Keep an even texture from start to finish suitable for a gentle crossfade loop; no big intro, climax, or final cadence. The music should feel like a small mechanical music box heard from the next room. A peaceful afternoon in the village. A few rounded 8-bit plucks answer one another, supported by a quiet warm triangle bass. Leisurely, bright but never energetic.", + "processing": "lowpass=f=5500,loudnorm=I=-20:TP=-3:LRA=7,afade=t=in:d=0.08,afade=t=out:st=64.740:d=0.3", + "format": "MP3 · 44.1 kHz stereo · 96 kbps" + }, + { + "id": "dusk", + "title": "Lamplight", + "phase": "dusk", + "provider": "ElevenLabs", + "model": "music_v2", + "generatedAt": "2026-09-14T23:57:30.191Z", + "songId": "93cUmSyPzhbvwwSEoZ6E", + "durationSeconds": 65.04, + "bytes": 781268, + "sha256": "9611aa4fc570ea27e9931a82e84da4182508f28dbd4100e1cecd72dd89fec1f5", + "prompt": "Original instrumental background music for a quiet pixel cottage village. Very restrained low-bit ambient game music, rounded triangle-wave bass, soft filtered pulse-wave plucks, tiny sine-wave music-box notes, a warm faint pad. Slow 64 BPM, gentle G major and E minor colors. Only two or three soft notes at a time, generous rests, delicate dynamics, simple unhurried motifs. Calm and cozy, unobtrusive enough to read and concentrate. No vocals, no speech, no drums, no snare, no sharp high frequencies, no harsh bitcrushing, no busy arpeggios, no loud lead, no risers or drops. Keep an even texture from start to finish suitable for a gentle crossfade loop; no big intro, climax, or final cadence. The music should feel like a small mechanical music box heard from the next room. Golden dusk and cottage lamps coming on. Slower-feeling phrases, warm suspended chords, wistful tiny music-box notes with long soft tails. A very gentle winding down.", + "processing": "lowpass=f=5500,loudnorm=I=-20:TP=-3:LRA=7,afade=t=in:d=0.08,afade=t=out:st=64.740:d=0.3", + "format": "MP3 · 44.1 kHz stereo · 96 kbps" + }, + { + "id": "night", + "title": "After the Fireflies", + "phase": "night", + "provider": "ElevenLabs", + "model": "music_v2", + "generatedAt": "2026-09-15T00:01:24.314Z", + "songId": "aqELzVcJ6pkSQkPnnqxX", + "durationSeconds": 65.04, + "bytes": 781278, + "sha256": "a067537b909faa05934b9786b285f9e1cf829f75a948a94cd6aed183975bed8a", + "prompt": "Original instrumental background music for a quiet pixel cottage village. Very restrained low-bit ambient game music, rounded triangle-wave bass, soft filtered pulse-wave plucks, tiny sine-wave music-box notes, a warm faint pad. Slow 64 BPM, gentle G major and E minor colors. Only two or three soft notes at a time, generous rests, delicate dynamics, simple unhurried motifs. Calm and cozy, unobtrusive enough to read and concentrate. No vocals, no speech, no drums, no snare, no sharp high frequencies, no harsh bitcrushing, no busy arpeggios, no loud lead, no risers or drops. Keep an even texture from start to finish suitable for a gentle crossfade loop; no big intro, climax, or final cadence. The music should feel like a small mechanical music box heard from the next room. Still night beside a small pond. The sparsest arrangement: distant rounded bell notes, a low soft pad, tiny falling two-note phrases separated by silence. Deeply restful and quiet, no tension.", + "processing": "lowpass=f=5500,loudnorm=I=-20:TP=-3:LRA=7,afade=t=in:d=0.08,afade=t=out:st=64.740:d=0.3", + "format": "MP3 · 44.1 kHz stereo · 96 kbps" + }, + { + "id": "app-town", + "title": "Pocket Signals", + "phase": [ + "morning", + "day" + ], + "town": "AppTown", + "provider": "ElevenLabs", + "model": "music_v2", + "generatedAt": "2026-09-15T00:01:23.550Z", + "songId": "BZFe28cTXDflHeiykx4Q", + "durationSeconds": 65.04, + "bytes": 781273, + "sha256": "f3282b7611966fc8610c83dd3d7e2c570a6932303f530240b13549870c9479e0", + "prompt": "Original instrumental background music for a quiet pixel cottage village. Very restrained low-bit ambient game music, rounded triangle-wave bass, soft filtered pulse-wave plucks, tiny sine-wave music-box notes, a warm faint pad. Slow 64 BPM, gentle G major and E minor colors. Only two or three soft notes at a time, generous rests, delicate dynamics, simple unhurried motifs. Calm and cozy, unobtrusive enough to read and concentrate. No vocals, no speech, no drums, no snare, no sharp high frequencies, no harsh bitcrushing, no busy arpeggios, no loud lead, no risers or drops. Keep an even texture from start to finish suitable for a gentle crossfade loop; no big intro, climax, or final cadence. The music should feel like a small mechanical music box heard from the next room. A cozy workshop with little phones and computers. Small soft digital bleeps in a leisurely call and response, muted rounded square waves, a playful but very sparse melody. Never imitate notification or alarm sounds.", + "processing": "lowpass=f=5500,loudnorm=I=-20:TP=-3:LRA=7,afade=t=in:d=0.08,afade=t=out:st=64.740:d=0.3", + "format": "MP3 · 44.1 kHz stereo · 96 kbps" + }, + { + "id": "mem-town", + "title": "Between the Pages", + "phase": [ + "morning", + "day" + ], + "town": "MemTown", + "provider": "ElevenLabs", + "model": "music_v2", + "generatedAt": "2026-09-15T00:03:24.285Z", + "songId": "8FqKlVTvXxDkolWj875I", + "durationSeconds": 65.04, + "bytes": 781276, + "sha256": "e38ecc07428e1e7a8a06471334a3cdb9cf8af70a78bf09d0d3aa45a39747c09c", + "prompt": "Original instrumental background music for a quiet pixel cottage village. Very restrained low-bit ambient game music, rounded triangle-wave bass, soft filtered pulse-wave plucks, tiny sine-wave music-box notes, a warm faint pad. Slow 64 BPM, gentle G major and E minor colors. Only two or three soft notes at a time, generous rests, delicate dynamics, simple unhurried motifs. Calm and cozy, unobtrusive enough to read and concentrate. No vocals, no speech, no drums, no snare, no sharp high frequencies, no harsh bitcrushing, no busy arpeggios, no loud lead, no risers or drops. Keep an even texture from start to finish suitable for a gentle crossfade loop; no big intro, climax, or final cadence. The music should feel like a small mechanical music box heard from the next room. A hushed cottage library lined with books. Barely-there round chiptune piano notes and a gentle page-like chord progression. Reflective, spacious, soothing; slightly slower than the other village pieces.", + "processing": "lowpass=f=5500,loudnorm=I=-20:TP=-3:LRA=7,afade=t=in:d=0.08,afade=t=out:st=64.740:d=0.3", + "format": "MP3 · 44.1 kHz stereo · 96 kbps" + }, + { + "id": "vault-town", + "title": "The Clockmaker", + "phase": [ + "morning", + "day" + ], + "town": "VaultTown", + "provider": "ElevenLabs", + "model": "music_v2", + "generatedAt": "2026-09-15T00:03:23.056Z", + "songId": "p7DRahOlgm0LaMWANv4e", + "durationSeconds": 65.04, + "bytes": 781273, + "sha256": "9a655a3db95eed37339418a81e4103b66e72f40b4eb7fe429471d9dcfc2ac249", + "prompt": "Original instrumental background music for a quiet pixel cottage village. Very restrained low-bit ambient game music, rounded triangle-wave bass, soft filtered pulse-wave plucks, tiny sine-wave music-box notes, a warm faint pad. Slow 64 BPM, gentle G major and E minor colors. Only two or three soft notes at a time, generous rests, delicate dynamics, simple unhurried motifs. Calm and cozy, unobtrusive enough to read and concentrate. No vocals, no speech, no drums, no snare, no sharp high frequencies, no harsh bitcrushing, no busy arpeggios, no loud lead, no risers or drops. Keep an even texture from start to finish suitable for a gentle crossfade loop; no big intro, climax, or final cadence. The music should feel like a small mechanical music box heard from the next room. A tiny clock repair shop. Soft music-box pin notes and rounded plucked tones form a delicate clockwork motif, widely spaced. A tender mechanical lullaby with no literal ticking sound and no percussion.", + "processing": "lowpass=f=5500,loudnorm=I=-20:TP=-3:LRA=7,afade=t=in:d=0.08,afade=t=out:st=64.740:d=0.3", + "format": "MP3 · 44.1 kHz stereo · 96 kbps" + } +] diff --git a/package.json b/package.json index a2b8a1a..6ab52da 100644 --- a/package.json +++ b/package.json @@ -32,7 +32,8 @@ "build:demo": "node scripts/build-demo.mjs", "deploy:demo": "node scripts/deploy-demo.mjs", "test": "node --test", - "test:browser": "node scripts/browser-smoke.mjs" + "test:browser": "node scripts/browser-smoke.mjs", + "test:ambience": "node scripts/browser-ambience-smoke.mjs" }, "engines": { "node": ">=22.13" diff --git a/scripts/browser-ambience-smoke.mjs b/scripts/browser-ambience-smoke.mjs new file mode 100644 index 0000000..a8b926c --- /dev/null +++ b/scripts/browser-ambience-smoke.mjs @@ -0,0 +1,168 @@ +#!/usr/bin/env node +/** Real Chrome: opt-in audio, contextual beds, lighting and private PNG export. */ +import assert from 'node:assert/strict'; +import {execFile} from 'node:child_process'; +import {promisify} from 'node:util'; +import {createFeedServer} from '../src/feed.mjs'; + +const run=promisify(execFile),page='cottagecode-ambience-smoke'; +const explicit=process.argv.indexOf('--url'),remote=explicit>=0?process.argv[explicit+1]:null; +const now=Date.now(),agents=['HubTown','AppTown','MemTown','VaultTown'].map((town,i)=>({ + id:'ambience-'+i,taskId:'ambience-task-'+i,name:['Hazel','Pip','Moss','Tock'][i],town,status:'working', + task:'Synthetic browser check',originalAsk:'This request must never appear on a scenic postcard.', + taskStartedAt:now-300000,sessionStartedAt:now-600000,updatedAt:now, + pr:{state:'none',source:'browser-fixture',checkedAt:now}, + events:[{id:'recorded-result',kind:'result',timestamp:now-1000,text:'Verified a peaceful walk through the village.',url:'https://example.com/recorded-artifact'}], +})); +const feed={snapshot:()=>({agents,source:'browser-fixture',checkedAt:Date.now()})}; +const server=remote?null:createFeedServer(feed).listen(0,'127.0.0.1'); +if(server)await new Promise(resolve=>server.once('listening',resolve)); +const url=remote||'http://127.0.0.1:'+server.address().port; +const delay=ms=>new Promise(resolve=>setTimeout(resolve,ms)); +async function browser(operation,args=[]){ + const {stdout}=await run('browser-hand',[operation,'--page-name',page,...args],{timeout:30000,maxBuffer:2e6}); + const result=JSON.parse(stdout);assert.equal(result.success,true,JSON.stringify(result));return result; +} +const evaluate=async code=>(await browser('evaluate',['--code',code])).result; +const click=selector=>browser('click',['--selector',selector]); +const fill=(selector,value)=>browser('fill',['--fields',JSON.stringify({[selector]:value})]); +const state=()=>evaluate('window.cottageObservatory.state'); +async function until(code,message,timeout=12000){ + const end=Date.now()+timeout;while(Date.now()a.town==='+JSON.stringify(town)+'&&!a.parent)?.id');assert.ok(id,town); + if(!await evaluate('document.querySelector(".cottage-directory").open'))await click('.cottage-directory summary'); + await click('[data-cottage='+JSON.stringify(id)+']'); + await click('[data-action="enter"]'); + await until('window.cottageObservatory.state.town==='+JSON.stringify(town),'Interior did not select '+town); + await until('window.cottageObservatory.state.music.track?.id==='+JSON.stringify(expected),'Music did not follow '+town); + assert.equal((await state()).music.error,''); +} +try{ + await browser('open',['--url',url]); + await until('!!window.cottageObservatory?.state.selected','Village did not load'); + await browser('snapshot'); + assert.equal((await state()).music.enabled,false); + assert.equal(await evaluate('performance.getEntriesByType("resource").filter(e=>/\\.mp3(?:$|\\?)/.test(e.name)).length'),0); + assert.equal((await state()).timeMode,'auto'); + pass('fresh page is silent and requests no audio'); + await click('#village-settings summary');await fill('#village-time','Day'); + await click('#music-toggle'); + await until('window.cottageObservatory.state.music.audible','Opt-in did not start native audio'); + assert.equal((await state()).music.track.id,'day'); + assert.equal((await state()).sound,false,'Music must not enable sound effects'); + pass('explicit play starts the daytime bed independently of sound effects'); + await visit('AppTown','app-town'); + const seed=(await state()).roomSeed; + assert.ok(Math.abs((await state()).music.effectiveVolume-.104)<.0001); + await click('[data-action="talk"]'); + await until('window.cottageObservatory.state.music.effectiveVolume<.03','Conversation did not hush the music'); + await click('[data-action="tab:request"]'); + await until('window.cottageObservatory.state.music.effectiveVolume>.10','Music stayed hushed after talking'); + pass('AppTown gets its own bed; interiors and conversations reduce volume'); + await visit('MemTown','mem-town');await visit('VaultTown','vault-town'); + await fill('#village-time','Dusk'); + await until('window.cottageObservatory.state.music.track?.id==="dusk"','Dusk did not replace the regional bed'); + assert.equal((await state()).phase,'dusk'); + await fill('#village-time','Night'); + await until('window.cottageObservatory.state.music.track?.id==="night"','Night bed did not load'); + pass('MemTown, VaultTown, dusk and night transitions play without errors'); + await evaluate('window.__ambienceNativeDate=window.Date;window.__ambienceHour=12;window.Date=class extends window.__ambienceNativeDate{getHours(){return window.__ambienceHour}getMinutes(){return 0}getSeconds(){return 0}}'); + await fill('#village-time','Follow my clock'); + await until('window.cottageObservatory.state.music.track?.id==="vault-town"','Noon clock fixture did not select daytime music'); + await evaluate('Object.defineProperty(document,"hidden",{configurable:true,value:true});document.dispatchEvent(new Event("visibilitychange"))'); + assert.equal((await state()).music.playing,false); + const resumed=await evaluate('(()=>{window.__ambienceHour=23;Object.defineProperty(document,"hidden",{configurable:true,value:false});document.dispatchEvent(new Event("visibilitychange"));return window.cottageObservatory.state.music;})()'); + assert.ok(resumed.pendingTrackId==='night'||resumed.track?.id==='night',JSON.stringify(resumed)); + await until('window.cottageObservatory.state.music.track?.id==="night"','Return after sunset did not select night immediately'); + await evaluate('window.Date=window.__ambienceNativeDate;delete window.__ambienceNativeDate;delete window.__ambienceHour;delete document.hidden'); + await fill('#village-time','Night'); + pass('returning after a clock boundary selects the new phase before resuming'); + // Exercise page-cache lifecycle without navigating away from the fixture. + await evaluate('window.dispatchEvent(new PageTransitionEvent("pagehide",{persisted:true}))'); + assert.equal((await state()).music.status,'paused'); + assert.equal((await state()).music.playing,false); + await evaluate('window.dispatchEvent(new PageTransitionEvent("pageshow",{persisted:true}))'); + await until('window.cottageObservatory.state.music.audible','Cached page restore did not resume opted-in music'); + pass('cached-page lifecycle releases media and restores the opted-in controller'); + await browser('snapshot');await click('[data-action="postcard"]'); + await until('document.getElementById("postcard-preview").naturalWidth===1200','Postcard PNG did not render'); + assert.equal((await state()).postcardOpen,true); + assert.equal(await evaluate('document.getElementById("postcard-preview").naturalHeight'),840); + assert.equal(await evaluate('document.getElementById("postcard-milestone").value'),''); + assert.match(await evaluate('document.getElementById("postcard-save").download'),/^cottagecode-vaulttown-.*\.png$/); + assert.match(await evaluate('document.getElementById("postcard-save").href'),/^blob:/); + assert.equal(await evaluate('(()=>{const image=document.getElementById("postcard-preview"),canvas=document.createElement("canvas");canvas.width=image.naturalWidth;canvas.height=image.naturalHeight;const ctx=canvas.getContext("2d");ctx.drawImage(image,0,0);return ctx.getImageData(0,0,1,1).data[3]})()'),255); + const scenicURL=await evaluate('document.getElementById("postcard-preview").src'); + const milestone=await evaluate('document.getElementById("postcard-milestone").options[1]?.text'); + if(milestone){ + await fill('#postcard-milestone',milestone); + await until('document.getElementById("postcard-preview").naturalWidth===1200&&document.getElementById("postcard-preview").src!=='+JSON.stringify(scenicURL),'Explicit milestone did not redraw postcard'); + assert.match(await evaluate('document.getElementById("postcard-status").textContent'),/note you chose/); + } + await delay(1300); // Exercise focus return after the inspector refreshes. + await click('#postcard-close'); + assert.equal((await state()).postcardOpen,false); + assert.equal(await evaluate('document.activeElement.dataset.action'),'postcard'); + assert.equal(await evaluate('document.getElementById("postcard-preview").hasAttribute("src")'),false); + pass('opaque PNG preview, explicit milestone, download link and restored keyboard focus'); + await fill('#village-time','Day');await visit('AppTown','app-town');assert.equal((await state()).roomSeed,seed); + // A directory selection is inspection, not a change in Jack’s location. + await click('[data-action="leave"]'); + const hub=await evaluate('window.cottageState().agents.find(a=>a.town==="HubTown"&&!a.parent).id'); + await click('[data-cottage='+JSON.stringify(hub)+']'); + assert.equal((await state()).town,'AppTown'); + await click('#music-toggle'); + await until('!window.cottageObservatory.state.music.playing','Stop did not release native media'); + pass('revisits preserve rooms; inspecting another town does not move the music; stop silences playback'); + if(!remote){ + const mobile=await evaluate(`(async()=>{ + const frame=document.createElement('iframe');frame.style='position:fixed;inset:0;width:390px;height:844px;z-index:99999;border:0';frame.src=location.href;document.body.append(frame); + await new Promise(resolve=>frame.onload=resolve);const doc=frame.contentDocument,win=frame.contentWindow; + const end=Date.now()+5000;while(!win.cottageObservatory?.state.selected&&Date.now()setTimeout(r,100)); + doc.querySelector('#village-settings').open=true;doc.querySelector('[data-action="postcard"]').click(); + while(!doc.getElementById('postcard-preview').naturalWidth&&Date.now()setTimeout(r,100)); + const dialog=doc.getElementById('postcard-dialog'),r=dialog.getBoundingClientRect(); + const result={width:win.innerWidth,scrollWidth:doc.documentElement.scrollWidth,dialogWidth:r.width,dialogLeft:r.left,dialogRight:r.right,dialogScroll:dialog.scrollWidth,dialogClient:dialog.clientWidth,png:doc.getElementById('postcard-preview').naturalWidth,music:win.cottageObservatory.state.music.enabled}; + frame.remove();return result; + })()`); + assert.equal(mobile.width,390);assert.ok(mobile.scrollWidth<=390,JSON.stringify(mobile)); + assert.ok(mobile.dialogLeft>=0&&mobile.dialogRight<=390,JSON.stringify(mobile)); + assert.equal(mobile.dialogScroll,mobile.dialogClient);assert.equal(mobile.png,1200);assert.equal(mobile.music,false); + pass('390px controls and postcard dialog fit without overflow or autoplay'); + } + await browser('goto',['--url',url]);await until('!!window.cottageObservatory?.state.selected','Reload failed'); + assert.equal((await state()).music.enabled,false); + assert.equal(await evaluate('performance.getEntriesByType("resource").filter(e=>/\\.mp3(?:$|\\?)/.test(e.name)).length'),0); + pass('reload never resumes music or preloads the soundtrack'); + if(!remote){ + // Force the ignored-volume branch with real media and real Web Audio. The + // browser itself stays unchanged; only this isolated output gets the shim. + await evaluate(`(async()=>{ + const {createMusicOutput}=await import('/modules/music-output.mjs'),{createMusic}=await import('/modules/music.mjs'); + const gains=[],media=[],contexts=[],NativeAudio=window.Audio,NativeContext=window.AudioContext; + const output=createMusicOutput({Audio:function(){const a=new NativeAudio();Object.defineProperty(a,'volume',{get:()=>1,set:()=>{}});media.push(a);return a;},AudioContext:class extends NativeContext{constructor(){super();contexts.push(this)}createGain(){const gain=super.createGain();gains.push(gain);return gain;}}}); + const music=createMusic({tracks:[{id:'quiet-proof',phase:'day',url:new URL('/modules/audio/day.mp3',location.href).href}],audioFactory:output.create,visibilitySource:null,fadeMs:200}); + const button=document.createElement('button');button.id='quiet-output-proof';button.textContent='Test quiet music output';button.onclick=()=>music.enable(!music.enabled);document.body.append(button); + window.__quietOutputProof={music,output,gains,media,contexts,button}; + })()`); + await browser('snapshot');await click('#quiet-output-proof'); + await until('window.__quietOutputProof.music.state.audible','Gain fallback did not play real media'); + await evaluate('window.__quietOutputProof.music.setScene({indoors:true,talking:true})'); + await until('Math.abs(window.__quietOutputProof.gains[0].gain.value-.026)<.001','Real output gain did not hush for conversation'); + assert.deepEqual(await evaluate('(()=>{const q=window.__quietOutputProof;return {contexts:q.contexts.length,media:q.media.length,native:q.media[0].volume,state:q.contexts[0].state}})()'),{contexts:1,media:1,native:1,state:'running'}); + await click('#quiet-output-proof');await until('!window.__quietOutputProof.music.state.playing','Gain fallback did not stop'); + const closed=await evaluate('(async()=>{const q=window.__quietOutputProof;q.music.dispose();await q.output.dispose();q.button.remove();return q.contexts[0].state})()'); + assert.equal(closed,'closed'); + await evaluate('delete window.__quietOutputProof'); + pass('real Web Audio keeps ignored native volume quiet, ducks to 2.6%, and closes cleanly'); + } +}finally{ + try{await evaluate('(async()=>{const q=window.__quietOutputProof;if(q){q.music.dispose();await q.output.dispose();q.button.remove();delete window.__quietOutputProof;}})()');}catch{} + try{await evaluate('if(window.__ambienceNativeDate){window.Date=window.__ambienceNativeDate;delete window.__ambienceNativeDate;delete window.__ambienceHour;delete document.hidden;}');}catch{} + try{if(await evaluate('window.cottageObservatory?.state.music.enabled'))await click('#music-toggle');}catch{} + if(server)await new Promise(resolve=>server.close(resolve)); +} diff --git a/scripts/build-demo.mjs b/scripts/build-demo.mjs index 8a42c55..26297f7 100644 --- a/scripts/build-demo.mjs +++ b/scripts/build-demo.mjs @@ -10,7 +10,7 @@ const SIGNATURE = 'CottageCode public sample build v1\n'; const PUBLIC_ASSETS = new Set(['.mjs', '.js', '.css', '.svg', '.png', '.jpg', '.jpeg', '.gif', '.webp', '.ico', '.woff', '.woff2', '.wav', '.ogg', '.mp3']); const SERVER_MODULES = new Set(['feed.mjs', 'hub.mjs', 'messages.mjs', 'transcripts.mjs', 'activity.mjs', 'github.mjs', 'towns.mjs']); const FONT_HOSTS = new Set(['fonts.googleapis.com', 'fonts.gstatic.com']); -const CSP = "default-src 'none'; script-src 'self'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com; img-src 'self' data:; connect-src 'none'; object-src 'none'; base-uri 'self'; form-action 'none'"; +const CSP = "default-src 'none'; script-src 'self'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com; img-src 'self' data: blob:; media-src 'self'; connect-src 'none'; object-src 'none'; base-uri 'self'; form-action 'none'"; const DESCRIPTION = 'A playable sample village for CottageCode. Walk between pixel cottages, meet fictional agents, and explore tasks, journals, and pull request progress.'; const htmlEscape = value => String(value).replace(/[&<>"']/g, char => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[char])); const within = (root, path) => path === root || path.startsWith(root + sep); diff --git a/scripts/generate-music.mjs b/scripts/generate-music.mjs new file mode 100644 index 0000000..dd465a5 --- /dev/null +++ b/scripts/generate-music.mjs @@ -0,0 +1,54 @@ +#!/usr/bin/env node +/** Manual asset generation only. Never runs in the app, build, or test suite. */ +import {mkdir,readFile,writeFile,access} from 'node:fs/promises'; +import {fileURLToPath,pathToFileURL} from 'node:url'; +import {resolve,join} from 'node:path'; + +const ROOT=fileURLToPath(new URL('../',import.meta.url)); +const STYLE='Original instrumental background music for a quiet pixel cottage village. Very restrained low-bit ambient game music, rounded triangle-wave bass, soft filtered pulse-wave plucks, tiny sine-wave music-box notes, a warm faint pad. Slow 64 BPM, gentle G major and E minor colors. Only two or three soft notes at a time, generous rests, delicate dynamics, simple unhurried motifs. Calm and cozy, unobtrusive enough to read and concentrate. No vocals, no speech, no drums, no snare, no sharp high frequencies, no harsh bitcrushing, no busy arpeggios, no loud lead, no risers or drops. Keep an even texture from start to finish suitable for a gentle crossfade loop; no big intro, climax, or final cadence. The music should feel like a small mechanical music box heard from the next room. '; +export const MUSIC_SPECS=[ + {id:'morning',title:'Mosslight Morning',phase:'morning',direction:'Early morning sunlight on a wooden floor. A gently curious three-note plucked motif above soft sustained harmony, a little hopeful movement, very sparse and tender.'}, + {id:'day',title:'Little Roads',phase:'day',direction:'A peaceful afternoon in the village. A few rounded 8-bit plucks answer one another, supported by a quiet warm triangle bass. Leisurely, bright but never energetic.'}, + {id:'dusk',title:'Lamplight',phase:'dusk',direction:'Golden dusk and cottage lamps coming on. Slower-feeling phrases, warm suspended chords, wistful tiny music-box notes with long soft tails. A very gentle winding down.'}, + {id:'night',title:'After the Fireflies',phase:'night',direction:'Still night beside a small pond. The sparsest arrangement: distant rounded bell notes, a low soft pad, tiny falling two-note phrases separated by silence. Deeply restful and quiet, no tension.'}, + {id:'app-town',title:'Pocket Signals',phase:['morning','day'],town:'AppTown',direction:'A cozy workshop with little phones and computers. Small soft digital bleeps in a leisurely call and response, muted rounded square waves, a playful but very sparse melody. Never imitate notification or alarm sounds.'}, + {id:'mem-town',title:'Between the Pages',phase:['morning','day'],town:'MemTown',direction:'A hushed cottage library lined with books. Barely-there round chiptune piano notes and a gentle page-like chord progression. Reflective, spacious, soothing; slightly slower than the other village pieces.'}, + {id:'vault-town',title:'The Clockmaker',phase:['morning','day'],town:'VaultTown',direction:'A tiny clock repair shop. Soft music-box pin notes and rounded plucked tones form a delicate clockwork motif, widely spaced. A tender mechanical lullaby with no literal ticking sound and no percussion.'}, +]; + +async function generate(id){ + const spec=MUSIC_SPECS.find(track=>track.id===id); + if(!spec)throw new Error('Choose a track: '+MUSIC_SPECS.map(track=>track.id).join(', ')); + if(!process.env.ELEVENLABS_API_KEY)throw new Error('ELEVENLABS_API_KEY is not configured.'); + const directory=join(ROOT,'tmp','music-generation'); + await mkdir(directory,{recursive:true}); + const receiptPath=join(directory,id+'.json'),audioPath=join(directory,id+'.mp3'); + try{await access(receiptPath);throw new Error('An attempt already exists for '+id+'. Inspect its receipt before generating again.');}catch(error){if(error.code!=='ENOENT')throw error;} + const body={prompt:STYLE+spec.direction,music_length_ms:65000,model_id:'music_v2',force_instrumental:true}; + const receipt={...spec,provider:'ElevenLabs',model:body.model_id,requestedAt:new Date().toISOString(),status:'requested',request:body}; + await writeFile(receiptPath,JSON.stringify(receipt,null,2)); + let response; + try{ + response=await fetch('https://api.elevenlabs.io/v1/music?output_format=mp3_48000_192',{method:'POST',headers:{'xi-api-key':process.env.ELEVENLABS_API_KEY,'content-type':'application/json',accept:'audio/mpeg'},body:JSON.stringify(body),signal:AbortSignal.timeout(240000)}); + if(!response.ok){ + const error=await response.text(); + await writeFile(receiptPath,JSON.stringify({...receipt,status:'rejected',httpStatus:response.status,error:error.slice(0,1600)},null,2)); + throw new Error('ElevenLabs rejected '+id+' (HTTP '+response.status+'): '+error.slice(0,1000)); + } + const bytes=Buffer.from(await response.arrayBuffer()); + if(bytes.length<1000)throw new Error('ElevenLabs returned an incomplete audio file.'); + await writeFile(audioPath,bytes); + const result={...receipt,status:'generated',completedAt:new Date().toISOString(),songId:response.headers.get('song-id'),requestId:response.headers.get('request-id'),bytes:bytes.length}; + await writeFile(receiptPath,JSON.stringify(result,null,2)); + process.stdout.write(JSON.stringify({id,title:spec.title,status:'generated',bytes:bytes.length,audioPath})+'\n'); + }catch(error){ + const saved=JSON.parse(await readFile(receiptPath,'utf8')); + if(saved.status==='requested')await writeFile(receiptPath,JSON.stringify({...saved,status:'unconfirmed',error:String(error.message).slice(0,1000)},null,2)); + throw error; + } +} +if(process.argv[1]&&import.meta.url===pathToFileURL(resolve(process.argv[1])).href){ + const args=process.argv.slice(2); + if(args.length!==2||args[0]!=='--generate')throw new Error('Manual paid generation: node scripts/generate-music.mjs --generate '); + generate(args[1]).catch(error=>{process.stderr.write(error.message+'\n');process.exitCode=1;}); +} diff --git a/scripts/prepare-music.mjs b/scripts/prepare-music.mjs new file mode 100644 index 0000000..2e9b1fa --- /dev/null +++ b/scripts/prepare-music.mjs @@ -0,0 +1,27 @@ +#!/usr/bin/env node +// Optimize previously generated files. This command never calls an API. +import {mkdir,readFile,writeFile} from 'node:fs/promises'; +import {execFile} from 'node:child_process'; +import {promisify} from 'node:util'; +import {createHash} from 'node:crypto'; +import {fileURLToPath} from 'node:url'; +import {join} from 'node:path'; +import {MUSIC_SPECS} from './generate-music.mjs'; + +const run=promisify(execFile),root=fileURLToPath(new URL('../',import.meta.url)); +const input=join(root,'tmp/music-generation'),output=join(root,'src/audio'),manifest=[]; +await mkdir(output,{recursive:true}); +for(const track of MUSIC_SPECS){ + const receipt=JSON.parse(await readFile(join(input,track.id+'.json'),'utf8')); + if(receipt.status!=='generated')throw new Error('No confirmed generation for '+track.id); + const raw=join(input,track.id+'.mp3'),final=join(output,track.id+'.mp3'); + const probe=JSON.parse((await run('ffprobe',['-v','error','-show_format','-of','json',raw])).stdout); + const duration=Number(probe.format.duration); + if(!Number.isFinite(duration)||duration<60||duration>70)throw new Error('Unexpected duration: '+track.id); + const filter='lowpass=f=5500,loudnorm=I=-20:TP=-3:LRA=7,afade=t=in:d=0.08,afade=t=out:st='+(duration-.3).toFixed(3)+':d=0.3'; + await run('ffmpeg',['-hide_banner','-loglevel','error','-y','-i',raw,'-map_metadata','-1','-af',filter,'-ar','44100','-ac','2','-c:a','libmp3lame','-b:a','96k','-metadata','title='+track.title,'-metadata','artist=CottageCode · ElevenLabs',final]); + const bytes=await readFile(final),sha256=createHash('sha256').update(bytes).digest('hex'); + manifest.push({id:track.id,title:track.title,phase:track.phase,...(track.town?{town:track.town}:{}),provider:'ElevenLabs',model:receipt.model,generatedAt:receipt.completedAt,songId:receipt.songId,durationSeconds:duration,bytes:bytes.length,sha256,prompt:receipt.request.prompt,processing:filter,format:'MP3 · 44.1 kHz stereo · 96 kbps'}); + process.stdout.write(track.id+': '+duration.toFixed(1)+'s, '+Math.round(bytes.length/1024)+' KB\n'); +} +await writeFile(join(root,'docs/music-provenance.json'),JSON.stringify(manifest,null,2)+'\n'); diff --git a/src/atmosphere.mjs b/src/atmosphere.mjs new file mode 100644 index 0000000..3c26855 --- /dev/null +++ b/src/atmosphere.mjs @@ -0,0 +1,255 @@ +/** Decorative light only: no task data, status, collision, or geometry changes. */ +const clamp = (value, min = 0, max = 1) => Math.max(min, Math.min(max, value)); +const finite = value => typeof value === 'number' && Number.isFinite(value); +const mix = (a, b, t) => a + (b - a) * t; +const smooth = value => value * value * (3 - 2 * value); +const color = (rgb, alpha = 1) => `rgba(${rgb.map(Math.round).join(',')},${clamp(alpha).toFixed(4)})`; +const PHASES = Object.freeze({ morning: 7, day: 12, dusk: 19, night: 23 }); +const LIMITS = Object.freeze({ ponds: 6, trees: 12, particles: 40, litCottages: 96, plots: 256 }); + +// Neighboring keyframes interpolate smoothly, including across midnight. The +// darkest wash is 22%; paths, labels, and Jack keep most of their original color. +const LIGHT = [ + { hour: 0, darkness: .22, warmth: 0, windowGlow: 1, fireflies: 1, windowShade: 1, tint: [22, 37, 70], sky: [37, 53, 86] }, + { hour: 4, darkness: .22, warmth: 0, windowGlow: 1, fireflies: .85, windowShade: 1, tint: [22, 37, 70], sky: [44, 60, 91] }, + { hour: 5, darkness: .12, warmth: .25, windowGlow: .4, fireflies: .15, windowShade: .7, tint: [100, 104, 127], sky: [115, 137, 157] }, + { hour: 7, darkness: .025, warmth: .4, windowGlow: 0, fireflies: 0, windowShade: .12, tint: [183, 139, 95], sky: [187, 198, 184] }, + { hour: 9, darkness: 0, warmth: 0, windowGlow: 0, fireflies: 0, windowShade: 0, tint: [50, 75, 94], sky: [157, 191, 194] }, + { hour: 16, darkness: 0, warmth: 0, windowGlow: 0, fireflies: 0, windowShade: 0, tint: [50, 75, 94], sky: [157, 191, 194] }, + { hour: 17, darkness: .035, warmth: .45, windowGlow: .1, fireflies: 0, windowShade: .2, tint: [163, 101, 92], sky: [210, 174, 150] }, + { hour: 19, darkness: .13, warmth: .65, windowGlow: .75, fireflies: .45, windowShade: .82, tint: [86, 64, 99], sky: [132, 115, 151] }, + { hour: 21, darkness: .22, warmth: .15, windowGlow: 1, fireflies: 1, windowShade: 1, tint: [22, 37, 70], sky: [46, 62, 97] }, + { hour: 24, darkness: .22, warmth: 0, windowGlow: 1, fireflies: 1, windowShade: 1, tint: [22, 37, 70], sky: [37, 53, 86] }, +]; + +/** + * Read the local device clock by default. Explicit hours wrap at 24, while a + * phase string chooses its representative hour. Invalid inputs use noon. + * Animation time in the painters is separate, and is measured in seconds. + */ +export function villageTime(input = new Date()) { + let hour, source; + if (input instanceof Date && finite(input.getTime())) { + hour = input.getHours() + input.getMinutes() / 60 + input.getSeconds() / 3600 + input.getMilliseconds() / 3600000; + source = 'device-clock'; + } else if (finite(input)) { + hour = ((input % 24) + 24) % 24; + source = 'hour'; + } else if (typeof input === 'string' && Object.hasOwn(PHASES, input)) { + hour = PHASES[input]; source = 'phase'; + } else { hour = 12; source = 'fallback'; } + const phase = hour >= 5 && hour < 9 ? 'morning' : hour >= 9 && hour < 17 ? 'day' : hour >= 17 && hour < 21 ? 'dusk' : 'night'; + const phaseProgress = phase === 'morning' ? (hour - 5) / 4 : phase === 'day' ? (hour - 9) / 8 : + phase === 'dusk' ? (hour - 17) / 4 : (hour >= 21 ? hour - 21 : hour + 3) / 8; + const i = LIGHT.findIndex((frame, index) => index < LIGHT.length - 1 && hour >= frame.hour && hour < LIGHT[index + 1].hour); + const a = LIGHT[Math.max(0, i)], b = LIGHT[Math.max(0, i) + 1]; + const t = smooth(clamp((hour - a.hour) / (b.hour - a.hour))); + const state = { phase, hour, source, phaseProgress, label: phase[0].toUpperCase() + phase.slice(1) }; + for (const key of ['darkness', 'warmth', 'windowGlow', 'fireflies', 'windowShade']) state[key] = mix(a[key], b[key], t); + state.roomDarkness = state.darkness * .4; + state.tint = Object.freeze(a.tint.map((value, index) => mix(value, b.tint[index], t))); + state.sky = Object.freeze(a.sky.map((value, index) => mix(value, b.sky[index], t))); + return Object.freeze(state); +} + +const validRect = rect => rect && [rect.x, rect.y, rect.w, rect.h].every(finite) && rect.w > 0 && rect.h > 0; +const distanceToRect = (point, rect) => Math.hypot(Math.max(rect.x - point.x, 0, point.x - rect.x - rect.w), + Math.max(rect.y - point.y, 0, point.y - rect.y - rect.h)); + +/** Foot-center position determines the town; selecting a remote cottage does not. */ +export function locateVillageTown(player, districts = [], { interiorTown } = {}) { + if (typeof interiorTown === 'string' && interiorTown.trim()) return interiorTown.trim(); + if (!player || !finite(player.x) || !finite(player.y)) return 'HubTown'; + let nearest = null, distance = Infinity; + for (const district of Array.isArray(districts) ? districts : []) { + if (!validRect(district) || typeof district.key !== 'string' || !district.key.trim()) continue; + const d = distanceToRect(player, district); + // Roads choose the nearest actual district. Ties favor the home town, then + // the key, so reordering the feed cannot change the music at a crossroads. + if (d < distance || d === distance && (!nearest || district.key === 'HubTown' || + nearest.key !== 'HubTown' && district.key < nearest.key)) { + nearest = district; distance = d; + } + } + return nearest?.key || 'HubTown'; +} + +function hash(value) { + let result = 2166136261; + for (const char of String(value)) result = Math.imul(result ^ char.codePointAt(0), 16777619); + return result >>> 0; +} +function random(seed) { + let state = seed; + return () => { + state = Math.imul(state ^ state >>> 15, 1 | state) + 0x6d2b79f5 | 0; + return ((state ^ state >>> 14) >>> 0) / 4294967296; + }; +} +function stableRects(rects, limit) { + // Limit the inspected set as well as the draw count on very large fleets. + return (Array.isArray(rects) ? rects : []).slice(0, 192).filter(validRect) + .map(rect => ({ rect, seed: hash([rect.x, rect.y, rect.w, rect.h].join(':')) })) + .sort((a, b) => a.seed - b.seed).slice(0, limit); +} + +/** Stable decorative anchors, seeded only from real pond and trunk geometry. */ +export function atmosphereAccents({ width, height, ponds = [], trees = [] } = {}) { + if (!finite(width) || !finite(height) || width < 8 || height < 8) return []; + const accents = []; + const add = (kind, rectangle, count) => { + const seed = hash(kind + ':' + rectangle.seed), next = random(seed), rect = rectangle.rect; + for (let i = 0; i < count && accents.length < LIMITS.particles; i++) { + const angle = next() * Math.PI * 2, radius = .75 + next() * .5; + const x = kind === 'pond' ? rect.x + rect.w / 2 + Math.cos(angle) * (rect.w / 2 + 3) * radius : rect.x + rect.w / 2 + (next() - .5) * 24; + const y = kind === 'pond' ? rect.y + rect.h / 2 + Math.sin(angle) * (rect.h / 2 + 5) * radius : rect.y - 4 + (next() - .5) * 18; + accents.push({ id: kind + ':' + seed + ':' + i, kind, x: clamp(x, 3, width - 4), y: clamp(y, 3, height - 4), + phase: next() * Math.PI * 2, speed: .22 + next() * .24, drift: 1.5 + next() * 2.5 }); + } + }; + for (const pond of stableRects(ponds, LIMITS.ponds)) add('pond', pond, 4); + for (const tree of stableRects(trees, LIMITS.trees)) add('tree', tree, 2); + return accents; +} + +function painter(ctx) { + return (x, y, w, h, fill) => { + if (w <= 0 || h <= 0) return; + ctx.fillStyle = fill; + ctx.fillRect(Math.round(x), Math.round(y), Math.round(w), Math.round(h)); + }; +} +function clipped(ctx, width, height, protectedRects) { + ctx.beginPath(); ctx.rect(0, 0, width, height); + for (const rect of protectedRects) if (validRect(rect)) ctx.rect(rect.x, rect.y, rect.w, rect.h); + ctx.clip('evenodd'); +} +function stateOf(state) { + return state && finite(state.darkness) && Array.isArray(state.tint) && Array.isArray(state.sky) ? state : villageTime(state ?? 12); +} +function glow(p, x, y, w, h, strength) { + p(x - 4, y - 3, w + 8, h + 7, color([255, 195, 105], strength * .045)); + p(x - 2, y - 1, w + 4, h + 3, color([255, 207, 133], strength * .075)); +} + +/** + * Paint after houses/fauna and before thought bubbles, Jack, and other UI. + * Optional `trees` are actual trunk rectangles (treeTo emits 6×8 solids). + * Plots may supply opacity when their town has been faded by a filter. + */ +export function paintTownAtmosphere(ctx, world = {}, timeState = villageTime(12), { time = 0, reduce = false } = {}) { + const { width, height } = world || {}, state = stateOf(timeState); + const result = { phase: state.phase, windows: 0, fireflies: 0 }; + if (!ctx || !finite(width) || !finite(height) || width <= 0 || height <= 0) return result; + if (state.darkness <= .0001 && state.warmth <= .0001 && state.windowGlow <= .001 && state.fireflies <= .01) return result; + const plots = (Array.isArray(world.plots) ? world.plots : []).slice(0, LIMITS.plots).filter(plot => finite(plot.x) && finite(plot.y)); + const frame = reduce || !finite(time) ? 0 : time; + ctx.save(); + try { + ctx.imageSmoothingEnabled = false; + // Match drawDispatch: parcel, state icon, and number are outside the house. + clipped(ctx, width, height, plots.flatMap(plot => [ + { x: plot.x - 19, y: plot.y + 35, w: 22, h: 40 }, + { x: plot.x - 21, y: plot.y + 80, w: 56, h: 11 }, + ])); + const p = painter(ctx); + if (state.darkness > .0001) p(0, 0, width, height, color(state.tint, Math.min(.22, state.darkness))); + if (state.warmth > .0001) p(0, 0, width, height, color([246, 184, 119], state.warmth * .028)); + if (state.windowGlow > .001) for (const plot of plots.slice(0, LIMITS.litCottages)) { + const agent = plot.agent || {}; + if (agent.status === 'offline') continue; + const strength = state.windowGlow * (agent.status === 'working' ? 1 : .62) * (finite(plot.opacity) ? clamp(plot.opacity) : 1); + if (strength <= .001) continue; + const warmth = .25 + (hash(agent.id || plot.x + ':' + plot.y) % 25) / 100; + for (const x of [plot.x + 12, plot.x + 34]) { + const y = plot.y + 41; + glow(p, x, y, 8, 8, strength); + // Four glass panes leave the original wooden mullions and frame intact. + const fill = color([255, 213 + warmth * 20, 125 + warmth * 35], strength * .63); + p(x, y, 3, 3, fill); p(x + 4, y, 4, 3, fill); + p(x, y + 4, 3, 4, fill); p(x + 4, y + 4, 4, 4, fill); + p(x, y, 3, 1, color([255, 238, 184], strength * .35)); + result.windows++; + } + } + if (state.fireflies > .01) for (const accent of atmosphereAccents(world)) { + const x = clamp(accent.x + Math.sin(frame * accent.speed + accent.phase) * accent.drift, 3, width - 4); + const y = clamp(accent.y + Math.cos(frame * accent.speed * .7 + accent.phase) * accent.drift, 3, height - 4); + const blink = .4 + .6 * (.5 + .5 * Math.sin(frame * .8 + accent.phase)); + const alpha = state.fireflies * blink; + p(x - 2, y - 1, 5, 3, color([213, 240, 139], alpha * .09)); + p(x - 1, y - 2, 3, 5, color([218, 243, 145], alpha * .075)); + p(x, y, 1, 1, color([250, 247, 172], alpha * .9)); + result.fireflies++; + } + } finally { ctx.restore(); } + return result; +} + +/** Paint in the room's existing 240×176 coordinate system after its renderer. */ +export function paintRoomAtmosphere(ctx, room, timeState = villageTime(12), { time = 0, reduce = false } = {}) { + const state = stateOf(timeState), result = { phase: state.phase, windows: 0 }; + if (!ctx || !room || !finite(room.width) || !finite(room.height)) return result; + if (state.roomDarkness <= .0001 && state.windowShade <= .001 && state.windowGlow <= .001) return result; + const review = room.objects?.find(object => object.id === 'review'); + const protectedRects = review ? [{ x: review.x - 2, y: review.y - 15, w: review.w + 4, h: review.h + 20 }] : []; + const frame = reduce || !finite(time) ? 0 : time; + ctx.save(); + try { + ctx.imageSmoothingEnabled = false; + // Includes the status plaque and the drawer signal: ready remains gold, + // unknown remains gray, and no warm ambient light changes those meanings. + clipped(ctx, room.width, room.height, protectedRects); + const p = painter(ctx); + if (state.roomDarkness > .0001) p(0, 0, room.width, room.height, color(state.tint, Math.min(.09, state.roomDarkness))); + const win = room.window; + if (validRect(win) && state.windowShade > .001) { + ctx.save(); + try { + // drawRoom's curtains and crossbars stay visible around the glass. + ctx.beginPath(); + ctx.rect(win.x + 3, win.y, 7, 11); ctx.rect(win.x + 12, win.y, 8, 11); + ctx.rect(win.x + 3, win.y + 13, 7, 10); ctx.rect(win.x + 12, win.y + 13, 8, 10); + ctx.clip(); + p(win.x, win.y, win.w, win.h, color(state.sky, state.windowShade)); + p(win.x, win.y + 17, win.w, 6, color([48, 73, 61], state.windowShade * .85)); + p(win.x + 7, win.y + 14, 9, 8, color([55, 77, 65], state.windowShade * .85)); + const moon = state.darkness / .22; + p(win.x + 14, win.y + 4, 4, 4, color([246, 227, 172], state.windowShade * .75)); + p(win.x + 16, win.y + 3, 3, 4, color(state.sky, state.windowShade * moon)); + if (state.fireflies > .2) p(win.x + 6, win.y + 5, 1, 1, color([223, 230, 218], state.fireflies * .6)); + result.windows = 1; + } finally { ctx.restore(); } + // A small moonlit projection follows the existing authored window shaft. + for (let i = 0; i < 4; i++) p(win.x + 4 + i * 3, 54 + i * 7, 21, 6, + color(state.darkness > .12 ? [123, 157, 196] : [253, 203, 138], state.windowShade * .055)); + } + const hearth = room.objects?.find(object => object.id === 'hearth'); + if (validRect(hearth) && state.windowGlow > .001) { + const flicker = .9 + Math.sin(frame * 1.7 + Number(room.seed || 0) % 17) * .1; + glow(p, hearth.x + 3, hearth.y + hearth.h - 10, hearth.w - 6, 8, state.windowGlow * flicker); + p(hearth.x - 5, hearth.y + hearth.h - 2, hearth.w + 10, 6, color([255, 190, 105], state.windowGlow * flicker * .07)); + } + } finally { ctx.restore(); } + return result; +} + +/** Optional 24×23 pixel prop. Top-left coordinates; never adds a collision. */ +export function paintGramophone(ctx, x, y, { time = 0, playing = false, reduce = false, scale = 1 } = {}) { + if (!ctx || !finite(x) || !finite(y) || !finite(scale) || scale <= 0) return; + ctx.save(); + try { + ctx.translate(Math.round(x), Math.round(y)); ctx.scale(scale, scale); ctx.imageSmoothingEnabled = false; + const p = painter(ctx), step = playing && !reduce && finite(time) ? Math.floor(time * 3) % 4 : 0; + p(1, 21, 23, 2, '#263a3066'); p(2, 15, 20, 7, '#493627'); p(3, 16, 18, 5, '#9e7243'); + p(4, 17, 16, 1, '#d2a563'); p(5, 21, 3, 2, '#493627'); p(17, 21, 3, 2, '#493627'); + p(4, 13, 13, 3, '#493627'); p(6, 12, 9, 3, '#29343a'); p(9, 13, 3, 1, '#c19658'); + p(6 + [0, 3, 6, 3][step], 12 + [1, 0, 1, 2][step], 2, 1, '#788078'); + p(17, 7, 2, 8, '#715230'); p(14, 10, 4, 2, '#c49950'); p(16, 13, 3, 1, '#e7c476'); + p(9, 3, 10, 6, '#493627'); p(6, 1, 8, 9, '#493627'); p(5, 2, 2, 7, '#493627'); + p(7, 2, 6, 7, '#d5aa57'); p(13, 4, 5, 4, '#ba8a40'); p(18, 6, 2, 3, '#e2bc66'); + p(6, 3, 3, 5, '#edce84'); p(8, 3, 3, 5, '#60462d'); p(9, 4, 2, 3, '#3d3226'); + p(12, 3, 2, 1, '#f3d68b'); p(13, 8, 4, 1, '#785732'); p(22, 18, 2, 1, '#d8b260'); + p(23, 17, 1, 3, '#493627'); + } finally { ctx.restore(); } +} diff --git a/src/audio/app-town.mp3 b/src/audio/app-town.mp3 new file mode 100644 index 0000000..551b238 Binary files /dev/null and b/src/audio/app-town.mp3 differ diff --git a/src/audio/day.mp3 b/src/audio/day.mp3 new file mode 100644 index 0000000..e224004 Binary files /dev/null and b/src/audio/day.mp3 differ diff --git a/src/audio/dusk.mp3 b/src/audio/dusk.mp3 new file mode 100644 index 0000000..782f086 Binary files /dev/null and b/src/audio/dusk.mp3 differ diff --git a/src/audio/mem-town.mp3 b/src/audio/mem-town.mp3 new file mode 100644 index 0000000..219601e Binary files /dev/null and b/src/audio/mem-town.mp3 differ diff --git a/src/audio/morning.mp3 b/src/audio/morning.mp3 new file mode 100644 index 0000000..3c5ef56 Binary files /dev/null and b/src/audio/morning.mp3 differ diff --git a/src/audio/night.mp3 b/src/audio/night.mp3 new file mode 100644 index 0000000..584df18 Binary files /dev/null and b/src/audio/night.mp3 differ diff --git a/src/audio/vault-town.mp3 b/src/audio/vault-town.mp3 new file mode 100644 index 0000000..2f17886 Binary files /dev/null and b/src/audio/vault-town.mp3 differ diff --git a/src/feed.mjs b/src/feed.mjs index 0c78b6d..ed90262 100644 --- a/src/feed.mjs +++ b/src/feed.mjs @@ -1,6 +1,7 @@ #!/usr/bin/env node /** Zero-dependency CottageCode feed: local transcripts, optional Hub DB, read-only PRs. */ import { createServer } from "node:http"; +import { createHash } from "node:crypto"; import { readdir, stat, readFile, realpath } from "node:fs/promises"; import { execFile } from "node:child_process"; import { promisify } from "node:util"; @@ -495,12 +496,43 @@ export function createFeedServer(feed, { directory = HERE, messages = createHubM let contentType = ""; if (["/", "/index.html"].includes(url.pathname)) { filename = "town.html"; contentType = "text/html; charset=utf-8"; } else if (/^\/modules\/[a-z][a-z0-9-]*\.mjs$/.test(url.pathname)) { filename = url.pathname.slice("/modules/".length); contentType = "text/javascript; charset=utf-8"; } + else if (/^\/modules\/audio\/[a-z][a-z0-9-]*\.mp3$/.test(url.pathname)) { filename = url.pathname.slice("/modules/".length); contentType = "audio/mpeg"; } if (filename) { const root = await realpath(directory); const path = await realpath(join(root, filename)); if (!path.startsWith(root + sep)) return json(404, { error: "Not found" }); const body = await readFile(path); - res.writeHead(200, { ...headers, "content-type": contentType }); + const assetHeaders = { ...headers, "content-type": contentType, "content-length": body.length }; + if (contentType === "audio/mpeg") { + // Tracks can change under a stable path, so retain their cached body + // but revalidate it before media loops select it again. + const etag = `"${createHash("sha256").update(body).digest("hex")}"`; + assetHeaders["cache-control"] = "public, max-age=0, must-revalidate"; + assetHeaders["accept-ranges"] = "bytes"; + assetHeaders.etag = etag; + if (req.headers["if-none-match"]?.split(",").some(value => [etag, "*"].includes(value.trim()))) { + const notModified = { ...assetHeaders }; + delete notModified["content-length"]; + res.writeHead(304, notModified); + return res.end(); + } + // A single byte range supports native media seeking and loop overlap. + // HEAD describes the full resource and ignores Range per HTTP semantics. + // A stale If-Range validator tells a media client to discard its + // partial cache and fetch the complete replacement instead. + const rangeAccepted = !req.headers["if-range"] || req.headers["if-range"].trim() === etag; + if (req.method === "GET" && req.headers.range && rangeAccepted) { + const range = /^bytes=(\d*)-(\d*)$/.exec(req.headers.range); + const start = range?.[1] ? Number(range[1]) : Math.max(0, body.length - Number(range?.[2])); + const end = range?.[1] && range[2] ? Math.min(body.length - 1, Number(range[2])) : body.length - 1; + if (!range || (!range[1] && !range[2]) || !Number.isSafeInteger(start) || !Number.isSafeInteger(end) || start >= body.length || start > end) { + res.writeHead(416, { ...assetHeaders, "content-length": 0, "content-range": `bytes */${body.length}` }); return res.end(); + } + res.writeHead(206, { ...assetHeaders, "content-length": end - start + 1, "content-range": `bytes ${start}-${end}/${body.length}` }); + return res.end(body.subarray(start, end + 1)); + } + } + res.writeHead(200, assetHeaders); return res.end(req.method === "HEAD" ? undefined : body); } return json(404, { error: "Not found" }); diff --git a/src/music-output.mjs b/src/music-output.mjs new file mode 100644 index 0000000..6df78f7 --- /dev/null +++ b/src/music-output.mjs @@ -0,0 +1,125 @@ +/** Lazy, quiet output for the two reusable media elements used by createMusic. */ +export function createMusicOutput({ + Audio = globalThis.Audio, + AudioContext = globalThis.AudioContext || globalThis.webkitAudioContext, +} = {}) { + const entries = []; + let context = null, disposed = false, closing = null; + const attempt = fn => { try { fn(); } catch {} }; + const cancelled = () => Object.assign(new Error('Music playback was cancelled.'), { name: 'AbortError' }); + + function quiet(entry) { + entry.version++; entry.wanted = false; entry.ready = false; + try { + if (entry.gain) entry.gain.gain.value = 0; + else entry.audio.volume = 0; + } catch { attempt(() => { entry.audio.muted = true; }); } + attempt(() => entry.audio.pause()); + } + function release(entry) { + quiet(entry); + attempt(() => { entry.audio.muted = true; }); + for (const event of ['onended', 'onerror', 'onwaiting', 'onplaying']) attempt(() => { entry.audio[event] = null; }); + attempt(() => entry.audio.removeAttribute('src')); + attempt(() => entry.audio.load()); + attempt(() => entry.source?.disconnect()); + attempt(() => entry.gain?.disconnect()); + } + function gain(entry, value) { + try { entry.gain.gain.value = value; } + catch (error) { quiet(entry); throw error; } + } + function routed(entry) { + const audio = entry.audio; + const adapter = { + play() { + if (disposed) return Promise.reject(cancelled()); + const version = ++entry.version; + entry.wanted = true; entry.ready = false; + let resumed, played; + const failed = error => { + if (entry.version === version || !entry.wanted || disposed) quiet(entry); + throw error; + }; + try { + gain(entry, 0); audio.muted = false; + // Both calls happen in the enabling gesture, without an await between + // them. The gain stays zero until both operations have succeeded. + resumed = Promise.resolve(context.resume()); + played = Promise.resolve(audio.play()); + } catch (error) { + resumed?.catch(() => {}); + try { failed(error); } catch (failure) { return Promise.reject(failure); } + } + // Some engines settle an old play after pause. Keep it silent without + // allowing an obsolete completion to interrupt a newer play request. + void played.then(() => { + if (!entry.wanted || disposed) quiet(entry); + }, () => {}); + return Promise.all([resumed, played]).then(() => { + if (disposed || entry.version !== version || !entry.wanted) throw cancelled(); + if (context.state !== 'running') throw new Error('The music output did not start.'); + entry.ready = true; gain(entry, entry.volume); + }).catch(failed); + }, + pause() { quiet(entry); }, + load() { quiet(entry); audio.load(); }, + removeAttribute(name) { if (name === 'src') quiet(entry); audio.removeAttribute(name); }, + get volume() { return entry.volume; }, + set volume(value) { + if (!Number.isFinite(value) || value < 0 || value > 1) throw new RangeError('Music volume must be between 0 and 1.'); + entry.volume = value; + if (!disposed) gain(entry, entry.ready ? value : 0); + }, + get src() { return audio.src; }, + set src(value) { quiet(entry); audio.src = value; }, + }; + for (const name of ['paused', 'duration']) Object.defineProperty(adapter, name, { get: () => audio[name] }); + for (const name of ['preload', 'loop', 'currentTime', 'onended', 'onerror', 'onwaiting', 'onplaying']) { + Object.defineProperty(adapter, name, { get: () => audio[name], set: value => { audio[name] = value; } }); + } + return adapter; + } + + return { + create() { + if (disposed) throw cancelled(); + if (entries.length >= 2) throw new Error('Music already has two output elements.'); + if (typeof Audio !== 'function') throw new Error('Audio playback is unavailable.'); + const audio = new Audio(), entry = { audio, volume: 0, version: 0, wanted: false, ready: false }; + let nativeVolume = false; + try { audio.volume = 0; nativeVolume = audio.volume === 0; } catch {} + if (nativeVolume) { entries.push(entry); return audio; } + try { + audio.muted = true; + if (typeof AudioContext !== 'function') throw new Error('Quiet music output is unavailable.'); + context ||= new AudioContext(); + entry.source = context.createMediaElementSource(audio); + entry.gain = context.createGain(); + entry.gain.gain.value = 0; + entry.source.connect(entry.gain); entry.gain.connect(context.destination); + audio.muted = false; + const adapter = routed(entry); + entries.push(entry); + return adapter; + } catch (error) { + release(entry); // A failed attempt does not consume a reusable slot. + throw error; + } + }, + suspend() { + for (const entry of entries) quiet(entry); + if (!context || disposed || context.state === 'closed') return Promise.resolve(true); + try { return Promise.resolve(context.suspend()).then(() => true, () => false); } + catch { return Promise.resolve(false); } + }, + dispose() { + if (closing) return closing; + disposed = true; + for (const entry of entries) release(entry); + try { closing = context ? Promise.resolve(context.close()).then(() => true, () => false) : Promise.resolve(true); } + catch { closing = Promise.resolve(false); } + return closing; + }, + }; +} diff --git a/src/music-tracks.mjs b/src/music-tracks.mjs new file mode 100644 index 0000000..258ff1a --- /dev/null +++ b/src/music-tracks.mjs @@ -0,0 +1,10 @@ +// Static assets only. Generation is a separate, manual authoring step. +export const MUSIC_TRACKS = Object.freeze([ + {id:'morning',title:'Mosslight Morning',phase:'morning',url:new URL('./audio/morning.mp3',import.meta.url).href,duration:65}, + {id:'day',title:'Little Roads',phase:'day',url:new URL('./audio/day.mp3',import.meta.url).href,duration:65}, + {id:'dusk',title:'Lamplight',phase:'dusk',url:new URL('./audio/dusk.mp3',import.meta.url).href,duration:65}, + {id:'night',title:'After the Fireflies',phase:'night',url:new URL('./audio/night.mp3',import.meta.url).href,duration:65}, + {id:'app-town',title:'Pocket Signals',phase:['morning','day'],town:'AppTown',url:new URL('./audio/app-town.mp3',import.meta.url).href,duration:65}, + {id:'mem-town',title:'Between the Pages',phase:['morning','day'],town:'MemTown',url:new URL('./audio/mem-town.mp3',import.meta.url).href,duration:65}, + {id:'vault-town',title:'The Clockmaker',phase:['morning','day'],town:'VaultTown',url:new URL('./audio/vault-town.mp3',import.meta.url).href,duration:65}, +].map(Object.freeze)); diff --git a/src/music.mjs b/src/music.mjs new file mode 100644 index 0000000..528c3fa --- /dev/null +++ b/src/music.mjs @@ -0,0 +1,249 @@ +/** + * Opt-in ambient beds played through at most two reusable HTMLAudio elements. + * Catalog phase may be a string or an array; exact town/phase wins, then a + * generic phase bed, then generic `any`. No preferences are persisted. + */ +export function createMusic({ + tracks = [], audioFactory = () => new globalThis.Audio(), + now = () => globalThis.performance?.now() ?? Date.now(), + setTimeoutFn = globalThis.setTimeout, clearTimeoutFn = globalThis.clearTimeout, + visibilitySource = globalThis.document, fadeMs = 1800, switchDelayMs = 4000, + loadTimeoutMs = 15000, +} = {}) { + const clamp = (value, fallback) => Number.isFinite(Number(value)) ? Math.max(0, Math.min(1, Number(value))) : fallback; + const catalog = []; + for (const raw of Array.isArray(tracks) ? tracks : []) { + if (!raw || typeof raw.id !== 'string' || !raw.id || typeof raw.url !== 'string' || !raw.url.trim() || catalog.some(track => track.id === raw.id)) continue; + const phases = (Array.isArray(raw.phase) ? raw.phase : [raw.phase || 'any']).filter(phase => typeof phase === 'string').map(phase => phase.trim().toLowerCase()); + catalog.push({ id: raw.id, title: typeof raw.title === 'string' ? raw.title : raw.id, url: raw.url, + phases, town: typeof raw.town === 'string' ? raw.town : '', duration: Number(raw.duration) || null }); + if (catalog.length === 64) break; + } + fadeMs = Math.max(100, Number(fadeMs) || 1800); + switchDelayMs = Math.max(0, Number(switchDelayMs) || 0); + loadTimeoutMs = Math.max(1000, Number(loadTimeoutMs) || 15000); + const slots = []; + let enabled = false, disposed = false, hidden = Boolean(visibilitySource?.hidden), volume = .16; + let scene = { phase: 'day', town: '', indoors: false, talking: false }; + let current = null, incoming = null, timer = null, change = null, error = ''; + let master = .16, masterRamp = null; + const targetVolume = () => volume * (scene.indoors ? .65 : 1) * (scene.talking ? .25 : 1); + const sameTrack = (a, b) => (a?.id || null) === (b?.id || null); + function select() { + const phase = track => track.phases.includes(scene.phase); + return catalog.find(track => track.town === scene.town && track.town && phase(track)) || + catalog.find(track => !track.town && phase(track)) || + catalog.find(track => !track.town && track.phases.includes('any')) || null; + } + let desired = select(); + + function interpolate(ramp, time) { + const progress = Math.max(0, Math.min(1, (time - ramp.start) / ramp.duration)); + const smooth = progress * progress * (3 - 2 * progress); + return { value: ramp.from + (ramp.to - ramp.from) * smooth, done: progress === 1 }; + } + function settle(slot, value) { const resolve = slot.resolve; slot.resolve = null; resolve?.(value); } + function release(slot) { + if (!slot) return; + slot.version++; + slot.audio.onended = slot.audio.onerror = slot.audio.onwaiting = slot.audio.onplaying = null; + try { slot.audio.volume = 0; slot.audio.pause(); } catch { /* A failed device is already silent. */ } + try { slot.audio.removeAttribute('src'); slot.audio.load(); } catch { /* Release when supported. */ } + if (current === slot) current = null; + if (incoming === slot) incoming = null; + slot.track = null; slot.playing = false; slot.loading = false; slot.buffering = false; + slot.weight = 0; slot.ramp = null; + settle(slot, false); + } + function stopImmediately() { + if (timer !== null) clearTimeoutFn(timer); + timer = null; change = null; + for (const slot of slots) if (slot.track) release(slot); + } + function fail(message) { + error = message; enabled = false; + stopImmediately(); + } + function rampSlot(slot, to, duration = fadeMs, retire = false) { + if (slot.ramp) slot.weight = interpolate(slot.ramp, now()).value; + slot.ramp = { from: slot.weight, to, start: now(), duration, retire }; + } + function applyVolume(slot) { + try { slot.audio.volume = clamp(master * slot.weight, 0); } + catch { fail('Music volume could not be changed on this device.'); } + } + function updateMaster(duration = 650) { + if (masterRamp) master = interpolate(masterRamp, now()).value; + masterRamp = { from: master, to: targetVolume(), start: now(), duration }; + schedule(); + } + function obtainSlot() { + let slot = slots.find(candidate => !candidate.track); + if (!slot && slots.length < 2) { + const audio = audioFactory(); + if (!audio || typeof audio.play !== 'function' || typeof audio.pause !== 'function') throw new Error('unsupported_audio'); + slot = { audio, version: 0, track: null, weight: 0, playing: false, loading: false, buffering: false, ramp: null, resolve: null }; + slots.push(slot); + } + if (!slot) { + slot = slots.find(candidate => candidate !== current) || slots[0]; + release(slot); + } + return slot; + } + function start(track, { loop = false, duration = fadeMs } = {}) { + if (!enabled || hidden || disposed || !track) return Promise.resolve(false); + if (!loop && incoming && sameTrack(incoming.track, track)) return incoming.promise; + if (!loop && current?.playing && sameTrack(current.track, track)) { + if (incoming) release(incoming); + rampSlot(current, 1); + for (const slot of slots) if (slot !== current && slot.track) rampSlot(slot, 0, duration, true); + schedule(); + return Promise.resolve(true); + } + if (incoming) release(incoming); + let slot; + try { slot = obtainSlot(); } + catch { fail('Music playback is not supported on this device.'); return Promise.resolve(false); } + const version = ++slot.version; + slot.track = track; slot.weight = 0; slot.ramp = null; slot.loading = true; + slot.deadline = now() + loadTimeoutMs; + slot.promise = new Promise(resolve => { slot.resolve = resolve; }); + incoming = slot; + const active = () => slot.version === version && slot.track === track && enabled && !hidden && !disposed; + slot.audio.onended = () => { + if (!active()) return; + const wasCurrent = current === slot; + release(slot); + if (wasCurrent && !incoming) void start(track, { loop: true }); + }; + slot.audio.onerror = () => { if (active()) fail('Music could not load. Enable it again to retry.'); }; + slot.audio.onwaiting = () => { if (active()) { slot.buffering = true; slot.deadline = now() + loadTimeoutMs; } }; + slot.audio.onplaying = () => { if (active()) slot.buffering = false; }; + try { + slot.audio.preload = 'auto'; slot.audio.loop = false; slot.audio.volume = 0; + slot.audio.src = track.url; + // Keep this call synchronous with enable(true), preserving the user's + // activation gesture. No fetch, decoding, or media exists before opt-in. + const played = slot.audio.play(); + Promise.resolve(played).then(() => { + if (!active()) { + // A reused element may already be playing a newer source. Only stop + // abandoned playback; an obsolete promise must not pause its successor. + if (!slot.track || !enabled || hidden || disposed) { try { slot.audio.pause(); } catch {} } + return; + } + const previous = current; + slot.loading = false; slot.playing = true; slot.buffering = false; + current = slot; incoming = null; + rampSlot(slot, 1, duration); + if (previous && previous !== slot) rampSlot(previous, 0, duration, true); + settle(slot, true); schedule(); + }, reason => { + if (active()) fail(reason?.name === 'NotAllowedError' + ? 'Playback was blocked. Enable music again to allow it.' + : 'Music could not play. Enable it again to retry.'); + }); + } catch { fail('Music could not play on this device.'); } + schedule(); + return slot.promise; + } + function tick() { + const time = now(); + if (disposed || hidden) return; + if (masterRamp) { + const next = interpolate(masterRamp, time); master = next.value; + if (next.done) masterRamp = null; + } + for (const slot of slots) { + if (!slot.track) continue; + if ((slot.loading || slot.buffering) && time >= slot.deadline) { fail('Music took too long to load. Enable it again to retry.'); return; } + if (slot.ramp) { + const next = interpolate(slot.ramp, time); slot.weight = next.value; + if (next.done) { + const retire = slot.ramp.retire; slot.ramp = null; + if (retire) { release(slot); continue; } + } + } + applyVolume(slot); + } + if (!enabled) return; + if (change && time >= change.at) { + const track = change.track; change = null; + if (track) void start(track); + else for (const slot of slots) if (slot.track) rampSlot(slot, 0, fadeMs, true); + } + if (current?.playing && !incoming && !current.ramp?.retire) { + const duration = Number.isFinite(current.audio.duration) && current.audio.duration > 0 ? current.audio.duration : current.track.duration; + const overlap = duration ? Math.min(fadeMs / 1000, duration / 4) : 0; + if (overlap > 0 && current.audio.currentTime >= duration - overlap) + void start(current.track, { loop: true, duration: overlap * 1000 }); + } + } + function schedule() { + if (timer !== null || disposed || hidden || (!enabled && !slots.some(slot => slot.track))) return; + timer = setTimeoutFn(() => { timer = null; tick(); schedule(); }, 50); + } + function enable(value) { + if (disposed) return Promise.resolve(false); + if (!value && !enabled) return Promise.resolve(false); + enabled = Boolean(value); change = null; + if (!enabled) { + if (incoming) release(incoming); + for (const slot of slots) if (slot.track) rampSlot(slot, 0, Math.min(fadeMs, 450), true); + schedule(); + return Promise.resolve(false); + } + error = ''; desired = select(); + if (hidden) return Promise.resolve(true); + if (!desired) { fail('No music is available for this scene.'); return Promise.resolve(false); } + if (!slots.some(slot => slot.track)) { master = targetVolume(); masterRamp = null; } + return start(desired); + } + function setScene(value = {}) { + if (disposed) return; + if (typeof value.phase === 'string') scene.phase = value.phase.trim().toLowerCase(); + if (typeof value.town === 'string') scene.town = value.town; + const before = targetVolume(); + if (typeof value.indoors === 'boolean') scene.indoors = value.indoors; + if (typeof value.talking === 'boolean') scene.talking = value.talking; + if (targetVolume() !== before) updateMaster(scene.talking ? 350 : 800); + const next = select(); + if (!sameTrack(next, desired)) { + desired = next; + if (enabled && !hidden) { + if (incoming && !sameTrack(incoming.track, next)) release(incoming); + change = sameTrack(current?.track, next) ? null : { track: next, at: now() + switchDelayMs }; + schedule(); + } + } + } + function setHidden(value) { + if (disposed || hidden === Boolean(value)) return; + hidden = Boolean(value); + if (hidden) stopImmediately(); + else if (enabled) { master = targetVolume(); masterRamp = null; desired = select(); if (desired) void start(desired); } + } + const visibilityChanged = () => setHidden(Boolean(visibilitySource?.hidden)); + visibilitySource?.addEventListener?.('visibilitychange', visibilityChanged); + return { + enable, setScene, setHidden, + get volume() { return volume; }, + set volume(value) { if (!disposed) { volume = clamp(value, volume); updateMaster(); } }, + get enabled() { return enabled; }, + get state() { + const playing = !hidden && slots.some(slot => slot.playing && !slot.audio.paused); + const audible = playing && slots.some(slot => slot.playing && !slot.audio.paused && slot.audio.volume > .0001); + const status = disposed ? 'disposed' : error ? 'error' : hidden && enabled ? 'paused' : !enabled ? playing ? 'stopping' : 'off' + : incoming ? current?.playing ? 'switching' : 'starting' : current?.buffering ? 'buffering' : playing ? 'playing' : 'unavailable'; + return { enabled, playing, audible, hidden, status, volume, effectiveVolume: targetVolume(), + track: current?.track ? { id: current.track.id, title: current.track.title } : null, + pendingTrackId: incoming?.track?.id || change?.track?.id || null, error }; + }, + dispose() { + if (disposed) return; + disposed = true; enabled = false; stopImmediately(); + visibilitySource?.removeEventListener?.('visibilitychange', visibilityChanged); + }, + }; +} diff --git a/src/observatory.mjs b/src/observatory.mjs index e98db84..fe88149 100644 --- a/src/observatory.mjs +++ b/src/observatory.mjs @@ -9,6 +9,7 @@ import {normalizeTodos} from './todos.mjs'; import {conversationCapability,MAX_MESSAGE_LENGTH} from './conversation.mjs'; import {normalizeInputRequest,inputRequestVersion} from './input-request.mjs'; import {PUBLIC_DEMO} from './runtime.mjs'; +import {createVillageExtras} from './village-extras.mjs'; export const STAGES={ none:{label:'No PR',color:'#a3a99d',symbol:'—'}, @@ -74,7 +75,7 @@ export function createObservatory(api){ let mode='town',selected=null,interiorId=null,room=null,roomPlayer=null,player=null,returnTo=null,tab='overview',prFilter=null; let followId=null,lastFrame=0,transition=1,latestAgents=[],relationships=[],handoffs=[],couriers=[],apprentices=[],knownKids=new Set(); let sourceKey='',history=null,historyInitialized=false,stageSignature='',rosterSignature='',panelKey='',lastHint='',replayIndex=-1,replaying=false,replayTimer=0,replayEvents=[]; - let lastHealthCheck=0,selectedObject=null,board=null,feedStale=false,historyView='since'; + let lastHealthCheck=0,selectedObject=null,board=null,gramophone=null,feedStale=false,historyView='since'; let talkingId=null,talkingUntil=0; let reduce=matchMedia('(prefers-reduced-motion: reduce)').matches; matchMedia('(prefers-reduced-motion: reduce)').addEventListener('change',e=>{reduce=e.matches;if(reduce)transition=1;}); @@ -82,6 +83,11 @@ export function createObservatory(api){ const byId=id=>latestAgents.find(a=>a.id===id); const plotFor=id=>{for(const p of api.getPlots()){if(p.agent.id===id)return p;const k=p.kids?.find(k=>k.agent.id===id);if(k)return {...k,agent:k.agent,parentPlot:p};}return null;}; const roomFor=a=>{const key=a.id+'|'+(a.taskId||'');if(!rooms.has(key))rooms.set(key,createInterior(a));return rooms.get(key);}; + const extras=createVillageExtras({clearKeys:()=>keys.clear(),getPostcard(id){ + const agent=byId(id);if(!agent)return null; + const milestones=(history?.events()||[]).filter(e=>e.agentId===id&&(e.taskId||'')===(agent.taskId||'')&&['result','pr','status'].includes(e.kind)).slice(-30).reverse(); + return {agent,room:roomFor(agent),milestones}; + }}); const conversationFor=a=>{ const taskKey=sourceKey+'|'+a.id+'|'+(a.taskId||''),request=normalizeInputRequest(a.inputRequest); const key=taskKey+'|'+(inputRequestVersion(request)||'conversation'); @@ -149,9 +155,10 @@ export function createObservatory(api){ } } if(board)targets.push({kind:'board',...board}); + if(gramophone)targets.push({kind:'gramophone',...gramophone}); const near=targets.sort((a,b)=>distance(player,a)-distance(player,b))[0]; if(near&&distance(player,near)<(near.kind==='bench'?20:31)){ - if(near.kind==='board')showHistory('board');else if(near.kind==='bench')follow(near.id); + if(near.kind==='board')showHistory('board');else if(near.kind==='gramophone')void extras.toggleMusic();else if(near.kind==='bench')follow(near.id); }else hint('Walk into a doorway to enter. Press E near an agent to talk.'); } function talk(id){ @@ -232,6 +239,7 @@ export function createObservatory(api){ if(inside(p,{x:plot.x+26,y:plot.y+91,w:26,h:18})){follow(plot.agent.id);return true;} } if(board&&distance(p,board)<20){showHistory('board');return true;} + if(gramophone&&inside(p,{x:gramophone.x-13,y:gramophone.y-15,w:27,h:25})){void extras.toggleMusic();return true;} return false; } function replacePanel(html,key){ @@ -382,7 +390,7 @@ export function createObservatory(api){ } const nav=[['overview','Clock'],['request','Request'],['journal','Journal'],['todos','To-do'],['review','PR desk'],['artifacts','Shelves']].map(([key,label])=>button('tab:'+key,label,'aria-pressed="'+(tab===key)+'"')).join(''); const header='
'+esc(a.town)+' · '+(mode==='room'?'INSIDE':'COTTAGE')+'

'+esc(a.name)+'

'+esc(a.status)+''+s.symbol+' '+s.label+'
'; - const actions='
'+button(mode==='room'?'leave':'enter',mode==='room'?'Leave cottage ↗':'Enter cottage ↗')+button('talk','Talk to '+esc(a.name),'aria-pressed="'+(tab==='talk')+'"')+button('follow',followId===a.id?'Leave bench':'Follow from bench')+handoffAction(a.handoffUrl)+'
'; + const actions='
'+button(mode==='room'?'leave':'enter',mode==='room'?'Leave cottage ↗':'Enter cottage ↗')+button('talk','Talk to '+esc(a.name),'aria-pressed="'+(tab==='talk')+'"')+button('follow',followId===a.id?'Leave bench':'Follow from bench')+handoffAction(a.handoffUrl)+button('postcard','Make a postcard')+'
'; const worktree=a.worktreePath?'
'+button('copy','Copy worktree path')+(a.worktreePath.startsWith('/')?'Open worktree':'')+'
':''; replacePanel(header+actions+''+content+worktree,(mode==='room'?interiorId:id)+'|'+(a.taskId||'')+':'+tab); return true; @@ -417,6 +425,7 @@ export function createObservatory(api){ else if(action==='leave')leave(); else if(action==='follow')follow(interiorId||selected); else if(action==='talk')talk(interiorId||selected); + else if(action==='postcard')extras.openPostcard(interiorId||selected); else if(action.startsWith('input-choice:')){ const a=byId(interiorId||selected),request=normalizeInputRequest(a?.inputRequest);if(!request)return; const conversation=conversationFor(a);if(conversation.phase==='sending')return; @@ -498,6 +507,7 @@ export function createObservatory(api){ const w=api.getWorld();relationships=normalizeRelationships(meta.relationships||[],[...new Set(agents.map(a=>a.town))]); handoffs=normalizedHandoffs(meta.handoffs||[]);handoffs.forEach(appendHandoff); board={x:w.roadX+26,y:(w.roadYs?.[0]||w.height-60)+16}; + gramophone={x:board.x+40,y:board.y}; if(mode==='room'&&byId(interiorId)){const newRoom=roomFor(byId(interiorId));if(newRoom.seed!==room.seed){room=newRoom;roomPlayer={...room.door};}} updatePrTally();updateRoster();ensureActivity(byId(interiorId||selected));renderPanel(selected); } @@ -533,18 +543,21 @@ export function createObservatory(api){ ctx.fillStyle='#e4bd7c';ctx.fillRect(board.x-12,board.y-15,24,17);ctx.fillStyle='#fff1d0';ctx.fillRect(board.x-9,board.y-12,8,10);ctx.fillRect(board.x+2,board.y-12,8,10); if(history?.sinceVisit().length){ctx.fillStyle='#d55345';ctx.fillRect(board.x+11,board.y-19,5,5);} } + extras.drawGramophone(ctx,gramophone,time,reduce); if(player)api.drawJack(Math.round(player.x)-5,Math.round(player.y)-14,moving&&!reduce?Math.floor(time*8)%2:0); const replay=mode==='scrapbook'&&replayEvents[replayIndex]; if(replay){ const p=plotFor(replay.agentId);if(p){ctx.strokeStyle='#ffe296';ctx.lineWidth=3;ctx.strokeRect(p.x-5,p.y-12,64,91);ctx.font='8px "Silkscreen",monospace';ctx.fillStyle='#ffe296';ctx.fillText('RECORDED',p.x-5,p.y-17);} } if(followId)hint('On the bench with '+(byId(followId)?.name||'your agent')+' · Move or press Escape to leave'); + else if(mode==='town'&&gramophone&&distance(player,gramophone)<24)hint('E · '+(extras.state.music.enabled?'Stop':'Play')+' the village gramophone'); else if(mode==='town')hint('Arrow keys / WASD to walk · Walk into doors · E to talk or use a bench · Click to inspect'); } function draw(time){ const dt=Math.min(.05,lastFrame?time-lastFrame:1/60);lastFrame=time; if(time-lastHealthCheck>1){lastHealthCheck=time;updatePrTally();updateRoster();if(mode==='room'||mode==='town')renderPanel(selected);} const moving=move(dt); + extras.update({player,world:api.getWorld(),indoors:mode==='room',interiorTown:mode==='room'?byId(interiorId)?.town:null,talking:tab==='talk'&&(mode==='room'||mode==='town')},time); if(mode==='room'&&room){ transition=Math.min(1,transition+dt*2.8); const r=roomCanvas.getBoundingClientRect(),ratio=window.devicePixelRatio||1; @@ -555,6 +568,7 @@ export function createObservatory(api){ roomCtx.save();roomCtx.translate(width/2,height/2);roomCtx.scale(scale*(.83+.17*eased),scale*(.83+.17*eased));roomCtx.translate(-room.width/2,-room.height/2); roomCtx.globalAlpha=eased; renderInterior(roomCtx,room,{time:time*1000,agent:byId(interiorId)||{},player:{...roomPlayer,walking:moving},selectedObject,reduce,talking:talkingId===interiorId&&performance.now()o.interactable&&o.id!=='exit').sort((a,b)=>nearRect(roomPlayer,a)-nearRect(roomPlayer,b))[0]; @@ -578,9 +592,10 @@ export function createObservatory(api){ else if(stage==='none'){ctx.fillStyle='#253729';ctx.font='6px "Silkscreen",monospace';ctx.fillText('NO PR',x-4,y+26);} } return {update,draw,renderPanel,handleClick,enter,leave,follow,focusCottage,drawDispatch,latestLine,visible,talk, + drawAtmosphere:(ctx,world,time)=>extras.drawTown(ctx,world,time,reduce), resident:a=>roomFor(a).resident,sound, isTalking:id=>talkingId===id&&performance.now() segmenter ? Array.from(segmenter.segment(value), part => part.segment) : Array.from(value); +const object = value => value && typeof value === 'object' && !Array.isArray(value) ? value : {}; + +function text(value, limit = 240) { + if (typeof value !== 'string') return ''; + let clean = value.slice(0, 8192); + clean = typeof clean.toWellFormed === 'function' ? clean.toWellFormed() : Array.from(clean, char => /^[\uD800-\uDFFF]$/.test(char) ? '\uFFFD' : char).join(''); + clean = clean.replace(/[\u0000-\u001f\u007f-\u009f]/g, ' ').replace(/[\u202a-\u202e\u2066-\u2069]/g, '').replace(/\s+/gu, ' ').trim().normalize('NFC'); + const parts = graphemes(clean); + return parts.length > limit ? parts.slice(0, limit - 1).join('') + '…' : clean; +} + +function timestamp(value) { + if (value === null || value === undefined || value === '') return null; + const number = value instanceof Date ? value.getTime() : typeof value === 'number' ? value : + typeof value === 'string' && /^\d{4}-\d{2}-\d{2}(?:T| |$)/.test(value) ? Date.parse(value) : NaN; + return Number.isFinite(number) && number >= 0 && number <= 253402300799999 ? number : null; +} + +function calendar(value) { + const ms = timestamp(value); + if (ms === null) return { timestamp: null, iso: 'undated', label: 'Date not supplied' }; + const date = new Date(ms), month = ['JAN', 'FEB', 'MAR', 'APR', 'MAY', 'JUN', 'JUL', 'AUG', 'SEP', 'OCT', 'NOV', 'DEC'][date.getUTCMonth()]; + return { timestamp: ms, iso: date.toISOString().slice(0, 10), label: `${date.getUTCDate()} ${month} ${date.getUTCFullYear()}` }; +} + +function slug(value, fallback, length) { + return text(value, 100).normalize('NFKD').replace(/\p{M}/gu, '').toLowerCase().replace(/[^a-z0-9]+/g, '-').slice(0, length).replace(/^-+|-+$/g, '') || fallback; +} + +/** Portable basename only; source paths, IDs and task text never enter filenames. */ +export function postcardFilename({ agent = {}, now = Date.now() } = {}) { + const source = object(agent); + return `cottagecode-${slug(source.town || source.role, 'town', 30)}-${slug(source.name, 'resident', 40)}-${calendar(now).iso}.png`; +} + +function artifactLink(value) { + if (typeof value !== 'string' || value.length > 2048) return null; + try { + const url = new URL(value); + if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password) return null; + // A printed link is a location, not a transport for signed query strings. + url.search = ''; url.hash = ''; + return { url: url.href, label: text(url.host + url.pathname.replace(/\/$/, ''), 160) }; + } catch { return null; } +} + +/** Plain text, safe for canvas. HTML previews must use textContent or escape it. */ +export function buildPostcardModel({ agent = {}, milestone = null, now = Date.now(), timeState = null } = {}) { + const source = object(agent), date = calendar(now), selected = object(milestone); + const milestoneText = text(selected.text, 240), recorded = calendar(selected.timestamp); + const link = milestoneText ? artifactLink(selected.url) : null; + const phase = typeof timeState === 'string' && PHASES.has(timeState) ? timeState : + Number.isFinite(timeState?.hour) ? timeState.hour : + PHASES.has(timeState?.phase) ? timeState.phase : date.timestamp === null ? 'day' : new Date(date.timestamp); + return { + brand: 'CottageCode', resident: text(source.name, 48) || 'A village resident', + town: text(source.town || source.role, 48) || 'The village', + capturedAt: date.timestamp, dateISO: date.iso, dateLabel: date.label, + filename: postcardFilename({ agent: source, now }), + timeState: { ...villageTime(phase) }, + // Deliberately never infer a milestone from agent.status, result, or events. + milestone: milestoneText ? { text: milestoneText, timestamp: recorded.timestamp, + dateLabel: recorded.label, url: link?.url || '', linkLabel: link?.label || '' } : null, + }; +} + +/** Bounded wrapping keeps emoji clusters intact and never paints outside a column. */ +export function wrapPostcardText(value, measure, width, maxLines = 1) { + if (typeof measure !== 'function' || !Number.isFinite(width) || width <= 0 || !Number.isFinite(maxLines) || maxLines < 1) return []; + const cap = Math.min(12, Math.floor(maxLines)); + let remaining = graphemes(text(value, 1000)); + const lines = [], fits = line => { const size = measure(line); return Number.isFinite(size) && size <= width; }; + const ellipsis = value => { + const parts = graphemes(value.trimEnd()); + while (parts.length && !fits(parts.join('') + '…')) parts.pop(); + return fits(parts.join('') + '…') ? parts.join('') + '…' : ''; + }; + while (remaining.length && lines.length < cap) { + let count = 0; + while (count < remaining.length && fits(remaining.slice(0, count + 1).join(''))) count++; + if (count === remaining.length) { lines.push(remaining.join('').trimEnd()); break; } + if (!count) { const end = ellipsis(''); if (end) lines.push(end); break; } + let end = count; + if (lines.length < cap - 1 && !/\s/u.test(remaining[count])) { + for (let i = count - 1; i > 0; i--) { + if (/\s/u.test(remaining[i])) { end = i; break; } + if (remaining[i] === '/') { end = i + 1; break; } + } + } + const line = remaining.slice(0, end).join('').trimEnd(); + remaining = remaining.slice(end); + while (remaining.length && /\s/u.test(remaining[0])) remaining.shift(); + lines.push(lines.length === cap - 1 && remaining.length ? ellipsis(line) : line); + } + return lines.filter(Boolean); +} + +function rect(ctx, x, y, width, height, color) { + ctx.fillStyle = color; + ctx.fillRect(Math.round(x), Math.round(y), Math.round(width), Math.round(height)); +} + +function words(ctx, value, x, y, width, { size = 7, lines = 1, lineHeight = size + 3, color = INK } = {}) { + ctx.fillStyle = color; + ctx.font = `${size}px ui-monospace, "SFMono-Regular", Menlo, Consolas, monospace`; + ctx.textAlign = 'left'; ctx.textBaseline = 'top'; + const wrapped = wrapPostcardText(value, line => ctx.measureText(line).width, width, lines); + wrapped.forEach((line, index) => ctx.fillText(line, Math.round(x), Math.round(y + index * lineHeight))); +} + +function stamp(ctx, phase) { + const x = 320, y = 24, w = 60, h = 69; + const lettering = phase === 'night' ? '#f8edcf' : INK; + rect(ctx, x + 2, y + 2, w, h, '#cbbd97'); + rect(ctx, x, y, w, h, '#fff8e4'); + for (let dx = 3; dx < w; dx += 7) for (const yy of [y, y + h - 2]) rect(ctx, x + dx, yy, 2, 2, PAPER); + for (let dy = 3; dy < h; dy += 7) for (const xx of [x, x + w - 2]) rect(ctx, xx, y + dy, 2, 2, PAPER); + rect(ctx, x + 5, y + 5, w - 10, h - 10, GREEN); + rect(ctx, x + 7, y + 7, w - 14, h - 14, phase === 'night' ? '#465967' : '#bed3c0'); + words(ctx, 'VILLAGE POST', x + 10, y + 11, w - 20, { size: 4.5, color: lettering }); + rect(ctx, x + 10, y + 43, 40, 7, '#8aadae'); + rect(ctx, x + 14, y + 39, 32, 15, '#9bbcbc'); + rect(ctx, x + 17, y + 46, 26, 1, '#d6e3cf'); + // One postal duck, assembled from pixels rather than an external image. + rect(ctx, x + 16, y + 32, 4, 5, '#fff4d0'); + rect(ctx, x + 19, y + 35, 25, 8, '#fff4d0'); + rect(ctx, x + 23, y + 41, 17, 3, '#e1d4ae'); + rect(ctx, x + 33, y + 25, 10, 14, '#fff4d0'); + rect(ctx, x + 32, y + 28, 12, 7, '#fff4d0'); + rect(ctx, x + 42, y + 30, 7, 3, '#c99551'); + rect(ctx, x + 39, y + 28, 2, 2, '#334739'); + rect(ctx, x + 24, y + 37, 11, 3, '#e1d4ae'); + words(ctx, 'ONE SMALL HELLO', x + 10, y + 56, w - 20, { size: 4, color: lettering }); +} + +function drawCard(ctx, scene, room, model) { + ctx.save(); + try { + ctx.imageSmoothingEnabled = false; + rect(ctx, 0, 0, POSTCARD_WIDTH, POSTCARD_HEIGHT, '#e0d0ac'); + rect(ctx, 4, 4, POSTCARD_WIDTH - 8, POSTCARD_HEIGHT - 8, INK); + rect(ctx, 6, 6, POSTCARD_WIDTH - 12, POSTCARD_HEIGHT - 12, PAPER); + for (let x = 12; x < POSTCARD_WIDTH - 14; x += 14) { + rect(ctx, x, 9, 7, 2, x % 28 < 14 ? GREEN : CORAL); + rect(ctx, x, POSTCARD_HEIGHT - 11, 7, 2, x % 28 < 14 ? CORAL : GREEN); + } + for (let y = 18; y < POSTCARD_HEIGHT - 15; y += 14) { + rect(ctx, 9, y, 2, 6, GREEN); rect(ctx, POSTCARD_WIDTH - 11, y, 2, 6, CORAL); + } + words(ctx, model.brand, 21, 22, 236, { size: 17 }); + words(ctx, 'A LITTLE HOME IN THE VILLAGE', 22, 43, 232, { size: 5.5, color: MUTED }); + rect(ctx, 17, 55, 246, 182, '#d6c6a0'); + rect(ctx, 15, 52, 246, 182, INK); + rect(ctx, 16, 53, 244, 180, '#fff8e1'); + ctx.drawImage(scene, 18, 55); + words(ctx, model.town, 22, 241, 237, { size: 10 }); + words(ctx, model.dateLabel + (model.capturedAt === null ? '' : ' · UTC'), 22, 257, 237, { size: 6, color: MUTED }); + for (let y = 20; y < 260; y += 6) rect(ctx, 269, y, 1, 2, '#c6b893'); + // The portrait is the very same generated resident as the furnished room. + rect(ctx, 278, 28, 32, 62, '#d6c6a0'); + rect(ctx, 276, 26, 32, 62, INK); + rect(ctx, 277, 27, 30, 60, '#e2d9b9'); + rect(ctx, 279, 69, 26, 13, '#c7c79e'); + renderResident(ctx, 292, 75, room.resident, { scale: 2, reduce: true }); + words(ctx, 'RESIDENT', 280, 80, 26, { size: 4 }); + stamp(ctx, model.timeState.phase); + words(ctx, 'FROM', 280, 101, 98, { size: 6, color: MUTED }); + words(ctx, model.resident, 280, 113, 98, { size: 9, lines: 2, lineHeight: 11 }); + rect(ctx, 280, 140, 98, 1, '#d1c19a'); + if (model.milestone) { + words(ctx, 'RECORDED MOMENT', 280, 149, 98, { size: 5.5, color: MUTED }); + words(ctx, model.milestone.text, 280, 161, 98, { size: 7, lines: 5, lineHeight: 9 }); + words(ctx, model.milestone.dateLabel + (model.milestone.timestamp === null ? '' : ' UTC'), 280, 213, 98, { size: 5.5, color: MUTED }); + if (model.milestone.linkLabel) words(ctx, model.milestone.linkLabel, 280, 227, 98, { size: 5.5, lines: 2, lineHeight: 8, color: GREEN }); + } else { + words(ctx, text(room.themeLabel, 80), 280, 150, 98, { size: 8, lines: 3, lineHeight: 11 }); + words(ctx, 'A small hello from this corner of the village.', 280, 192, 98, { size: 7, lines: 4, lineHeight: 10, color: MUTED }); + } + rect(ctx, 280, 252, 3, 3, CORAL); + words(ctx, text(model.timeState.label, 40), 289, 251, 89, { size: 5.5, color: MUTED }); + } finally { ctx.restore(); } +} + +function domCanvas() { + if (!globalThis.document?.createElement) throw new Error('Postcards need a browser canvas.'); + return document.createElement('canvas'); +} + +function pngBlob(canvas, timeoutMs) { + return new Promise((resolve, reject) => { + if (typeof canvas.toBlob !== 'function') { reject(new Error('This browser cannot save a postcard PNG.')); return; } + const timer = setTimeout(() => reject(new Error('The postcard PNG took too long to encode. Try again.')), timeoutMs); + try { + canvas.toBlob(blob => { + clearTimeout(timer); + if (!blob || blob.type !== 'image/png' || !Number.isFinite(blob.size) || blob.size <= 0) reject(new Error('The browser could not create the postcard PNG. Try again.')); + else resolve(blob); + }, 'image/png'); + } catch { + clearTimeout(timer); + reject(new Error('The browser could not export the postcard canvas. Try again.')); + } + }); +} + +/** + * Detached canvases only: no DOM capture, network, upload or automatic download. + * At most 1600×1120 output pixels; the two small drawing surfaces are released. + * Pass exactly one deliberately chosen recorded milestone, or leave it null. + */ +export async function createPostcard(options = {}, { createCanvas = domCanvas, toBlobTimeoutMs = 8000 } = {}) { + const { agent = {}, milestone = null, now = Date.now(), timeState = null } = options; + const model = buildPostcardModel({ agent, milestone, now, timeState }); + const scale = Number.isFinite(options.scale) ? Math.max(1, Math.min(4, Math.round(options.scale))) : 3; + const width = POSTCARD_WIDTH * scale, height = POSTCARD_HEIGHT * scale; + const supplied = options.room; + const room = supplied?.width === INTERIOR_WIDTH && supplied?.height === INTERIOR_HEIGHT ? supplied : createInterior(object(agent)); + const canvases = []; + function surface(w, h) { + const canvas = createCanvas(); + if (!canvas) throw new Error('A postcard canvas could not be created.'); + canvases.push(canvas); canvas.width = w; canvas.height = h; + const ctx = canvas.getContext?.('2d', { alpha: false }); + if (!ctx) throw new Error('A postcard canvas could not be created.'); + ctx.imageSmoothingEnabled = false; + return { canvas, ctx }; + } + try { + const scene = surface(INTERIOR_WIDTH, INTERIOR_HEIGHT); + // Only generated furniture and appearance enter this render. Live requests, + // results, status, PR details and artifacts stay out even if supplied on agent. + renderInterior(scene.ctx, room, { agent: {}, time: 0, reduce: true }); + paintRoomAtmosphere(scene.ctx, room, model.timeState, { time: 0, reduce: true }); + const card = surface(POSTCARD_WIDTH, POSTCARD_HEIGHT); + drawCard(card.ctx, scene.canvas, room, model); + const output = surface(width, height); + rect(output.ctx, 0, 0, width, height, PAPER); + output.ctx.drawImage(card.canvas, 0, 0, width, height); + const timeout = Number.isFinite(toBlobTimeoutMs) ? Math.max(1, Math.min(10000, toBlobTimeoutMs)) : 8000; + const blob = await pngBlob(output.canvas, timeout); + return { blob, filename: model.filename, width, height, model }; + } finally { + for (const canvas of canvases) { canvas.width = 0; canvas.height = 0; } + } +} diff --git a/src/town.html b/src/town.html index 18379f9..864545f 100644 --- a/src/town.html +++ b/src/town.html @@ -113,6 +113,25 @@ #room-canvas:not([hidden]){margin-top:0} .scene-status{font-size:11px;color:#c9c9a9;margin:9px 0 1px;min-height:18px} #sound-settings{font-size:12px;color:#bdd2c4;margin-bottom:8px} + .village-settings{margin:0 0 10px;color:#c9d4c7;font-size:12px} + .village-settings summary{cursor:pointer;display:list-item;padding:3px 0;color:#d8c69b} + .village-settings summary span{color:#a6b7ae;margin-left:8px;font-size:11px} + .village-settings-body{padding:12px;background:#1d292a;border:1px solid #46564c;border-radius:3px;margin-top:7px} + .village-controls{display:flex;align-items:center;flex-wrap:wrap;gap:10px 22px} + .village-controls label{display:flex;gap:9px;align-items:center;flex-wrap:wrap} + .village-controls input[type="range"]{width:125px;accent-color:#d7bd7c} + select{font:12px ui-sans-serif,system-ui;color:#e6e3cf;background:#303e39;border:1px solid #5d7161;border-radius:3px;padding:7px;max-width:100%} + select:focus-visible{outline:2px solid #edcf85;outline-offset:3px} + #music-status{margin:10px 0 5px;color:#e1d4af;overflow-wrap:anywhere} + .postcard-dialog{width:min(750px,calc(100% - 24px));max-height:92vh;overflow:auto;background:#273238;color:var(--ink);border:1px solid #9d9779;border-radius:5px;padding:20px;box-shadow:0 15px 80px #0008} + .postcard-dialog::backdrop{background:#101d20c9} + .postcard-dialog h2{font:16px "Silkscreen",monospace;color:#f0e5c7;margin:0} + .postcard-heading,.postcard-actions{display:flex;gap:15px;justify-content:space-between;align-items:center;flex-wrap:wrap} + .postcard-dialog button,.postcard-dialog a{font:12px "Silkscreen",monospace;padding:9px 13px;border:1px solid #8a9472;border-radius:3px;background:#485743;color:#f8ebc8;cursor:pointer;text-decoration:none} + .postcard-dialog label{display:block;margin:16px 0 7px;font-size:12px;color:#cdd6c9} + #postcard-milestone{width:100%} + #postcard-preview{display:block;width:100%;height:auto;image-rendering:pixelated;margin:15px 0;background:#dbcca6;border:5px solid #d5c398} + #postcard-status{flex:1;min-width:150px;font-size:12px;color:#c8cfbe} .card{padding:17px;background:#273238;border-color:#44524f;min-height:430px} .card h2{font-size:19px;margin-top:6px;color:#f0e5c7} .card h3{font:12px "Silkscreen",monospace;color:#dbd1b8;margin:20px 0 8px} @@ -205,9 +224,21 @@

CottageCode

+ +
+ Gramophone & light +
+
+ + +
+

The gramophone is quiet.

+

Seven quiet pieces made with ElevenLabs. Music follows Jack between towns, softens indoors, and pauses when you leave this tab.

+
+
@@ -242,12 +273,21 @@

CottageCode

Cottage directory · keyboard and direct access
+ +

Your cottage postcard

+ + + + +

Made here in your browser. Save a copy to share whenever you like.

+
+ diff --git a/src/town.mjs b/src/town.mjs index c5ac8ed..d09afb0 100644 --- a/src/town.mjs +++ b/src/town.mjs @@ -1590,6 +1590,7 @@ function draw(){ }); moveFauna(); drawFauna(); + observatory?.drawAtmosphere(ctx,{width:W,height:H,ponds:scenePonds,trees:sceneSolids.filter(r=>r.w===6&&r.h===8),plots:plots.map(p=>({...p,opacity:(filter&&filter!==townKey(p.agent))||!observatory.visible(p.agent)?0.3:1}))},t); plots.forEach(p=>{ if(filter && filter !== townKey(p.agent)) return; diff --git a/src/village-extras.mjs b/src/village-extras.mjs new file mode 100644 index 0000000..7762410 --- /dev/null +++ b/src/village-extras.mjs @@ -0,0 +1,114 @@ +import {createMusic} from './music.mjs'; +import {createMusicOutput} from './music-output.mjs'; +import {MUSIC_TRACKS} from './music-tracks.mjs'; +import {villageTime,locateVillageTown,paintTownAtmosphere,paintRoomAtmosphere,paintGramophone} from './atmosphere.mjs'; +import {createPostcard} from './postcard.mjs'; + +/** Small domestic extras live outside the frequently refreshed task inspector. */ +export function createVillageExtras({getPostcard,clearKeys}) { + const $=id=>document.getElementById(id),output=createMusicOutput(); + const music=createMusic({tracks:MUSIC_TRACKS,audioFactory:output.create,visibilitySource:null}); + let timeMode='auto',light=villageTime(),scene={town:'HubTown',indoors:false,talking:false}; + let lastClock=-Infinity,lastUI='',postcard=null,postcardURL=null,postcardVersion=0; + const dialog=$('postcard-dialog'),preview=$('postcard-preview'),milestones=$('postcard-milestone'),save=$('postcard-save'); + const text=(id,value)=>{const node=$(id);if(node.textContent!==value)node.textContent=value;}; + function renderMusic() { + const state=music.state,signature=JSON.stringify([state,timeMode,light.phase,scene]); + if(signature===lastUI)return;lastUI=signature; + const toggle=$('music-toggle'); + toggle.setAttribute('aria-pressed',String(state.enabled)); + toggle.textContent=state.enabled?'♫ Music on':'♫ Music off'; + toggle.setAttribute('aria-label',state.enabled?'Stop village music':'Play village music'); + const place=light.label+' in '+scene.town; + const playing=state.track?.title||'Winding up the gramophone…'; + const status=state.error||(!state.enabled?'The gramophone is quiet':state.status==='paused'?'Music paused while you’re away':playing); + text('music-status',status+' · '+place+(state.enabled&&scene.talking?' · hushed for conversation':state.enabled&&scene.indoors?' · softly indoors':'')); + text('village-clock',light.label+(timeMode==='auto'?' · local time':' · preview')); + text('music-volume-value',Math.round(music.volume*100)+'%'); + if(!state.playing&&(!state.enabled||state.hidden))void output.suspend().catch(()=>{}); + } + function updateClock(time,force=false) { + if(force||time-lastClock>=1){lastClock=time;light=villageTime(timeMode==='auto'?new Date():timeMode);} + } + function update(context,time) { + updateClock(time); + scene={town:locateVillageTown(context.player,context.world.districts,{interiorTown:context.interiorTown}),indoors:context.indoors,talking:context.talking}; + music.setScene({phase:light.phase,...scene});renderMusic(); + } + function restoreVisibility() { + // Refresh the clock while still paused, so a night-time return never starts + // yesterday afternoon's track and waits for the normal scene debounce. + updateClock(performance.now()/1000,true); + music.setScene({phase:light.phase,...scene});music.setHidden(document.hidden); + if(document.hidden)void output.suspend().catch(()=>{}); + renderMusic(); + } + music.setHidden(document.hidden); + document.addEventListener('visibilitychange',restoreVisibility); + function toggleMusic() { + // enable calls play synchronously in the click/key gesture; a second click + // can stop an in-flight load without waiting for its promise. + const pending=music.enable(!music.enabled);renderMusic(); + void pending.then(renderMusic);return pending; + } + $('music-toggle').onclick=toggleMusic; + $('music-volume').oninput=e=>{music.volume=Number(e.target.value)/100;renderMusic();}; + $('village-time').onchange=e=>{ + timeMode=['morning','day','dusk','night'].includes(e.target.value)?e.target.value:'auto'; + updateClock(performance.now()/1000,true);music.setScene({phase:light.phase,...scene});renderMusic(); + }; + + function releasePreview() { + if(postcardURL)URL.revokeObjectURL(postcardURL); + postcardURL=null;preview.removeAttribute('src');preview.hidden=true;save.hidden=true;save.removeAttribute('href'); + } + async function renderPostcard() { + if(!postcard||!dialog.open)return; + const version=++postcardVersion; + releasePreview();text('postcard-status','Drawing your postcard…'); + const milestone=milestones.value===''?null:postcard.milestones[Number(milestones.value)]||null; + try { + const result=await createPostcard({...postcard,milestone,timeState:postcard.light}); + if(version!==postcardVersion||!dialog.open)return; + postcardURL=URL.createObjectURL(result.blob); + preview.src=postcardURL;preview.alt='Postcard from '+result.model.town+' showing '+result.model.resident+'’s cottage';preview.hidden=false; + save.href=postcardURL;save.download=result.filename;save.hidden=false; + text('postcard-status',milestone?'Preview includes the recorded note you chose.':'Just the cottage and resident. No work details included.'); + }catch {if(version===postcardVersion)text('postcard-status','The postcard could not be drawn. Close this window and try again.');} + } + function openPostcard(id) { + const context=getPostcard(id);if(!context)return; + clearKeys();postcard={...context,light,now:Date.now()}; + milestones.replaceChildren(new Option('Scenic postcard · no work details','')); + for(const [i,event] of postcard.milestones.entries()) { + const when=event.timestamp?new Date(event.timestamp).toLocaleDateString([], {month:'short',day:'numeric'})+' · ':''; + milestones.add(new Option(when+String(event.text).slice(0,110),String(i))); + } + milestones.value='';milestones.disabled=!postcard.milestones.length; + text('postcard-title','A postcard from '+(context.agent.town||'the village')); + dialog.showModal();void renderPostcard(); + } + milestones.onchange=()=>void renderPostcard(); + $('postcard-close').onclick=()=>dialog.close(); + dialog.addEventListener('close',()=>{ + postcardVersion++;postcard=null;releasePreview(); + // Feed refreshes can replace the original opener while the dialog is up. + document.querySelector('[data-action="postcard"]')?.focus({preventScroll:true}); + }); + save.addEventListener('click',()=>text('postcard-status','Saving your PNG postcard…')); + window.addEventListener('pagehide',event=>{ + // Back/forward cache restores this exact controller, without rerunning setup. + if(event.persisted){music.setHidden(true);void output.suspend().catch(()=>{});} + else{music.dispose();void output.dispose().catch(()=>{});} + postcardVersion++;if(dialog.open)dialog.close();releasePreview(); + }); + window.addEventListener('pageshow',event=>{if(event.persisted)restoreVisibility();}); + renderMusic(); + return { + update,toggleMusic,openPostcard, + drawTown(ctx,world,time,reduce){updateClock(time);return paintTownAtmosphere(ctx,world,light,{time,reduce});}, + drawRoom(ctx,room,time,reduce){return paintRoomAtmosphere(ctx,room,light,{time,reduce});}, + drawGramophone(ctx,point,time,reduce){if(point)paintGramophone(ctx,point.x-12,point.y-14,{time,reduce,playing:music.state.audible});}, + get state(){return {timeMode,phase:light.phase,town:scene.town,music:music.state,postcardOpen:dialog.open};}, + }; +} diff --git a/test/atmosphere.test.mjs b/test/atmosphere.test.mjs new file mode 100644 index 0000000..7cae97c --- /dev/null +++ b/test/atmosphere.test.mjs @@ -0,0 +1,213 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { villageTime, locateVillageTown, atmosphereAccents, paintTownAtmosphere, paintRoomAtmosphere, paintGramophone } from '../src/atmosphere.mjs'; +import { createInterior } from '../src/interiors.mjs'; + +const WORLD = { + width: 800, height: 460, + plots: [ + { x: 70, y: 90, agent: { id: 'hazel', town: 'HubTown', status: 'working', pr: { state: 'open', reviewState: 'ready' } } }, + { x: 150, y: 90, agent: { id: 'fern', town: 'HubTown', status: 'idle' } }, + { x: 520, y: 90, agent: { id: 'willow', town: 'AppTown', status: 'offline' } }, + ], + ponds: [{ x: 90, y: 320, w: 96, h: 45 }, { x: 650, y: 322, w: 70, h: 40 }], + trees: [{ x: 17, y: 290, w: 6, h: 8 }, { x: 769, y: 300, w: 6, h: 8 }], + districts: [ + { key: 'HubTown', x: 40, y: 40, w: 300, h: 350 }, + { key: 'AppTown', x: 450, y: 40, w: 300, h: 350 }, + ], +}; + +function freeze(value) { + if (value && typeof value === 'object' && !Object.isFrozen(value)) { + Object.freeze(value); for (const child of Object.values(value)) freeze(child); + } + return value; +} +function recordingContext() { + const calls = [], stack = []; + const ctx = { + calls, fillStyle: 'original-ink', globalAlpha: .65, imageSmoothingEnabled: true, + save() { stack.push({ fillStyle: this.fillStyle, globalAlpha: this.globalAlpha, imageSmoothingEnabled: this.imageSmoothingEnabled }); calls.push(['save']); }, + restore() { assert.ok(stack.length, 'balanced canvas state'); Object.assign(this, stack.pop()); calls.push(['restore']); }, + beginPath() { calls.push(['beginPath']); }, + rect(...args) { calls.push(['rect', ...args]); }, + clip(rule) { calls.push(['clip', rule]); }, + fillRect(...args) { calls.push(['fillRect', ...args, this.fillStyle, this.globalAlpha]); }, + translate(...args) { calls.push(['translate', ...args]); }, + scale(...args) { calls.push(['scale', ...args]); }, + get depth() { return stack.length; }, + }; + return ctx; +} +function renderTown(phase, options = {}, world = WORLD) { + const ctx = recordingContext(), result = paintTownAtmosphere(ctx, world, villageTime(phase), options); + return { ctx, calls: ctx.calls, result }; +} +function renderRoom(room, phase, options = {}) { + const ctx = recordingContext(), result = paintRoomAtmosphere(ctx, room, villageTime(phase), options); + return { ctx, calls: ctx.calls, result }; +} + +test('local phase boundaries include midnight and explicit hours wrap consistently', () => { + for (const [hour, expected] of [[0, 'night'], [4.999, 'night'], [5, 'morning'], [8.999, 'morning'], [9, 'day'], [16.999, 'day'], [17, 'dusk'], [20.999, 'dusk'], [21, 'night'], [23.999, 'night'], [24, 'night'], [-1, 'night'], [33, 'day']]) { + assert.equal(villageTime(hour).phase, expected, String(hour)); + } + assert.equal(villageTime(24).hour, 0); + assert.equal(villageTime(-1).hour, 23); + assert.equal(villageTime(48).phaseProgress, villageTime(0).phaseProgress); + for (const phase of ['morning', 'day', 'dusk', 'night']) assert.equal(villageTime(phase).phase, phase); + const date = new Date(2026, 8, 15, 18, 30, 15, 250), result = villageTime(date); + assert.equal(result.source, 'device-clock'); + assert.equal(result.hour, 18.5 + 15 / 3600 + .25 / 3600); + assert.equal(result.phase, 'dusk'); + for (const invalid of [NaN, Infinity, new Date('invalid'), null, {}, 'unexpected']) { + assert.equal(villageTime(invalid).hour, 12); + assert.equal(villageTime(invalid).source, 'fallback'); + } +}); + +test('lighting changes continuously and remains legible at every hour', () => { + for (let hour = 0; hour < 24; hour += .05) { + const state = villageTime(hour); + assert.ok(state.darkness >= 0 && state.darkness <= .22); + assert.ok(state.roomDarkness >= 0 && state.roomDarkness <= .09); + for (const key of ['warmth', 'windowGlow', 'fireflies', 'windowShade']) assert.ok(state[key] >= 0 && state[key] <= 1, key); + for (const color of [...state.tint, ...state.sky]) assert.ok(color >= 0 && color <= 255); + } + for (const boundary of [0, 4, 5, 7, 9, 16, 17, 19, 21, 24]) { + const before = villageTime(boundary - .0001), after = villageTime(boundary + .0001); + for (const key of ['darkness', 'warmth', 'windowGlow', 'fireflies', 'windowShade']) + assert.ok(Math.abs(before[key] - after[key]) < .0001, key + ' jumped at ' + boundary); + for (let i = 0; i < 3; i++) assert.ok(Math.abs(before.tint[i] - after.tint[i]) < .0001); + } + assert.equal(villageTime('day').darkness, 0); + assert.equal(villageTime('day').windowGlow, 0); + assert.equal(villageTime('day').fireflies, 0); + assert.ok(villageTime('dusk').windowGlow > .5); + assert.equal(villageTime('night').windowGlow, 1); +}); + +test('town follows Jack’s feet, nearest road district, or the actual interior', () => { + assert.equal(locateVillageTown({ x: 90, y: 90 }, WORLD.districts), 'HubTown'); + assert.equal(locateVillageTown({ x: 600, y: 200 }, WORLD.districts), 'AppTown'); + assert.equal(locateVillageTown({ x: 435, y: 200 }, WORLD.districts), 'AppTown'); + assert.equal(locateVillageTown({ x: 355, y: 200 }, WORLD.districts), 'HubTown'); + const midpoint = { x: 395, y: 200 }; + assert.equal(locateVillageTown(midpoint, WORLD.districts), 'HubTown'); + assert.equal(locateVillageTown(midpoint, [...WORLD.districts].reverse()), 'HubTown'); + assert.equal(locateVillageTown({ x: 600, y: 200 }, WORLD.districts, { interiorTown: 'MemTown' }), 'MemTown'); + assert.equal(locateVillageTown(null, WORLD.districts), 'HubTown'); + assert.equal(locateVillageTown({ x: NaN, y: 20 }, WORLD.districts), 'HubTown'); + assert.equal(locateVillageTown({ x: 20, y: 20 }, [{ key: 'MissingBounds' }]), 'HubTown'); + assert.equal(locateVillageTown({ x: 20, y: 20 }, [], { interiorTown: 'VaultTown' }), 'VaultTown'); + const annex = [...WORLD.districts, { key: 'AppTown', x: 40, y: 450, w: 300, h: 350 }]; + assert.equal(locateVillageTown({ x: 90, y: 600 }, annex), 'AppTown'); +}); + +test('firefly anchors are deterministic, bounded, and attached to real ponds or trees', () => { + const first = atmosphereAccents(WORLD); + assert.deepEqual(first, atmosphereAccents(structuredClone(WORLD))); + assert.deepEqual(first, atmosphereAccents({ ...WORLD, ponds: [...WORLD.ponds].reverse(), trees: [...WORLD.trees].reverse() })); + assert.deepEqual(first, atmosphereAccents({ ...WORLD, plots: WORLD.plots.map(plot => ({ ...plot, agent: { ...plot.agent, status: 'done', lastLine: 'Unrelated progress', updatedAt: Date.now() } })) })); + assert.ok(first.some(accent => accent.kind === 'pond')); + assert.ok(first.some(accent => accent.kind === 'tree')); + assert.equal(first.length, 12); + for (const accent of first) { + assert.ok(accent.x >= 3 && accent.x <= WORLD.width - 4); + assert.ok(accent.y >= 3 && accent.y <= WORLD.height - 4); + const anchors = accent.kind === 'pond' ? WORLD.ponds : WORLD.trees; + assert.ok(anchors.some(rect => Math.hypot(accent.x - (rect.x + rect.w / 2), accent.y - (rect.y + rect.h / 2)) < Math.max(rect.w, rect.h) * .8 + 25)); + } + const huge = { width: 4000, height: 4000, + ponds: Array.from({ length: 1000 }, (_, i) => ({ x: i, y: i * 2, w: 100, h: 40 })), + trees: Array.from({ length: 1000 }, (_, i) => ({ x: i * 2, y: i, w: 6, h: 8 })), + }; + assert.equal(atmosphereAccents(huge).length, 40); + assert.deepEqual(atmosphereAccents({ width: 10, height: 10 }), []); + assert.deepEqual(atmosphereAccents({ width: NaN, height: 10, ponds: WORLD.ponds }), []); +}); + +test('town evening windows match the four existing panes and leave offline cottages dark', () => { + const day = renderTown('day'); + assert.equal(day.result.windows, 0); + assert.equal(day.result.fireflies, 0); + assert.equal(day.calls.filter(call => call[0] === 'fillRect').length, 0); + const dusk = renderTown('dusk'); + assert.equal(dusk.result.windows, 4); + assert.equal(dusk.result.fireflies, 12); + const plot = WORLD.plots[0], glass = dusk.calls.filter(call => call[0] === 'fillRect' && call[3] <= 4 && call[4] >= 3); + for (const x of [plot.x + 12, plot.x + 34]) for (const [dx, dy, w, h] of [[0, 0, 3, 3], [4, 0, 4, 3], [0, 4, 3, 4], [4, 4, 4, 4]]) + assert.ok(glass.some(call => call[1] === x + dx && call[2] === plot.y + 41 + dy && call[3] === w && call[4] === h)); + assert.ok(dusk.calls.some(call => call[0] === 'clip' && call[1] === 'evenodd')); + const rects = dusk.calls.filter(call => call[0] === 'rect'); + for (const plot of WORLD.plots) { + assert.ok(rects.some(call => call[1] === plot.x - 19 && call[2] === plot.y + 35 && call[3] === 22 && call[4] === 40)); + assert.ok(rects.some(call => call[1] === plot.x - 21 && call[2] === plot.y + 80)); + } + const faded = renderTown('night', {}, { ...WORLD, plots: WORLD.plots.map(plot => ({ ...plot, opacity: 0 })) }); + assert.equal(faded.result.windows, 0); +}); + +test('reduced motion freezes fireflies and hearth light without turning them off', () => { + const still = renderTown('night', { time: 0, reduce: true }); + assert.deepEqual(still.calls, renderTown('night', { time: 300, reduce: true }).calls); + assert.ok(still.result.fireflies > 0); + assert.notDeepEqual(renderTown('night', { time: 0 }).calls, renderTown('night', { time: 30 }).calls); + const room = createInterior({ id: 'cottage-one', taskId: 'first-task', town: 'HubTown' }); + assert.deepEqual(renderRoom(room, 'night', { time: 0, reduce: true }).calls, renderRoom(room, 'night', { time: 300, reduce: true }).calls); + assert.notDeepEqual(renderRoom(room, 'night', { time: 0 }).calls, renderRoom(room, 'night', { time: 3 }).calls); +}); + +test('room overlay protects the review desk and plaque across all authored layouts', () => { + const layouts = new Set(); + for (let i = 0; i < 40; i++) { + const room = createInterior({ id: 'house-' + i, town: ['HubTown', 'AppTown', 'MemTown', 'VaultTown'][i % 4] }); + layouts.add(room.layout); + const night = renderRoom(room, 'night', { reduce: true }), review = room.objects.find(object => object.id === 'review'); + assert.equal(night.result.windows, 1); + assert.ok(night.calls.some(call => call[0] === 'rect' && call[1] === review.x - 2 && call[2] === review.y - 15 && call[3] === review.w + 4 && call[4] === review.h + 20)); + assert.ok(night.calls.some(call => call[0] === 'clip' && call[1] === 'evenodd')); + const win = room.window; + assert.ok(night.calls.some(call => call[0] === 'rect' && call[1] === win.x + 3 && call[2] === win.y && call[3] === 7 && call[4] === 11)); + assert.equal(renderRoom(room, 'day').calls.filter(call => call[0] === 'fillRect').length, 0); + } + assert.equal(layouts.size, 4); +}); + +test('painters restore canvas state and never mutate frozen room, world, or task data', () => { + const world = freeze(structuredClone(WORLD)), before = JSON.stringify(world); + const room = freeze(createInterior({ id: 'freeze', town: 'VaultTown' })), roomBefore = JSON.stringify(room); + const state = villageTime('night'); + for (const render of [renderTown('night', { time: 15 }, world), renderRoom(room, 'night', { time: 15 })]) { + assert.equal(render.ctx.depth, 0); + assert.equal(render.ctx.fillStyle, 'original-ink'); + assert.equal(render.ctx.globalAlpha, .65); + assert.equal(render.ctx.imageSmoothingEnabled, true); + } + assert.equal(JSON.stringify(world), before); + assert.equal(JSON.stringify(room), roomBefore); + assert.ok(Object.isFrozen(state)); + const ctx = recordingContext(); + assert.doesNotThrow(() => paintTownAtmosphere(ctx, null)); +}); + +test('draw cost is bounded for large scenes, and all canvas numbers are finite', () => { + const world = { ...WORLD, plots: Array.from({ length: 10000 }, (_, i) => ({ x: i * 80, y: 90, agent: { id: 'cottage-' + i, status: 'working' } })) }; + const { calls, result } = renderTown('night', { time: Infinity }, world); + assert.equal(result.windows, 192); + assert.ok(calls.length < 2200); + for (const call of calls) for (const value of call.slice(1)) if (typeof value === 'number') assert.ok(Number.isFinite(value)); + const ctx = recordingContext(); + assert.deepEqual(paintRoomAtmosphere(ctx, null), { phase: 'day', windows: 0 }); + assert.equal(paintTownAtmosphere(ctx, { width: NaN, height: 10 }).windows, 0); +}); + +test('gramophone is a deterministic tiny prop with opt-in disc animation', () => { + const draw = options => { const ctx = recordingContext(); paintGramophone(ctx, 12, 34, options); return ctx; }; + assert.deepEqual(draw({ time: 0 }).calls, draw({ time: 15 }).calls); + assert.deepEqual(draw({ time: 0, playing: true, reduce: true }).calls, draw({ time: 15, playing: true, reduce: true }).calls); + assert.notDeepEqual(draw({ time: 0, playing: true }).calls, draw({ time: 1, playing: true }).calls); + assert.equal(draw({ playing: true }).depth, 0); + assert.ok(draw({ scale: 2 }).calls.some(call => call[0] === 'scale' && call[1] === 2 && call[2] === 2)); +}); diff --git a/test/feed.test.mjs b/test/feed.test.mjs index d37d41d..8795eb0 100644 --- a/test/feed.test.mjs +++ b/test/feed.test.mjs @@ -698,6 +698,59 @@ test("HTTP serves modules and incremental activity while refusing writes and tra assert.equal((await fetch(`${base}/agents/missing/activity`)).status, 404); }); +test("bundled music supports native ranges, HEAD, and rejects missing or escaping audio", async t => { + const directory = await mkdtemp(join(tmpdir(), "cottage-audio-")); + t.after(() => rm(directory, { recursive: true, force: true })); + const web = join(directory, "web"); + await mkdir(join(web, "audio"), { recursive: true }); + const bytes = Buffer.from("0123456789abcdefghijklmnopqrstuvwxyz"); + await writeFile(join(web, "audio", "day.mp3"), bytes); + await writeFile(join(directory, "outside.mp3"), "outside audio"); + await symlink(join(directory, "outside.mp3"), join(web, "audio", "escape.mp3")); + const feed = { snapshot: () => ({ agents: [] }) }; + const server = createFeedServer(feed, { directory: web }).listen(0, "127.0.0.1"); + await once(server, "listening"); + t.after(() => new Promise(resolve => server.close(resolve))); + const base = `http://127.0.0.1:${server.address().port}/modules/audio/`; + const full = await fetch(base + "day.mp3"); + assert.equal(full.headers.get("content-type"), "audio/mpeg"); + assert.equal(full.headers.get("accept-ranges"), "bytes"); + assert.equal(full.headers.get("cache-control"), "public, max-age=0, must-revalidate"); + const etag = full.headers.get("etag"); + assert.match(etag, /^"[a-f0-9]{64}"$/); + assert.deepEqual(Buffer.from(await full.arrayBuffer()), bytes); + const cached = await fetch(base + "day.mp3", { headers: { "if-none-match": etag } }); + assert.equal(cached.status, 304); + assert.equal(cached.headers.get("etag"), etag); + assert.equal((await cached.arrayBuffer()).byteLength, 0); + for (const [range, start, end] of [["bytes=0-9", 0, 9], ["bytes=10-", 10, 35], ["bytes=-3", 33, 35], ["bytes=30-900", 30, 35], ["bytes=-90", 0, 35]]) { + const response = await fetch(base + "day.mp3", { headers: { range } }); + assert.equal(response.status, 206); + assert.equal(response.headers.get("content-range"), `bytes ${start}-${end}/36`); + assert.equal(Number(response.headers.get("content-length")), end - start + 1); + assert.deepEqual(Buffer.from(await response.arrayBuffer()), bytes.subarray(start, end + 1)); + } + const currentRange = await fetch(base + "day.mp3", { headers: { range: "bytes=0-9", "if-range": etag } }); + assert.equal(currentRange.status, 206); + assert.deepEqual(Buffer.from(await currentRange.arrayBuffer()), bytes.subarray(0, 10)); + const staleRange = await fetch(base + "day.mp3", { headers: { range: "bytes=0-9", "if-range": '"stale-version"' } }); + assert.equal(staleRange.status, 200); + assert.equal(staleRange.headers.get("content-range"), null); + assert.equal(staleRange.headers.get("content-length"), "36"); + assert.deepEqual(Buffer.from(await staleRange.arrayBuffer()), bytes); + for (const range of ["bytes=36-", "bytes=9-2", "bytes=-0", "bytes=-", "bytes=0-1,3-4", "items=0-1", "bytes=999999999999999999999-"]) { + const response = await fetch(base + "day.mp3", { headers: { range } }); + assert.equal(response.status, 416, range); + assert.equal(response.headers.get("content-range"), "bytes */36"); + assert.equal((await response.arrayBuffer()).byteLength, 0); + } + const head = await fetch(base + "day.mp3", { method: "HEAD", headers: { range: "bytes=0-1" } }); + assert.equal(head.status, 200); + assert.equal(head.headers.get("content-length"), "36"); + assert.equal((await head.arrayBuffer()).byteLength, 0); + for (const file of ["missing.mp3", "escape.mp3", "%2e%2e%2foutside.mp3"]) assert.equal((await fetch(base + file)).status, 404); +}); + test("missing optional transcript directory is empty; a vanished configured directory is a scan error", async t => { const directory = await mkdtemp(join(tmpdir(), "cottage-scan-")); t.after(() => rm(directory, { recursive: true, force: true })); diff --git a/test/music-output.test.mjs b/test/music-output.test.mjs new file mode 100644 index 0000000..7078534 --- /dev/null +++ b/test/music-output.test.mjs @@ -0,0 +1,180 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { createMusicOutput } from '../src/music-output.mjs'; + +const deferred = () => { + let resolve, reject; + const promise = new Promise((yes, no) => { resolve = yes; reject = no; }); + return { promise, resolve, reject }; +}; +function fixture({ ignored = false, playPlan, resumePlan, failNode = false, lifecycleFailure = false } = {}) { + const media = [], contexts = [], calls = []; + class Audio { + storedVolume = 1; muted = false; paused = true; src = ''; duration = 65; currentTime = 0; + constructor() { media.push(this); } + get volume() { return ignored ? 1 : this.storedVolume; } + set volume(value) { if (!ignored) this.storedVolume = value; } + play() { + calls.push('play'); + if (ignored) assert.equal(contexts[0].gains.find(gain => gain.audio === this)?.gain.value, 0, 'gain is zero before native playback'); + if (playPlan instanceof Error) throw playPlan; + const result = typeof playPlan === 'function' ? playPlan() : playPlan; + return Promise.resolve(result).then(() => { this.paused = false; }); + } + pause() { this.paused = true; calls.push('pause'); } + removeAttribute(name) { if (name === 'src') this.src = ''; } + load() { this.paused = true; this.currentTime = 0; } + } + class AudioContext { + state = 'suspended'; destination = {}; gains = []; sources = []; resumes = 0; suspends = 0; closes = 0; + constructor() { contexts.push(this); } + createMediaElementSource(audio) { + const source = { audio, connected: null, disconnected: false, + connect: gain => { gain.audio = audio; source.connected = gain; return gain; }, + disconnect: () => { source.disconnected = true; source.connected = null; } }; + this.sources.push(source); return source; + } + createGain() { + if (typeof failNode === 'function' ? failNode() : failNode) throw new Error('Node unavailable'); + const gain = { gain: { value: 1 }, connected: null, disconnected: false, + connect: destination => { assert.equal(gain.gain.value, 0, 'connect starts silent'); gain.connected = destination; }, + disconnect: () => { gain.disconnected = true; gain.connected = null; } }; + this.gains.push(gain); return gain; + } + resume() { + calls.push('resume'); this.resumes++; + if (resumePlan instanceof Error) throw resumePlan; + const result = typeof resumePlan === 'function' ? resumePlan() : resumePlan; + return Promise.resolve(result).then(() => { this.state = 'running'; }); + } + suspend() { this.suspends++; this.state = 'suspended'; return lifecycleFailure ? Promise.reject(new Error('suspend failed')) : Promise.resolve(); } + close() { this.closes++; this.state = 'closed'; return lifecycleFailure ? Promise.reject(new Error('close failed')) : Promise.resolve(); } + } + const output = createMusicOutput({ Audio, AudioContext }); + return { output, media, contexts, calls }; +} + +test('native output stays lazy and avoids an audio context when volume works', async () => { + const f = fixture(); + await f.output.suspend(); + assert.equal(f.media.length, 0); assert.equal(f.contexts.length, 0); + const audio = f.output.create(); + assert.equal(audio, f.media[0]); assert.equal(audio.volume, 0); + audio.src = '/day.mp3'; audio.volume = .16; await audio.play(); + assert.equal(audio.volume, .16); assert.equal(audio.paused, false); + await f.output.suspend(); + assert.equal(audio.paused, true); assert.equal(audio.volume, 0); + assert.equal(f.contexts.length, 0); + f.output.create(); + assert.throws(() => f.output.create(), /two output/); + await f.output.dispose(); + assert.ok(f.media.every(audio => audio.paused && audio.muted && !audio.src)); + assert.throws(() => f.output.create(), { name: 'AbortError' }); +}); + +test('ignored native volume uses one context and two independently quiet gain paths', async () => { + const f = fixture({ ignored: true }), a = f.output.create(), b = f.output.create(); + assert.equal(f.contexts.length, 1); assert.equal(f.contexts[0].sources.length, 2); + assert.equal(f.contexts[0].gains.length, 2); assert.throws(() => f.output.create(), /two output/); + a.volume = .16; b.volume = .08; + assert.equal(a.volume, .16); assert.equal(f.media[0].volume, 1); + assert.equal(f.contexts[0].gains[0].gain.value, 0); + const playing = a.play(); + assert.deepEqual(f.calls.slice(-2), ['resume', 'play'], 'both calls are synchronous'); + assert.equal(f.contexts[0].gains[0].gain.value, 0); + await playing; + assert.equal(f.contexts[0].gains[0].gain.value, .16); + a.volume = .026; assert.equal(f.contexts[0].gains[0].gain.value, .026); + await b.play(); assert.equal(f.contexts[0].gains[1].gain.value, .08); + assert.equal(f.contexts[0].gains[0].gain.value, .026); + await f.output.dispose(); + assert.equal(f.contexts[0].closes, 1); + assert.ok(f.contexts[0].sources.every(source => source.disconnected)); + assert.ok(f.contexts[0].gains.every(gain => gain.disconnected && gain.gain.value === 0)); +}); + +test('fallback delegates the media interface and pauses while preserving requested volume', async () => { + const f = fixture({ ignored: true }), a = f.output.create(), raw = f.media[0]; + a.src = '/night.mp3'; a.preload = 'auto'; a.loop = false; a.currentTime = 12; + const event = () => {}; + for (const name of ['onended', 'onerror', 'onwaiting', 'onplaying']) { a[name] = event; assert.equal(raw[name], event); assert.equal(a[name], event); } + assert.equal(a.src, raw.src); assert.equal(raw.preload, 'auto'); assert.equal(raw.loop, false); + assert.equal(a.currentTime, 12); assert.equal(a.duration, 65); assert.equal(a.paused, true); + a.volume = .1; await a.play(); a.pause(); + assert.equal(a.paused, true); assert.equal(a.volume, .1); assert.equal(f.contexts[0].gains[0].gain.value, 0); + await a.play(); assert.equal(f.contexts[0].gains[0].gain.value, .1); + a.load(); assert.equal(a.currentTime, 0); assert.equal(a.paused, true); + a.removeAttribute('src'); assert.equal(a.src, ''); + assert.throws(() => { a.volume = NaN; }, RangeError); + assert.throws(() => { a.volume = 2; }, RangeError); + await f.output.dispose(); +}); + +test('resume and native play failures never release the gain and late starts stay paused', async () => { + for (const failing of ['resume', 'play']) { + const pending = deferred(), reason = Object.assign(new Error('Denied'), { name: 'NotAllowedError' }); + const f = fixture({ ignored: true, resumePlan: failing === 'resume' ? () => Promise.reject(reason) : pending.promise, + playPlan: failing === 'play' ? () => Promise.reject(reason) : pending.promise }); + const a = f.output.create(); a.volume = .16; + await assert.rejects(a.play(), { name: 'NotAllowedError' }); + assert.equal(f.contexts[0].gains[0].gain.value, 0); assert.equal(a.paused, true); + pending.resolve(); await Promise.resolve(); await Promise.resolve(); await Promise.resolve(); + assert.equal(a.paused, true); assert.equal(f.contexts[0].gains[0].gain.value, 0); + await f.output.dispose(); + } + for (const key of ['resumePlan', 'playPlan']) { + const f = fixture({ ignored: true, [key]: new Error('Synchronous failure') }), a = f.output.create(); + await assert.rejects(a.play(), /Synchronous failure/); + assert.equal(a.paused, true); assert.equal(f.contexts[0].gains[0].gain.value, 0); + await f.output.dispose(); + } +}); + +test('failed graph creation frees the slot budget and cannot expose unattenuated media', async () => { + let attempts = 0; + const f = fixture({ ignored: true, failNode: () => attempts++ === 0 }); + assert.throws(() => f.output.create(), /Node unavailable/); + assert.equal(f.media[0].muted, true); assert.equal(f.media[0].paused, true); + assert.equal(f.contexts[0].sources[0].disconnected, true); + f.output.create(); f.output.create(); + assert.throws(() => f.output.create(), /two output/); + assert.equal(f.contexts.length, 1); + await f.output.dispose(); + const unsupported = createMusicOutput({ Audio: class { get volume() { return 1; } set volume(value) {} pause() {} }, AudioContext: null }); + assert.throws(() => unsupported.create(), /Quiet music output/); + await unsupported.dispose(); +}); + +test('suspend and disposal are silent immediately and suppress late play completion', async () => { + for (const action of ['suspend', 'dispose']) { + const pending = deferred(), f = fixture({ ignored: true, playPlan: pending.promise }), a = f.output.create(); + a.volume = .16; const playing = a.play(); + await f.output[action](); + assert.equal(a.paused, true); assert.equal(f.contexts[0].gains[0].gain.value, 0); + pending.resolve(); await assert.rejects(playing, { name: 'AbortError' }); + assert.equal(a.paused, true); assert.equal(f.contexts[0].gains[0].gain.value, 0); + await f.output.dispose(); await f.output.dispose(); + assert.equal(f.contexts[0].closes, 1); + await assert.rejects(a.play(), { name: 'AbortError' }); + } +}); + +test('a cancelled play cannot silence a newer play on the same element', async () => { + const pending = deferred(); let attempts = 0; + const f = fixture({ ignored: true, playPlan: () => attempts++ ? undefined : pending.promise }), a = f.output.create(); + a.volume = .16; const first = a.play(); a.pause(); await a.play(); + pending.resolve(); await assert.rejects(first, { name: 'AbortError' }); + assert.equal(a.paused, false); assert.equal(f.contexts[0].gains[0].gain.value, .16); + await f.output.dispose(); +}); + +test('lifecycle rejection is handled and a gain failure mutes and pauses playback', async () => { + const f = fixture({ ignored: true, lifecycleFailure: true }), a = f.output.create(); + a.volume = .16; await a.play(); + Object.defineProperty(f.contexts[0].gains[0].gain, 'value', { get: () => .16, set: () => { throw new Error('Gain failed'); } }); + assert.throws(() => { a.volume = .05; }, /Gain failed/); + assert.equal(a.paused, true); assert.equal(f.media[0].muted, true); + assert.equal(await f.output.suspend(), false); + assert.equal(await f.output.dispose(), false); + assert.equal(await f.output.dispose(), false); +}); diff --git a/test/music.test.mjs b/test/music.test.mjs new file mode 100644 index 0000000..3d43a16 --- /dev/null +++ b/test/music.test.mjs @@ -0,0 +1,317 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { createMusic } from '../src/music.mjs'; + +class Clock { + time = 0; + sequence = 0; + timers = new Map(); + now = () => this.time; + set = (callback, delay) => { const id = ++this.sequence; this.timers.set(id, { at: this.time + delay, callback }); return id; }; + clear = id => this.timers.delete(id); + async advance(ms) { + const end = this.time + ms; + while (true) { + const next = [...this.timers].filter(([, timer]) => timer.at <= end).sort((a, b) => a[1].at - b[1].at)[0]; + if (!next) break; + this.time = next[1].at; this.timers.delete(next[0]); next[1].callback(); + await Promise.resolve(); await Promise.resolve(); + } + this.time = end; + await Promise.resolve(); await Promise.resolve(); + } +} +class Visibility { + hidden = false; + listeners = new Set(); + addEventListener(name, listener) { assert.equal(name, 'visibilitychange'); this.listeners.add(listener); } + removeEventListener(name, listener) { this.listeners.delete(listener); } + set(value) { this.hidden = value; for (const listener of this.listeners) listener(); } +} +class Media { + paused = true; + volume = 1; + duration = 60; + position = 0; + startedAt = 0; + requests = []; + playCalls = 0; + loads = 0; + constructor(clock, plans) { this.clock = clock; this.plans = plans; } + set src(value) { this.url = value; if (value) this.requests.push(value); this.position = 0; } + get src() { return this.url || ''; } + get currentTime() { return this.position + (this.paused ? 0 : (this.clock.time - this.startedAt) / 1000); } + set currentTime(value) { this.position = value; this.startedAt = this.clock.time; } + begin() { this.startedAt = this.clock.time; this.paused = false; this.onplaying?.(); } + play() { + this.playCalls++; + const plan = this.plans.shift(); + if (plan === 'reject') return Promise.reject(Object.assign(new Error('private URL or device details'), { name: 'NotAllowedError' })); + if (plan?.defer) return new Promise((resolve, reject) => { + plan.resolve = () => { this.begin(); resolve(); }; + plan.reject = reject; + }); + this.begin(); return Promise.resolve(); + } + pause() { this.position = this.currentTime; this.paused = true; } + removeAttribute(name) { assert.equal(name, 'src'); this.url = ''; } + load() { this.loads++; this.position = 0; } +} +const tracks = [ + { id: 'morning', title: 'Morning bed', phase: 'morning', url: '/music/morning.mp3' }, + { id: 'day', title: 'Day bed', phase: 'day', url: '/music/day.mp3' }, + { id: 'dusk', title: 'Dusk bed', phase: 'dusk', url: '/music/dusk.mp3' }, + { id: 'night', title: 'Night bed', phase: 'night', url: '/music/night.mp3' }, + { id: 'app', title: 'AppTown bed', phase: ['morning', 'day'], town: 'AppTown', url: '/music/app.mp3' }, + { id: 'vault', title: 'VaultTown bed', phase: ['morning', 'day'], town: 'VaultTown', url: '/music/vault.mp3' }, +]; +function fixture(options = {}) { + const clock = new Clock(), visibility = new Visibility(), media = [], plans = options.plans || []; + const music = createMusic({ tracks, now: clock.now, setTimeoutFn: clock.set, clearTimeoutFn: clock.clear, visibilitySource: visibility, + audioFactory: () => { const audio = new Media(clock, plans); if (options.duration) audio.duration = options.duration; media.push(audio); return audio; }, ...options }); + return { music, clock, visibility, media, get requests() { return media.flatMap(audio => audio.requests); } }; +} +const near = (actual, expected) => assert.ok(Math.abs(actual - expected) < .00001, `${actual} should equal ${expected}`); +const live = f => f.media.filter(audio => audio.src); + +test('music starts off with no media work until opt-in, and a fresh controller stays off', async () => { + const f = fixture(); + f.music.setScene({ phase: 'day', town: 'AppTown' }); + f.music.volume = .16; + await f.clock.advance(60000); + assert.equal(f.media.length, 0); + assert.equal(f.clock.timers.size, 0); + assert.equal(f.music.state.enabled, false); + assert.equal(f.music.state.audible, false); + assert.equal(await f.music.enable(true), true); + assert.deepEqual(f.requests, ['/music/app.mp3']); + assert.equal(f.music.state.track.id, 'app'); + assert.equal(f.music.state.audible, false, 'fade-in starts at silence'); + await f.clock.advance(1800); + near(f.media[0].volume, .16); + assert.equal(f.music.state.audible, true); + const fresh = fixture(); + assert.equal(fresh.music.state.enabled, false); + assert.equal(fresh.media.length, 0); + f.music.dispose(); fresh.music.dispose(); + assert.equal(f.music.state.playing, false); + assert.equal(live(f).length, 0); + assert.equal(f.clock.timers.size, 0); + assert.equal(f.visibility.listeners.size, 0); +}); + +test('town selection waits for stable boundaries and regional beds never replace dusk or night', async () => { + const f = fixture(); + await f.music.enable(true); await f.clock.advance(1800); + f.music.setScene({ town: 'AppTown' }); await f.clock.advance(2000); + f.music.setScene({ town: 'HubTown' }); await f.clock.advance(5000); + assert.deepEqual(f.requests, ['/music/day.mp3']); + f.music.setScene({ town: 'AppTown' }); await f.clock.advance(3999); + assert.equal(f.requests.length, 1); + await f.clock.advance(1); + assert.equal(f.music.state.track.id, 'app'); + await f.clock.advance(1800); + f.music.setScene({ phase: 'night' }); await f.clock.advance(4000); + assert.equal(f.music.state.track.id, 'night'); + await f.clock.advance(1800); + f.music.setScene({ town: 'VaultTown' }); await f.clock.advance(5000); + assert.equal(f.music.state.track.id, 'night'); + assert.equal(f.requests.length, 3, 'town changes at night reuse the same bed'); + f.music.setScene({ phase: 'morning' }); await f.clock.advance(4000); + assert.equal(f.music.state.track.id, 'vault'); + assert.equal(f.media.length, 2); + f.music.dispose(); +}); + +test('music volume fades independently, gently indoors and more strongly during conversation', async () => { + const f = fixture(); + await f.music.enable(true); await f.clock.advance(1800); + f.music.setScene({ indoors: true }); + await f.clock.advance(400); + assert.ok(f.media[0].volume < .16 && f.media[0].volume > .104); + await f.clock.advance(400); near(f.media[0].volume, .16 * .65); + f.music.setScene({ talking: true }); await f.clock.advance(350); + near(f.media[0].volume, .16 * .65 * .25); + f.music.setScene({ talking: false }); await f.clock.advance(800); + near(f.media[0].volume, .16 * .65); + f.music.volume = .4; await f.clock.advance(650); near(f.media[0].volume, .4 * .65); + assert.equal(f.music.volume, .4); + assert.equal(f.requests.length, 1, 'gain changes never change or reload the selected track'); + f.music.volume = 2; await f.clock.advance(650); near(f.media[0].volume, .65); + f.music.volume = -1; await f.clock.advance(650); near(f.media[0].volume, 0); + assert.equal(f.music.state.audible, false); + f.music.dispose(); +}); + +test('track changes crossfade and retire outgoing media without allocating a third element', async () => { + const f = fixture(); + await f.music.enable(true); await f.clock.advance(1800); + f.music.setScene({ town: 'AppTown' }); await f.clock.advance(4000); + assert.equal(live(f).length, 2); + const old = f.media[0], next = f.media[1]; + near(old.volume, .16); near(next.volume, 0); + await f.clock.advance(900); near(old.volume, .08); near(next.volume, .08); + await f.clock.advance(900); + assert.equal(old.paused, true); assert.equal(old.src, ''); near(next.volume, .16); + f.music.setScene({ town: 'VaultTown' }); await f.clock.advance(4900); + assert.equal(f.media.length, 2); assert.equal(live(f).length, 2); + f.music.dispose(); + assert.ok(f.media.every(audio => audio.paused && !audio.src && !audio.onended && !audio.onerror)); +}); + +test('short tracks loop with overlap while reusing exactly two elements', async () => { + const f = fixture({ duration: 5, fadeMs: 1000 }); + await f.music.enable(true); await f.clock.advance(4000); + assert.equal(f.media.length, 2); + assert.equal(live(f).length, 2); + await f.clock.advance(500); + near(f.media[0].volume, .08); near(f.media[1].volume, .08); + await f.clock.advance(500); + assert.equal(f.media[0].paused, true); + assert.equal(f.music.state.track.id, 'day'); + await f.clock.advance(40000); + assert.equal(f.media.length, 2); + assert.ok(live(f).length <= 2); + assert.equal(f.clock.timers.size, 1); + assert.ok(f.requests.length > 5); + f.music.dispose(); +}); + +test('visibility stops all audio and resumes only the existing opt-in session', async () => { + const f = fixture(); + await f.music.enable(true); await f.clock.advance(1800); + f.music.setScene({ town: 'AppTown' }); await f.clock.advance(4500); + f.visibility.set(true); + assert.equal(f.music.state.status, 'paused'); + assert.equal(f.music.state.enabled, true); + assert.equal(f.music.state.playing, false); + assert.equal(live(f).length, 0); + assert.equal(f.clock.timers.size, 0); + const requests = f.requests.length; + f.music.setScene({ phase: 'night' }); await f.clock.advance(60000); + assert.equal(f.requests.length, requests); + f.visibility.set(false); await f.clock.advance(1800); + assert.equal(f.music.state.track.id, 'night'); + assert.equal(f.music.state.audible, true); + assert.equal(await f.music.enable(false), false); + assert.equal(f.music.state.status, 'stopping'); + await f.clock.advance(450); + assert.equal(f.music.state.status, 'off'); + assert.equal(live(f).length, 0); + const stoppedRequests = f.requests.length; + f.visibility.set(true); f.visibility.set(false); await f.clock.advance(10000); + assert.equal(f.requests.length, stoppedRequests); + f.music.dispose(); +}); + +test('late play completion cannot undo disable or disposal', async () => { + for (const action of ['disable', 'dispose', 'hidden']) { + const deferred = { defer: true }, f = fixture({ plans: [deferred] }); + const enabling = f.music.enable(true); + assert.equal(f.music.state.status, 'starting'); + if (action === 'disable') await f.music.enable(false); + else if (action === 'dispose') f.music.dispose(); + else f.visibility.set(true); + assert.equal(await enabling, false); + deferred.resolve(); await f.clock.advance(1000); + assert.ok(f.media.every(audio => audio.paused && !audio.src && audio.volume === 0)); + assert.equal(f.music.state.audible, false); + assert.equal(f.clock.timers.size, 0); + f.music.dispose(); + } +}); + +test('changing scenes cancels a pending start and stale completion cannot replace the new track', async () => { + const deferred = { defer: true }, f = fixture({ plans: [deferred] }); + const initial = f.music.enable(true); + f.music.setScene({ phase: 'night' }); + assert.equal(await initial, false); + await f.clock.advance(4000); + assert.equal(f.music.state.track.id, 'night'); + deferred.resolve(); await f.clock.advance(1800); + assert.equal(f.music.state.track.id, 'night'); + assert.equal(f.music.state.audible, true); + assert.equal(live(f).length, 1); + assert.ok(f.media.length <= 2); + f.music.dispose(); +}); + +test('rapid toggles preserve a live fade and repeated disable does not extend stopping', async () => { + const f = fixture(); + await f.music.enable(true); await f.clock.advance(1800); + await f.music.enable(false); await f.clock.advance(200); + assert.ok(f.media[0].volume > 0 && f.media[0].volume < .16); + assert.equal(await f.music.enable(true), true); + await f.clock.advance(1800); + assert.equal(f.requests.length, 1, 're-enabling the same bed preserves its source'); + near(f.media[0].volume, .16); + await f.music.enable(false); await f.clock.advance(250); + await f.music.enable(false); await f.clock.advance(200); + assert.equal(f.music.state.status, 'off'); + assert.equal(live(f).length, 0); + assert.equal(f.clock.timers.size, 0); + assert.equal(await f.music.enable(true), true); + await f.clock.advance(1800); + assert.equal(f.music.state.audible, true); + assert.equal(f.media.length, 1); + f.music.dispose(); +}); + +test('a cancelled start stays paused while a different scene is still waiting for debounce', async () => { + const deferred = { defer: true }, f = fixture({ plans: [deferred] }); + const enabling = f.music.enable(true); + f.music.setScene({ phase: 'night' }); + assert.equal(await enabling, false); + deferred.resolve(); await f.clock.advance(1000); + assert.equal(f.media[0].paused, true); + assert.equal(f.media[0].src, ''); + assert.equal(f.music.state.audible, false); + await f.clock.advance(4800); + assert.equal(f.music.state.track.id, 'night'); + assert.equal(f.music.state.audible, true); + f.music.dispose(); +}); + +test('play, media, construction, and loading errors stop cleanly without retries or private error details', async () => { + const denied = fixture({ plans: ['reject'] }); + assert.equal(await denied.music.enable(true), false); + assert.equal(denied.music.state.status, 'error'); + assert.doesNotMatch(denied.music.state.error, /private URL|device details/); + await denied.clock.advance(60000); + assert.equal(denied.requests.length, 1); + assert.equal(live(denied).length, 0); + denied.music.dispose(); + const broken = fixture({ audioFactory: () => { throw new Error('Unavailable'); } }); + assert.equal(await broken.music.enable(true), false); + assert.equal(broken.clock.timers.size, 0); + broken.music.dispose(); + const failed = fixture(); + await failed.music.enable(true); await failed.clock.advance(1800); + failed.media[0].onerror(); + assert.equal(failed.music.state.enabled, false); + assert.equal(failed.music.state.audible, false); + assert.equal(live(failed).length, 0); + failed.music.dispose(); + const delayed = fixture({ plans: [{ defer: true }] }); + const enable = delayed.music.enable(true); + await delayed.clock.advance(15000); + assert.equal(await enable, false); + assert.match(delayed.music.state.error, /too long/); + assert.equal(delayed.clock.timers.size, 0); + assert.equal(live(delayed).length, 0); + delayed.music.dispose(); +}); + +test('catalog fallback uses only a generic any-phase bed and empty catalogs stay silent', async () => { + const f = fixture({ tracks: [{ id: 'fallback', title: 'Common bed', phase: 'any', url: '/music/common.mp3' }, + { id: 'regional', phase: ['morning', 'day'], town: 'AppTown', url: '/music/regional.mp3' }] }); + f.music.setScene({ phase: 'night', town: 'AppTown' }); + await f.music.enable(true); + assert.equal(f.music.state.track.id, 'fallback'); + f.music.dispose(); + const empty = fixture({ tracks: [] }); + assert.equal(await empty.music.enable(true), false); + assert.equal(empty.media.length, 0); + assert.equal(empty.music.state.playing, false); + empty.music.dispose(); +}); diff --git a/test/postcard.test.mjs b/test/postcard.test.mjs new file mode 100644 index 0000000..8f7ff8e --- /dev/null +++ b/test/postcard.test.mjs @@ -0,0 +1,168 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { createInterior } from '../src/interiors.mjs'; +import { buildPostcardModel, postcardFilename, wrapPostcardText, createPostcard, POSTCARD_WIDTH, POSTCARD_HEIGHT } from '../src/postcard.mjs'; + +const now = Date.parse('2026-09-15T12:00:00Z'); +const agent = { id: 'postcard-fixture', taskId: 'task-42', name: 'Bolt', town: 'HubTown', status: 'working' }; + +function frozen(value) { + if (value && typeof value === 'object') { Object.values(value).forEach(frozen); Object.freeze(value); } + return value; +} + +function canvasFixture({ encode = 'ok', context = true } = {}) { + const surfaces = [], texts = [], rects = [], draws = [], requests = []; + let depth = 0; + const createCanvas = () => { + const surface = { + width: 0, height: 0, + getContext(kind, options) { + requests.push({ kind, options }); + if (!context) return null; + return { + save() { depth++; }, restore() { depth--; }, + translate(x, y) { assert.ok([x, y].every(Number.isFinite)); }, + scale(x, y) { assert.ok([x, y].every(Number.isFinite)); }, + beginPath() {}, rect() {}, clip() {}, + fillRect(x, y, w, h) { assert.ok([x, y, w, h].every(Number.isFinite)); assert.ok(w >= 0 && h >= 0); rects.push({ x, y, w, h, color: this.fillStyle }); }, + fillText(value, x, y) { assert.equal(typeof value, 'string'); assert.ok([x, y].every(Number.isFinite)); texts.push(value); }, + measureText(value) { const size = Number.parseFloat(this.font) || 7; return { width: Array.from(value).length * size * .6 }; }, + drawImage(source, ...coordinates) { assert.ok(coordinates.every(Number.isFinite)); draws.push({ sourceWidth: source.width, sourceHeight: source.height, coordinates, smoothing: this.imageSmoothingEnabled }); }, + }; + }, + toBlob(callback, type) { + assert.equal(type, 'image/png'); + surface.encoded = { width: surface.width, height: surface.height }; + if (encode === 'throw') throw new DOMException('Synthetic tainted canvas', 'SecurityError'); + if (encode === 'never') return; + const blob = encode === 'null' ? null : new Blob(encode === 'empty' ? [] : ['fixture-png'], { type: encode === 'wrong-type' ? 'image/jpeg' : 'image/png' }); + queueMicrotask(() => callback(blob)); + }, + }; + if (encode === 'missing') delete surface.toBlob; + surfaces.push(surface); return surface; + }; + return { createCanvas, surfaces, texts, rects, draws, requests, get depth() { return depth; } }; +} + +test('postcards default to scene identity, without inferring work or completion', () => { + const unknown = buildPostcardModel({ agent: { ...agent, status: 'unknown' }, now }); + const done = buildPostcardModel({ agent: { ...agent, status: 'done', task: 'Private task', originalAsk: 'Private request', result: 'Everything completed', events: [{ text: 'Completed milestone' }], artifacts: [{ url: 'https://example.test/private' }] }, now }); + assert.deepEqual(done, unknown); + assert.equal(done.milestone, null); + assert.equal(done.brand, 'CottageCode'); + assert.equal(done.resident, 'Bolt'); + assert.equal(done.town, 'HubTown'); + assert.equal(done.dateISO, '2026-09-15'); + assert.doesNotMatch(JSON.stringify(done), /Private|Everything completed|Completed milestone|example\.test/); + assert.equal(buildPostcardModel({ agent, now, milestone: { status: 'completed' } }).milestone, null); +}); + +test('one explicit recorded milestone keeps its source time; an unknown time stays unknown', () => { + const completed = buildPostcardModel({ agent, now, milestone: { text: 'PR merged', timestamp: now - 86400000, url: 'https://github.com/example/project/pull/42?token=private#checks' } }); + assert.equal(completed.milestone.text, 'PR merged'); + assert.equal(completed.milestone.timestamp, now - 86400000); + assert.equal(completed.milestone.dateLabel, '14 SEP 2026'); + assert.equal(completed.milestone.url, 'https://github.com/example/project/pull/42'); + assert.equal(completed.milestone.linkLabel, 'github.com/example/project/pull/42'); + const unknown = buildPostcardModel({ agent, now: null, milestone: { text: 'Recorded observation' } }); + assert.equal(unknown.capturedAt, null); + assert.equal(unknown.dateISO, 'undated'); + assert.equal(unknown.milestone.timestamp, null); + assert.equal(unknown.milestone.dateLabel, 'Date not supplied'); + for (const url of ['javascript:alert(1)', 'data:text/html,hi', 'file:///private/file', '//example.test/path', 'https://name:secret@example.test/path', 'not a url']) + assert.equal(buildPostcardModel({ agent, now, milestone: { text: 'A record', url } }).milestone.url, ''); +}); + +test('the captured lighting preserves the selected hour within a phase', () => { + const model = buildPostcardModel({ agent, now, timeState: { hour: 20.75, phase: 'dusk' } }); + assert.equal(model.timeState.hour, 20.75); + assert.equal(model.timeState.phase, 'dusk'); + assert.equal(buildPostcardModel({ agent, now, timeState: 'night' }).timeState.phase, 'night'); +}); + +test('names and milestone text remain literal Unicode text, with controls removed and bounded clusters', () => { + const family = '👩🏽‍💻'; + const model = buildPostcardModel({ agent: { ...agent, name: ' & \u202eJack\n\t' + family.repeat(100) }, now, + milestone: { text: 'Literal & "quotes"\u0000\n' + family.repeat(5000) } }); + assert.match(model.resident, /^ & Jack /); + assert.match(model.milestone.text, /^Literal <\/script> & "quotes" /); + assert.doesNotMatch(model.resident + model.milestone.text, /[\u0000-\u001f\u202a-\u202e\ufffd]/u); + assert.ok(model.resident.endsWith('…')); + assert.ok(model.milestone.text.endsWith(family + '…')); + assert.ok(Array.from(new Intl.Segmenter('en', { granularity: 'grapheme' }).segment(model.resident)).length <= 48); + assert.ok(Array.from(new Intl.Segmenter('en', { granularity: 'grapheme' }).segment(model.milestone.text)).length <= 240); +}); + +test('portable filenames never contain source paths, reserved characters or unbounded names', () => { + assert.equal(postcardFilename({ agent, now }), 'cottagecode-hubtown-bolt-2026-09-15.png'); + for (const name of ['../../private/CON:*?<>|\\\"', 'üñîçødé resident', '🦆🏡', 'x'.repeat(20000)]) { + const filename = postcardFilename({ agent: { ...agent, name, town: '../AppTown' }, now }); + assert.match(filename, /^cottagecode-[a-z0-9-]+-2026-09-15\.png$/); + assert.ok(filename.length < 100); + assert.doesNotMatch(filename, /\.\.|[/\\:*?<>|\"]/); + } + assert.equal(postcardFilename({ agent: { name: '', town: '' }, now: NaN }), 'cottagecode-town-resident-undated.png'); +}); + +test('text wraps within its column and ellipsizes long words without splitting emoji', () => { + const measure = value => Array.from(new Intl.Segmenter('en', { granularity: 'grapheme' }).segment(value)).length; + assert.deepEqual(wrapPostcardText('one two three', measure, 7, 2), ['one two', 'three']); + assert.deepEqual(wrapPostcardText('abcdefghijklm', measure, 5, 2), ['abcde', 'fghi…']); + assert.deepEqual(wrapPostcardText('github.com/example/project/pull/42', measure, 25, 2), ['github.com/example/', 'project/pull/42']); + const result = wrapPostcardText('👩🏽‍💻'.repeat(20), measure, 5, 2); + assert.equal(result.length, 2); + assert.ok(result.every(line => measure(line) <= 5)); + assert.equal(result[1], '👩🏽‍💻'.repeat(4) + '…'); + assert.deepEqual(wrapPostcardText('hello', measure, 0, 2), []); + assert.deepEqual(wrapPostcardText('hello', () => Infinity, 5, 2), []); +}); + +test('PNG render preserves seeded inputs, renders only scene data, and uses crisp opaque bounded surfaces', async () => { + const privateAgent = frozen({ ...agent, originalAsk: 'NEVER ORIGINAL REQUEST', result: 'NEVER RESULT', lastLine: 'NEVER JOURNAL', pr: { state: 'merged', title: 'NEVER PR TITLE' } }); + const room = frozen(createInterior(privateAgent)), before = JSON.stringify({ privateAgent, room }); + const fixture = canvasFixture(); + const output = await createPostcard({ agent: privateAgent, room, now, timeState: { phase: 'day' }, scale: 9999 }, fixture); + assert.equal(output.blob.type, 'image/png'); + assert.ok(output.blob.size > 0); + assert.equal(output.width, POSTCARD_WIDTH * 4); + assert.equal(output.height, POSTCARD_HEIGHT * 4); + assert.equal(fixture.surfaces.length, 3); + assert.equal(fixture.depth, 0); + assert.ok(fixture.rects.length > 400); + assert.equal(fixture.requests.every(request => request.kind === '2d' && request.options.alpha === false), true); + assert.equal(fixture.draws.every(draw => draw.smoothing === false), true); + assert.deepEqual(fixture.surfaces.at(-1).encoded, { width: output.width, height: output.height }); + assert.equal(fixture.surfaces.every(surface => surface.width === 0 && surface.height === 0), true); + assert.equal(JSON.stringify({ privateAgent, room }), before); + assert.doesNotMatch(fixture.texts.join(' '), /NEVER|MERGED|Recorded moment/i); + assert.ok(fixture.texts.includes('CottageCode')); + assert.ok(fixture.texts.includes('HubTown')); + assert.ok(fixture.texts.includes('Bolt')); + const again = canvasFixture(); + await createPostcard({ agent: privateAgent, room, now, timeState: { phase: 'day' }, scale: 4 }, again); + assert.deepEqual(fixture.rects, again.rects); +}); + +test('only an explicit milestone is printed and absent room uses the same seeded generation', async () => { + const fixture = canvasFixture(); + const result = await createPostcard({ agent, now, milestone: { text: 'Review finished', timestamp: now, url: 'https://example.test/pr/42' } }, fixture); + assert.equal(result.width, 1200); + assert.equal(result.height, 840); + assert.ok(fixture.texts.includes('RECORDED MOMENT')); + assert.ok(fixture.texts.includes('Review finished')); + assert.equal(result.model.milestone.timestamp, now); +}); + +test('missing, tainted, empty and failed canvas encoding reject cleanly and release memory', async () => { + for (const encode of ['missing', 'throw', 'null', 'empty', 'wrong-type', 'never']) { + const fixture = canvasFixture({ encode }); + await assert.rejects(createPostcard({ agent, now }, { ...fixture, toBlobTimeoutMs: 2 }), /postcard/i, encode); + assert.equal(fixture.surfaces.every(surface => surface.width === 0 && surface.height === 0), true, encode); + } + const fixture = canvasFixture({ context: false }); + await assert.rejects(createPostcard({ agent, now }, fixture), /canvas/i); + assert.equal(fixture.surfaces[0].width, 0); + await assert.rejects(createPostcard({ agent, now }, { createCanvas: () => null }), /canvas/i); +}); diff --git a/test/public-demo.test.mjs b/test/public-demo.test.mjs index 7e7e314..e8a72b2 100644 --- a/test/public-demo.test.mjs +++ b/test/public-demo.test.mjs @@ -64,12 +64,16 @@ test('default build contains the complete browser graph and no backend or local for (const id of ['endpoint', 'connect']) assert.match(html, new RegExp('<[^>]+id="' + id + '"[^>]+disabled[^>]*>')); assert.match(html, /src="\.\/modules\/town\.mjs"/); assert.doesNotMatch(html, /(?:src|href)="\/modules\//); - for (const name of ['town', 'runtime', 'observatory', 'interiors', 'world', 'pr', 'history', 'sound', 'occupancy', 'feed-client']) + for (const name of ['town', 'runtime', 'observatory', 'interiors', 'world', 'pr', 'history', 'sound', 'occupancy', 'feed-client', 'village-extras', 'atmosphere', 'music', 'music-output', 'music-tracks', 'postcard']) assert.ok(result.files.includes(name + '.mjs'), name + ' should be included'); const all = await listFiles(outDir), bundled = new Set(result.files); assert.deepEqual(all.filter(path => path.includes('/modules/')).map(path => path.slice('cottagecode/modules/'.length)).sort(), result.files); assert.doesNotMatch(all.join('\n'), /(?:^|\/)(?:feed|hub|messages|transcripts|activity|github|towns)\.mjs|\.jsonl?$|\.sqlite$|\.env|snapshot|secret/i); for (const path of result.files) { + if (path.endsWith('.mp3')) { + assert.deepEqual(await readFile(join(outDir, 'cottagecode/modules', path)), await readFile(join(SOURCE, path))); + continue; + } const content = await readFile(join(outDir, 'cottagecode/modules', path), 'utf8'); assert.doesNotMatch(content, /(?:from\s*|import\s*)["']node:|\/Users\//); // Every actual source module currently uses these static ESM forms. @@ -78,6 +82,7 @@ test('default build contains the complete browser graph and no backend or local assert.ok(bundled.has(target), path + ' is missing its dependency ' + target); } } + assert.equal(result.files.filter(path => path.endsWith('.mp3')).length, 7); assert.deepEqual(all.filter(path => !path.includes('/modules/')), ['.cottagecode-demo-build', '404.html', '_headers', 'cottagecode/index.html']); }); @@ -91,7 +96,7 @@ test('headers deny live connections and 404 is a standalone response, not a town assert.match(html, //); assert.match(html, /src="\.\/modules\/town\.mjs"/); assert.match(headers, /^\/\*\n/); - for (const directive of ["connect-src 'none'", "script-src 'self'", "frame-ancestors 'none'", "object-src 'none'", "form-action 'none'"]) + for (const directive of ["connect-src 'none'", "script-src 'self'", "frame-ancestors 'none'", "object-src 'none'", "form-action 'none'", "media-src 'self'", "img-src 'self' data: blob:"]) assert.ok(headers.includes(directive)); assert.match(headers, /https:\/\/fonts.googleapis.com/); assert.match(headers, /https:\/\/fonts.gstatic.com/);