Skip to content
63 changes: 63 additions & 0 deletions HANDOFF.md
Original file line number Diff line number Diff line change
@@ -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 <town>` → 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: '<town>'`), 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).
44 changes: 44 additions & 0 deletions src/__tests__/world/handlers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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);
Expand Down
46 changes: 36 additions & 10 deletions src/__tests__/world/plaza-smoke.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down
25 changes: 25 additions & 0 deletions src/__tests__/world/store.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'))!;
Expand Down
21 changes: 21 additions & 0 deletions src/__tests__/world/towns-drift.test.ts
Original file line number Diff line number Diff line change
@@ -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]);
});
});
42 changes: 42 additions & 0 deletions src/__tests__/world/towns.test.ts
Original file line number Diff line number Diff line change
@@ -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();
}
});
});
38 changes: 38 additions & 0 deletions src/__tests__/world/world-cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -93,4 +93,42 @@ describe('worldCommand', () => {
const out = await worldCommand(['dance'], deps);
expect(out.join('\n')).toMatch(/usage/i);
});

it('teleport <town> 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 <town> 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);
}
});
});
Loading