diff --git a/HANDOFF.md b/HANDOFF.md new file mode 100644 index 0000000..ddc0b9a --- /dev/null +++ b/HANDOFF.md @@ -0,0 +1,63 @@ +# HANDOFF — Buddy World plaza + choose-your-town teleport + +> **Temporary handoff doc.** Delete before merging PR #147 into the release. +> Written 2026-07-30 for an agent picking up in a different environment. + +## TL;DR +This branch (`feat/buddy-world-main`) is the **canonical** Buddy World plaza branch. +**PR #147 → `integration/2.0.0` is open and validated, awaiting merge.** + +## What's on this branch (7 commits on top of `integration/2.0.0`) +1. ASCII sprite legibility (WCAG-AA contrast) +2. Saturated sprite color, dropped the heavy outline +3. Dropped the frosted backing plate behind sprites +4. Authentic per-city RO music + distinct town palettes/weather +5. Per-town flora — pines (Lutie), cacti (Morroc), palms (Alberta), autumn (Payon), leafy (Prontera/Geffen) +6. RO-blue jukebox (YouTube music styled as a classic RO window; ToS-compliant ≥200×200 visible; 🎵/✕ both stop) +7. **Choose-your-town teleport** — the headline feature + +## Choose-your-town teleport — how it works +Owners pick their buddy's home RO city instead of capacity-sharding assigning one. +- `buddy-world teleport ` → puts the buddy in that town; re-running with a different town **moves** it. +- `buddy-world towns` → lists the 6 cities + blurbs. +- `buddy-world status` → names the town. +- **CLI-only by design — NO new MCP tool** (user's explicit token-budget constraint). A `/teleport` skill is the future-ergonomics path, never a tool. + +Key files: +- `src/lib/world/towns.ts` — town name ⇄ `plaza-N` registry (ordered to match `world/public/plaza.js` `TOWNS[]`; drift-guarded by `src/__tests__/world/towns-drift.test.ts`). +- `src/lib/world/store.ts` + `d1-store.ts` — `teleport(tokenHash, snap, now, desiredDistrict?)`; sets district on create, UPDATEs it on re-teleport (the "move" path). Both impls verified via shared `describe.each`. +- `src/lib/world/handlers.ts` — `handleTeleport` resolves a town name/`plaza-N` from the request body, returns `400 unknown_town` / `409 town_full` (never bounces an existing occupant re-syncing). +- `src/lib/world/client.ts` — `teleport(snapshot, { district })`; the worker forwards the whole body, so no route change. +- `src/cli/world-cli.ts` — `teleport [town]`, `towns`, town-aware `status`. + +## Validation status +- `npx tsc --noEmit` clean. +- `npx vitest run src/__tests__/world/` — 63 teleport/store/handler/CLI/drift tests green. +- **Full E2E passed** (real worker + SqliteWorldStore + puppeteer browser): buddies land in their chosen town for all 6 cities; moving a buddy relocates it (gone from old plaza, present in new); each town renders its distinct palette + per-town music label. 7/7 API + 2/2 browser checks. +- **Known-flaky (NOT this branch's fault):** 2 `plaza-smoke` pixel tests (`jitter`, `RO essence`) fail under heavy local machine load (headless-Chromium rAF starvation) — pre-existing, verified by stashing edits. Pass on a freed machine / CI. + +## How to merge PR #147 +It's `MERGEABLE` (no conflicts) but `BLOCKED` by a GitHub **ruleset** — this repo admin-merges its protected branches: +``` +gh pr merge 147 --merge --admin --delete-branch +``` +Integration only moved ahead by the installer ABI fix (#149), which is installer-only and does not touch plaza files, so **no conflict** and a rebase is optional. Backup tags exist: `backup/buddy-world-main-prerebase`, `backup/choose-your-town-prerebase`. + +## Ship path for 2.0.0 (still pending) +1. Merge #147 → `integration/2.0.0`. +2. Merge `integration/2.0.0` → `master` (installers pull master = release). +3. Cloudflare deploy (wrangler) + live teleport E2E against the deployed Worker — still pending. + +## Landmines / do-not-touch +- **Stale branches, do NOT build on:** `feat/ro-progression`, `feat/buddy-world` (their work is carried forward here). +- **Old PRs target wrong bases:** #143 (`feat/buddy-world`→master), #144 (`feat/xp-events-blessing`→`feat/buddy-world`) predate the integration strategy — close/retarget, don't merge as-is. +- Unrelated master-targeted PRs #151–158 (detector specs, doctor, penguin frame, hermetic tests, mute persistence) are from other work streams — not part of the plaza feature. +- **Installer saga is DONE and live on master** (node-pin/ABI #142→#146, stale-dir re-clone + build-verify #145/#146, ABI-probe-must-instantiate #148/#149). Don't re-open it. + +## Running the plaza locally +The plaza is `world/public/` (`plaza.js` + `index.html`), served by the Cloudflare Worker (`src/world/worker-core.ts`) behind `GET /v1/world/:district`. For a local E2E: build (`npm run build`), stand up an HTTP server that routes `/v1/*` to `createWorldFetchHandler({ db: sqliteAsD1(new Database(':memory:')) })` and serves `world/public/` for everything else, seed via `POST /v1/teleport` (pass `district: ''`), then load `http://localhost:PORT/?district=plaza-3&time=day`. `window.__PLAZA__.citizens` exposes the rendered roster for assertions. + +## Future (post-2.0.0, in memory) +- Extract the World **server** into its own repo (client sync + CLI stay in buddy repo). +- Choose a coding-trade at level 50 (agency beyond the auto-derived RO job class). +- Human chibi avatar rendered beside the buddy (the `avatar` field already syncs; deferred). diff --git a/src/__tests__/world/handlers.test.ts b/src/__tests__/world/handlers.test.ts index 78e5bbf..0215581 100644 --- a/src/__tests__/world/handlers.test.ts +++ b/src/__tests__/world/handlers.test.ts @@ -10,6 +10,7 @@ import { RateLimiter, } from '../../lib/world/handlers.js'; import { totalXpForLevel } from '../../lib/leveling.js'; +import { DISTRICT_CAPACITY } from '../../lib/world/districts.js'; import type { WorldSnapshot } from '../../lib/world/validate.js'; const T0 = 1_800_000_000_000; @@ -60,6 +61,49 @@ describe('world handlers', () => { expect(res.status).toBe(400); }); + it('teleport into a named town resolves it to the right plaza', async () => { + const res = await handleTeleport( + { token: 'tok-0123456789abcdef', snapshot: snap(), district: 'geffen' }, + store, + OPTS + ); + expect(res.status).toBe(200); + expect((res.body as { district: string }).district).toBe('plaza-3'); + }); + + it('teleport to an unknown town is a 400 unknown_town', async () => { + const res = await handleTeleport( + { token: 'tok-0123456789abcdef', snapshot: snap(), district: 'gondor' }, + store, + OPTS + ); + expect(res.status).toBe(400); + expect((res.body as { error: string }).error).toBe('unknown_town'); + }); + + it('teleport to a full town is 409, but an occupant may still re-teleport there', async () => { + const occupant = 'occupant-0123456789'; + for (let i = 0; i < DISTRICT_CAPACITY; i++) { + const token = i === 0 ? occupant : `filler-${i}-aaaaaaaa`; + await handleTeleport({ token, snapshot: snap({ name: `Cit${i}` }), district: 'geffen' }, store, OPTS); + } + const late = await handleTeleport( + { token: 'latecomer-0123456789', snapshot: snap({ name: 'Late' }), district: 'geffen' }, + store, + OPTS + ); + expect(late.status).toBe(409); + expect((late.body as { error: string }).error).toBe('town_full'); + + // Someone already living in the full town can still re-sync/refresh there. + const stay = await handleTeleport( + { token: occupant, snapshot: snap({ name: 'Cit0' }), district: 'geffen' }, + store, + OPTS + ); + expect(stay.status).toBe(200); + }); + it('events with an unknown token returns 401', async () => { const res = await handleEvents({ token: 'nope', events: [{ type: 'commit', ts: T0 }] }, store, OPTS); expect(res.status).toBe(401); diff --git a/src/__tests__/world/plaza-smoke.test.ts b/src/__tests__/world/plaza-smoke.test.ts index 03042ea..d72c243 100644 --- a/src/__tests__/world/plaza-smoke.test.ts +++ b/src/__tests__/world/plaza-smoke.test.ts @@ -336,21 +336,47 @@ describe('plaza smoke test (headless browser)', () => { iframes: document.querySelectorAll('iframe').length, }))()`)) as { hasButton: boolean; label: string; iframes: number }; expect(before.hasButton).toBe(true); - expect(before.label.toLowerCase()).toContain('music'); + // Label names this town's RO theme (per-town music); still no iframe yet. + expect(before.label.toLowerCase()).toContain('theme'); expect(before.iframes).toBe(0); await page.click('#music-toggle'); await page.waitForSelector('#music-player iframe', { timeout: 5000 }); - const src = (await page.evaluate( - `document.querySelector('#music-player iframe').getAttribute('src')` - )) as string; - expect(src).toContain('youtube-nocookie.com/embed'); - expect(src).toContain('PLWa6qxs0LO-v6pR8B9vVmqN-asyi8Crpp'); - - // Toggle off removes the player entirely (stops audio + network). + const opened = (await page.evaluate(`(() => { + const p = document.getElementById('music-player'); + return { + src: p.querySelector('iframe').getAttribute('src'), + // ToS: the player must stay >=200x200 and visible while playing. + visible: !p.hidden, + w: p.querySelector('iframe').width, + h: p.querySelector('iframe').height, + // RO-blue jukebox chrome: a titled bar naming this town + a close (x). + hasBar: !!p.querySelector('.jukebox-bar'), + title: (p.querySelector('.jukebox-title')?.textContent || '').toLowerCase(), + hasClose: !!document.getElementById('music-close'), + }; + })()`)) as { src: string; visible: boolean; w: string; h: string; hasBar: boolean; title: string; hasClose: boolean }; + expect(opened.src).toContain('youtube-nocookie.com/embed'); + // Per-town single-video loop: THIS town's verified RO city theme id. + expect(opened.src).toMatch(/embed\/[\w-]{6,}\?/); + expect(opened.visible).toBe(true); + expect(Number(opened.w)).toBeGreaterThanOrEqual(200); + expect(Number(opened.h)).toBeGreaterThanOrEqual(200); + expect(opened.hasBar).toBe(true); + expect(opened.title).toContain('prontera'); + expect(opened.hasClose).toBe(true); + + // The panel's x button is the "put it away" gesture: collapse AND stop. + await page.click('#music-close'); + const afterClose = (await page.evaluate(`document.querySelectorAll('iframe').length`)) as number; + expect(afterClose).toBe(0); + + // Re-opening then clicking the toggle again also stops it. + await page.click('#music-toggle'); + await page.waitForSelector('#music-player iframe', { timeout: 5000 }); await page.click('#music-toggle'); - const after = (await page.evaluate(`document.querySelectorAll('iframe').length`)) as number; - expect(after).toBe(0); + const afterToggle = (await page.evaluate(`document.querySelectorAll('iframe').length`)) as number; + expect(afterToggle).toBe(0); }, 60_000); it('renders every sprite with WCAG AA contrast against the plaza tiles', async () => { diff --git a/src/__tests__/world/store.test.ts b/src/__tests__/world/store.test.ts index d181102..9515d94 100644 --- a/src/__tests__/world/store.test.ts +++ b/src/__tests__/world/store.test.ts @@ -54,6 +54,31 @@ describe.each(IMPLS)('%s', (_name, makeStore) => { expect(citizen?.level).toBe(5); }); + it('teleport into a chosen district places the citizen there', async () => { + const res = await store.teleport('tokenhash-1', snap(), T0, 'plaza-3'); + expect(res.created).toBe(true); + expect(res.district).toBe('plaza-3'); + const view = await store.district('plaza-3', 0); + expect(view.citizens.map((c) => c.slug)).toContain(res.slug); + }); + + it('re-teleport with a new district MOVES the citizen', async () => { + const first = await store.teleport('tokenhash-1', snap(), T0, 'plaza-1'); + const moved = await store.teleport('tokenhash-1', snap(), T0 + 1000, 'plaza-3'); + expect(moved.created).toBe(false); + expect(moved.district).toBe('plaza-3'); + expect((await store.findByTokenHash('tokenhash-1'))?.district).toBe('plaza-3'); + expect((await store.district('plaza-1', 0)).citizens.map((c) => c.slug)).not.toContain(first.slug); + expect((await store.district('plaza-3', 0)).citizens.map((c) => c.slug)).toContain(first.slug); + }); + + it('re-teleport without a district preserves the current one', async () => { + await store.teleport('tokenhash-1', snap(), T0, 'plaza-5'); + const again = await store.teleport('tokenhash-1', snap(), T0 + 1000); + expect(again.district).toBe('plaza-5'); + expect((await store.findByTokenHash('tokenhash-1'))?.district).toBe('plaza-5'); + }); + it('records events and bumps last_seen_at', async () => { await store.teleport('tokenhash-1', snap(), T0); const citizen = (await store.findByTokenHash('tokenhash-1'))!; diff --git a/src/__tests__/world/towns-drift.test.ts b/src/__tests__/world/towns-drift.test.ts new file mode 100644 index 0000000..61b0d0b --- /dev/null +++ b/src/__tests__/world/towns-drift.test.ts @@ -0,0 +1,21 @@ +import { describe, it, expect } from 'vitest'; +import { readFileSync } from 'node:fs'; +import { join, dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { TOWN_NAMES } from '../../lib/world/towns.js'; + +const repoRoot = join(dirname(fileURLToPath(import.meta.url)), '..', '..', '..'); + +// The server-side town registry (towns.ts) and the client's rich TOWNS[] in +// plaza.js must agree on names AND order — order is what maps a town to its +// plaza-N. This guards against the two drifting silently. +describe('world/public/plaza.js TOWNS drift guard', () => { + it('plaza.js town names/order match TOWN_NAMES', () => { + const src = readFileSync(join(repoRoot, 'world', 'public', 'plaza.js'), 'utf8'); + const start = src.indexOf('const TOWNS = ['); + expect(start, 'TOWNS array not found in plaza.js').toBeGreaterThan(-1); + const block = src.slice(start, src.indexOf('];', start)); + const names = [...block.matchAll(/name:\s*'([^']+)'/g)].map((m) => m[1]); + expect(names).toEqual([...TOWN_NAMES]); + }); +}); diff --git a/src/__tests__/world/towns.test.ts b/src/__tests__/world/towns.test.ts new file mode 100644 index 0000000..65b196d --- /dev/null +++ b/src/__tests__/world/towns.test.ts @@ -0,0 +1,42 @@ +import { describe, it, expect } from 'vitest'; +import { TOWN_NAMES, TOWN_BLURB, districtForTown, townForDistrict } from '../../lib/world/towns.js'; + +describe('town registry', () => { + it('maps town name → plaza-N by order', () => { + expect(districtForTown('Prontera')).toBe('plaza-1'); + expect(districtForTown('Geffen')).toBe('plaza-3'); + expect(districtForTown('Lutie')).toBe('plaza-6'); + }); + + it('resolves town names case-insensitively and trimmed', () => { + expect(districtForTown('geffen')).toBe('plaza-3'); + expect(districtForTown(' MORROC ')).toBe('plaza-5'); + }); + + it('returns null for an unknown town', () => { + expect(districtForTown('Gondor')).toBeNull(); + expect(districtForTown('')).toBeNull(); + }); + + it('accepts a raw plaza-N as its own district', () => { + expect(districtForTown('plaza-4')).toBe('plaza-4'); + }); + + it('maps plaza-N → town name (wrapping like the plaza)', () => { + expect(townForDistrict('plaza-1')).toBe('Prontera'); + expect(townForDistrict('plaza-3')).toBe('Geffen'); + expect(townForDistrict('plaza-7')).toBe('Prontera'); // wraps mod 6 + expect(townForDistrict('plaza-12')).toBe('Lutie'); + }); + + it('returns null for a non-district string', () => { + expect(townForDistrict('downtown')).toBeNull(); + expect(townForDistrict('plaza-0')).toBeNull(); + }); + + it('has a blurb for every town', () => { + for (const name of TOWN_NAMES) { + expect(TOWN_BLURB[name], `blurb for ${name}`).toBeTruthy(); + } + }); +}); diff --git a/src/__tests__/world/world-cli.test.ts b/src/__tests__/world/world-cli.test.ts index 4e7f647..cbfd097 100644 --- a/src/__tests__/world/world-cli.test.ts +++ b/src/__tests__/world/world-cli.test.ts @@ -93,4 +93,42 @@ describe('worldCommand', () => { const out = await worldCommand(['dance'], deps); expect(out.join('\n')).toMatch(/usage/i); }); + + it('teleport sends the buddy to that town and names it', async () => { + const out = await worldCommand(['teleport', 'geffen'], deps); + expect(out.join('\n')).toMatch(/geffen/i); + expect(loadWorldConfig(configPath)?.district).toBe('plaza-3'); + }); + + it('teleport works alongside --avatar', async () => { + await worldCommand(['teleport', 'morroc', '--avatar', 'chibi-7'], deps); + const cfg = loadWorldConfig(configPath); + expect(cfg?.district).toBe('plaza-5'); + expect(cfg?.avatar).toBe('chibi-7'); + }); + + it('teleport to an unknown town lists the towns and saves nothing', async () => { + const out = (await worldCommand(['teleport', 'gondor'], deps)).join('\n'); + expect(out).toMatch(/unknown town/i); + expect(out).toContain('Geffen'); + expect(loadWorldConfig(configPath)).toBeNull(); + }); + + it('re-teleport to a different town moves the buddy', async () => { + await worldCommand(['teleport', 'prontera'], deps); + await worldCommand(['teleport', 'lutie'], deps); + expect(loadWorldConfig(configPath)?.district).toBe('plaza-6'); + }); + + it('status names the town after teleporting to one', async () => { + await worldCommand(['teleport', 'geffen'], deps); + expect((await worldCommand(['status'], deps)).join('\n')).toMatch(/Geffen/); + }); + + it('towns lists all six RO cities', async () => { + const out = (await worldCommand(['towns'], deps)).join('\n'); + for (const t of ['Prontera', 'Payon', 'Geffen', 'Alberta', 'Morroc', 'Lutie']) { + expect(out).toContain(t); + } + }); }); diff --git a/src/cli/world-cli.ts b/src/cli/world-cli.ts index 9334f6e..ee1034a 100644 --- a/src/cli/world-cli.ts +++ b/src/cli/world-cli.ts @@ -15,6 +15,7 @@ import { DEFAULT_API_URL, type WorldConfig, } from '../lib/world/client.js'; +import { TOWN_NAMES, TOWN_BLURB, districtForTown, townForDistrict } from '../lib/world/towns.js'; import type { Companion } from '../lib/types.js'; type FetchLike = (url: string, init?: RequestInit) => Promise; @@ -30,12 +31,15 @@ export interface WorldCliDeps { const USAGE = [ 'Usage: buddy-world ', '', - ' teleport [--avatar chibi-1..8] opt in and beam your buddy into the plaza', + ' teleport [town] [--avatar chibi-1..8] beam your buddy into a town (default: auto)', + ' towns list the RO cities you can teleport to', ' status show your buddy\'s world link', ' anon toggle anonymous mode ("a wild Void Cat")', ' recall [--purge] leave the world (--purge deletes all server data)', ]; +const AVAILABLE_TOWNS = `Available: ${TOWN_NAMES.join(', ')}.`; + const PRIVACY_NOTE = [ 'Buddy World syncs GAME STATE ONLY: name, species, level, XP, mood, stats.', 'Never your code, prompts, or messages. Anonymous mode: buddy-world anon on.', @@ -57,6 +61,20 @@ export async function worldCommand(argv: string[], deps: WorldCliDeps): Promise< const companion = deps.loadCompanion(); if (!companion) return ['No buddy found. Hatch one first!']; + const avatarIdx = rest.indexOf('--avatar'); + const avatar = avatarIdx >= 0 ? rest[avatarIdx + 1] : undefined; + // First positional that isn't a flag or the --avatar value = the town. + const townArg = rest.find((a, i) => !a.startsWith('--') && !(avatarIdx >= 0 && i === avatarIdx + 1)); + + // Resolve the chosen town up front — reject a bad name before any network + // call or config write. + let desiredDistrict: string | undefined; + if (townArg) { + const resolved = districtForTown(townArg); + if (!resolved) return [`Unknown town "${townArg}". ${AVAILABLE_TOWNS}`]; + desiredDistrict = resolved; + } + const existing = loadWorldConfig(configPath); if (!existing) { out.push(...PRIVACY_NOTE); @@ -67,24 +85,43 @@ export async function worldCommand(argv: string[], deps: WorldCliDeps): Promise< } } - const avatarIdx = rest.indexOf('--avatar'); - const avatar = avatarIdx >= 0 ? rest[avatarIdx + 1] : existing?.avatar ?? 'chibi-1'; - + const avatarChoice = avatar ?? existing?.avatar ?? 'chibi-1'; const cfg: WorldConfig = existing ?? { token: generateToken(), apiUrl }; const sync = makeSync(cfg, deps); - const res = await sync.teleport(buildWorldSnapshot(companion, avatar)); - saveWorldConfig({ ...cfg, slug: res.slug, url: res.url, district: res.district, avatar }, configPath); - - out.push(`✨ ${companion.name} teleported into Buddy World!`); + let res; + try { + res = await sync.teleport(buildWorldSnapshot(companion, avatarChoice), { district: desiredDistrict }); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + if (msg.includes('town_full') && townArg) { + return [`${townArg} is full right now. Pick another — buddy-world towns.`]; + } + return [`Teleport failed (${msg}). Try again in a moment.`]; + } + saveWorldConfig( + { ...cfg, slug: res.slug, url: res.url, district: res.district, avatar: avatarChoice }, + configPath + ); + + const town = townForDistrict(res.district); + const where = town ? `${town} (${TOWN_BLURB[town]})` : 'the plaza'; + out.push(`✨ ${companion.name} warped to ${where}!`); out.push(` Watch them wander: ${res.url}`); out.push(' Level-ups, commits, and deploys now celebrate in the plaza.'); return out; } + case 'towns': { + out.push('Towns you can teleport to (buddy-world teleport ):'); + for (const name of TOWN_NAMES) out.push(` ${name} — ${TOWN_BLURB[name]}`); + return out; + } + case 'status': { const cfg = loadWorldConfig(configPath); if (!cfg?.slug) return ['Your buddy is not in the world yet. Run: buddy-world teleport']; - return [`Your buddy is in ${cfg.district ?? 'the plaza'}: ${cfg.url}`]; + const town = cfg.district ? townForDistrict(cfg.district) : null; + return [`Your buddy is in ${town ?? cfg.district ?? 'the plaza'}: ${cfg.url}`]; } case 'anon': { diff --git a/src/lib/world/client.ts b/src/lib/world/client.ts index 7215688..3c69bbd 100644 --- a/src/lib/world/client.ts +++ b/src/lib/world/client.ts @@ -129,8 +129,15 @@ export class WorldSync { } } - async teleport(snapshot: WorldSnapshot): Promise<{ slug: string; url: string; district: string }> { - const res = await this.post('/v1/teleport', { token: this.cfg.token, snapshot }); + async teleport( + snapshot: WorldSnapshot, + opts: { district?: string } = {} + ): Promise<{ slug: string; url: string; district: string }> { + const res = await this.post('/v1/teleport', { + token: this.cfg.token, + snapshot, + ...(opts.district ? { district: opts.district } : {}), + }); if (!res || res.status !== 200) { const detail = res ? ((await res.json()) as { error?: string }).error : 'network unreachable'; throw new Error(`teleport failed: ${detail}`); diff --git a/src/lib/world/d1-store.ts b/src/lib/world/d1-store.ts index a912ad1..4898257 100644 --- a/src/lib/world/d1-store.ts +++ b/src/lib/world/d1-store.ts @@ -66,7 +66,12 @@ export class D1WorldStore implements WorldStore { return new D1WorldStore(db); } - async teleport(tokenHash: string, snap: WorldSnapshot, nowMs: number): Promise { + async teleport( + tokenHash: string, + snap: WorldSnapshot, + nowMs: number, + desiredDistrict?: string + ): Promise { const existing = await this.db .prepare('SELECT * FROM citizens WHERE token_hash = ?') .bind(tokenHash) @@ -74,15 +79,17 @@ export class D1WorldStore implements WorldStore { if (existing) { // Snapshot fields are NOT written here: re-teleport must go through - // the handler's clamped update path, never around it. + // the handler's clamped update path, never around it. The one exception + // is `district` when the owner explicitly asks to move towns. + const district = desiredDistrict ?? (existing.district as string); await this.db - .prepare('UPDATE citizens SET hidden = 0, avatar = COALESCE(?, avatar) WHERE id = ?') - .bind(snap.avatar ?? null, existing.id) + .prepare('UPDATE citizens SET hidden = 0, avatar = COALESCE(?, avatar), district = ? WHERE id = ?') + .bind(snap.avatar ?? null, district, existing.id) .run(); - return { created: false, slug: existing.slug as string, district: existing.district as string }; + return { created: false, slug: existing.slug as string, district }; } - const district = pickDistrict(await this.districtCounts()); + const district = desiredDistrict ?? pickDistrict(await this.districtCounts()); const id = randomUUID(); let slug = makeSlug(snap.name); while (await this.db.prepare('SELECT 1 FROM citizens WHERE slug = ?').bind(slug).first()) { diff --git a/src/lib/world/handlers.ts b/src/lib/world/handlers.ts index d434e69..6ea2c4e 100644 --- a/src/lib/world/handlers.ts +++ b/src/lib/world/handlers.ts @@ -8,6 +8,8 @@ import { validateSnapshot, type WorldSnapshot } from './validate.js'; import { isNameClean } from './identity.js'; import { spendXpBudget } from './antiabuse.js'; import { levelFromXp } from '../leveling.js'; +import { districtForTown } from './towns.js'; +import { DISTRICT_CAPACITY } from './districts.js'; import type { WorldStore, CitizenRow } from './store.js'; export const MAX_EVENT_BATCH = 50; @@ -53,7 +55,7 @@ async function applyClampedSnapshot( } export async function handleTeleport( - payload: { token?: unknown; snapshot?: unknown }, + payload: { token?: unknown; snapshot?: unknown; district?: unknown }, store: WorldStore, opts: HandlerOpts ): Promise { @@ -66,13 +68,28 @@ export async function handleTeleport( if (!isNameClean(snap.name)) return bad(400, 'name rejected by filter'); const tokenHash = hashToken(payload.token); + + // Optional chosen destination: an owner may pick a town by name (or plaza-N). + let desiredDistrict: string | undefined; + if (payload.district !== undefined && payload.district !== null && payload.district !== '') { + if (typeof payload.district !== 'string') return bad(400, 'unknown_town'); + const resolved = districtForTown(payload.district); + if (!resolved) return bad(400, 'unknown_town'); + // Capacity gate — but never bounce someone who already lives there. + const counts = await store.districtCounts(); + if ((counts[resolved] ?? 0) >= DISTRICT_CAPACITY) { + const current = await store.findByTokenHash(tokenHash); + if (current?.district !== resolved) return bad(409, 'town_full'); + } + desiredDistrict = resolved; + } // ACCEPTED RISK: a first teleport's claimed XP is trusted (the server // never saw the buddy's local history — that's inherent to opt-in sync // of a local-first game). Mitigations: level must match the XP curve, // fresh citizens start with a near-empty budget (no fast growth on top), // and analytics can segment by entry level. Post-creation changes all go // through the clamped path below. - const result = await store.teleport(tokenHash, snap, opts.now); + const result = await store.teleport(tokenHash, snap, opts.now, desiredDistrict); if (!result.created) { // Existing citizen: snapshot changes go through the clamp, never around it. const citizen = await store.findByTokenHash(tokenHash); diff --git a/src/lib/world/store.ts b/src/lib/world/store.ts index 7ecb1b6..45e87de 100644 --- a/src/lib/world/store.ts +++ b/src/lib/world/store.ts @@ -52,7 +52,7 @@ export interface DistrictView { } export interface WorldStore { - teleport(tokenHash: string, snap: WorldSnapshot, nowMs: number): Promise; + teleport(tokenHash: string, snap: WorldSnapshot, nowMs: number, desiredDistrict?: string): Promise; findByTokenHash(tokenHash: string): Promise; updateSnapshot(citizenId: string, snap: WorldSnapshot, nowMs: number, xpBucket?: number): Promise; recordEvents(citizenId: string, events: Array<{ type: string; ts: number }>): Promise; @@ -96,21 +96,28 @@ export class SqliteWorldStore implements WorldStore { db.exec(WORLD_SCHEMA_SQL); } - async teleport(tokenHash: string, snap: WorldSnapshot, nowMs: number): Promise { + async teleport( + tokenHash: string, + snap: WorldSnapshot, + nowMs: number, + desiredDistrict?: string + ): Promise { const existing = this.db.prepare('SELECT * FROM citizens WHERE token_hash = ?').get(tokenHash) as | Record | undefined; if (existing) { // Snapshot fields are NOT written here: re-teleport must go through - // the handler's clamped update path, never around it. + // the handler's clamped update path, never around it. The one exception + // is `district` when the owner explicitly asks to move towns. + const district = desiredDistrict ?? (existing.district as string); this.db - .prepare('UPDATE citizens SET hidden = 0, avatar = COALESCE(?, avatar) WHERE id = ?') - .run(snap.avatar ?? null, existing.id); - return { created: false, slug: existing.slug as string, district: existing.district as string }; + .prepare('UPDATE citizens SET hidden = 0, avatar = COALESCE(?, avatar), district = ? WHERE id = ?') + .run(snap.avatar ?? null, district, existing.id); + return { created: false, slug: existing.slug as string, district }; } - const district = pickDistrict(await this.districtCounts()); + const district = desiredDistrict ?? pickDistrict(await this.districtCounts()); const id = randomUUID(); let slug = makeSlug(snap.name); // Regenerate on the (rare) suffix collision rather than failing the insert. diff --git a/src/lib/world/towns.ts b/src/lib/world/towns.ts new file mode 100644 index 0000000..1c66b71 --- /dev/null +++ b/src/lib/world/towns.ts @@ -0,0 +1,43 @@ +// src/lib/world/towns.ts +// Shared town identity for Buddy World. The server speaks generic `plaza-N`; +// this maps the RO city names owners actually type to those districts and back. +// Order MUST match `TOWNS[]` in world/public/plaza.js — guarded by +// src/__tests__/world/towns-drift.test.ts. + +export const TOWN_NAMES = ['Prontera', 'Payon', 'Geffen', 'Alberta', 'Morroc', 'Lutie'] as const; +export type TownName = (typeof TOWN_NAMES)[number]; + +// One-line vibe used for CLI flavor ("warped to Geffen (mage city)"). +export const TOWN_BLURB: Record = { + Prontera: 'the capital', + Payon: 'wooden village', + Geffen: 'mage city', + Alberta: 'port town', + Morroc: 'desert city', + Lutie: 'snow city', +}; + +/** + * Resolve what an owner types into a `plaza-N` district. + * Accepts a town name (case-insensitive, trimmed) or a raw `plaza-N`. + * Returns null for anything unrecognized. + */ +export function districtForTown(input: string): string | null { + const s = input.trim(); + const plaza = s.match(/^plaza-(\d+)$/i); + if (plaza && Number(plaza[1]) >= 1) return `plaza-${Number(plaza[1])}`; + const idx = TOWN_NAMES.findIndex((t) => t.toLowerCase() === s.toLowerCase()); + return idx >= 0 ? `plaza-${idx + 1}` : null; +} + +/** + * Reverse a stored `plaza-N` back to a town name, wrapping the same way the + * plaza's townFor() does (plaza-7 → Prontera). Null for non-district strings. + */ +export function townForDistrict(district: string): TownName | null { + const m = district.match(/^plaza-(\d+)$/i); + if (!m) return null; + const n = Number(m[1]); + if (n < 1) return null; + return TOWN_NAMES[(n - 1) % TOWN_NAMES.length]; +} diff --git a/world/public/index.html b/world/public/index.html index 06b5150..b93e0a0 100644 --- a/world/public/index.html +++ b/world/public/index.html @@ -44,10 +44,32 @@ font-family: inherit; } #music-toggle:hover, #music-toggle:focus-visible { border-color: var(--gold); color: var(--gold); } + /* RO-blue "jukebox" window — styled like a classic Ragnarok Online UI panel. + Stays small + visible while playing (YouTube ToS: >=200x200, not hidden). */ #music-player { position: fixed; bottom: 52px; left: 14px; z-index: 10; - border: 1px solid #3a2f6b; border-radius: 8px; overflow: hidden; + border: 2px solid #23305c; border-radius: 4px; overflow: hidden; background: #0f0c29; + box-shadow: 0 0 0 1px #6d8fd0, 0 6px 18px rgba(0,0,0,0.55); + } + #music-player .jukebox-bar { + display: flex; align-items: center; justify-content: space-between; + height: 22px; padding: 0 4px 0 8px; + background: linear-gradient(#6d8fd0, #3f5f9e); + border-bottom: 1px solid #23305c; + font-size: 11px; color: #fff; text-shadow: 0 1px 1px rgba(0,0,0,0.45); + user-select: none; + } + #music-player .jukebox-title { font-weight: bold; letter-spacing: 0.3px; } + #music-close { + width: 16px; height: 16px; padding: 0; line-height: 1; + display: flex; align-items: center; justify-content: center; + border: 1px solid #23305c; border-radius: 2px; + background: linear-gradient(#d3ddf0, #9fb2da); color: #22345e; + font-size: 11px; font-family: inherit; cursor: pointer; + } + #music-close:hover, #music-close:focus-visible { + background: linear-gradient(#efc0c0, #d38a8a); color: #5a1010; outline: none; } #music-player iframe { display: block; border: 0; } .visually-hidden { diff --git a/world/public/plaza.js b/world/public/plaza.js index 62e8738..959f83e 100644 --- a/world/public/plaza.js +++ b/world/public/plaza.js @@ -25,14 +25,34 @@ const AVATARS = ['🧍', '🧍‍♀️', '🚶', '🧍', '🧑‍💻', '🚶‍♀️', '🧍', '🧙']; // Districts are RO towns. plaza-1 is always Prontera; the rest cycle - // through the classics, each with its own sky/tile mood. + // through the classics. Each carries its AUTHENTIC RO city BGM (verified + // YouTube video IDs from the supplied OST playlist) and its own day + // palette + weather so every city has a distinct vibe like the old game. const TOWNS = [ - { name: 'Prontera', sky: ['#2a2150', '#3a2f6b'], tiles: ['#5d5180', '#564a78'] }, - { name: 'Payon', sky: ['#3a2a1a', '#4d3a24'], tiles: ['#7a5c3a', '#6d5233'] }, - { name: 'Geffen', sky: ['#1a1040', '#2a1a5e'], tiles: ['#4a3a7e', '#413470'] }, - { name: 'Alberta', sky: ['#1a2a3a', '#24455e'], tiles: ['#4a6a7e', '#416070'] }, - { name: 'Morroc', sky: ['#3a241a', '#5e3a24'], tiles: ['#8a6a4a', '#7e6042'] }, - { name: 'Comodo', sky: ['#1a3a3a', '#245e50'], tiles: ['#4a8a6a', '#428060'] }, + { name: 'Prontera', music: 'D30M_vLMvWk', // Theme of Prontera + daySky: ['#8fc9ec', '#d6ecf7'], floor: '#cfc4a8', + roofs: ['#2f8f8a', '#b8563f', '#5f6f96', '#c98a3a'], weather: null, flora: 'leafy', + night: ['#2a2150', '#3a2f6b'] }, + { name: 'Payon', music: 'zwM4pDzR-6g', // Theme of Payon (wooden village) + daySky: ['#e6c79c', '#f5e6cc'], floor: '#c2a878', + roofs: ['#8a4a3a', '#6a4028', '#a0603a', '#7a5230'], weather: 'petals', flora: 'autumn', + night: ['#3a2a1a', '#4d3a24'] }, + { name: 'Geffen', music: 'yWgf7p3_z8w', // Theme of Geffen (mage city) + daySky: ['#b0a0e0', '#dcd2f2'], floor: '#bcb2cc', + roofs: ['#6a4a9a', '#4a5a9a', '#8a5aaa', '#5a4a8a'], weather: null, flora: 'leafy', + night: ['#1a1040', '#2a1a5e'] }, + { name: 'Alberta', music: 'lQjoaA9QqRA', // Theme of Alberta (port) + daySky: ['#8fd0ec', '#cdeef6'], floor: '#c2bfa6', + roofs: ['#3a7ab8', '#d8d0c0', '#4a8aa8', '#c85a4a'], weather: null, flora: 'palm', + night: ['#1a2a3a', '#24455e'] }, + { name: 'Morroc', music: '3-mLZN830y8', // Theme of Morroc (desert) + daySky: ['#f0d597', '#f7edc9'], floor: '#d8c090', + roofs: ['#c98a3a', '#b06a2a', '#d8b060', '#a05a2a'], weather: 'sand', flora: 'cactus', + night: ['#3a241a', '#5e3a24'] }, + { name: 'Lutie', music: '7MSwhIBnqHg', // Theme of Lutie (snow / Christmas) + daySky: ['#c6dbf0', '#ecf3fb'], floor: '#dde5ee', + roofs: ['#b83a48', '#2f7a3a', '#c0c8d8', '#a03040'], weather: 'snow', flora: 'pine', + night: ['#1a2438', '#26375a'] }, ]; function townFor(districtName) { let n = parseInt(String(districtName).replace(/\D/g, ''), 10); @@ -81,7 +101,7 @@ return `${line[jobTier(c.level)]} · Lv.${c.level}`; } - const SPRITE_FONT = '13px Menlo, Consolas, monospace'; + const SPRITE_FONT = 'bold 14px Menlo, Consolas, monospace'; const SPRITE_LINE_H = 13; // ── deterministic rng ────────────────────────────────────────────────── @@ -139,7 +159,7 @@ // Pavement contrast baselines — must track the ACTUAL floor per lighting // (day = light flagstone, night = dark slate), or night sprites go // invisible. Chosen at draw time via activeTileBg(). - const DAY_TILE_BG = [207, 196, 168]; // #cfc4a8 bright flagstone + const DAY_TILE_BG = hexToRgb(TOWN.floor); // sprite-contrast baseline tracks this town's floor const NIGHT_TILE_BG = [59, 53, 80]; // #3b3550 const AA_RATIO = 4.5; function activeTileBg() { return isNight() ? NIGHT_TILE_BG : DAY_TILE_BG; } @@ -162,7 +182,14 @@ const t = Math.min(1, Math.max(0, (level - 1) / 49)) * (pal.length - 1); const i = Math.min(pal.length - 2, Math.floor(t)); const f = t - i; - return ensureContrast([0, 1, 2].map((c) => Math.round(lerp(pal[i][c], pal[i + 1][c], f)))); + const raw = [0, 1, 2].map((c) => Math.round(lerp(pal[i][c], pal[i + 1][c], f))); + return ensureContrast(saturate(raw, 1.55)); // punch up saturation before AA + } + + // Boost chroma: push channels away from luminance (grey) — vivid, not neon. + function saturate(rgb, fac) { + const lum = 0.299 * rgb[0] + 0.587 * rgb[1] + 0.114 * rgb[2]; + return rgb.map((c) => Math.max(0, Math.min(255, Math.round(lum + (c - lum) * fac)))); } // Stable per-species sprite box: max cols/rows across ALL frames, so a @@ -276,10 +303,10 @@ const g = envBuf.getContext('2d'); const b = plazaBounds(); - // backdrop sky — bright RO blue by day (with a soft sun), dark at night. + // backdrop sky — each town's own day palette (or its night mood). const sky = g.createLinearGradient(0, 0, 0, b.skyline + 60); - if (night) { sky.addColorStop(0, '#0b0820'); sky.addColorStop(1, '#141030'); } - else { sky.addColorStop(0, '#8fc9ec'); sky.addColorStop(1, '#d6ecf7'); } + if (night) { sky.addColorStop(0, TOWN.night[0]); sky.addColorStop(1, TOWN.night[1]); } + else { sky.addColorStop(0, TOWN.daySky[0]); sky.addColorStop(1, TOWN.daySky[1]); } g.fillStyle = sky; g.fillRect(0, 0, canvas.width, b.skyline + 60); if (!night) { @@ -380,7 +407,7 @@ function drawPavement(g, b, night) { const top = b.skyline; // horizon: floor meets the buildings const floorH = canvas.height - top; - const base = night ? '#3b3550' : '#cfc4a8'; + const base = night ? '#3b3550' : TOWN.floor; // a slight gradient: cooler/darker near the horizon, warmer near camera const grad = g.createLinearGradient(0, top, 0, canvas.height); grad.addColorStop(0, shadeColor(base, night ? 0.78 : 0.86)); @@ -430,8 +457,8 @@ // Prontera skyline: varied buildings with the iconic RO steep roofs in // teal / terracotta / slate, cream walls, tidy shuttered windows. - const ROOF_DAY = ['#2f8f8a', '#b8563f', '#5f6f96', '#c98a3a']; // teal, terracotta, slate, ochre function drawBuildings(g, b, night) { + const ROOF_DAY = TOWN.roofs; // per-town roof palette const y0 = b.skyline; const wall = night ? '#3a3352' : '#efe6cf'; const wallShade = night ? '#312a48' : '#ddceac'; @@ -526,16 +553,47 @@ } function drawTree(g, x, y, night) { - // trunk - g.fillStyle = night ? '#3a2a1a' : '#7a5230'; - g.fillRect(x - 4, y, 8, 22); - // layered canopy blobs - const canopy = night ? ['#1e3a24', '#254a2c'] : ['#4e8f4a', '#5fa858']; + const flora = TOWN.flora || 'leafy'; + const trunk = night ? '#3a2a1a' : '#7a5230'; + + if (flora === 'pine') { // Lutie: snow-capped conifer + g.fillStyle = trunk; g.fillRect(x - 3, y + 6, 6, 16); + const green = night ? '#1f4a2e' : '#2f7a3a'; + for (const [dy, w] of [[-2, 20], [-14, 15], [-24, 10]]) { + g.fillStyle = green; + g.beginPath(); g.moveTo(x - w, y + dy); g.lineTo(x, y + dy - 16); g.lineTo(x + w, y + dy); g.closePath(); g.fill(); + g.fillStyle = 'rgba(255,255,255,0.85)'; // snow cap + g.beginPath(); g.moveTo(x - w * 0.5, y + dy - 6); g.lineTo(x, y + dy - 16); g.lineTo(x + w * 0.5, y + dy - 6); g.closePath(); g.fill(); + } + g.fillStyle = '#ffd700'; g.beginPath(); g.arc(x, y - 40, 3, 0, Math.PI * 2); g.fill(); // star + return; + } + if (flora === 'palm') { // Alberta: seaside palm + g.fillStyle = trunk; g.fillRect(x - 3, y - 4, 6, 26); + g.strokeStyle = night ? '#1e3a24' : '#3f9a4a'; g.lineWidth = 5; g.lineCap = 'round'; + for (const a of [-2.4, -1.9, -1.2, -0.7]) { + g.beginPath(); g.moveTo(x, y - 4); g.quadraticCurveTo(x + Math.cos(a) * 22, y - 20, x + Math.cos(a) * 34, y - 8 + Math.sin(a) * 6); g.stroke(); + } + g.lineCap = 'butt'; + return; + } + if (flora === 'cactus') { // Morroc: desert cactus + const c = night ? '#2a5a34' : '#4a8a4a'; + g.fillStyle = c; + g.fillRect(x - 5, y - 24, 10, 46); + g.fillRect(x - 16, y - 8, 8, 6); g.fillRect(x - 16, y - 18, 6, 12); + g.fillRect(x + 8, y - 4, 8, 6); g.fillRect(x + 12, y - 14, 6, 12); + return; + } + // trunk + canopy (leafy / autumn) + g.fillStyle = trunk; g.fillRect(x - 4, y, 8, 22); + const canopy = flora === 'autumn' + ? (night ? ['#5a3a1a', '#6a4a1a'] : ['#c87a2a', '#d89a3a']) + : (night ? ['#1e3a24', '#254a2c'] : ['#4e8f4a', '#5fa858']); for (const [dx, dy, r, ci] of [[-10, -6, 15, 0], [10, -6, 15, 0], [0, -16, 18, 1], [0, -2, 16, 1]]) { g.fillStyle = canopy[ci]; g.beginPath(); g.arc(x + dx, y + dy, r, 0, Math.PI * 2); g.fill(); } - // highlight g.fillStyle = night ? 'rgba(120,180,120,0.15)' : 'rgba(255,255,255,0.2)'; g.beginPath(); g.arc(x - 4, y - 20, 7, 0, Math.PI * 2); g.fill(); } @@ -635,15 +693,14 @@ ctx.save(); ctx.font = SPRITE_FONT; ctx.textAlign = 'left'; - // dark halo behind glyphs for separation from the checkered tiles - ctx.shadowColor = active ? `rgb(${r},${g},${b2})` : 'rgba(0,0,0,0.9)'; - ctx.shadowBlur = active ? 14 : 3; - ctx.fillStyle = `rgb(${r},${g},${b2})`; - // Horizontal: center on the stable per-species box (immune to frames - // that render narrower). Vertical: bottom-anchor THIS frame's lines so - // variable line counts can never bob the sprite. Sitting buddies drop a - // few px (RO seated posture) and get a little cushion. const sitDrop = actor.sitting ? 6 : 0; + + // Horizontal: center on the stable per-species box. Vertical: + // bottom-anchor THIS frame's lines. Just a soft halo — the saturated + // color carries legibility, no opaque backing plate. + ctx.shadowColor = active ? `rgb(${r},${g},${b2})` : 'rgba(0,0,0,0.85)'; + ctx.shadowBlur = active ? 14 : 4; + ctx.fillStyle = `rgb(${r},${g},${b2})`; let lastLineY = actor.y; lines.forEach((line, i) => { const y = actor.y + (i - lines.length) * SPRITE_LINE_H + sitDrop; @@ -849,6 +906,7 @@ drawCitizen(c, actor, now); } state.sittingCount = sitting; + drawWeather(now); drawButterflies(now); drawKafra(now); drawClickMarkers(now); @@ -971,6 +1029,34 @@ } } + // Per-town weather: Lutie snows, Morroc has blowing sand, Payon drifts + // cherry petals. Deterministic particles seeded by district. + const weatherParts = []; + function drawWeather(now) { + const kind = TOWN.weather; + if (!kind || REDUCED_MOTION) return; + if (!weatherParts.length) { + const seed = mulberry32(hashStr('wx' + district)); + const count = kind === 'sand' ? 60 : 44; + for (let i = 0; i < count; i++) { + weatherParts.push({ x: seed(), y: seed(), sp: 0.2 + seed() * 0.8, drift: seed(), sz: 1.5 + seed() * 2.5 }); + } + } + const style = { + snow: { color: 'rgba(255,255,255,0.9)', fall: 0.4, sway: 30, round: true }, + sand: { color: 'rgba(210,180,120,0.5)', fall: 0.3, sway: 90, round: false }, + petals: { color: 'rgba(255,180,200,0.8)', fall: 0.35, sway: 50, round: false }, + }[kind]; + const t = now / 1000; + for (const p of weatherParts) { + const y = ((p.y + t * p.sp * style.fall) % 1) * canvas.height; + const x = ((p.x + Math.sin(t * 0.5 + p.drift * 10) * (style.sway / canvas.width)) % 1) * canvas.width; + ctx.fillStyle = style.color; + if (style.round) { ctx.beginPath(); ctx.arc(x, y, p.sz, 0, Math.PI * 2); ctx.fill(); } + else { ctx.fillRect(x, y, p.sz + 1, p.sz); } + } + } + // Ambient butterflies — a few drift across the square (RO town life). const butterflies = []; function drawButterflies(now) { @@ -1276,43 +1362,67 @@ requestAnimationFrame(tick); } - // ── plaza music (Ragnarok Online OST) ───────────────────────────────── - // Strictly opt-in: no YouTube iframe (and therefore no third-party - // request) exists until the visitor clicks. Official embed only — - // rights holders keep attribution/monetization. youtube-nocookie keeps - // tracking to the minimum YouTube offers. - const MUSIC_PLAYLIST = 'PLWa6qxs0LO-v6pR8B9vVmqN-asyi8Crpp'; + // ── plaza music: THIS town's authentic RO city BGM ──────────────────── + // Each town plays its real Ragnarok Online city theme (verified video IDs + // from the supplied OST playlist). Strictly opt-in: no YouTube iframe (or + // third-party request) until the visitor clicks. Official embed only — + // rights holders keep attribution/monetization; youtube-nocookie minimizes + // tracking. const musicToggle = document.getElementById('music-toggle'); const musicPlayer = document.getElementById('music-player'); if (musicToggle && musicPlayer) { - musicToggle.addEventListener('click', () => { - const playing = musicPlayer.querySelector('iframe'); - if (playing) { - musicPlayer.replaceChildren(); // removes iframe → stops audio + network - musicPlayer.hidden = true; - musicToggle.textContent = '🎵 music'; - musicToggle.setAttribute('aria-pressed', 'false'); - musicToggle.setAttribute('aria-label', 'Play plaza music (Ragnarok Online OST via YouTube)'); - return; - } + // "Put it away": remove the iframe → stops audio + all network to YouTube. + // Both the 🎵 toggle and the panel's ✕ route through here. + const stopMusic = () => { + musicPlayer.replaceChildren(); + musicPlayer.hidden = true; + musicToggle.textContent = `🎵 ${TOWN.name} theme`; + musicToggle.setAttribute('aria-pressed', 'false'); + musicToggle.setAttribute('aria-label', `Play the ${TOWN.name} theme (Ragnarok Online OST via YouTube)`); + }; + + const startMusic = () => { + // RO-blue jukebox title bar: names the town + a ✕ "put it away" button. + const bar = document.createElement('div'); + bar.className = 'jukebox-bar'; + const title = document.createElement('span'); + title.className = 'jukebox-title'; + title.textContent = `♪ ${TOWN.name}`; + const close = document.createElement('button'); + close.id = 'music-close'; + close.type = 'button'; + close.textContent = '✕'; + close.setAttribute('aria-label', `Close the ${TOWN.name} jukebox and stop the music`); + close.addEventListener('click', stopMusic); + bar.append(title, close); + const iframe = document.createElement('iframe'); - // YouTube ToS requires the embedded player be >=200x200 and visible - // (Required Minimum Functionality). No smaller, no hiding. - iframe.width = '300'; + // YouTube ToS requires the embedded player stay >=200x200 and visible. + iframe.width = '220'; iframe.height = '200'; + // Single-video loop needs playlist= alongside loop=1. iframe.src = - `https://www.youtube-nocookie.com/embed/videoseries?list=${MUSIC_PLAYLIST}` + - '&autoplay=1&loop=1'; - iframe.title = 'Plaza music — Ragnarok Online OST (YouTube)'; + `https://www.youtube-nocookie.com/embed/${TOWN.music}` + + `?autoplay=1&loop=1&playlist=${TOWN.music}`; + iframe.title = `${TOWN.name} theme — Ragnarok Online OST (YouTube)`; iframe.allow = 'autoplay; encrypted-media'; iframe.referrerPolicy = 'strict-origin-when-cross-origin'; - musicPlayer.replaceChildren(iframe); + + musicPlayer.replaceChildren(bar, iframe); musicPlayer.hidden = false; musicToggle.textContent = '🔇 stop music'; musicToggle.setAttribute('aria-pressed', 'true'); musicToggle.setAttribute('aria-label', 'Stop plaza music'); + }; + + musicToggle.addEventListener('click', () => { + if (musicPlayer.querySelector('iframe')) stopMusic(); + else startMusic(); }); + // Initial label names this town's theme. + musicToggle.textContent = `🎵 ${TOWN.name} theme`; + musicToggle.setAttribute('aria-label', `Play the ${TOWN.name} theme (Ragnarok Online OST via YouTube)`); } boot();