From 207dc1f69629f74853bbc466ce8acc7e085be472 Mon Sep 17 00:00:00 2001 From: Jack Arturo Date: Tue, 15 Sep 2026 02:32:09 +0200 Subject: [PATCH 1/4] feat(village): add adaptive music, lighting and postcards --- AGENTS.md | 11 + README.md | 15 +- docs/MUSIC.md | 62 ++++++ docs/PUBLIC_DEMO.md | 9 +- docs/music-provenance.json | 119 +++++++++++ package.json | 3 +- scripts/browser-ambience-smoke.mjs | 168 +++++++++++++++ scripts/build-demo.mjs | 2 +- scripts/generate-music.mjs | 54 +++++ scripts/prepare-music.mjs | 27 +++ src/atmosphere.mjs | 255 +++++++++++++++++++++++ src/audio/app-town.mp3 | Bin 0 -> 781273 bytes src/audio/day.mp3 | Bin 0 -> 781271 bytes src/audio/dusk.mp3 | Bin 0 -> 781268 bytes src/audio/mem-town.mp3 | Bin 0 -> 781276 bytes src/audio/morning.mp3 | Bin 0 -> 780649 bytes src/audio/night.mp3 | Bin 0 -> 781278 bytes src/audio/vault-town.mp3 | Bin 0 -> 781273 bytes src/feed.mjs | 19 +- src/music-output.mjs | 125 ++++++++++++ src/music-tracks.mjs | 10 + src/music.mjs | 249 ++++++++++++++++++++++ src/observatory.mjs | 23 ++- src/postcard.mjs | 258 +++++++++++++++++++++++ src/town.html | 42 +++- src/town.mjs | 1 + src/village-extras.mjs | 114 +++++++++++ test/atmosphere.test.mjs | 213 +++++++++++++++++++ test/feed.test.mjs | 38 ++++ test/music-output.test.mjs | 180 ++++++++++++++++ test/music.test.mjs | 317 +++++++++++++++++++++++++++++ test/postcard.test.mjs | 168 +++++++++++++++ test/public-demo.test.mjs | 9 +- 33 files changed, 2478 insertions(+), 13 deletions(-) create mode 100644 docs/MUSIC.md create mode 100644 docs/music-provenance.json create mode 100644 scripts/browser-ambience-smoke.mjs create mode 100644 scripts/generate-music.mjs create mode 100644 scripts/prepare-music.mjs create mode 100644 src/atmosphere.mjs create mode 100644 src/audio/app-town.mp3 create mode 100644 src/audio/day.mp3 create mode 100644 src/audio/dusk.mp3 create mode 100644 src/audio/mem-town.mp3 create mode 100644 src/audio/morning.mp3 create mode 100644 src/audio/night.mp3 create mode 100644 src/audio/vault-town.mp3 create mode 100644 src/music-output.mjs create mode 100644 src/music-tracks.mjs create mode 100644 src/music.mjs create mode 100644 src/postcard.mjs create mode 100644 src/village-extras.mjs create mode 100644 test/atmosphere.test.mjs create mode 100644 test/music-output.test.mjs create mode 100644 test/music.test.mjs create mode 100644 test/postcard.test.mjs 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 0000000000000000000000000000000000000000..551b23829f9d0393704d8dcef86c299f652f0863 GIT binary patch literal 781273 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