From 7d66c9a131d6fa22f23e524ae9aeaa5e082e979a Mon Sep 17 00:00:00 2001 From: Brent G Date: Wed, 7 Oct 2026 17:56:51 +0000 Subject: [PATCH 01/71] Add the Stoa spaces design and the spaces core plan MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🤖 Built with SMT --- .../plans/2026-10-07-spaces-core.md | 2206 +++++++++++++++++ .../specs/2026-10-07-stoa-spaces-design.md | 703 ++++++ 2 files changed, 2909 insertions(+) create mode 100644 docs/superpowers/plans/2026-10-07-spaces-core.md create mode 100644 docs/superpowers/specs/2026-10-07-stoa-spaces-design.md diff --git a/docs/superpowers/plans/2026-10-07-spaces-core.md b/docs/superpowers/plans/2026-10-07-spaces-core.md new file mode 100644 index 0000000..08c54ad --- /dev/null +++ b/docs/superpowers/plans/2026-10-07-spaces-core.md @@ -0,0 +1,2206 @@ +# Spaces Core (Plan A of 5) Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Build the server side of Spaces: the space model, permissions, entry leases and hosts, plaza capacity, invitations, decorations, Signaling and RTC token issuing, the shared map module, the built-in worlds, and the HTTP API that Stoa's client (Plan C) will use. + +**Architecture:** Spaces live in the existing store aggregate (`state.spaces`, `state.spaceLeases`), so every change runs inside the store's single atomic `transaction`. Pure modules (`permissions`, `model`, `leases`, `decor`, `signaling`) hold the rules and are unit-tested in isolation. `service.mjs` composes them over the store, and `routes.mjs` maps HTTP requests to the service. `world/map.js` is plain ESM shared by the browser and the server. + +**Tech Stack:** Node.js 20+, built-in `node:test`, `node:crypto`, `agora-token` 2.0.6 (already a dependency). No new dependencies. + +**Spec:** `docs/superpowers/specs/2026-10-07-stoa-spaces-design.md` + +**Later plans:** B (world engine and themes), C (Stoa client), D (Agora Chat teams and DMs), E (discovery hooks). This plan prepares for them through `spaces.onEvents()` (D), the `tags` field and the `noticeboard` map objects (E), and `world/map.js` (B and C). + +## Global Constraints + +- Node.js 20.12 or newer; built-in modules only; no new npm dependencies. +- JavaScript style: two-space indent, double quotes, semicolons, `.mjs` for Node scripts. Follow the compact one-line style of `scripts/store.mjs`. +- Errors are `AppError(status, message, details?)` with friendly, plain sentences. +- Never store message content. Signaling and Chat carry it. +- Unlisted and private spaces never appear in `GET /api/spaces`, in guest or plaza Signaling tokens, or in any plaza data. +- A person who may not see a private space gets the same 404 as for a missing space: `"This space was not found."` +- Actor identity: `actor.id` is the account ID when an account exists, otherwise the profile (member) ID. `actor.ids` holds both, and ownership and membership match against either. +- Plaza capacity is 1000. Lease length is 60 seconds. Signaling tokens last 900 seconds. A space has at most 60 decorations, and a topic has at most 5 tags. +- Basic plans get 3 user-created spaces and every other plan gets 20 (`entitlements(plan).spaces`). +- Every commit message ends with the line `🤖 Built with SMT `. Do not add co-author trailers. +- Run `npm test` before every commit; it must pass completely. + +## File Structure + +| File | Responsibility | +| --- | --- | +| `scripts/store.mjs` (modify) | `AppError` details, `actor(token)`, `spaces`/`spaceLeases` state | +| `scripts/plans.mjs` (modify) | `spaces` entitlement | +| `world/map.js` (create) | Parse Tiled maps; walkability, A*, reachability, path checks (browser and server) | +| `scripts/maps/build-maps.mjs` (create) | Generates the built-in Tiled maps | +| `worlds/plaza/map.json`, `worlds/room/map.json` (generated) | Built-in worlds | +| `scripts/spaces/permissions.mjs` (create) | `can()` and visibility/access rules | +| `scripts/spaces/model.mjs` (create) | Space records, input validation, system spaces, migration, tags | +| `scripts/spaces/leases.mjs` (create) | Entry leases, capacity, hosts, sweeping | +| `scripts/spaces/decor.mjs` (create) | Decoration validation | +| `scripts/spaces/signaling.mjs` (create) | Channel names, space keys, Signaling tokens | +| `scripts/calls.mjs` (modify) | `issueSpace()` RTC tokens | +| `scripts/spaces/service.mjs` (create) | Spaces operations over the store | +| `scripts/spaces/routes.mjs` (create) | HTTP routing for spaces, worlds, Signaling | +| `scripts/serve.mjs` (modify) | Wiring, `/stoa` pages, error details, sweeper | +| `stoa.html` (create) | Page shell that Plan C fills in | +| `scripts/build.mjs`, `.env.example`, `README.md` (modify) | Build output and configuration | +| `tests/map.test.mjs`, `tests/spaces-core.test.mjs`, `tests/spaces-service.test.mjs`, `tests/spaces-api.test.mjs` (create) | Tests | + +--- + +### Task 1: Store groundwork and the spaces entitlement + +**Files:** +- Modify: `scripts/store.mjs` (the `AppError` class, `normalize`, `read`'s empty state, and a new `actor` method) +- Modify: `scripts/plans.mjs` (`entitlements`) +- Modify: `scripts/serve.mjs` (the error handler at the end of the request `catch`) +- Modify: `tests/platform.test.mjs:146` +- Test: `tests/spaces-core.test.mjs` + +**Interfaces:** +- Produces: `new AppError(status, message, details)` with `error.details`; `store.actor(token) -> { id, accountId, memberId, name, avatar, hasProfile, plan } | null`; `state.spaces` and `state.spaceLeases` arrays; `entitlements(plan).spaces` (3 on Basic, 20 on other plans). + +- [ ] **Step 1: Write the failing test** + +Create `tests/spaces-core.test.mjs`: + +```js +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { resolve } from "node:path"; +import { AppError, createStore } from "../scripts/store.mjs"; +import { entitlements } from "../scripts/plans.mjs"; + +const root = new URL("../", import.meta.url).pathname; +const profile = (name = "Test builder") => ({ name, bio: "Building voice agents with Rust.", intent: "Build a language practice voice agent with natural interruptions.", lookingFor: "Audio engineers", skills: ["Rust", "Voice AI"], location: "Remote", contact: "https://example.com/builder", monitor: false }); + +test("plans grant 3 spaces on Basic and 20 on every other plan", () => { + assert.equal(entitlements("basic").spaces, 3); + for (const plan of ["premium", "principal", "fellow"]) assert.equal(entitlements(plan).spaces, 20); +}); +test("errors can carry details for the client", () => { + const error = new AppError(409, "Full.", { full: true }); + assert.equal(error.status, 409); + assert.deepEqual(error.details, { full: true }); + assert.equal(new AppError(404, "Missing.").details, undefined); +}); +test("the store resolves the acting person for accounts, profiles, and guests", async () => { + const directory = await mkdtemp(resolve(tmpdir(), "spaces-actor-")); + try { + const store = createStore(resolve(root, "data/people.json"), resolve(directory, "state.json")); + assert.equal(await store.actor(""), null); + assert.equal(await store.actor("unknown"), null); + const { token: profileToken, person } = await store.join(profile("Profile only")); + assert.deepEqual(await store.actor(profileToken), { id: person.id, accountId: null, memberId: person.id, name: "Profile only", avatar: "", hasProfile: true, plan: "basic" }); + const login = await store.login({ provider: "github", issuer: "https://github.com", subject: "42", name: "Account only" }); + const actor = await store.actor(login.token); + assert.equal(actor.id, login.account.id); + assert.equal(actor.memberId, null); + assert.equal(actor.hasProfile, false); + assert.equal(actor.name, "Account only"); + const state = await store.snapshot(); + assert.deepEqual([state.spaces, state.spaceLeases], [[], []]); + } finally { await rm(directory, { recursive: true, force: true }); } +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `node --test tests/spaces-core.test.mjs` +Expected: FAIL. `entitlements("basic").spaces` is `undefined`, `error.details` is missing, and `store.actor is not a function`. + +- [ ] **Step 3: Implement** + +In `scripts/plans.mjs`, replace the `return` line of `entitlements`: + +```js + return { projects: premium ? 20 : 5, evalFlowsPerProject: premium ? 20 : 10, apiEvalFlows: premium ? 200 : 50, privateResources: premium, ownStorage: premium, publishMainline: ["principal", "fellow"].includes(plan), spaces: premium ? 20 : 3 }; +``` + +In `tests/platform.test.mjs` line 146, add `spaces: 3` to the expected object: + +```js + assert.deepEqual(entitlements("basic"), { projects: 5, evalFlowsPerProject: 10, apiEvalFlows: 50, privateResources: false, ownStorage: false, publishMainline: false, spaces: 3 }); +``` + +In `scripts/store.mjs`, replace the `AppError` class: + +```js +export class AppError extends Error { + constructor(status, message, details) { super(message); this.status = status; if (details) this.details = details; } +} +``` + +In `createStore`'s `normalize`, change the second line to: + +```js + state.people ||= []; state.rooms ||= []; state.radar ||= {}; state.spaces ||= []; state.spaceLeases ||= []; +``` + +In `read()`, change the `ENOENT` return value to: + +```js +{ people: [], rooms: [], radar: {}, accounts: [], sessions: [], authorizationCodes: [], grants: [], spaces: [], spaceLeases: [] } +``` + +Add this method to the returned object, directly after `async me(token) { ... },`: + +```js + async actor(token) { + if (!token) return null; + const state = await read(); + const account = accountFor(state, token); + const person = personFor(state, token); + if (!account && !person) return null; + return { id: account?.id || person.id, accountId: account?.id || null, memberId: person?.id || null, name: person?.name || account?.name || "Builder", avatar: person?.avatar || account?.avatar || "", hasProfile: Boolean(person), plan: effectivePlan(account) }; + }, +``` + +In `scripts/serve.mjs`, replace the last line of the request `catch` block (the `json(response, error.code === "ENOENT" ...` line) with: + +```js + json(response, error.code === "ENOENT" ? 404 : error.status || 500, { error: error.code === "ENOENT" ? "This page was not found." : error.status ? error.message : "The house could not complete this request. Please try again.", ...(error.status && error.details ? error.details : {}) }); +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `npm test` +Expected: PASS, including `tests/spaces-core.test.mjs` and the updated platform test. + +- [ ] **Step 5: Commit** + +```bash +git add scripts/store.mjs scripts/plans.mjs scripts/serve.mjs tests/platform.test.mjs tests/spaces-core.test.mjs +git commit -m "Add spaces state, actor lookup, and space entitlements" -m "🤖 Built with SMT " +``` + +--- + +### Task 2: Shared map module + +**Files:** +- Create: `world/map.js` +- Test: `tests/map.test.mjs` + +**Interfaces:** +- Produces (all exported from `world/map.js`): + - `CORE_ROLES: string[]`: `["floor","path","grass","water","wall","column","door","table","seat","plant","decor"]` + - `parseMap(source) -> { id, width, height, tileSize, roles: (string|null)[], blocked: Uint8Array, lots: [{ lotId, slug, title, capacity, door:{x,y}, entry:{x,y}, interior:{x,y,width,height} }], spawns: [{x,y}], zones: [{name,x,y,width,height}], interactables: [{id,kind,label,query,x,y}] }`. Throws `Error` for an unsupported map. + - `inside(map, x, y) -> boolean`, `roleAt(map, x, y) -> string|null`, `walkable(map, x, y, extra = new Set()) -> boolean` (`extra` holds `"x,y"` keys of extra blocked tiles) + - `findPath(map, from, to, extra) -> [{x,y}] | null` (four directions, includes both ends) + - `reachable(map, start, extra) -> Set<"x,y">` + - `validPath(map, path, extra) -> boolean` (each step adjacent and walkable, at most 400 steps) + +- [ ] **Step 1: Write the failing test** + +Create `tests/map.test.mjs`: + +```js +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { CORE_ROLES, findPath, parseMap, reachable, roleAt, validPath, walkable } from "../world/map.js"; + +const role = (id, name, extra = []) => ({ id, properties: [{ name: "role", type: "string", value: name }, ...extra] }); +const fixture = () => ({ + type: "map", orientation: "orthogonal", width: 5, height: 4, tilewidth: 32, tileheight: 32, + properties: [{ name: "id", type: "string", value: "tiny" }], + tilesets: [{ firstgid: 1, tiles: [role(0, "floor"), role(1, "wall"), role(2, "water", [{ name: "walkable", type: "bool", value: true }]), role(3, "seat")] }], + layers: [ + { type: "tilelayer", name: "ground", data: new Array(20).fill(1) }, + { type: "tilelayer", name: "structure", data: [0, 0, 2, 0, 0, 0, 0, 2, 0, 4, 0, 0, 2, 0, 3, 0, 0, 0, 0, 0] }, + { type: "objectgroup", name: "spawn", objects: [{ id: 1, name: "", x: 0, y: 0, width: 0, height: 0 }] }, + { type: "objectgroup", name: "lot", objects: [{ id: 2, name: "", x: 96, y: 0, width: 64, height: 96, properties: [{ name: "lotId", value: "a" }, { name: "slug", value: "a-room" }, { name: "title", value: "A" }, { name: "capacity", value: 4 }, { name: "doorX", value: 3 }, { name: "doorY", value: 3 }, { name: "entryX", value: 3 }, { name: "entryY", value: 2 }] }] }, + { type: "objectgroup", name: "interactable", objects: [{ id: 3, name: "", x: 32, y: 96, width: 0, height: 0, properties: [{ name: "id", value: "board" }, { name: "kind", value: "noticeboard" }, { name: "label", value: "Board" }, { name: "query", value: "voice" }] }] } + ] +}); + +test("core roles are fixed and ordered", () => { + assert.deepEqual(CORE_ROLES, ["floor", "path", "grass", "water", "wall", "column", "door", "table", "seat", "plant", "decor"]); +}); +test("maps parse roles, walkability overrides, lots, spawns, and interactables", () => { + const map = parseMap(fixture()); + assert.equal(map.id, "tiny"); + assert.deepEqual([map.width, map.height, map.tileSize], [5, 4, 32]); + assert.equal(roleAt(map, 2, 0), "wall"); + assert.equal(roleAt(map, 4, 2), "water"); + assert.equal(walkable(map, 2, 0), false); + assert.equal(walkable(map, 4, 2), true, "a tile property can make water walkable"); + assert.equal(walkable(map, 4, 1), true, "seats are walkable"); + assert.equal(walkable(map, -1, 0), false); + assert.equal(walkable(map, 0.5, 0), false); + assert.deepEqual(map.spawns, [{ x: 0, y: 0 }]); + assert.deepEqual(map.lots, [{ lotId: "a", slug: "a-room", title: "A", capacity: 4, door: { x: 3, y: 3 }, entry: { x: 3, y: 2 }, interior: { x: 3, y: 0, width: 2, height: 3 } }]); + assert.deepEqual(map.interactables, [{ id: "board", kind: "noticeboard", label: "Board", query: "voice", x: 1, y: 3 }]); +}); +test("unsupported maps are rejected", () => { + assert.throws(() => parseMap({ ...fixture(), orientation: "isometric" })); + const unknown = fixture(); unknown.layers[1].data[0] = 99; + assert.throws(() => parseMap(unknown), /Unknown tile/); + const short = fixture(); short.layers[0].data = [1]; + assert.throws(() => parseMap(short), /wrong size/); +}); +test("paths are shortest, avoid blocked tiles, and respect extra blockers", () => { + const map = parseMap(fixture()); + const path = findPath(map, { x: 0, y: 0 }, { x: 4, y: 0 }); + assert.equal(path.length, 11); + assert.deepEqual(path[0], { x: 0, y: 0 }); + assert.deepEqual(path.at(-1), { x: 4, y: 0 }); + assert.equal(validPath(map, path), true); + assert.equal(findPath(map, { x: 0, y: 0 }, { x: 2, y: 0 }), null); + assert.equal(findPath(map, { x: 0, y: 0 }, { x: 4, y: 0 }, new Set(["2,3"])), null); + const open = reachable(map, { x: 0, y: 0 }, new Set(["1,3"])); + assert.ok(open.has("0,3")); + assert.ok(!open.has("4,0")); +}); +test("received paths are rejected when they jump or cross blocked tiles", () => { + const map = parseMap(fixture()); + assert.equal(validPath(map, [{ x: 0, y: 0 }, { x: 0, y: 2 }]), false); + assert.equal(validPath(map, [{ x: 1, y: 0 }, { x: 2, y: 0 }]), false); + assert.equal(validPath(map, []), false); + assert.equal(validPath(map, "nope"), false); + assert.equal(validPath(map, new Array(401).fill({ x: 0, y: 0 })), false); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `node --test tests/map.test.mjs` +Expected: FAIL with `Cannot find module` for `world/map.js`. + +- [ ] **Step 3: Implement** + +Create `world/map.js`: + +```js +// Shared by the browser engine and the server: maps describe purpose (roles), never artwork. +export const CORE_ROLES = ["floor", "path", "grass", "water", "wall", "column", "door", "table", "seat", "plant", "decor"]; +const BLOCKING = new Set(["wall", "column", "water", "table", "plant"]); +const STEPS = [[1, 0], [-1, 0], [0, 1], [0, -1]]; +const props = (entry) => Object.fromEntries((entry?.properties || []).map((property) => [property.name, property.value])); + +export function parseMap(source) { + const { width, height, tilewidth: size } = source || {}; + if (source?.orientation !== "orthogonal" || !Number.isInteger(width) || !Number.isInteger(height) || width < 2 || height < 2 || width > 200 || height > 200 || !Number.isInteger(size) || size !== source.tileheight) throw new Error("Unsupported map."); + const tiles = new Map(); + for (const tileset of source.tilesets || []) for (const tile of tileset.tiles || []) tiles.set(tileset.firstgid + tile.id, props(tile)); + const roles = new Array(width * height).fill(null); + const blocked = new Uint8Array(width * height); + for (const name of ["ground", "structure"]) { + const data = source.layers.find((layer) => layer.type === "tilelayer" && layer.name === name)?.data; + if (!data) continue; + if (data.length !== width * height) throw new Error("Layer " + name + " has the wrong size."); + data.forEach((gid, index) => { + if (!gid) return; + const tile = tiles.get(gid); + if (!tile || !CORE_ROLES.includes(tile.role)) throw new Error("Unknown tile " + gid + "."); + roles[index] = tile.role; + blocked[index] = (tile.walkable === undefined ? BLOCKING.has(tile.role) : !tile.walkable) ? 1 : 0; + }); + } + roles.forEach((role, index) => { if (!role) blocked[index] = 1; }); + const objects = (name) => (source.layers.find((layer) => layer.type === "objectgroup" && layer.name === name)?.objects || []).map((object) => ({ name: object.name, ...props(object), x: Math.floor(object.x / size), y: Math.floor(object.y / size), width: Math.round((object.width || 0) / size), height: Math.round((object.height || 0) / size) })); + return { + id: props(source).id, width, height, tileSize: size, roles, blocked, + lots: objects("lot").map((lot) => ({ lotId: lot.lotId, slug: lot.slug, title: lot.title, capacity: lot.capacity, door: { x: lot.doorX, y: lot.doorY }, entry: { x: lot.entryX, y: lot.entryY }, interior: { x: lot.x, y: lot.y, width: lot.width, height: lot.height } })), + spawns: objects("spawn").map(({ x, y }) => ({ x, y })), + zones: objects("zone").map(({ name, x, y, width: w, height: h }) => ({ name, x, y, width: w, height: h })), + interactables: objects("interactable").map(({ id, kind, label, query, x, y }) => ({ id, kind, label, query, x, y })) + }; +} + +export const inside = (map, x, y) => Number.isInteger(x) && Number.isInteger(y) && x >= 0 && y >= 0 && x < map.width && y < map.height; +export const roleAt = (map, x, y) => inside(map, x, y) ? map.roles[y * map.width + x] : null; +export const walkable = (map, x, y, extra = new Set()) => inside(map, x, y) && !map.blocked[y * map.width + x] && !extra.has(x + "," + y); + +export function findPath(map, from, to, extra = new Set()) { + if (!inside(map, from.x, from.y) || !walkable(map, to.x, to.y, extra)) return null; + const key = (x, y) => y * map.width + x; + const start = key(from.x, from.y), goal = key(to.x, to.y); + const came = new Map(), cost = new Map([[start, 0]]); + const open = [[Math.abs(from.x - to.x) + Math.abs(from.y - to.y), start]]; + while (open.length) { + let best = 0; + for (let index = 1; index < open.length; index += 1) if (open[index][0] < open[best][0]) best = index; + const [, current] = open.splice(best, 1)[0]; + if (current === goal) { + const path = []; + for (let node = goal; node !== undefined; node = came.get(node)) path.unshift({ x: node % map.width, y: Math.floor(node / map.width) }); + return path; + } + const x = current % map.width, y = Math.floor(current / map.width); + for (const [dx, dy] of STEPS) { + const nx = x + dx, ny = y + dy; + if (!walkable(map, nx, ny, extra)) continue; + const next = key(nx, ny), score = cost.get(current) + 1; + if (score >= (cost.get(next) ?? Infinity)) continue; + cost.set(next, score); came.set(next, current); + open.push([score + Math.abs(nx - to.x) + Math.abs(ny - to.y), next]); + } + } + return null; +} + +export function reachable(map, start, extra = new Set()) { + const seen = new Set([start.x + "," + start.y]), queue = [start]; + while (queue.length) { + const { x, y } = queue.shift(); + for (const [dx, dy] of STEPS) { + const nx = x + dx, ny = y + dy, id = nx + "," + ny; + if (!seen.has(id) && walkable(map, nx, ny, extra)) { seen.add(id); queue.push({ x: nx, y: ny }); } + } + } + return seen; +} + +export const validPath = (map, path, extra = new Set()) => Array.isArray(path) && path.length > 0 && path.length <= 400 && path.every((step, index) => step && walkable(map, step.x, step.y, extra) && (index === 0 || Math.abs(step.x - path[index - 1].x) + Math.abs(step.y - path[index - 1].y) === 1)); +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `node --test tests/map.test.mjs` +Expected: PASS (5 tests). + +- [ ] **Step 5: Commit** + +```bash +git add world/map.js tests/map.test.mjs +git commit -m "Add shared map parsing and pathfinding" -m "🤖 Built with SMT " +``` + +--- + +### Task 3: Built-in worlds + +**Files:** +- Create: `scripts/maps/build-maps.mjs` +- Create (generated): `worlds/plaza/map.json`, `worlds/room/map.json` +- Modify: `package.json` (add a `maps` script) +- Test: `tests/map.test.mjs` (append) + +**Interfaces:** +- Consumes: `CORE_ROLES`, `parseMap`, `reachable`, `roleAt`, `walkable` from Task 2. +- Produces: + - The plaza (44×34): six lots, with lot IDs and slugs `agents/ai-agents`, `voice/voice-ai`, `rtc/rtc-lab`, `founders/founders-table`, `oss/open-source`, `lounge/lounge` and capacities 8, 8, 6, 6, 10, 12. Spawns are at (21,17) and (22,17). Three `noticeboard` interactables have the IDs `collaborators`, `voice-ai`, `offers`. + - Lot `i` (0 to 5) has its interior at `x0 = 1 + 7i`, `y = 21`, 6×12, with entry `(x0+2, 31)` and exit door `(x0+2, 32)`. + - The room (20×14) has spawns at (9,11) and (10,11) and 8 seats. + +- [ ] **Step 1: Write the failing test** + +Append to `tests/map.test.mjs`: + +```js +import { readFile } from "node:fs/promises"; +const world = async (id) => parseMap(JSON.parse(await readFile(new URL("../worlds/" + id + "/map.json", import.meta.url), "utf8"))); +const seatsIn = (map, area) => { const seats = []; for (let y = area.y; y < area.y + area.height; y += 1) for (let x = area.x; x < area.x + area.width; x += 1) if (roleAt(map, x, y) === "seat") seats.push({ x, y }); return seats; }; + +test("the plaza has six reachable lots whose interiors are separate and fully reachable", async () => { + const plaza = await world("plaza"); + assert.equal(plaza.id, "plaza"); + assert.deepEqual(plaza.lots.map((lot) => [lot.slug, lot.capacity]), [["ai-agents", 8], ["voice-ai", 8], ["rtc-lab", 6], ["founders-table", 6], ["open-source", 10], ["lounge", 12]]); + const [spawn] = plaza.spawns; + assert.ok(walkable(plaza, spawn.x, spawn.y)); + const outside = reachable(plaza, spawn); + for (const lot of plaza.lots) { + assert.equal(roleAt(plaza, lot.door.x, lot.door.y), "door", lot.slug); + assert.ok(outside.has(lot.door.x + "," + lot.door.y), lot.slug + " door is reachable"); + assert.ok(!outside.has(lot.entry.x + "," + lot.entry.y), lot.slug + " interior is separate"); + const inside = reachable(plaza, lot.entry); + const seats = seatsIn(plaza, lot.interior); + assert.equal(seats.length, 6, lot.slug); + for (const seat of seats) assert.ok(inside.has(seat.x + "," + seat.y), lot.slug + " seat"); + assert.ok(inside.has(lot.entry.x + "," + (lot.entry.y + 1)), lot.slug + " exit"); + assert.equal(roleAt(plaza, lot.entry.x, lot.entry.y + 1), "door"); + } + assert.deepEqual(plaza.interactables.map((entry) => [entry.id, entry.kind]), [["collaborators", "noticeboard"], ["voice-ai", "noticeboard"], ["offers", "noticeboard"]]); +}); +test("the room world seats everyone reachably", async () => { + const room = await world("room"); + assert.equal(room.id, "room"); + const open = reachable(room, room.spawns[0]); + const seats = seatsIn(room, { x: 0, y: 0, width: room.width, height: room.height }); + assert.equal(seats.length, 8); + for (const seat of seats) assert.ok(open.has(seat.x + "," + seat.y)); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `node --test tests/map.test.mjs` +Expected: FAIL with `ENOENT` for `worlds/plaza/map.json`. + +- [ ] **Step 3: Implement the generator and generate the maps** + +Create `scripts/maps/build-maps.mjs`: + +```js +// Generates the built-in Tiled-compatible worlds. Designers may replace the JSON with maps edited in Tiled. +import { mkdir, writeFile } from "node:fs/promises"; +import { resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { CORE_ROLES } from "../../world/map.js"; + +const SIZE = 32; +const prop = (values) => Object.entries(values).map(([name, value]) => ({ name, type: typeof value === "number" ? "int" : typeof value === "boolean" ? "bool" : "string", value })); +const blank = (width, height, role) => Array.from({ length: height }, () => new Array(width).fill(role)); +const fill = (grid, x, y, width, height, role) => { for (let row = y; row < y + height; row += 1) for (let column = x; column < x + width; column += 1) grid[row][column] = role; }; + +export function tiled(id, grid, objects) { + const height = grid.length, width = grid[0].length, gid = (role) => CORE_ROLES.indexOf(role) + 1; + const ground = [], structure = []; + for (const row of grid) for (const role of row) { + const base = ["water", "grass", "path"].includes(role) ? role : "floor"; + ground.push(gid(base)); structure.push(role === base ? 0 : gid(role)); + } + let next = 1; + const group = (name, list) => ({ type: "objectgroup", name, objects: list.map(({ x, y, width: w = 0, height: h = 0, name: label = "", ...rest }) => ({ id: next++, name: label, x: x * SIZE, y: y * SIZE, width: w * SIZE, height: h * SIZE, point: !w && !h, properties: prop(rest) })) }); + const layers = [{ type: "tilelayer", name: "ground", width, height, data: ground }, { type: "tilelayer", name: "structure", width, height, data: structure }, ...Object.entries(objects).map(([name, list]) => group(name, list))]; + return { type: "map", version: "1.10", tiledversion: "1.10.2", orientation: "orthogonal", renderorder: "right-down", width, height, tilewidth: SIZE, tileheight: SIZE, infinite: false, nextobjectid: next, properties: prop({ id }), tilesets: [{ firstgid: 1, name: "roles", tilewidth: SIZE, tileheight: SIZE, tilecount: CORE_ROLES.length, columns: 0, tiles: CORE_ROLES.map((role, index) => ({ id: index, properties: prop({ role }) })) }], layers }; +} + +export const LOTS = [ + { lotId: "agents", slug: "ai-agents", title: "AI agents", capacity: 8 }, + { lotId: "voice", slug: "voice-ai", title: "Voice AI", capacity: 8 }, + { lotId: "rtc", slug: "rtc-lab", title: "RTC lab", capacity: 6 }, + { lotId: "founders", slug: "founders-table", title: "Founders' table", capacity: 6 }, + { lotId: "oss", slug: "open-source", title: "Open source", capacity: 10 }, + { lotId: "lounge", slug: "lounge", title: "The lounge", capacity: 12 } +]; +// Four facades face down from the top edge of the plaza and two face up from the bottom. +const FACADES = [{ x: 2, y: 2, top: true }, { x: 10, y: 2, top: true }, { x: 27, y: 2, top: true }, { x: 35, y: 2, top: true }, { x: 6, y: 14, top: false }, { x: 29, y: 14, top: false }]; + +export function plaza() { + const grid = blank(44, 34, "wall"); + fill(grid, 1, 1, 42, 18, "grass"); + fill(grid, 1, 9, 42, 2, "path"); fill(grid, 21, 1, 2, 18, "path"); + fill(grid, 17, 6, 10, 8, "path"); fill(grid, 19, 8, 6, 4, "water"); + for (const [x, y] of [[17, 6], [26, 6], [17, 13], [26, 13]]) grid[y][x] = "column"; + for (const [x, y] of [[20, 7], [23, 7], [20, 12], [23, 12]]) grid[y][x] = "seat"; + for (const [x, y] of [[5, 8], [38, 8], [5, 11], [38, 11]]) grid[y][x] = "plant"; + const lots = LOTS.map((lot, index) => { + const facade = FACADES[index], door = { x: facade.x + 3, y: facade.top ? facade.y + 3 : facade.y }; + fill(grid, facade.x, facade.y, 7, 4, "wall"); + grid[door.y][door.x] = "door"; + const x0 = 1 + index * 7; + fill(grid, x0, 21, 6, 12, "floor"); + fill(grid, x0 + 2, 25, 2, 3, "table"); + for (let y = 25; y < 28; y += 1) { grid[y][x0 + 1] = "seat"; grid[y][x0 + 4] = "seat"; } + grid[21][x0] = "plant"; grid[21][x0 + 5] = "plant"; + grid[32][x0 + 2] = "door"; + return { ...lot, x: x0, y: 21, width: 6, height: 12, doorX: door.x, doorY: door.y, entryX: x0 + 2, entryY: 31 }; + }); + return tiled("plaza", grid, { + lot: lots, + spawn: [{ x: 21, y: 17 }, { x: 22, y: 17 }], + zone: [{ name: "plaza", x: 1, y: 1, width: 42, height: 18 }, { name: "fountain", x: 17, y: 6, width: 10, height: 8 }], + interactable: [ + { x: 12, y: 8, id: "collaborators", kind: "noticeboard", label: "Looking for collaborators", query: "collaborators" }, + { x: 31, y: 8, id: "voice-ai", kind: "noticeboard", label: "Voice AI", query: "voice ai" }, + { x: 25, y: 16, id: "offers", kind: "noticeboard", label: "Offers", query: "offers" } + ] + }); +} + +export function room() { + const grid = blank(20, 14, "wall"); + fill(grid, 1, 1, 18, 12, "floor"); + fill(grid, 8, 5, 4, 3, "table"); + for (let x = 8; x < 12; x += 1) { grid[4][x] = "seat"; grid[8][x] = "seat"; } + for (const [x, y] of [[1, 1], [18, 1], [1, 12], [18, 12]]) grid[y][x] = "plant"; + return tiled("room", grid, { spawn: [{ x: 9, y: 11 }, { x: 10, y: 11 }], zone: [{ name: "room", x: 1, y: 1, width: 18, height: 12 }] }); +} + +if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + const root = new URL("../../", import.meta.url); + for (const [id, map] of [["plaza", plaza()], ["room", room()]]) { + await mkdir(new URL("worlds/" + id + "/", root), { recursive: true }); + await writeFile(new URL("worlds/" + id + "/map.json", root), JSON.stringify(map) + "\n"); + } + console.log("Wrote worlds/plaza/map.json and worlds/room/map.json."); +} +``` + +In `package.json` `scripts`, add after `"test"`: + +```json + "maps": "node scripts/maps/build-maps.mjs" +``` + +(Add a comma after the `"test"` entry.) + +Run: `npm run maps` +Expected output: `Wrote worlds/plaza/map.json and worlds/room/map.json.` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `node --test tests/map.test.mjs` +Expected: PASS (7 tests). + +- [ ] **Step 5: Commit** + +```bash +git add scripts/maps/build-maps.mjs worlds/ package.json tests/map.test.mjs +git commit -m "Add the plaza and room worlds" -m "🤖 Built with SMT " +``` + +--- + +### Task 4: Permissions + +**Files:** +- Create: `scripts/spaces/permissions.mjs` +- Test: `tests/spaces-core.test.mjs` (append) + +**Interfaces:** +- Consumes: actor objects shaped `{ id, ids: string[], hasProfile, admin }` (built by the service in Task 10). The guest actor is `null`. +- Produces: + - `VISIBILITY = ["listed","unlisted","private"]`, `ACCESS = ["open","house","members"]` + - `validCombination(visibility, access) -> boolean` (`private` requires `members`) + - `can(actor, action, space, context = {}) -> boolean` + - actions: `see | enter | chat | speak | host | decorate | moderate | edit | invite | delete` + - context: `{ invited?: boolean, present?: boolean }` + - `space` fields used: `visibility, access, ownerId, members, blocked, hostId` + +- [ ] **Step 1: Write the failing test** + +Append to `tests/spaces-core.test.mjs`: + +```js +import { ACCESS, VISIBILITY, can, validCombination } from "../scripts/spaces/permissions.mjs"; + +const actor = (id, extra = {}) => ({ id: "account:" + id, ids: ["account:" + id, ...(extra.memberId ? [extra.memberId] : [])], hasProfile: Boolean(extra.memberId), admin: false, ...extra }); +const owner = actor("owner"), member = actor("member"), stranger = actor("stranger", { memberId: "member:stranger" }), noProfile = actor("plain"), admin = actor("admin", { admin: true }); +const space = (visibility, access, extra = {}) => ({ visibility, access, ownerId: owner.id, members: [owner.id, member.id], blocked: [], hostId: null, ...extra }); + +test("visibility and access combine except private with open or house access", () => { + assert.deepEqual(VISIBILITY, ["listed", "unlisted", "private"]); + assert.deepEqual(ACCESS, ["open", "house", "members"]); + for (const visibility of VISIBILITY) for (const access of ACCESS) assert.equal(validCombination(visibility, access), visibility !== "private" || access === "members", visibility + "/" + access); + assert.equal(validCombination("secret", "open"), false); +}); +test("seeing a space depends only on visibility, membership, invitation, or presence", () => { + for (const visibility of ["listed", "unlisted"]) assert.equal(can(null, "see", space(visibility, "members")), true); + const hidden = space("private", "members"); + assert.equal(can(null, "see", hidden), false); + assert.equal(can(stranger, "see", hidden), false); + assert.equal(can(stranger, "see", hidden, { invited: true }), true); + assert.equal(can(member, "see", hidden), true); + assert.equal(can(owner, "see", hidden), true); + assert.equal(can(admin, "see", hidden), true); +}); +test("entering follows the access rule and never admits guests or blocked people", () => { + assert.equal(can(null, "enter", space("listed", "open")), false); + assert.equal(can(noProfile, "enter", space("listed", "open")), true); + assert.equal(can(noProfile, "enter", space("unlisted", "house")), false); + assert.equal(can(stranger, "enter", space("unlisted", "house")), true); + assert.equal(can(member, "enter", space("unlisted", "house")), true, "members may enter house spaces without a profile"); + assert.equal(can(stranger, "enter", space("unlisted", "members")), false); + assert.equal(can(stranger, "enter", space("unlisted", "members"), { invited: true }), true); + assert.equal(can(member, "enter", space("private", "members")), true); + assert.equal(can(stranger, "enter", space("listed", "open", { blocked: ["member:stranger"] })), false, "blocks match any of the person's IDs"); +}); +test("hosting powers belong to the present host or the present owner; editing belongs to the owner", () => { + const hosted = space("unlisted", "open", { hostId: member.id }); + for (const action of ["host", "decorate", "moderate"]) { + assert.equal(can(member, action, hosted, { present: true }), true, action); + assert.equal(can(member, action, hosted), false, action + " requires presence"); + assert.equal(can(owner, action, hosted, { present: true }), true, action + " owner"); + assert.equal(can(stranger, action, hosted, { present: true }), false, action + " stranger"); + } + for (const action of ["edit", "invite", "delete"]) { + assert.equal(can(owner, action, hosted), true, action); + assert.equal(can(member, action, hosted), false, action); + } + assert.equal(can(stranger, "chat", hosted, { present: true }), true); + assert.equal(can(stranger, "chat", hosted), false); + assert.equal(can(null, "speak", hosted, { present: true }), false); + assert.equal(can(admin, "delete", hosted), true); + assert.equal(can(owner, "fly", hosted), false); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `node --test tests/spaces-core.test.mjs` +Expected: FAIL with `Cannot find module` for `scripts/spaces/permissions.mjs`. + +- [ ] **Step 3: Implement** + +Create `scripts/spaces/permissions.mjs`: + +```js +// One rule set for REST handlers and token issuing. Visibility decides who can learn a space exists; access decides who can enter. +export const VISIBILITY = ["listed", "unlisted", "private"]; +export const ACCESS = ["open", "house", "members"]; +export const validCombination = (visibility, access) => VISIBILITY.includes(visibility) && ACCESS.includes(access) && (visibility !== "private" || access === "members"); + +export function can(actor, action, space, context = {}) { + if (actor?.admin) return true; + const mine = (id) => Boolean(actor && id && actor.ids.includes(id)); + const owner = mine(space.ownerId); + const member = owner || (space.members || []).some(mine); + const blocked = (space.blocked || []).some(mine); + const present = Boolean(actor && context.present); + switch (action) { + case "see": return space.visibility !== "private" || member || Boolean(context.invited) || present; + case "enter": + if (!actor || blocked || !can(actor, "see", space, context)) return false; + if (space.access === "open") return true; + if (space.access === "house") return actor.hasProfile || member; + return member || Boolean(context.invited); + case "chat": case "speak": return present && !blocked; + case "host": case "decorate": case "moderate": return present && (mine(space.hostId) || owner); + case "edit": case "invite": case "delete": return owner; + default: return false; + } +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `node --test tests/spaces-core.test.mjs` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add scripts/spaces/permissions.mjs tests/spaces-core.test.mjs +git commit -m "Add space permissions" -m "🤖 Built with SMT " +``` + +--- + +### Task 5: Space model + +**Files:** +- Create: `scripts/spaces/model.mjs` +- Test: `tests/spaces-core.test.mjs` (append) + +**Interfaces:** +- Consumes: `AppError` (Task 1), `validCombination` (Task 4), a parsed plaza map (Task 2/3). +- Produces: + - `DECOR_KINDS` (13 kinds), `THEMES = ["agora","minimal","cyberpunk"]`, `PLAZA_ID = "plaza"`, `PLAZA_CAPACITY = 1000`, `lotSpaceId(slug) -> "lot-" + slug` + - `spaceInput(input, current = {}) -> { title, purpose, visibility, access, capacity, themeId }`. Throws 422. + - `normalizeTags(value = []) -> string[]`. Throws 422. + - `newSpace(fields, { id, ownerId, now }) -> Space` + - `systemSpaces(plazaMap, now, plazaCapacity = PLAZA_CAPACITY) -> Space[]` (the plaza first, then one per lot) + - `migratedRoom(room, now) -> Space` + - `publicSpace(space, { occupants = [], manage = false }) -> object`. It never includes `invitations[].tokenHash` or `channelEpoch`. It includes `members`, `blocked`, and `invitations` only when `manage` is true. + - A Space record has `{ id, type, worldId, themeId, title, purpose, visibility, access, capacity, ownerId, members, invitations, blocked, channelEpoch, hostId, topic, tags, decor, decorVersion, slug?, lot?, createdAt, updatedAt }`. + +- [ ] **Step 1: Write the failing test** + +Append to `tests/spaces-core.test.mjs`: + +```js +import { readFile } from "node:fs/promises"; +import { parseMap } from "../world/map.js"; +import { DECOR_KINDS, PLAZA_CAPACITY, PLAZA_ID, THEMES, lotSpaceId, migratedRoom, newSpace, normalizeTags, publicSpace, spaceInput, systemSpaces } from "../scripts/spaces/model.mjs"; + +const plazaMap = parseMap(JSON.parse(await readFile(new URL("../worlds/plaza/map.json", import.meta.url), "utf8"))); + +test("space input is validated with defaults and partial updates", () => { + assert.deepEqual(spaceInput({ title: " Demo night " }), { title: "Demo night", purpose: "", visibility: "unlisted", access: "members", capacity: 12, themeId: "agora" }); + const current = spaceInput({ title: "Team", visibility: "private", access: "members", capacity: 4, themeId: "cyberpunk" }); + assert.deepEqual(spaceInput({ capacity: 6 }, current), { ...current, capacity: 6 }); + for (const bad of [{ title: "x" }, { title: "Ok", visibility: "listed" }, { title: "Ok", visibility: "private", access: "open" }, { title: "Ok", capacity: 1 }, { title: "Ok", capacity: 51 }, { title: "Ok", capacity: 2.5 }, { title: "Ok", themeId: "neon" }, { title: "Ok", purpose: "p".repeat(301) }]) assert.throws(() => spaceInput(bad), { status: 422 }, JSON.stringify(bad)); +}); +test("tags are limited to five and normalized", () => { + assert.deepEqual(normalizeTags([" Voice AI ", "voice ai", "Rust"]), ["voice ai", "rust"]); + assert.deepEqual(normalizeTags(), []); + assert.throws(() => normalizeTags(["a", "b", "c", "d", "e", "f"]), { status: 422 }); + assert.throws(() => normalizeTags(["t".repeat(31)]), { status: 422 }); + assert.throws(() => normalizeTags("rust"), { status: 422 }); +}); +test("system spaces come from the plaza map and lots are listed, open, and FCFS", () => { + assert.equal(DECOR_KINDS.length, 13); + assert.deepEqual(THEMES, ["agora", "minimal", "cyberpunk"]); + const [plaza, ...lots] = systemSpaces(plazaMap, 0); + assert.equal(plaza.id, PLAZA_ID); + assert.equal(plaza.capacity, PLAZA_CAPACITY); + assert.equal(systemSpaces(plazaMap, 0, 2)[0].capacity, 2); + assert.equal(lots.length, 6); + assert.deepEqual(lots[0], { ...lots[0], id: lotSpaceId("ai-agents"), slug: "ai-agents", lot: { worldId: "plaza", lotId: "agents" }, visibility: "listed", access: "open", ownerId: null, capacity: 8, worldId: "plaza", topic: null, tags: [], decor: [] }); +}); +test("legacy meeting rooms become unlisted members-only spaces with their rosters", () => { + const room = { id: "fd616fdb-aa48-4c67-bd26-222222222222", title: "Demo", intent: "Plan the demo", ownerId: "member:a", participants: ["member:a", "member:b"], createdAt: "2026-01-01T00:00:00.000Z" }; + const space = migratedRoom(room, 0); + assert.deepEqual([space.id, space.visibility, space.access, space.ownerId, space.purpose, space.createdAt], [room.id, "unlisted", "members", "member:a", "Plan the demo", room.createdAt]); + assert.deepEqual(space.members, ["member:a", "member:b"]); +}); +test("public spaces hide invitation hashes and the channel epoch", () => { + const space = { ...newSpace(spaceInput({ title: "Team" }), { id: "s1", ownerId: "account:o", now: 0 }), invitations: [{ id: "i1", tokenHash: "secret", usesLeft: 1, expiresAt: 1 }], channelEpoch: 3 }; + const outside = publicSpace(space, { occupants: [{ id: "account:o", name: "O" }] }); + assert.equal(outside.occupancy, 1); + for (const field of ["invitations", "members", "blocked", "channelEpoch"]) assert.equal(field in outside, false, field); + const managed = publicSpace(space, { manage: true }); + assert.deepEqual(managed.invitations, [{ id: "i1", usesLeft: 1, expiresAt: 1 }]); + assert.deepEqual(managed.members, ["account:o"]); + assert.equal(JSON.stringify(managed).includes("secret"), false); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `node --test tests/spaces-core.test.mjs` +Expected: FAIL with `Cannot find module` for `scripts/spaces/model.mjs`. + +- [ ] **Step 3: Implement** + +Create `scripts/spaces/model.mjs`: + +```js +import { AppError } from "../store.mjs"; +import { validCombination } from "./permissions.mjs"; + +export const DECOR_KINDS = ["plant", "lamp", "rug", "sofa", "chair", "table", "whiteboard", "bookshelf", "screen", "banner", "poster", "statue", "fountain"]; +export const THEMES = ["agora", "minimal", "cyberpunk"]; +export const PLAZA_ID = "plaza"; +export const PLAZA_CAPACITY = 1000; +export const lotSpaceId = (slug) => "lot-" + slug; +const pick = (input, field, fallback) => input[field] === undefined ? fallback : input[field]; + +export function spaceInput(input, current = {}) { + const value = { title: pick(input, "title", current.title), purpose: pick(input, "purpose", current.purpose ?? ""), visibility: pick(input, "visibility", current.visibility ?? "unlisted"), access: pick(input, "access", current.access ?? "members"), capacity: pick(input, "capacity", current.capacity ?? 12), themeId: pick(input, "themeId", current.themeId ?? "agora") }; + if (typeof value.title !== "string" || value.title.trim().length < 2 || value.title.trim().length > 80) throw new AppError(422, "Add a title of 2 to 80 characters."); + if (typeof value.purpose !== "string" || value.purpose.trim().length > 300) throw new AppError(422, "Keep the purpose under 300 characters."); + if (!["unlisted", "private"].includes(value.visibility)) throw new AppError(422, "Your spaces are unlisted or private."); + if (!validCombination(value.visibility, value.access)) throw new AppError(422, "A private space admits members and invited people only."); + if (!Number.isInteger(value.capacity) || value.capacity < 2 || value.capacity > 50) throw new AppError(422, "Choose a capacity from 2 to 50."); + if (!THEMES.includes(value.themeId)) throw new AppError(422, "Choose an available theme."); + return { ...value, title: value.title.trim(), purpose: value.purpose.trim() }; +} + +export function normalizeTags(value = []) { + if (!Array.isArray(value) || value.length > 5 || value.some((tag) => typeof tag !== "string")) throw new AppError(422, "Add up to 5 tags."); + const tags = [...new Set(value.map((tag) => tag.trim().toLowerCase().replace(/\s+/g, " ")).filter(Boolean))]; + if (tags.some((tag) => tag.length > 30)) throw new AppError(422, "Keep each tag under 30 characters."); + return tags; +} + +const runtime = () => ({ invitations: [], blocked: [], channelEpoch: 0, hostId: null, topic: null, tags: [], decor: [], decorVersion: 0 }); + +export function newSpace(fields, { id, ownerId, now }) { + const at = new Date(now).toISOString(); + return { id, type: "stoa", worldId: "room", ...fields, ownerId, members: [ownerId], ...runtime(), createdAt: at, updatedAt: at }; +} + +export function systemSpaces(plazaMap, now, plazaCapacity = PLAZA_CAPACITY) { + const at = new Date(now).toISOString(); + const base = { type: "stoa", worldId: "plaza", themeId: "agora", visibility: "listed", access: "open", ownerId: null, members: [], ...runtime(), purpose: "", createdAt: at, updatedAt: at }; + return [{ ...base, id: PLAZA_ID, title: "The plaza", capacity: plazaCapacity }, ...plazaMap.lots.map((lot) => ({ ...base, id: lotSpaceId(lot.slug), slug: lot.slug, lot: { worldId: "plaza", lotId: lot.lotId }, title: lot.title, capacity: lot.capacity }))]; +} + +export function migratedRoom(room, now) { + const space = newSpace({ title: String(room.title || "Meeting room").slice(0, 80), purpose: String(room.intent || "").slice(0, 300), visibility: "unlisted", access: "members", capacity: 12, themeId: "agora" }, { id: room.id, ownerId: room.ownerId, now }); + return { ...space, members: [...new Set([room.ownerId, ...(room.participants || [])])], createdAt: room.createdAt || space.createdAt }; +} + +export function publicSpace(space, { occupants = [], manage = false } = {}) { + const { invitations, blocked, members, channelEpoch, ...rest } = space; + return { ...rest, occupancy: occupants.length, occupants, ...(manage ? { members, blocked, invitations: invitations.map(({ tokenHash, ...entry }) => entry) } : {}) }; +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `node --test tests/spaces-core.test.mjs` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add scripts/spaces/model.mjs tests/spaces-core.test.mjs +git commit -m "Add the space model" -m "🤖 Built with SMT " +``` + +--- +### Task 6: Entry leases and hosts + +**Files:** +- Create: `scripts/spaces/leases.mjs` +- Test: `tests/spaces-core.test.mjs` (append) + +**Interfaces:** +- Consumes: `AppError`, `PLAZA_ID`. State is `{ spaces: Space[], spaceLeases: Lease[] }`, and actors have the shape `{ id, ids, name }`. +- Produces: + - `LEASE_MS = 60000` + - Lease record: `{ spaceId, actorId, ids: string[], name, enteredAt, expiresAt }` + - `leasesFor(state, spaceId, now) -> Lease[]` (active leases only) + - `leaseOf(state, spaceId, actorId, now) -> Lease|null` (matches `actorId` or any ID in `lease.ids`) + - `enter(state, space, actor, now) -> { lease, firstIn, events }`. Throws 409 `"This space is full right now."` + - `leave(state, space, actorId, now) -> events` + - `heartbeat(state, space, actorId, now) -> Lease`. Throws 410 `"You are no longer in this space."` + - `sweep(state, now) -> events` + - Events: `{ type: "entered"|"left", spaceId, actorId }`, `{ type: "host", spaceId, hostId }`, `{ type: "emptied", spaceId }` + +- [ ] **Step 1: Write the failing test** + +Append to `tests/spaces-core.test.mjs`: + +```js +import { LEASE_MS, enter, heartbeat, leaseOf, leasesFor, leave, sweep } from "../scripts/spaces/leases.mjs"; + +const walker = (id) => ({ id: "account:" + id, ids: ["account:" + id, "member:" + id], name: id.toUpperCase() }); +const world = () => { const state = { spaces: systemSpaces(plazaMap, 0), spaceLeases: [] }; return { state, plaza: state.spaces[0], lot: state.spaces.find((space) => space.id === "lot-rtc-lab") }; }; + +test("the first person in hosts, capacity holds, and one person holds one lease", () => { + const { state, lot } = world(); + const first = enter(state, lot, walker("a"), 0); + assert.equal(first.firstIn, true); + assert.equal(lot.hostId, "account:a"); + assert.deepEqual(first.events, [{ type: "entered", spaceId: lot.id, actorId: "account:a" }, { type: "host", spaceId: lot.id, hostId: "account:a" }]); + const again = enter(state, lot, walker("a"), 5000); + assert.equal(again.firstIn, false); + assert.equal(leasesFor(state, lot.id, 5000).length, 1); + assert.equal(again.lease.expiresAt, 5000 + LEASE_MS); + assert.equal(leaseOf(state, lot.id, "member:a", 5000), again.lease, "leases match any of the person's IDs"); + for (const id of ["b", "c", "d", "e", "f"]) assert.equal(enter(state, lot, walker(id), 6000).firstIn, false); + assert.equal(lot.hostId, "account:a"); + assert.throws(() => enter(state, lot, walker("g"), 7000), { status: 409, message: "This space is full right now." }); +}); +test("hosting passes to the longest-present person, and the owner hosts whenever present", () => { + const { state } = world(); + const space = newSpace(spaceInput({ title: "Team", access: "open" }), { id: "s1", ownerId: "account:o", now: 0 }); + state.spaces.push(space); + enter(state, space, walker("a"), 0); + enter(state, space, walker("b"), 1000); + enter(state, space, walker("o"), 2000); + assert.equal(space.hostId, "account:o"); + assert.deepEqual(leave(state, space, "account:o", 3000), [{ type: "left", spaceId: "s1", actorId: "account:o" }, { type: "host", spaceId: "s1", hostId: "account:a" }]); + leave(state, space, "account:a", 4000); + assert.equal(space.hostId, "account:b"); + assert.deepEqual(leave(state, space, "account:nobody", 5000), []); +}); +test("an emptied lot clears its session, but a user space keeps its own", () => { + const { state, lot } = world(); + enter(state, lot, walker("a"), 0); + Object.assign(lot, { topic: "Demos", tags: ["rtc"], decor: [{ id: "p", kind: "plant", x: 1, y: 1 }], blocked: ["account:z"] }); + const events = leave(state, lot, "account:a", 1000); + assert.ok(events.some((event) => event.type === "emptied")); + assert.deepEqual([lot.topic, lot.tags, lot.decor, lot.blocked, lot.hostId], [null, [], [], [], null]); + const space = { ...newSpace(spaceInput({ title: "Team", access: "open" }), { id: "s2", ownerId: "account:o", now: 0 }), topic: "Keep" }; + state.spaces.push(space); + enter(state, space, walker("a"), 0); + leave(state, space, "account:a", 1000); + assert.equal(space.topic, "Keep"); +}); +test("expired leases are swept, freeing seats and handing over the host", () => { + const { state, lot } = world(); + enter(state, lot, walker("a"), 0); + enter(state, lot, walker("b"), 10000); + assert.deepEqual(sweep(state, 30000), []); + const events = sweep(state, LEASE_MS + 1); + assert.deepEqual(events, [{ type: "left", spaceId: lot.id, actorId: "account:a" }, { type: "host", spaceId: lot.id, hostId: "account:b" }]); + assert.equal(heartbeat(state, lot, "account:b", 65000).expiresAt, 65000 + LEASE_MS); + assert.throws(() => heartbeat(state, lot, "account:a", 65000), { status: 410 }); +}); +test("the plaza never has a host", () => { + const { state, plaza } = world(); + assert.deepEqual(enter(state, plaza, walker("a"), 0).events, [{ type: "entered", spaceId: "plaza", actorId: "account:a" }]); + assert.equal(plaza.hostId, null); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `node --test tests/spaces-core.test.mjs` +Expected: FAIL with `Cannot find module` for `scripts/spaces/leases.mjs`. + +- [ ] **Step 3: Implement** + +Create `scripts/spaces/leases.mjs`: + +```js +import { AppError } from "../store.mjs"; +import { PLAZA_ID } from "./model.mjs"; + +// Leases count who is inside a space; capacity and hosting are decided only from active leases. +export const LEASE_MS = 60000; +const mine = (lease, id) => lease.actorId === id || lease.ids.includes(id); +export const leasesFor = (state, spaceId, now) => state.spaceLeases.filter((lease) => lease.spaceId === spaceId && lease.expiresAt > now); +export const leaseOf = (state, spaceId, actorId, now) => (actorId && leasesFor(state, spaceId, now).find((lease) => mine(lease, actorId))) || null; + +function settle(state, space, now, events) { + const present = leasesFor(state, space.id, now).sort((a, b) => a.enteredAt - b.enteredAt); + if (space.id !== PLAZA_ID) { + const keep = space.hostId && present.some((lease) => mine(lease, space.hostId)); + const next = keep ? space.hostId : (present.find((lease) => lease.ids.includes(space.ownerId)) || present[0])?.actorId || null; + if (next !== space.hostId) { space.hostId = next; events.push({ type: "host", spaceId: space.id, hostId: next }); } + } + if (!present.length && space.lot) { Object.assign(space, { topic: null, tags: [], decor: [], blocked: [] }); events.push({ type: "emptied", spaceId: space.id }); } +} + +export function enter(state, space, actor, now) { + const events = []; + const current = leaseOf(state, space.id, actor.id, now); + if (current) { current.expiresAt = now + LEASE_MS; return { lease: current, firstIn: false, events }; } + const present = leasesFor(state, space.id, now); + if (present.length >= space.capacity) throw new AppError(409, "This space is full right now."); + const lease = { spaceId: space.id, actorId: actor.id, ids: actor.ids, name: actor.name, enteredAt: now, expiresAt: now + LEASE_MS }; + state.spaceLeases.push(lease); + events.push({ type: "entered", spaceId: space.id, actorId: actor.id }); + const hostPresent = space.hostId && present.some((entry) => mine(entry, space.hostId)); + if (space.id !== PLAZA_ID && (actor.ids.includes(space.ownerId) || !hostPresent) && space.hostId !== actor.id) { space.hostId = actor.id; events.push({ type: "host", spaceId: space.id, hostId: actor.id }); } + return { lease, firstIn: present.length === 0, events }; +} + +export function leave(state, space, actorId, now) { + const events = []; + const gone = state.spaceLeases.filter((lease) => lease.spaceId === space.id && mine(lease, actorId)); + if (!gone.length) return events; + state.spaceLeases = state.spaceLeases.filter((lease) => !gone.includes(lease)); + for (const lease of gone) events.push({ type: "left", spaceId: space.id, actorId: lease.actorId }); + settle(state, space, now, events); + return events; +} + +export function heartbeat(state, space, actorId, now) { + const lease = leaseOf(state, space.id, actorId, now); + if (!lease) throw new AppError(410, "You are no longer in this space."); + lease.expiresAt = now + LEASE_MS; + return lease; +} + +export function sweep(state, now) { + const events = []; + const expired = state.spaceLeases.filter((lease) => lease.expiresAt <= now); + if (!expired.length) return events; + state.spaceLeases = state.spaceLeases.filter((lease) => lease.expiresAt > now); + for (const lease of expired) events.push({ type: "left", spaceId: lease.spaceId, actorId: lease.actorId }); + for (const id of new Set(expired.map((lease) => lease.spaceId))) { + const space = state.spaces.find((entry) => entry.id === id); + if (space) settle(state, space, now, events); + } + return events; +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `node --test tests/spaces-core.test.mjs` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add scripts/spaces/leases.mjs tests/spaces-core.test.mjs +git commit -m "Add entry leases, capacity, and hosting" -m "🤖 Built with SMT " +``` + +--- + +### Task 7: Decoration validation + +**Files:** +- Create: `scripts/spaces/decor.mjs` +- Test: `tests/spaces-core.test.mjs` (append) + +**Interfaces:** +- Consumes: `DECOR_KINDS` (Task 5); `reachable`, `roleAt`, `walkable` (Task 2); a parsed world map; a space with optional `lot: { lotId }`. +- Produces: `BLOCKING_DECOR` (a Set of `sofa, table, whiteboard, bookshelf, statue, fountain`), `DECOR_LIMIT = 60`, and `validateDecor(items, map, space) -> [{ id, kind, x, y, rotation, variant }]`. Throws 422 with one of these messages: + - `"Place up to 60 decorations."` + - `"Each decoration needs a short ID."` + - `"Choose a known decoration."` + - `"This decoration's rotation or style is not available."` + - `"Place decorations on open floor inside the room."` + - `"Keep doors and entrances clear."` + - `"Only one decoration fits on each tile."` + - `"Keep every door, entrance, and seat reachable."` + +- [ ] **Step 1: Write the failing test** + +Append to `tests/spaces-core.test.mjs`: + +```js +import { BLOCKING_DECOR, DECOR_LIMIT, validateDecor } from "../scripts/spaces/decor.mjs"; + +const roomMap = parseMap(JSON.parse(await readFile(new URL("../worlds/room/map.json", import.meta.url), "utf8"))); +const userSpace = { id: "s1" }; +const lotSpace = { id: "lot-rtc-lab", lot: { worldId: "plaza", lotId: "rtc" } }; +const rejects = (items, map, space, message) => assert.throws(() => validateDecor(items, map, space), { status: 422, message }); + +test("valid decorations are normalized", () => { + assert.equal(DECOR_LIMIT, 60); + assert.ok(BLOCKING_DECOR.has("sofa") && !BLOCKING_DECOR.has("plant")); + assert.deepEqual(validateDecor([{ id: "p1", kind: "plant", x: 3, y: 3 }, { id: "s1", kind: "sofa", x: 4, y: 3, rotation: 90, variant: 2, extra: "ignored" }], roomMap, userSpace), [{ id: "p1", kind: "plant", x: 3, y: 3, rotation: 0, variant: 0 }, { id: "s1", kind: "sofa", x: 4, y: 3, rotation: 90, variant: 2 }]); + assert.deepEqual(validateDecor([], roomMap, userSpace), []); +}); +test("decorations must be known, well-formed, on open floor, and inside the room", () => { + rejects(new Array(61).fill(0).map((_, index) => ({ id: "d" + index, kind: "plant", x: 2 + (index % 16), y: 2 + Math.floor(index / 16) })), roomMap, userSpace, "Place up to 60 decorations."); + rejects("plant", roomMap, userSpace, "Place up to 60 decorations."); + rejects([{ id: "bad id!", kind: "plant", x: 3, y: 3 }], roomMap, userSpace, "Each decoration needs a short ID."); + rejects([{ id: "d", kind: "dragon", x: 3, y: 3 }], roomMap, userSpace, "Choose a known decoration."); + rejects([{ id: "d", kind: "plant", x: 3, y: 3, rotation: 45 }], roomMap, userSpace, "This decoration's rotation or style is not available."); + rejects([{ id: "d", kind: "plant", x: 3, y: 3, variant: 8 }], roomMap, userSpace, "This decoration's rotation or style is not available."); + rejects([{ id: "d", kind: "plant", x: 0, y: 3 }], roomMap, userSpace, "Place decorations on open floor inside the room."); + rejects([{ id: "d", kind: "plant", x: 9, y: 6 }], roomMap, userSpace, "Place decorations on open floor inside the room."); + rejects([{ id: "d", kind: "plant", x: 9, y: 11 }], roomMap, userSpace, "Keep doors and entrances clear."); + rejects([{ id: "a", kind: "plant", x: 3, y: 3 }, { id: "b", kind: "lamp", x: 3, y: 3 }], roomMap, userSpace, "Only one decoration fits on each tile."); +}); +test("blocking decorations may not cut off seats, doors, or entrances", () => { + const walls = [[8, 11], [11, 11], [9, 10], [10, 10], [9, 12], [10, 12]].map(([x, y], index) => ({ id: "w" + index, kind: "statue", x, y })); + rejects(walls, roomMap, userSpace, "Keep every door, entrance, and seat reachable."); + assert.equal(validateDecor(walls.map((item) => ({ ...item, kind: "plant" })), roomMap, userSpace).length, 6, "non-blocking decorations never cut anything off"); + rejects([{ id: "d", kind: "sofa", x: 8, y: 4 }], roomMap, userSpace, "Keep every door, entrance, and seat reachable."); +}); +test("lot decorations stay inside the lot's interior and keep its doors clear", () => { + assert.equal(validateDecor([{ id: "p", kind: "plant", x: 15, y: 22 }], plazaMap, lotSpace).length, 1); + rejects([{ id: "p", kind: "plant", x: 22, y: 22 }], plazaMap, lotSpace, "Place decorations on open floor inside the room."); + rejects([{ id: "p", kind: "plant", x: 17, y: 31 }], plazaMap, lotSpace, "Keep doors and entrances clear."); + rejects([{ id: "p", kind: "plant", x: 17, y: 32 }], plazaMap, lotSpace, "Keep doors and entrances clear."); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `node --test tests/spaces-core.test.mjs` +Expected: FAIL with `Cannot find module` for `scripts/spaces/decor.mjs`. + +- [ ] **Step 3: Implement** + +Create `scripts/spaces/decor.mjs`: + +```js +import { AppError } from "../store.mjs"; +import { reachable, roleAt, walkable } from "../../world/map.js"; +import { DECOR_KINDS } from "./model.mjs"; + +// Decorations are semantic kinds; the theme draws them. Blocking kinds must never cut people off. +export const BLOCKING_DECOR = new Set(["sofa", "table", "whiteboard", "bookshelf", "statue", "fountain"]); +export const DECOR_LIMIT = 60; +const tilesIn = (map, area, role) => { const tiles = []; for (let y = area.y; y < area.y + area.height; y += 1) for (let x = area.x; x < area.x + area.width; x += 1) if (roleAt(map, x, y) === role) tiles.push({ x, y }); return tiles; }; + +function region(map, space) { + if (!space.lot) { + const area = { x: 0, y: 0, width: map.width, height: map.height }; + return { area, start: map.spawns[0], protect: [...map.spawns, ...tilesIn(map, area, "door")] }; + } + const lot = map.lots.find((entry) => entry.lotId === space.lot.lotId); + return { area: lot.interior, start: lot.entry, protect: [lot.entry, ...tilesIn(map, lot.interior, "door")] }; +} + +export function validateDecor(items, map, space) { + if (!Array.isArray(items) || items.length > DECOR_LIMIT) throw new AppError(422, "Place up to 60 decorations."); + const { area, start, protect } = region(map, space); + const protectedTiles = new Set(protect.map(({ x, y }) => x + "," + y)); + const used = new Set(); + const clean = items.map((item) => { + const { id, kind, x, y, rotation = 0, variant = 0 } = item && typeof item === "object" ? item : {}; + if (typeof id !== "string" || !/^[A-Za-z0-9_-]{1,40}$/.test(id)) throw new AppError(422, "Each decoration needs a short ID."); + if (!DECOR_KINDS.includes(kind)) throw new AppError(422, "Choose a known decoration."); + if (![0, 90, 180, 270].includes(rotation) || !Number.isInteger(variant) || variant < 0 || variant > 7) throw new AppError(422, "This decoration's rotation or style is not available."); + if (!walkable(map, x, y) || x < area.x || y < area.y || x >= area.x + area.width || y >= area.y + area.height) throw new AppError(422, "Place decorations on open floor inside the room."); + if (protectedTiles.has(x + "," + y)) throw new AppError(422, "Keep doors and entrances clear."); + if (used.has(x + "," + y)) throw new AppError(422, "Only one decoration fits on each tile."); + used.add(x + "," + y); + return { id, kind, x, y, rotation, variant }; + }); + const open = reachable(map, start, new Set(clean.filter((item) => BLOCKING_DECOR.has(item.kind)).map((item) => item.x + "," + item.y))); + if ([...protect, ...tilesIn(map, area, "seat")].some(({ x, y }) => !open.has(x + "," + y))) throw new AppError(422, "Keep every door, entrance, and seat reachable."); + return clean; +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `node --test tests/spaces-core.test.mjs` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add scripts/spaces/decor.mjs tests/spaces-core.test.mjs +git commit -m "Add decoration validation" -m "🤖 Built with SMT " +``` + +--- + +### Task 8: Signaling channels, keys, and tokens + +**Files:** +- Create: `scripts/spaces/signaling.mjs` +- Test: `tests/spaces-core.test.mjs` (append) + +**Interfaces:** +- Consumes: `agora-token` (`RtmTokenBuilder`, `Rtm2Permissions`), `AppError`. +- Produces: + - `signalingConfig(env) -> { appId, certificate, secret, permissions, ttl: 900, ready }`. It is `ready` only with a 32-hex app ID, a 32-hex certificate, and a secret of at least 32 characters. + - `channelName(secret, space) -> string`: + - listed spaces: `"ab--"` + - unlisted and private spaces: `"ab--" + 24 hex characters` of HMAC-SHA256(`secret`, `"channel::"`) + - `spaceKey(secret, space) -> string|null`: base64 of the 32-byte HMAC-SHA256(`secret`, `"key::"`); `null` for listed spaces. + - `signalingUser(actor|null) -> "a-" | "m-" | "g-<16 hex>"` + - `createSignaling(config, { now }) -> { ready, issue(userId, channels: [{ name, write }]) -> { appId, userId, token, expiresAt } }`. `issue` throws 503 when not ready. With `permissions` it uses `buildTokenWithPermissions`; otherwise it builds a login-only token. + +- [ ] **Step 1: Write the failing test** + +Append to `tests/spaces-core.test.mjs`: + +```js +import tokenTypes from "agora-token/src/AccessToken2.js"; +import { channelName, createSignaling, signalingConfig, signalingUser, spaceKey } from "../scripts/spaces/signaling.mjs"; + +const signalingEnv = { AGORA_APP_ID: "a".repeat(32), AGORA_APP_CERTIFICATE: "b".repeat(32), SPACE_CHANNEL_SECRET: "s".repeat(32) }; +const parse = (token) => { const parsed = new tokenTypes.AccessToken2(); assert.equal(parsed.from_string(token), true); assert.equal(parsed.verifySignature("b".repeat(32)), true); return parsed; }; + +test("Signaling needs Agora credentials and a long channel secret", () => { + const config = signalingConfig(signalingEnv); + assert.deepEqual([config.ready, config.ttl, config.permissions], [true, 900, false]); + assert.equal(signalingConfig({ ...signalingEnv, SPACE_CHANNEL_SECRET: "short" }).ready, false); + assert.equal(signalingConfig({}).ready, false); + assert.equal(signalingConfig({ ...signalingEnv, AGORA_RTM_PERMISSIONS: "true" }).permissions, true); + assert.throws(() => createSignaling(signalingConfig({})).issue("a-x", []), { status: 503 }); +}); +test("listed channels are public names; unlisted and private names and keys need the secret and change with the epoch", () => { + const secret = "s".repeat(32); + assert.equal(channelName(secret, { id: "plaza", type: "stoa", visibility: "listed" }), "ab-stoa-plaza"); + assert.equal(channelName(secret, { id: "lot-ai-agents", slug: "ai-agents", type: "stoa", visibility: "listed" }), "ab-stoa-ai-agents"); + assert.equal(spaceKey(secret, { id: "plaza", type: "stoa", visibility: "listed" }), null); + const hidden = { id: "8f9c4d02-0d56-4c79-9a39-111111111111", type: "stoa", visibility: "private", channelEpoch: 0 }; + const name = channelName(secret, hidden); + assert.match(name, /^ab-stoa-[a-f0-9]{24}$/); + assert.equal(name.includes(hidden.id), false); + assert.notEqual(channelName(secret, { ...hidden, channelEpoch: 1 }), name); + assert.notEqual(channelName("t".repeat(32), hidden), name); + const key = spaceKey(secret, hidden); + assert.equal(Buffer.from(key, "base64").length, 32); + assert.notEqual(spaceKey(secret, { ...hidden, channelEpoch: 1 }), key); +}); +test("Signaling user IDs come from the account, the profile, or a random guest ID", () => { + assert.equal(signalingUser({ id: "account:72a639ba-3a45-4afe-936b-111111111111" }), "a-72a639ba-3a45-4afe-936b-111111111111"); + assert.equal(signalingUser({ id: "member:72a639ba-3a45-4afe-936b-111111111111" }), "m-72a639ba-3a45-4afe-936b-111111111111"); + assert.match(signalingUser(null), /^g-[a-f0-9]{16}$/); + assert.notEqual(signalingUser(null), signalingUser(null)); +}); +test("Signaling tokens are signed for the user and add channel permissions only when enabled", () => { + const login = createSignaling(signalingConfig(signalingEnv), { now: () => 1700000000000 }).issue("a-x", [{ name: "ab-stoa-plaza", write: true }]); + assert.deepEqual([login.appId, login.userId, login.expiresAt], ["a".repeat(32), "a-x", new Date(1700000000000 + 900000).toISOString()]); + const parsed = parse(login.token); + assert.equal(parsed.expire, 900); + assert.equal(parsed.getServices(tokenTypes.kRtmServiceType)[0].__user_id.toString(), "a-x"); + const scoped = createSignaling(signalingConfig({ ...signalingEnv, AGORA_RTM_PERMISSIONS: "true" })).issue("g-1", [{ name: "ab-stoa-plaza", write: false }, { name: "ab-stoa-ai-agents", write: true }]); + const service = parse(scoped.token).getServices(tokenTypes.kRtm2ServiceType)[0]; + assert.deepEqual(service.__permissions.details, { 0: { 0: ["ab-stoa-plaza", "ab-stoa-ai-agents"], 1: ["ab-stoa-ai-agents"] } }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `node --test tests/spaces-core.test.mjs` +Expected: FAIL with `Cannot find module` for `scripts/spaces/signaling.mjs`. + +- [ ] **Step 3: Implement** + +Create `scripts/spaces/signaling.mjs`: + +```js +import { createHmac, randomBytes } from "node:crypto"; +import agoraToken from "agora-token"; +import { AppError } from "../store.mjs"; + +// Signaling tokens grant login only. Isolation comes from secret-derived channel names and per-space keys; +// AGORA_RTM_PERMISSIONS=true adds Agora's per-channel permissions on top when Agora enables them. +export function signalingConfig(env = process.env) { + const appId = env.AGORA_APP_ID || "", certificate = env.AGORA_APP_CERTIFICATE || "", secret = env.SPACE_CHANNEL_SECRET || ""; + return { appId, certificate, secret, permissions: env.AGORA_RTM_PERMISSIONS === "true", ttl: 900, ready: /^[a-f0-9]{32}$/i.test(appId) && /^[a-f0-9]{32}$/i.test(certificate) && secret.length >= 32 }; +} + +const mac = (secret, value) => createHmac("sha256", secret).update(value).digest(); +export const channelName = (secret, space) => "ab-" + space.type + "-" + (space.visibility === "listed" ? space.slug || space.id : mac(secret, "channel:" + space.id + ":" + (space.channelEpoch || 0)).toString("hex").slice(0, 24)); +export const spaceKey = (secret, space) => space.visibility === "listed" ? null : mac(secret, "key:" + space.id + ":" + (space.channelEpoch || 0)).toString("base64"); +export const signalingUser = (actor) => actor ? actor.id.replace(/^account:/, "a-").replace(/^member:/, "m-") : "g-" + randomBytes(8).toString("hex"); + +export function createSignaling(config = signalingConfig(), { now = Date.now } = {}) { + const { RtmTokenBuilder, Rtm2Permissions } = agoraToken; + return { + ready: config.ready, + issue(userId, channels) { + if (!config.ready) throw new AppError(503, "Live movement and messages are not connected yet."); + let token; + if (config.permissions) { + const permissions = new Rtm2Permissions(); + permissions.add(Rtm2Permissions.kMessageChannels, Rtm2Permissions.kRead, channels.map((channel) => channel.name)); + const writable = channels.filter((channel) => channel.write).map((channel) => channel.name); + if (writable.length) permissions.add(Rtm2Permissions.kMessageChannels, Rtm2Permissions.kWrite, writable); + token = RtmTokenBuilder.buildTokenWithPermissions(config.appId, config.certificate, userId, permissions, config.ttl); + } else token = RtmTokenBuilder.buildToken(config.appId, config.certificate, userId, config.ttl); + return { appId: config.appId, userId, token, expiresAt: new Date(now() + config.ttl * 1000).toISOString() }; + } + }; +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `node --test tests/spaces-core.test.mjs` +Expected: PASS. If `getServices` or a service type constant is named differently in `agora-token`, read `node_modules/agora-token/src/AccessToken2.js` and `node_modules/agora-token/test/RtmTokenBuilder2Test.js` and adjust **only the test's** token inspection to match them. + +- [ ] **Step 5: Commit** + +```bash +git add scripts/spaces/signaling.mjs tests/spaces-core.test.mjs +git commit -m "Add Signaling channel names, space keys, and tokens" -m "🤖 Built with SMT " +``` + +--- + +### Task 9: RTC tokens for spaces + +**Files:** +- Modify: `scripts/calls.mjs` (replace `createAgoraCalls`) +- Test: `tests/calls.test.mjs` (append) + +**Interfaces:** +- Produces: `calls.issueSpace(space, actor, input = {}) -> { provider, appId, channel: "agora-build-space-", uid, screenUid, token, screenToken, expiresAt }`. The UID prefix is the actor ID without its `account:` or `member:` prefix, then `_`. The service (Task 10) checks the lease before calling it. `calls.issue(room, person, input)` behaves exactly as before. + +- [ ] **Step 1: Write the failing test** + +Append to `tests/calls.test.mjs`: + +```js +test("space calls use the space channel and an identity derived from the account", () => { + const calls = createAgoraCalls(config); + const actor = { id: "account:72a639ba-3a45-4afe-936b-333333333333" }; + const credentials = calls.issueSpace({ id: "lot-ai-agents" }, actor); + assert.equal(credentials.channel, "agora-build-space-lot-ai-agents"); + assert.match(credentials.uid, /^72a639ba-3a45-4afe-936b-333333333333_[a-f0-9]{12}$/); + assert.equal(credentials.screenUid, credentials.uid + "_screen"); + assert.equal(calls.issueSpace({ id: "lot-ai-agents" }, actor, { uid: credentials.uid }).uid, credentials.uid); + assert.throws(() => calls.issueSpace({ id: "lot-ai-agents" }, actor, { uid: "someone_123456789abc" }), { status: 422 }); + assert.throws(() => createAgoraCalls(agoraConfig({})).issueSpace({ id: "lot-ai-agents" }, actor), { status: 503 }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `node --test tests/calls.test.mjs` +Expected: FAIL with `calls.issueSpace is not a function`. + +- [ ] **Step 3: Implement** + +In `scripts/calls.mjs`, replace the whole `createAgoraCalls` function with: + +```js +export function createAgoraCalls(config = agoraConfig(), { now = Date.now } = {}) { + function credentials(channel, prefix, input) { + if (!config.ready) throw new AppError(503, "Agora calls are not connected yet."); + // Each tab gets its own identity; renewals can only reuse this person's IDs. + const uid = input.uid === undefined ? prefix + randomBytes(6).toString("hex") : input.uid; + if (typeof uid !== "string" || !uid.startsWith(prefix) || !/^[a-f0-9]{12}$/.test(uid.slice(prefix.length))) throw new AppError(422, "This call identity does not belong to your profile."); + const screenUid = uid + "_screen"; + const sign = (account) => agoraToken.RtcTokenBuilder.buildTokenWithUserAccount(config.appId, config.certificate, channel, account, agoraToken.RtcRole.PUBLISHER, config.ttl, config.ttl); + return { provider: "agora", appId: config.appId, channel, uid, screenUid, token: sign(uid), screenToken: sign(screenUid), expiresAt: new Date(now() + config.ttl * 1000).toISOString() }; + } + return { + ready: config.ready, + issue(room, person, input = {}) { + if (!room.participants.includes(person.id)) throw new AppError(403, "Join this room's roster before entering the call."); + return credentials("agora-build-" + room.id, person.id.replace(/^member:/, "") + "_", input); + }, + issueSpace(space, actor, input = {}) { + return credentials("agora-build-space-" + space.id, actor.id.replace(/^(account|member):/, "") + "_", input); + } + }; +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `node --test tests/calls.test.mjs` +Expected: PASS (4 tests, including the 3 existing ones). + +- [ ] **Step 5: Commit** + +```bash +git add scripts/calls.mjs tests/calls.test.mjs +git commit -m "Issue RTC tokens for spaces" -m "🤖 Built with SMT " +``` + +--- +### Task 10: Spaces service + +**Files:** +- Create: `scripts/spaces/service.mjs` +- Test: `tests/spaces-service.test.mjs` + +**Interfaces:** +- Consumes: + - `store.transaction`, `store.snapshot`, `store.actor`, `store.join`, `store.createRoom` (Task 1 and existing code) + - `parseMap` (Task 2), `can` (Task 4), the model (Task 5), the leases (Task 6), `validateDecor` (Task 7) + - `channelName`, `spaceKey`, `signalingUser`, `signaling.issue` (Task 8); `calls.issueSpace` (Task 9) +- Produces: + - `loadWorlds(directory) -> { plaza: { source, map }, room: { source, map } }` (synchronous; reads `worlds//map.json`) + - `createSpaces({ store, worlds, signaling, calls, secret = "", admins = [], now = Date.now, plazaCapacity }) -> spaces` with: + - `list({ q }) -> { plaza: { capacity, occupancy, blocked }, rooms: [{ id, slug, title, topic, tags, capacity, occupancy, occupants, hostName, path }] }` + - `create(token, input) -> PublicSpace`; `get(token, id, invite?) -> PublicSpace`; `visible(token, id, invite?) -> boolean`; `update(token, id, input) -> PublicSpace`; `destroy(token, id) -> { deleted: true }` + - `enter(token, id, invite?) -> { space, firstIn, channel, key, blocked, hostId }` + - `leave(token, id) -> { left: true }`; `heartbeat(token, id) -> { channel, key, blocked, hostId }` + - `topic(token, id, { topic, tags }) -> PublicSpace`; `setHost(token, id, { actorId }) -> { hostId }`; `decorate(token, id, items) -> { decor, version }` + - `removePerson(token, id, { actorId }) -> { removed: true, channel, key, blocked, hostId }` + - `invite(token, id, { uses, hours }) -> { token, path, usesLeft, expiresAt }` + - `addMember(token, id, memberId) -> { members }`; `removeMember(token, id, memberId) -> { members }` + - `signalingToken(token) -> { appId, userId, token, expiresAt, channels: [{ spaceId, name, write, key }] }` + - `rtcToken(token, id, input) -> RTC credentials` + - `sweep() -> Promise` + - `onEvents(listener)`: the listener receives each committed batch of events. Plan D uses it for Chat sync. Event types: `entered`, `left`, `host`, `emptied`, `member-added`, `member-removed`, `removed`, `deleted`. + +- [ ] **Step 1: Write the failing test** + +Create `tests/spaces-service.test.mjs`: + +```js +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { resolve } from "node:path"; +import { createStore } from "../scripts/store.mjs"; +import { agoraConfig, createAgoraCalls } from "../scripts/calls.mjs"; +import { createSignaling, signalingConfig } from "../scripts/spaces/signaling.mjs"; +import { createSpaces, loadWorlds } from "../scripts/spaces/service.mjs"; + +const root = new URL("../", import.meta.url).pathname; +const worlds = loadWorlds(root); +const secret = "s".repeat(32); +const env = { AGORA_APP_ID: "a".repeat(32), AGORA_APP_CERTIFICATE: "b".repeat(32), SPACE_CHANNEL_SECRET: secret }; +const profile = (name) => ({ name, bio: "Building voice agents with Rust.", intent: "Build a language practice voice agent with natural interruptions.", lookingFor: "Audio engineers", skills: ["Rust", "Voice AI"], location: "Remote", contact: "https://example.com/builder", monitor: false }); +const missing = { status: 404, message: "This space was not found." }; + +async function setup(t, { plazaCapacity, before } = {}) { + const directory = await mkdtemp(resolve(tmpdir(), "spaces-service-")); + t.after(() => rm(directory, { recursive: true, force: true })); + const clock = { now: 1800000000000 }; + const now = () => clock.now; + const store = createStore(resolve(root, "data/people.json"), resolve(directory, "state.json"), { now }); + const person = async (name) => { const { token } = await store.join(profile(name)); return { token, id: (await store.actor(token)).id }; }; + const context = before ? await before(store, person) : {}; + const spaces = createSpaces({ store, worlds, secret, now, plazaCapacity, signaling: createSignaling(signalingConfig(env), { now }), calls: createAgoraCalls(agoraConfig(env), { now }) }); + const events = []; + spaces.onEvents((list) => events.push(...list)); + return { store, spaces, events, clock, person, ...context }; +} + +test("system spaces and legacy rooms are prepared once", async (t) => { + const { spaces, store, owner, room } = await setup(t, { before: async (store, person) => { const owner = await person("Owner"); return { owner, room: await store.createRoom(owner.token, { title: "Demo prep", intent: "Plan the demo day together." }) }; } }); + const listing = await spaces.list(); + assert.deepEqual(listing.rooms.map((entry) => entry.slug), ["ai-agents", "voice-ai", "rtc-lab", "founders-table", "open-source", "lounge"]); + assert.deepEqual(listing.plaza, { capacity: 1000, occupancy: 0, blocked: [] }); + const migrated = await spaces.get(owner.token, room.id); + assert.deepEqual([migrated.visibility, migrated.access, migrated.members, migrated.title], ["unlisted", "members", [owner.id], "Demo prep"]); + await spaces.list(); + assert.equal((await store.snapshot()).spaces.length, 8); +}); + +test("the first person in a lot hosts and sets the topic; hosting passes on; an empty lot resets", async (t) => { + const { spaces, events, clock, person } = await setup(t); + const [a, b, c] = [await person("Ada"), await person("Bo"), await person("Cy")]; + const first = await spaces.enter(a.token, "lot-ai-agents"); + assert.deepEqual([first.firstIn, first.hostId, first.channel, first.key], [true, a.id, "ab-stoa-ai-agents", null]); + clock.now += 1000; await spaces.enter(b.token, "lot-ai-agents"); + clock.now += 1000; await spaces.enter(c.token, "lot-ai-agents"); + await assert.rejects(spaces.topic(b.token, "lot-ai-agents", { topic: "Agents" }), { status: 403 }); + await assert.rejects(spaces.topic(a.token, "lot-ai-agents", { topic: "x" }), { status: 422 }); + const topical = await spaces.topic(a.token, "lot-ai-agents", { topic: "Agent evals", tags: ["Evals", "agents"] }); + assert.deepEqual([topical.topic, topical.tags], ["Agent evals", ["evals", "agents"]]); + assert.equal((await spaces.list({ q: "evals" })).rooms.length, 1); + assert.equal((await spaces.list({ q: "evals" })).rooms[0].hostName, "Ada"); + await spaces.leave(a.token, "lot-ai-agents"); + assert.equal((await spaces.get(c.token, "lot-ai-agents")).hostId, b.id); + assert.deepEqual(await spaces.setHost(b.token, "lot-ai-agents", { actorId: c.id }), { hostId: c.id }); + await assert.rejects(spaces.setHost(b.token, "lot-ai-agents", { actorId: b.id }), { status: 403 }); + await assert.rejects(spaces.setHost(c.token, "lot-ai-agents", { actorId: a.id }), { status: 422 }); + await spaces.leave(b.token, "lot-ai-agents"); + await spaces.leave(c.token, "lot-ai-agents"); + const empty = await spaces.get(null, "lot-ai-agents"); + assert.deepEqual([empty.topic, empty.tags, empty.hostId, empty.occupancy], [null, [], null, 0]); + assert.ok(events.some((event) => event.type === "emptied" && event.spaceId === "lot-ai-agents")); +}); + +test("expired leases free seats and hand over hosting", async (t) => { + const { spaces, clock, person } = await setup(t); + const [a, b] = [await person("Ada"), await person("Bo")]; + await spaces.enter(a.token, "lot-rtc-lab"); + clock.now += 61000; + const next = await spaces.enter(b.token, "lot-rtc-lab"); + assert.deepEqual([next.firstIn, next.hostId], [true, b.id]); + await assert.rejects(spaces.heartbeat(a.token, "lot-rtc-lab"), { status: 410 }); + assert.equal((await spaces.heartbeat(b.token, "lot-rtc-lab")).hostId, b.id); +}); + +test("a full plaza offers the person's unlisted spaces, and lots stay open", async (t) => { + const { spaces, person } = await setup(t, { plazaCapacity: 1 }); + const [a, b] = [await person("Ada"), await person("Bo")]; + await spaces.enter(a.token, "plaza"); + const own = await spaces.create(b.token, { title: "Bo's corner" }); + await assert.rejects(spaces.enter(b.token, "plaza"), (error) => { + assert.equal(error.status, 409); + assert.equal(error.message, "The plaza is full right now."); + assert.deepEqual(error.details, { full: true, offer: { spaces: [{ id: own.id, title: "Bo's corner", visibility: "unlisted", path: "/stoa/s/" + own.id }], canCreate: true } }); + return true; + }); + assert.equal((await spaces.enter(b.token, "lot-lounge")).firstIn, true); + await spaces.enter(a.token, "lot-voice-ai"); + assert.equal((await spaces.list()).plaza.occupancy, 0, "people inside a lot leave the plaza"); + assert.equal((await spaces.enter(b.token, "plaza")).space.id, "plaza"); +}); + +test("people can own as many spaces as their plan allows", async (t) => { + const { spaces, person } = await setup(t); + const a = await person("Ada"); + for (const title of ["One", "Two", "Three"]) await spaces.create(a.token, { title }); + await assert.rejects(spaces.create(a.token, { title: "Four" }), { status: 409 }); + await assert.rejects(spaces.create(null, { title: "Guest" }), { status: 401 }); +}); + +test("private spaces stay hidden from strangers and admit invitation holders", async (t) => { + const { spaces, events, clock, person } = await setup(t); + const [owner, guest, late, later] = [await person("Owner"), await person("Guest"), await person("Late"), await person("Later")]; + const space = await spaces.create(owner.token, { title: "Board room", visibility: "private" }); + await assert.rejects(spaces.get(guest.token, space.id), missing); + await assert.rejects(spaces.get(guest.token, "00000000-0000-4000-8000-000000000000"), missing); + await assert.rejects(spaces.enter(guest.token, space.id), missing); + await assert.rejects(spaces.invite(guest.token, space.id, {}), missing); + assert.equal(await spaces.visible(guest.token, space.id), false); + const invitation = await spaces.invite(owner.token, space.id, { uses: 1, hours: 1 }); + assert.equal(invitation.path, "/stoa/s/" + space.id + "?invite=" + invitation.token); + assert.equal(await spaces.visible(guest.token, space.id, invitation.token), true); + const entered = await spaces.enter(guest.token, space.id, invitation.token); + assert.match(entered.channel, /^ab-stoa-[a-f0-9]{24}$/); + assert.equal(Buffer.from(entered.key, "base64").length, 32); + assert.ok(events.some((event) => event.type === "member-added" && event.actorId === guest.id)); + assert.equal((await spaces.get(guest.token, space.id)).title, "Board room", "members keep seeing the space"); + await assert.rejects(spaces.invite(guest.token, space.id, {}), { status: 403 }); + await assert.rejects(spaces.enter(late.token, space.id, invitation.token), missing, "used invitations stop working"); + const expiring = await spaces.invite(owner.token, space.id, { uses: 5, hours: 1 }); + clock.now += 3600001; + await assert.rejects(spaces.enter(later.token, space.id, expiring.token), missing); + await assert.rejects(spaces.invite(owner.token, space.id, { uses: 0 }), { status: 422 }); + assert.equal(JSON.stringify(await spaces.get(owner.token, space.id)).includes(invitation.token), false, "invitation tokens are never stored in the clear"); +}); + +test("removing someone blocks them, renames hidden channels, and ends their heartbeat", async (t) => { + const { spaces, events, person } = await setup(t); + const [owner, visitor] = [await person("Owner"), await person("Visitor")]; + const space = await spaces.create(owner.token, { title: "Open studio", access: "open" }); + const before = await spaces.enter(owner.token, space.id); + await spaces.enter(visitor.token, space.id); + await assert.rejects(spaces.removePerson(visitor.token, space.id, { actorId: owner.id }), { status: 403 }); + await assert.rejects(spaces.removePerson(owner.token, space.id, { actorId: owner.id }), { status: 422 }); + const after = await spaces.removePerson(owner.token, space.id, { actorId: visitor.id }); + assert.notEqual(after.channel, before.channel); + assert.notEqual(after.key, before.key); + assert.ok(events.some((event) => event.type === "removed" && event.actorId === visitor.id)); + await assert.rejects(spaces.heartbeat(visitor.token, space.id), { status: 410 }); + await assert.rejects(spaces.enter(visitor.token, space.id), { status: 403 }); + await spaces.addMember(owner.token, space.id, visitor.id); + assert.equal((await spaces.enter(visitor.token, space.id)).channel, after.channel, "the owner can let them back in"); + const left = await spaces.removeMember(owner.token, space.id, visitor.id); + assert.deepEqual(left.members, [owner.id]); + assert.ok(events.some((event) => event.type === "member-removed" && event.actorId === visitor.id)); + await assert.rejects(spaces.removeMember(owner.token, space.id, owner.id), { status: 422 }); +}); + +test("owners edit and delete their spaces; the map's rooms cannot be edited", async (t) => { + const { spaces, events, person } = await setup(t); + const [owner, other] = [await person("Owner"), await person("Other")]; + const space = await spaces.create(owner.token, { title: "Studio" }); + assert.equal((await spaces.update(owner.token, space.id, { title: "Studio two", themeId: "cyberpunk" })).title, "Studio two"); + await assert.rejects(spaces.update(other.token, space.id, { title: "Mine" }), { status: 403 }); + await assert.rejects(spaces.update(owner.token, "lot-lounge", { title: "Mine" }), { status: 403 }); + await assert.rejects(spaces.destroy(other.token, space.id), { status: 403 }); + assert.deepEqual(await spaces.destroy(owner.token, space.id), { deleted: true }); + assert.ok(events.some((event) => event.type === "deleted" && event.spaceId === space.id)); + await assert.rejects(spaces.get(owner.token, space.id), missing); +}); + +test("only the host decorates, within the room's rules", async (t) => { + const { spaces, person } = await setup(t); + const [a, b] = [await person("Ada"), await person("Bo")]; + await spaces.enter(a.token, "lot-rtc-lab"); + await spaces.enter(b.token, "lot-rtc-lab"); + assert.deepEqual(await spaces.decorate(a.token, "lot-rtc-lab", [{ id: "p", kind: "plant", x: 15, y: 22 }]), { decor: [{ id: "p", kind: "plant", x: 15, y: 22, rotation: 0, variant: 0 }], version: 1 }); + await assert.rejects(spaces.decorate(b.token, "lot-rtc-lab", []), { status: 403 }); + await assert.rejects(spaces.decorate(a.token, "lot-rtc-lab", [{ id: "p", kind: "plant", x: 17, y: 32 }]), { status: 422 }); + await assert.rejects(spaces.decorate(a.token, "plaza", []), { status: 403 }); +}); + +test("Signaling tokens list only channels the person may use", async (t) => { + const { spaces, person } = await setup(t); + const [owner, stranger] = [await person("Owner"), await person("Stranger")]; + const hidden = await spaces.create(owner.token, { title: "Secret plans", visibility: "private" }); + const entered = await spaces.enter(owner.token, hidden.id); + const guest = await spaces.signalingToken(null); + assert.match(guest.userId, /^g-[a-f0-9]{16}$/); + assert.deepEqual(guest.channels, [{ spaceId: "plaza", name: "ab-stoa-plaza", write: false, key: null }]); + const own = await spaces.signalingToken(owner.token); + assert.equal(own.userId, owner.id.replace("member:", "m-")); + assert.deepEqual(own.channels.map((channel) => [channel.spaceId, channel.name, channel.write, channel.key]), [["plaza", "ab-stoa-plaza", true, null], [hidden.id, entered.channel, true, entered.key]]); + const outside = JSON.stringify([await spaces.list(), await spaces.signalingToken(stranger.token), guest]); + for (const value of [hidden.id, "Secret plans", entered.channel, entered.key]) assert.equal(outside.includes(value), false, value); +}); + +test("RTC tokens require being inside a room", async (t) => { + const { spaces, person } = await setup(t); + const a = await person("Ada"); + await assert.rejects(spaces.rtcToken(a.token, "lot-lounge", {}), { status: 403 }); + await spaces.enter(a.token, "lot-lounge"); + assert.equal((await spaces.rtcToken(a.token, "lot-lounge", {})).channel, "agora-build-space-lot-lounge"); + await spaces.enter(a.token, "plaza"); + await assert.rejects(spaces.rtcToken(a.token, "plaza", {}), { status: 422 }); +}); + +test("simultaneous entries never exceed capacity and produce one host", async (t) => { + const { spaces, person } = await setup(t); + const walkers = []; + for (let index = 0; index < 8; index += 1) walkers.push(await person("Walker " + index)); + const results = await Promise.allSettled(walkers.map((walker) => spaces.enter(walker.token, "lot-rtc-lab"))); + assert.equal(results.filter((result) => result.status === "fulfilled").length, 6); + assert.ok(results.filter((result) => result.status === "rejected").every((result) => result.reason.status === 409)); + assert.equal(results.filter((result) => result.status === "fulfilled" && result.value.firstIn).length, 1); + const room = await spaces.get(null, "lot-rtc-lab"); + assert.equal(room.occupancy, 6); + assert.ok(room.occupants.some((occupant) => occupant.id === room.hostId)); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `node --test tests/spaces-service.test.mjs` +Expected: FAIL with `Cannot find module` for `scripts/spaces/service.mjs`. + +- [ ] **Step 3: Implement** + +Create `scripts/spaces/service.mjs`: + +```js +import { createHash, randomBytes, randomUUID } from "node:crypto"; +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; +import { AppError } from "../store.mjs"; +import { entitlements } from "../plans.mjs"; +import { parseMap } from "../../world/map.js"; +import { can } from "./permissions.mjs"; +import { PLAZA_ID, migratedRoom, newSpace, normalizeTags, publicSpace, spaceInput, systemSpaces } from "./model.mjs"; +import { enter as enterLease, heartbeat as heartbeatLease, leaseOf, leasesFor, leave as leaveLease, sweep as sweepLeases } from "./leases.mjs"; +import { validateDecor } from "./decor.mjs"; +import { channelName, signalingUser, spaceKey } from "./signaling.mjs"; + +const hash = (value) => createHash("sha256").update(value).digest("hex"); +const ACTOR_ID = /^(member|account):[a-f0-9-]{36}$/; +const notFound = () => new AppError(404, "This space was not found."); + +export function loadWorlds(directory) { + return Object.fromEntries(["plaza", "room"].map((id) => { + const source = JSON.parse(readFileSync(resolve(directory, "worlds", id, "map.json"), "utf8")); + return [id, { source, map: parseMap(source) }]; + })); +} + +export function createSpaces({ store, worlds, signaling, calls, secret = "", admins = [], now = Date.now, plazaCapacity }) { + const listeners = []; + let prepared; + // System spaces follow the plaza map; legacy meeting rooms become unlisted members-only spaces once. + const ready = () => prepared ||= store.transaction((state) => { + for (const system of systemSpaces(worlds.plaza.map, now(), plazaCapacity)) { + const existing = state.spaces.find((space) => space.id === system.id); + if (!existing) state.spaces.push(system); + else Object.assign(existing, { title: system.title, capacity: system.capacity, slug: system.slug, lot: system.lot }); + } + for (const room of state.rooms) if (!state.spaces.some((space) => space.id === room.id)) state.spaces.push(migratedRoom(room, now())); + }).catch((error) => { prepared = undefined; throw error; }); + async function mutate(work) { + await ready(); + const events = []; + const result = await store.transaction((state) => { events.push(...sweepLeases(state, now())); return work(state, events); }); + if (events.length) for (const listener of listeners) listener(events); + return result; + } + async function actorFor(token) { + const actor = token ? await store.actor(token) : null; + return actor && { ...actor, ids: [actor.accountId, actor.memberId].filter(Boolean), admin: Boolean(actor.accountId && admins.includes(actor.accountId)) }; + } + const signedIn = (actor) => { if (!actor) throw new AppError(401, "Sign in to step into the Stoa."); return actor; }; + const find = (state, id) => { const space = state.spaces.find((entry) => entry.id === id); if (!space) throw notFound(); return space; }; + const system = (space) => Boolean(space.lot) || space.id === PLAZA_ID; + const invitationFor = (space, invite) => typeof invite === "string" && invite ? space.invitations.find((entry) => entry.tokenHash === hash(invite) && entry.expiresAt > now() && entry.usesLeft > 0) || null : null; + const present = (state, space, actor) => Boolean(actor && leaseOf(state, space.id, actor.id, now())); + const visible = (state, space, actor, invite) => can(actor, "see", space, { invited: Boolean(invitationFor(space, invite)), present: present(state, space, actor) }); + const findVisible = (state, id, actor, invite) => { const space = find(state, id); if (!visible(state, space, actor, invite)) throw notFound(); return space; }; + const occupants = (state, space) => leasesFor(state, space.id, now()).sort((a, b) => a.enteredAt - b.enteredAt).map((lease) => ({ id: lease.actorId, name: lease.name })); + const view = (state, space, actor) => publicSpace(space, { occupants: occupants(state, space), manage: can(actor, "edit", space) }); + const owned = (state, actor) => state.spaces.filter((space) => !system(space) && actor.ids.includes(space.ownerId)); + const offerFor = (state, actor) => ({ spaces: state.spaces.filter((space) => !system(space) && space.members.some((id) => actor.ids.includes(id))).map((space) => ({ id: space.id, title: space.title, visibility: space.visibility, path: "/stoa/s/" + space.id })), canCreate: owned(state, actor).length < entitlements(actor.plan).spaces }); + const access = (space) => ({ channel: channelName(secret, space), key: spaceKey(secret, space), blocked: space.blocked, hostId: space.hostId }); + const requirePresent = (state, space, actor) => { if (!present(state, space, actor)) throw new AppError(403, "Step into this space first."); }; + + const spaces = { + onEvents(listener) { listeners.push(listener); }, + async sweep() { await mutate(() => null); }, + async list({ q = "" } = {}) { + await ready(); + const state = await store.snapshot(); + const query = String(q).trim().toLowerCase().slice(0, 100); + const plaza = find(state, PLAZA_ID); + const rooms = state.spaces.filter((space) => space.visibility === "listed" && space.lot).map((space) => { + const people = occupants(state, space); + return { id: space.id, slug: space.slug, title: space.title, topic: space.topic, tags: space.tags, capacity: space.capacity, occupancy: people.length, occupants: people, hostName: people.find((entry) => entry.id === space.hostId)?.name || null, path: "/stoa/room/" + space.slug }; + }).filter((room) => !query || [room.title, room.topic || "", ...room.tags].some((value) => value.toLowerCase().includes(query))); + return { plaza: { capacity: plaza.capacity, occupancy: leasesFor(state, PLAZA_ID, now()).length, blocked: plaza.blocked }, rooms }; + }, + async create(token, input) { + const actor = signedIn(await actorFor(token)); + const fields = spaceInput(input || {}); + return mutate((state) => { + if (owned(state, actor).length >= entitlements(actor.plan).spaces) throw new AppError(409, "You have reached your plan's space limit.", { offer: offerFor(state, actor) }); + const space = newSpace(fields, { id: randomUUID(), ownerId: actor.id, now: now() }); + state.spaces.push(space); + return view(state, space, actor); + }); + }, + async get(token, id, invite) { + await ready(); + const actor = await actorFor(token); + const state = await store.snapshot(); + return view(state, findVisible(state, id, actor, invite), actor); + }, + async visible(token, id, invite) { + try { await spaces.get(token, id, invite); return true; } + catch (error) { if (error.status === 404) return false; throw error; } + }, + async update(token, id, input) { + const actor = signedIn(await actorFor(token)); + return mutate((state) => { + const space = findVisible(state, id, actor); + if (system(space)) throw new AppError(403, "The map sets the plaza and its rooms."); + if (!can(actor, "edit", space)) throw new AppError(403, "Only the owner can change this space."); + const fields = spaceInput(input || {}, space); + if (fields.visibility !== space.visibility) space.channelEpoch += 1; + Object.assign(space, fields, { updatedAt: new Date(now()).toISOString() }); + return view(state, space, actor); + }); + }, + async destroy(token, id) { + const actor = signedIn(await actorFor(token)); + return mutate((state, events) => { + const space = findVisible(state, id, actor); + if (system(space) || !can(actor, "delete", space)) throw new AppError(403, "Only the owner can delete this space."); + state.spaces = state.spaces.filter((entry) => entry !== space); + state.spaceLeases = state.spaceLeases.filter((lease) => lease.spaceId !== space.id); + events.push({ type: "deleted", spaceId: space.id, members: space.members }); + return { deleted: true }; + }); + }, + async enter(token, id, invite) { + const actor = signedIn(await actorFor(token)); + return mutate((state, events) => { + const space = find(state, id); + const invitation = invitationFor(space, invite); + const context = { invited: Boolean(invitation), present: present(state, space, actor) }; + if (!can(actor, "see", space, context)) throw notFound(); + if (!can(actor, "enter", space, context)) throw new AppError(403, space.blocked.some((entry) => actor.ids.includes(entry)) ? "You can't enter this space right now." : "This space is open to its members."); + if (invitation && !space.members.some((entry) => actor.ids.includes(entry))) { + space.members.push(actor.id); + invitation.usesLeft -= 1; + events.push({ type: "member-added", spaceId: space.id, actorId: actor.id }); + } + if (space.lot) events.push(...leaveLease(state, find(state, PLAZA_ID), actor.id, now())); + let result; + try { result = enterLease(state, space, actor, now()); } + catch (error) { + if (space.id === PLAZA_ID && error.status === 409) throw new AppError(409, "The plaza is full right now.", { full: true, offer: offerFor(state, actor) }); + throw error; + } + events.push(...result.events); + return { space: view(state, space, actor), firstIn: result.firstIn, ...access(space) }; + }); + }, + async leave(token, id) { + const actor = signedIn(await actorFor(token)); + return mutate((state, events) => { events.push(...leaveLease(state, find(state, id), actor.id, now())); return { left: true }; }); + }, + async heartbeat(token, id) { + const actor = signedIn(await actorFor(token)); + return mutate((state) => { const space = find(state, id); heartbeatLease(state, space, actor.id, now()); return access(space); }); + }, + async topic(token, id, input = {}) { + const actor = signedIn(await actorFor(token)); + const topic = typeof input.topic === "string" ? input.topic.trim() : ""; + if (topic.length < 2 || topic.length > 80) throw new AppError(422, "Add a topic of 2 to 80 characters."); + const tags = normalizeTags(input.tags); + return mutate((state) => { + const space = find(state, id); + requirePresent(state, space, actor); + if (space.id === PLAZA_ID || !can(actor, "host", space, { present: true })) throw new AppError(403, "Only the host can set the topic."); + Object.assign(space, { topic, tags }); + return view(state, space, actor); + }); + }, + async setHost(token, id, input = {}) { + const actor = signedIn(await actorFor(token)); + return mutate((state, events) => { + const space = find(state, id); + requirePresent(state, space, actor); + if (space.id === PLAZA_ID || !can(actor, "host", space, { present: true })) throw new AppError(403, "Only the host or owner can hand over hosting."); + const target = leaseOf(state, space.id, input.actorId, now()); + if (!target) throw new AppError(422, "Choose someone who is here."); + space.hostId = target.actorId; + events.push({ type: "host", spaceId: space.id, hostId: space.hostId }); + return { hostId: space.hostId }; + }); + }, + async decorate(token, id, items) { + const actor = signedIn(await actorFor(token)); + return mutate((state) => { + const space = find(state, id); + requirePresent(state, space, actor); + if (space.id === PLAZA_ID || !can(actor, "decorate", space, { present: true })) throw new AppError(403, "Only the host can decorate."); + space.decor = validateDecor(items, worlds[space.worldId].map, space); + space.decorVersion = (space.decorVersion || 0) + 1; + return { decor: space.decor, version: space.decorVersion }; + }); + }, + async removePerson(token, id, input = {}) { + const actor = signedIn(await actorFor(token)); + return mutate((state, events) => { + const space = find(state, id); + requirePresent(state, space, actor); + if (!can(actor, "moderate", space, { present: true })) throw new AppError(403, "Only the host can remove someone."); + const target = leaseOf(state, space.id, input.actorId, now()); + const ids = target?.ids || [input.actorId]; + if (!ACTOR_ID.test(input.actorId || "") || ids.includes(space.ownerId) || ids.some((entry) => actor.ids.includes(entry))) throw new AppError(422, "This person can't be removed."); + space.blocked = [...new Set([...space.blocked, ...ids])]; + if (space.visibility !== "listed") space.channelEpoch += 1; + events.push(...leaveLease(state, space, input.actorId, now()), { type: "removed", spaceId: space.id, actorId: input.actorId }); + return { removed: true, ...access(space) }; + }); + }, + async invite(token, id, input = {}) { + const actor = signedIn(await actorFor(token)); + const uses = input.uses ?? 10, hours = input.hours ?? 72; + if (!Number.isInteger(uses) || uses < 1 || uses > 100 || !Number.isInteger(hours) || hours < 1 || hours > 720) throw new AppError(422, "Choose 1 to 100 uses and 1 to 720 hours."); + const raw = randomBytes(24).toString("base64url"); + return mutate((state) => { + const space = findVisible(state, id, actor); + if (system(space) || !can(actor, "invite", space)) throw new AppError(403, "Only the owner can invite people."); + space.invitations = space.invitations.filter((entry) => entry.expiresAt > now() && entry.usesLeft > 0).slice(-49); + const invitation = { id: randomUUID(), tokenHash: hash(raw), usesLeft: uses, expiresAt: now() + hours * 3600000, createdBy: actor.id, createdAt: new Date(now()).toISOString() }; + space.invitations.push(invitation); + return { token: raw, path: "/stoa/s/" + space.id + "?invite=" + raw, usesLeft: uses, expiresAt: new Date(invitation.expiresAt).toISOString() }; + }); + }, + async addMember(token, id, memberId) { + const actor = signedIn(await actorFor(token)); + if (!ACTOR_ID.test(memberId || "")) throw new AppError(422, "Choose a builder to add."); + return mutate((state, events) => { + const space = findVisible(state, id, actor); + if (system(space) || !can(actor, "edit", space)) throw new AppError(403, "Only the owner can change members."); + space.blocked = space.blocked.filter((entry) => entry !== memberId); + if (!space.members.includes(memberId)) { space.members.push(memberId); events.push({ type: "member-added", spaceId: space.id, actorId: memberId }); } + return { members: space.members }; + }); + }, + async removeMember(token, id, memberId) { + const actor = signedIn(await actorFor(token)); + return mutate((state, events) => { + const space = findVisible(state, id, actor); + if (system(space) || !can(actor, "edit", space)) throw new AppError(403, "Only the owner can change members."); + if (memberId === space.ownerId) throw new AppError(422, "The owner stays a member."); + space.members = space.members.filter((entry) => entry !== memberId); + if (space.visibility !== "listed") space.channelEpoch += 1; + events.push(...leaveLease(state, space, memberId, now()), { type: "member-removed", spaceId: space.id, actorId: memberId }); + return { members: space.members }; + }); + }, + async signalingToken(token) { + await ready(); + const actor = await actorFor(token); + const state = await store.snapshot(); + const channels = [{ spaceId: PLAZA_ID, name: channelName(secret, find(state, PLAZA_ID)), write: Boolean(actor), key: null }]; + if (actor) for (const lease of state.spaceLeases.filter((entry) => entry.actorId === actor.id && entry.expiresAt > now() && entry.spaceId !== PLAZA_ID)) { + const space = state.spaces.find((entry) => entry.id === lease.spaceId); + if (space) channels.push({ spaceId: space.id, name: channelName(secret, space), write: true, key: spaceKey(secret, space) }); + } + return { ...signaling.issue(signalingUser(actor), channels), channels }; + }, + async rtcToken(token, id, input = {}) { + const actor = signedIn(await actorFor(token)); + await ready(); + const state = await store.snapshot(); + const space = find(state, id); + if (space.id === PLAZA_ID) throw new AppError(422, "Calls happen inside rooms."); + if (!present(state, space, actor)) throw new AppError(403, "Step into this space before joining its call."); + return calls.issueSpace(space, actor, input); + } + }; + return spaces; +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `node --test tests/spaces-service.test.mjs` +Expected: PASS (12 tests). + +Run: `npm test` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add scripts/spaces/service.mjs tests/spaces-service.test.mjs +git commit -m "Add the spaces service" -m "🤖 Built with SMT " +``` + +--- + +### Task 11: HTTP routes, Stoa pages, build output, and configuration + +**Files:** +- Create: `scripts/spaces/routes.mjs`, `stoa.html` +- Modify: `scripts/serve.mjs` (imports, wiring in `createAppServer`, the last `/api/` branch, page routing, `publicFiles`, the sweeper) +- Modify: `scripts/build.mjs`, `.env.example`, `README.md` +- Modify: `tests/server.test.mjs` (the production output test) +- Test: `tests/spaces-api.test.mjs` + +**Interfaces:** +- Consumes: `createSpaces`, `loadWorlds` (Task 10); `createSignaling`, `signalingConfig` (Task 8). +- Produces: + - `handleSpaces({ path, method, url, token, read, spaces, worlds, limit, ip }) -> { status, body } | null` + - `createAppServer` options: `worlds`, `signaling`, `spaces`, `channelSecret`, `plazaCapacity` + - Pages: `/stoa/`, `/stoa/room/`, `/stoa/s/`, all served by `stoa.html` + - Endpoints as listed in spec section 5 (discovery endpoints come in Plan E) + +- [ ] **Step 1: Write the failing test** + +Create `tests/spaces-api.test.mjs`: + +```js +import { after, before, test } from "node:test"; +import assert from "node:assert/strict"; +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { resolve } from "node:path"; +import { createAppServer } from "../scripts/serve.mjs"; +import { createModelClient, modelConfig } from "../scripts/models.mjs"; +import { agoraConfig, createAgoraCalls } from "../scripts/calls.mjs"; +import { authConfig, createAuth } from "../scripts/auth.mjs"; +import { createSignaling, signalingConfig } from "../scripts/spaces/signaling.mjs"; + +const root = new URL("../", import.meta.url).pathname; +const env = { AGORA_APP_ID: "a".repeat(32), AGORA_APP_CERTIFICATE: "b".repeat(32), SPACE_CHANNEL_SECRET: "s".repeat(32) }; +const profile = (name) => ({ name, bio: "Building voice agents with Rust.", intent: "Build a language practice voice agent with natural interruptions.", lookingFor: "Audio engineers", skills: ["Rust", "Voice AI"], location: "Remote", contact: "https://example.com/builder", monitor: false }); +const absent = "/00000000-0000-4000-8000-000000000000"; +let temporary; +const close = (instance) => new Promise((done) => { instance.close(done); instance.closeIdleConnections(); }); +async function start(options = {}) { + const instance = createAppServer(root, { monitor: false, storageFile: resolve(temporary, Math.random() + ".json"), models: createModelClient(modelConfig({})), calls: createAgoraCalls(agoraConfig(env)), auth: createAuth(authConfig({})), signaling: createSignaling(signalingConfig(env)), channelSecret: env.SPACE_CHANNEL_SECRET, ...options }); + await new Promise((done) => instance.listen(0, "127.0.0.1", done)); + return { instance, url: "http://127.0.0.1:" + instance.address().port }; +} +const request = (base, path, { cookie, method = "GET", data } = {}) => fetch(base + path, { method, headers: { ...(cookie ? { Cookie: cookie } : {}), ...(data !== undefined ? { "Content-Type": "application/json" } : {}) }, ...(data !== undefined ? { body: JSON.stringify(data) } : {}) }); +const post = (base, path, cookie, data = {}) => request(base, path, { cookie, method: "POST", data }); +async function join(base, name) { + const response = await post(base, "/api/profile", undefined, profile(name)); + assert.equal(response.status, 201); + return response.headers.get("set-cookie").split(";")[0]; +} +before(async () => { temporary = await mkdtemp(resolve(tmpdir(), "spaces-api-")); }); +after(() => rm(temporary, { recursive: true, force: true })); + +test("Stoa pages and worlds are served, and hidden spaces look exactly like missing ones", async () => { + const { instance, url } = await start(); + try { + for (const path of ["/stoa/", "/stoa/room/ai-agents"]) { + const response = await request(url, path); + assert.equal(response.status, 200, path); + assert.match(await response.text(), /Stoa/); + } + assert.equal((await request(url, "/stoa/room/nowhere")).status, 404); + const owner = await join(url, "Owner"), stranger = await join(url, "Stranger"); + const created = await post(url, "/api/spaces", owner, { title: "Board room", visibility: "private" }); + assert.equal(created.status, 201); + const { space } = await created.json(); + assert.equal((await request(url, "/stoa/s/" + space.id, { cookie: owner })).status, 200); + const hidden = await request(url, "/stoa/s/" + space.id, { cookie: stranger }); + const missing = await request(url, "/stoa/s" + absent, { cookie: stranger }); + assert.deepEqual([hidden.status, await hidden.text()], [missing.status, await missing.text()]); + const apiHidden = await request(url, "/api/spaces/" + space.id, { cookie: stranger }); + const apiMissing = await request(url, "/api/spaces" + absent, { cookie: stranger }); + assert.deepEqual([apiHidden.status, await apiHidden.json()], [404, await apiMissing.json()]); + const plaza = await (await request(url, "/api/worlds/plaza")).json(); + assert.deepEqual([plaza.width, plaza.height], [44, 34]); + assert.equal((await request(url, "/api/worlds/secret")).status, 404); + assert.equal(JSON.stringify(await (await request(url, "/api/spaces")).json()).includes(space.id), false); + assert.equal((await post(url, "/api/spaces", undefined, { title: "Guest" })).status, 401); + } finally { await close(instance); } +}); + +test("walking in, hosting, decorating, and calls work over HTTP", async () => { + const { instance, url } = await start(); + try { + const ada = await join(url, "Ada"), bo = await join(url, "Bo"); + assert.equal((await post(url, "/api/spaces/lot-voice-ai/enter")).status, 401); + const entered = await (await post(url, "/api/spaces/lot-voice-ai/enter", ada)).json(); + assert.deepEqual([entered.firstIn, entered.channel], [true, "ab-stoa-voice-ai"]); + await post(url, "/api/spaces/lot-voice-ai/enter", bo); + assert.equal((await post(url, "/api/spaces/lot-voice-ai/topic", bo, { topic: "Turn taking" })).status, 403); + const topic = await post(url, "/api/spaces/lot-voice-ai/topic", ada, { topic: "Turn taking", tags: ["latency"] }); + assert.equal((await topic.json()).space.topic, "Turn taking"); + const listing = await (await request(url, "/api/spaces?q=latency")).json(); + assert.deepEqual(listing.rooms.map((room) => [room.slug, room.occupancy, room.hostName]), [["voice-ai", 2, "Ada"]]); + const decor = await request(url, "/api/spaces/lot-voice-ai/decor", { cookie: ada, method: "PUT", data: { items: [{ id: "lamp", kind: "lamp", x: 8, y: 22 }] } }); + assert.equal((await decor.json()).version, 1); + const call = await (await post(url, "/api/spaces/lot-voice-ai/rtc-token", ada)).json(); + assert.equal(call.channel, "agora-build-space-lot-voice-ai"); + assert.equal((await request(url, "/api/spaces/lot-voice-ai/heartbeat", { cookie: ada, method: "POST" })).status, 200); + assert.equal((await request(url, "/api/spaces/lot-voice-ai/leave", { cookie: ada, method: "POST" })).status, 200); + assert.equal((await request(url, "/api/spaces/lot-voice-ai/heartbeat", { cookie: ada, method: "POST" })).status, 410); + const signaling = await (await post(url, "/api/signaling/token")).json(); + assert.match(signaling.userId, /^g-/); + } finally { await close(instance); } +}); + +test("a full plaza answers with an offer of other places", async () => { + const { instance, url } = await start({ plazaCapacity: 1 }); + try { + const ada = await join(url, "Ada"), bo = await join(url, "Bo"); + assert.equal((await post(url, "/api/spaces/plaza/enter", ada)).status, 200); + const full = await post(url, "/api/spaces/plaza/enter", bo); + assert.equal(full.status, 409); + assert.deepEqual(await full.json(), { error: "The plaza is full right now.", full: true, offer: { spaces: [], canCreate: true } }); + } finally { await close(instance); } +}); + +test("invitations, membership, and deletion work over HTTP", async () => { + const { instance, url } = await start(); + try { + const owner = await join(url, "Owner"), guest = await join(url, "Guest"); + const guestId = (await (await request(url, "/api/me", { cookie: guest })).json()).profile.id; + const { space } = await (await post(url, "/api/spaces", owner, { title: "Board room", visibility: "private" })).json(); + const invitation = await post(url, "/api/spaces/" + space.id + "/invitations", owner, { uses: 2, hours: 24 }); + assert.equal(invitation.status, 201); + const { token } = await invitation.json(); + assert.equal((await request(url, "/api/spaces/" + space.id + "?invite=" + token, { cookie: guest })).status, 200); + assert.equal((await post(url, "/api/spaces/" + space.id + "/enter", guest, { invite: token })).status, 200); + assert.equal((await request(url, "/api/spaces/" + space.id + "/members/" + guestId, { cookie: owner, method: "DELETE" })).status, 200); + assert.equal((await request(url, "/api/spaces/" + space.id, { cookie: guest })).status, 404); + assert.equal((await request(url, "/api/spaces/" + space.id, { cookie: owner, method: "DELETE" })).status, 200); + assert.equal((await request(url, "/api/spaces/" + space.id, { cookie: owner })).status, 404); + } finally { await close(instance); } +}); + +test("missing Signaling or call configuration is reported plainly", async () => { + const { instance, url } = await start({ signaling: createSignaling(signalingConfig({})), calls: createAgoraCalls(agoraConfig({})) }); + try { + assert.equal((await post(url, "/api/signaling/token")).status, 503); + const ada = await join(url, "Ada"); + assert.equal((await post(url, "/api/spaces/lot-lounge/enter", ada)).status, 200); + assert.equal((await post(url, "/api/spaces/lot-lounge/rtc-token", ada)).status, 503); + } finally { await close(instance); } +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `node --test tests/spaces-api.test.mjs` +Expected: FAIL. `/stoa/` returns 404 and `/api/spaces` returns 404. + +- [ ] **Step 3: Implement routes and wiring** + +Create `scripts/spaces/routes.mjs`: + +```js +import { AppError } from "../store.mjs"; + +const SPACE = /^\/api\/spaces\/(plaza|lot-[a-z0-9-]{2,40}|[a-f0-9-]{36})(?:\/(enter|leave|heartbeat|topic|host|decor|remove|invitations|members|rtc-token)(?:\/((?:member|account):[a-f0-9-]{36}))?)?$/; + +// Maps HTTP requests to the spaces service. Returns null when the request is not a spaces route. +export async function handleSpaces({ path, method, url, token, read, spaces, worlds, limit, ip }) { + if (path === "/api/spaces" && method === "GET") return { status: 200, body: await spaces.list({ q: url.searchParams.get("q") || "" }) }; + if (path === "/api/spaces" && method === "POST") { limit("spaces:" + ip, 20, 3600000); return { status: 201, body: { space: await spaces.create(token, await read()) } }; } + if (path === "/api/signaling/token" && method === "POST") { limit("signaling:" + ip, 30, 60000); return { status: 200, body: await spaces.signalingToken(token) }; } + const world = /^\/api\/worlds\/([a-z]+)$/.exec(path); + if (world && method === "GET") { + if (!Object.hasOwn(worlds, world[1])) throw new AppError(404, "This world was not found."); + return { status: 200, body: worlds[world[1]].source }; + } + const match = SPACE.exec(path); + if (!match) return null; + const [, id, action, memberId] = match; + limit("space:" + ip, 240, 60000); + if (!action) { + if (method === "GET") return { status: 200, body: { space: await spaces.get(token, id, url.searchParams.get("invite")) } }; + if (method === "PUT") return { status: 200, body: { space: await spaces.update(token, id, await read()) } }; + if (method === "DELETE") return { status: 200, body: await spaces.destroy(token, id) }; + return null; + } + if (memberId) return action === "members" && method === "DELETE" ? { status: 200, body: await spaces.removeMember(token, id, memberId) } : null; + if (action === "decor" && method === "PUT") return { status: 200, body: await spaces.decorate(token, id, (await read()).items) }; + if (method !== "POST") return null; + // Leaving and heartbeats carry no body, so navigator.sendBeacon can call them. + if (action === "leave") return { status: 200, body: await spaces.leave(token, id) }; + if (action === "heartbeat") return { status: 200, body: await spaces.heartbeat(token, id) }; + const input = await read(); + if (action === "enter") return { status: 200, body: await spaces.enter(token, id, input.invite) }; + if (action === "topic") return { status: 200, body: { space: await spaces.topic(token, id, input) } }; + if (action === "host") return { status: 200, body: await spaces.setHost(token, id, input) }; + if (action === "remove") return { status: 200, body: await spaces.removePerson(token, id, input) }; + if (action === "invitations") return { status: 201, body: await spaces.invite(token, id, input) }; + if (action === "members") return { status: 200, body: await spaces.addMember(token, id, input.memberId) }; + if (action === "rtc-token") return { status: 200, body: await spaces.rtcToken(token, id, input) }; + return null; +} +``` + +In `scripts/serve.mjs`: + +1. Add the imports after the `createPostgresPersistence` import: + +```js +import { createSignaling, signalingConfig } from "./spaces/signaling.mjs"; +import { createSpaces, loadWorlds } from "./spaces/service.mjs"; +import { handleSpaces } from "./spaces/routes.mjs"; +``` + +2. Add `"stoa.html"` and `"world/map.js"` to the `publicFiles` set. + +3. In `createAppServer`, after the `const activity = ...` line, add: + +```js + const worlds = options.worlds || loadWorlds(directory); + const signaling = options.signaling || createSignaling(signalingConfig()); + const spaces = options.spaces || createSpaces({ store, worlds, signaling, calls, secret: options.channelSecret ?? signalingConfig().secret, admins: (process.env.PLATFORM_ADMINS || "").split(",").map((id) => id.trim()).filter(Boolean), plazaCapacity: options.plazaCapacity }); +``` + +4. Replace the final branch of the `/api/` chain, ` } else throw new AppError(404, "This page or service was not found.");`, with: + +```js + } else { + const handled = await handleSpaces({ path, method: request.method, url: requestUrl, token, read: () => body(request), spaces, worlds, limit, ip: request.socket.remoteAddress }); + if (!handled) throw new AppError(404, "This page or service was not found."); + json(response, handled.status, handled.body); + } +``` + +5. Replace the line ` const filename = meeting ? "meetings.html" : path === "/" ? "index.html" : path.slice(1);` with: + +```js + const stoa = /^\/stoa(?:\/|\/room\/([a-z0-9-]{2,40})|\/s\/([a-f0-9-]{36}))$/.exec(path); + if (stoa && ((stoa[1] && !worlds.plaza.map.lots.some((lot) => lot.slug === stoa[1])) || (stoa[2] && !(await spaces.visible(token, stoa[2], requestUrl.searchParams.get("invite")))))) { response.writeHead(404, { "Content-Type": "text/plain; charset=utf-8" }).end("This space was not found."); return; } + const filename = stoa ? "stoa.html" : meeting ? "meetings.html" : path === "/" ? "index.html" : path.slice(1); +``` + +6. Before `return server;`, add the sweeper: + +```js + const spaceSweep = setInterval(() => { spaces.sweep().catch(() => {}); }, 15000); + spaceSweep.unref(); + server.on("close", () => clearInterval(spaceSweep)); +``` + +Create `stoa.html` (a shell; Plan C replaces its body): + +```html + + + + + + + Stoa | Agora Build + + + + + +
+

THE STOA

The plaza is being built.

Soon you can walk in, meet builders, start a topic, and work together.

+
+ + +``` + +In `scripts/build.mjs`: + +1. Add `"stoa.html"` to the first file list, after `"account.js"`. +2. After the line that copies the `scripts/*.mjs` files, add: + +```js +await cp(new URL("scripts/spaces/", root), new URL("scripts/spaces/", output), { recursive: true }); +await cp(new URL("world/", root), new URL("world/", output), { recursive: true }); +await cp(new URL("worlds/", root), new URL("worlds/", output), { recursive: true }); +``` + +In `tests/server.test.mjs`, the production output test: + +1. Add `"stoa.html"`, `"world/map.js"`, `"worlds/plaza/map.json"`, `"worlds/room/map.json"`, `"scripts/spaces/service.mjs"`, and `"scripts/spaces/routes.mjs"` to the `files` array. +2. Inside its `try` block, add: + +```js + assert.equal((await request("/stoa/", { base: built.url })).status, 200); +``` + +In `.env.example`, after `AGORA_TOKEN_TTL_SECONDS=3600`, add: + +``` +# Agora Signaling (RTM 2.x) carries movement, presence and live messages, using AGORA_APP_ID and AGORA_APP_CERTIFICATE. +# Enable Signaling for the project in Agora Console. +# At least 32 random characters. Derives private channel names and per-space keys; keep it secret and stable. +SPACE_CHANNEL_SECRET= +# Set to true only after Agora enables per-channel Signaling permissions for this project. +AGORA_RTM_PERMISSIONS=false +# Comma-separated portal account IDs (account:) with platform admin rights in spaces. +PLATFORM_ADMINS= +``` + +In `README.md`, add a section after `## Agora group calls`: + +```markdown +## Stoa spaces + +The Stoa is a walkable plaza (`/stoa/`) with first-come rooms (`/stoa/room/`) and unlisted or private spaces (`/stoa/s/`). Calls use Agora RTC; movement, presence, and live messages use Agora Signaling. Enable Signaling for the Agora project and set `SPACE_CHANNEL_SECRET` to at least 32 random characters. The built-in maps in `worlds/` are generated by `npm run maps`. +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `node --test tests/spaces-api.test.mjs` +Expected: PASS (5 tests). + +Run: `npm test` +Expected: PASS, including the updated production output test. + +Run: `npm run build && node dist/scripts/serve.mjs --port 4199 & sleep 2; curl -s -o /dev/null -w "%{http_code}\n" localhost:4199/stoa/; curl -s localhost:4199/api/spaces | head -c 200; kill %1` +Expected: `200`, then JSON beginning with `{"plaza":{"capacity":1000`. + +- [ ] **Step 5: Commit** + +```bash +git add scripts/spaces/routes.mjs stoa.html scripts/serve.mjs scripts/build.mjs .env.example README.md tests/server.test.mjs tests/spaces-api.test.mjs +git commit -m "Serve the spaces API and Stoa pages" -m "🤖 Built with SMT " +``` + +--- + +## Spec Coverage (Plan A) + +| Spec requirement | Task | +| --- | --- | +| Space record, visibility and access kept separate, private needs `members` | 4, 5 | +| `can()` used by REST and tokens | 4, 10 | +| Plaza and lots from the map, FCFS, topic and tags, plaza capacity 1000 and the full-plaza offer | 3, 5, 6, 10 | +| Hosts (first in, longest present, owner priority, hand-over), host powers | 6, 10 | +| Decorating rules and limits | 7, 10 | +| User spaces, entitlements 3 and 20, invitations (hashed, expiring, limited uses), members | 1, 5, 10 | +| Migration of meeting rooms | 5, 10 | +| Entry leases, heartbeat, sweeper, lot reset | 6, 10, 11 | +| Signaling: channel names, per-space keys, epochs, guest and member tokens, optional permissions | 8, 10 | +| RTC only for lease holders | 9, 10 | +| Maps (Tiled subset, roles, lots, spawns, zones, interactables), shared module | 2, 3 | +| Pages and API, 404 that matches a missing space, error details | 1, 11 | +| Hooks for Chat sync (events) and discovery (tags, `?q=`, noticeboards on the map) | 3, 5, 10 | + +Deferred to later plans: `/meet/` redirect and the meetings page retirement, client-side encryption and receiver rules (C), themes and the renderer (B), Chat (D), and noticeboard results and profile cards (E). diff --git a/docs/superpowers/specs/2026-10-07-stoa-spaces-design.md b/docs/superpowers/specs/2026-10-07-stoa-spaces-design.md new file mode 100644 index 0000000..ef51c57 --- /dev/null +++ b/docs/superpowers/specs/2026-10-07-stoa-spaces-design.md @@ -0,0 +1,703 @@ +# Spaces Foundation And Stoa: Design + +Status: under review +Date: 2026-10-07 + +## Goal + +Agora.build should feel like a walkable virtual world, not a meeting-room directory. +This spec delivers the shared Spaces foundation through its first consumer, Stoa: + +`Space + World/Map + Presence + RTC + Permissions + Discovery + Invitations + Theme` + +- The **space type** defines behavior. +- The **world/map** defines where people can move and interact. +- The **theme** defines how the world looks and feels. + +Functionality, permissions, presence, and RTC never depend on the theme. + +### Purpose + +Agora.build's core is **resource discovery**: helping startups, teams, and individual +builders find the people, projects, offers, and events they need, through good +networking, meaningful connections, and impactful events. Stoa, Pnyx, and Odeon are +the presentation layer where discovery turns into real meetings. Every space feature +should lead toward finding someone or something useful. + +### Product model + +- **Agora**: the overall virtual world. +- **Stoa** (`/stoa/`): walkable spaces for meeting, talking, and social interaction. This spec. +- **Pnyx** (`/pnyx/`): stage and audience for talks, panels, town halls, and debates. Later spec. +- **Odeon** (`/odeon/`): premium produced experiences, which may include paid admission. Later spec. + +### Out of scope + +Pnyx, Odeon, uploaded user or community themes, proximity audio, plaza-wide calls, +and history for public or room messages. The design leaves room for the first five; +the portal never stores messages of any kind. + +### Roadmap + +1. **Spaces foundation and Stoa**, with discovery hooks (this spec) +2. **Discovery engine**: one search and matching layer across people, projects, + offers, live rooms, and events, including a connection graph +3. **Events**: scheduling, venue partners, RSVPs, and hybrid events where an offline + venue's live stream enters the virtual world. Free community events go to Pnyx; + premium ticketed events go to Odeon. +4. **Pnyx**: stage and audience +5. **Odeon**: premium and ticketed experiences +6. **Custom themes**: user and community themes + +### Agora services + +- **RTC** (Agora Video/Voice SDK): voice, video, and screen sharing inside rooms. +- **Signaling** (Agora RTM 2.x, `agora-rtm-sdk`): movement, live presence, public + plaza messages, and room messages. These are live only and never kept. +- **Chat** (Agora Chat, `agora-chat` Web SDK and REST API): team messages and direct + messages (DMs). Agora Chat holds their history under the project's Chat retention + settings; the portal never stores message content. +- The portal server never relays real-time traffic. It owns spaces, permissions, + capacity, invitations, and token issuing. + +## 1. Domain Model + +### Space + +``` +Space { + id // uuid + slug? // lot rooms only, e.g. "ai-agents" + type: "stoa" // later "pnyx" | "odeon" + worldId, themeId + visibility: "listed" | "unlisted" | "private" + access: "open" | "house" | "members" + capacity // whole number, enforced on entry + ownerId? // absent for system-owned plaza lots + members[] // member: IDs + invitations[] // { tokenHash, expiresAt, usesLeft, createdBy } + channelEpoch // increases when someone is removed; renames channels + lot?: { worldId, lotId }// set only for listed plaza lots + topic? // set by the host + hostId? // current host (section 1, Hosts) + decor[] // { id, kind, x, y, rotation, variant? } placed by the host + createdAt, updatedAt +} +``` + +Spaces are persisted in the existing store aggregate (`state.spaces`), so they work +with both `.data/community.json` and PostgreSQL. Entry leases (section 2) are stored +there too, for capacity. Positions and messages are never stored by the portal. + +### Visibility and access are independent + +Visibility answers "who can learn this space exists": + +| Visibility | Plaza and discovery | Reachable by | +| --- | --- | --- | +| `listed` | Rendered on the plaza and returned by `GET /api/spaces` | Anyone | +| `unlisted` | Never rendered or returned | Its direct URL | +| `private` | Never rendered or returned | Members and valid invitation holders only | + +Access answers "who can enter": + +| Access | Who may enter | +| --- | --- | +| `open` | Any signed-in account | +| `house` | Accounts with a published `member:` profile | +| `members` | The owner, listed members, and valid invitation holders | + +Combinations are allowed except `private` + `open`/`house`, which the API rejects: +private means only members and invitations. + +An unlisted or private space is not a hidden place on the public map. It is a +separate world. The plaza never renders it, and the plaza never receives its +presence or activity. + +### Plaza and lots + +The plaza is a system space with the route `/stoa/` and a capacity of **1000** +people walking at once. Guests who only watch do not count. + +**When the plaza is full**, a signed-in person who opens `/stoa/` sees the plaza as a +watcher and is offered unlisted spaces instead: + +- spaces they own or belong to, and invitations they hold +- **Start a space**: one step creates an unlisted space with the default room map and + takes them into it, with a link to share. This counts against their space + entitlement. If they have reached their limit, they are offered their existing + spaces instead. + +The offer stays visible while they watch, and a free place lets them walk in. Lot +rooms have their own capacities: a person inside a lot room does not count toward the +plaza's 1000, and someone with a lot room's URL can enter it while the plaza is full. +If the plaza is still full when they leave the room, they get the same offer. + +Plaza walking uses an entry lease like any other space (section 2). + + Its world map defines **lots**: +tables, lounges, discussion areas, and buildings, each with a capacity, a door, and +an interior. Each lot is one standing `listed` + `open` Space, created from the map +at startup and reachable at `/stoa/room/`. + +A lot room is first come, first served. The first person to enter an empty lot +becomes its **host** and is asked for a topic (or may keep the lot's default name). +The topic, the host, the occupant count, and the occupant names are visible from the +plaza. When the last person leaves, the topic, host, and decorations clear and the +lot is free. + +### Hosts + +Every room and place has one host at a time: + +- **Lot rooms**: the first person in becomes the host. When the host leaves, the + person present longest becomes host, and everyone is told who it is. +- **User-created spaces**: the owner is host whenever present. When the owner is + absent, the first person present is host until the owner returns. +- The host can pass the role to anyone present. The space owner and platform admins + can always take it back. +- The current host is recorded with the entry leases (section 2) and changes in the + same atomic store operation as entries and departures, so there is never more than + one host. + +The host can: + +- set and change the topic +- **decorate** the room or place (below) +- remove a disruptive person for the rest of the session. In a lot room the person + cannot come back until the room empties. In a user-created space removal lasts + until the owner allows them back; only the owner can change the member list. + +### Decorating + +The host arranges decorations in the room's interior, or anywhere in a +user-created world. + +- Decorations are **semantic kinds**, never artwork: `plant`, `lamp`, `rug`, `sofa`, + `chair`, `table`, `whiteboard`, `bookshelf`, `screen`, `banner`, `poster`, + `statue`, `fountain`. The active theme draws each kind, so the same arrangement + looks Greek in Agora and neon in Cyberpunk. `variant` chooses one of the theme's + styles for that kind, and themes must map every kind. +- A decor mode turns on for the host. They drag items from a palette, move them, + rotate them, and remove them, by pointer or keyboard (select an item, arrow keys to + move, `R` to rotate, `Delete` to remove). The panel lists the placed items for + screen readers. +- The server checks every change (`PUT /api/spaces//decor` with the whole + arrangement): + - the caller is the host, owner, or an admin + - each item is a known kind, lies inside the room's area, and does not cover a + door or spawn tile + - every door, spawn, and seat stays reachable once items that block movement are + in place + - there are at most 60 items +- After a change is saved, the host's client publishes `{ t: "decor", version }` on + the space's Signaling channel. Other clients then fetch the arrangement from + `GET /api/spaces/`, so the server's saved copy stays the source of truth. +- Lot room decorations are temporary and clear when the room empties. Decorations in + user-created spaces are saved and stay until the host changes them. + +### User-created spaces + +Every user-created Stoa space is its own world at `/stoa/s/`, with visibility +`unlisted` or `private`. It persists until its owner deletes it. Plan entitlements +cap how many spaces an account owns. The default is 3 on Basic and 20 on Premium +(`entitlements(plan).spaces`). + +### Worlds and themes + +- **World**: a Tiled-compatible JSON map with semantic tile roles and object + layers. It holds no artwork. See section 4. +- **Theme**: a data-only manifest and assets that give the roles their artwork + and style the page around the world. It holds no behavior. See section 4. + +### Permissions + +All authorization flows through one pure function: + +``` +can(person, action, space, context) -> boolean +actions: see | enter | chat | speak | host | decorate | moderate | edit | invite +context: { invitation?, presence?, hostId? } +``` + +Every REST handler and every RTC or Signaling token request calls it. Signaling +tokens carry the result as channel permissions (section 2). For Stoa, `speak` is granted to everyone present. The current host holds +`host`, `decorate`, and `moderate` for the session. The owner holds `edit` and `invite` +and can always take the host role back. Platform admins hold everything. Pnyx and Odeon +will add rules for their types without changing callers. + +### Migrating existing rooms + +Each existing `/meet/` room becomes a Stoa space with the same ID. It is +`unlisted` with access `members`, its owner stays the same, and its current roster +becomes its member list. It uses the default world and theme. `/meet/` answers +with a 308 redirect to `/stoa/s/`. The migration runs once at startup and is +idempotent. + +## 2. Real-Time Presence, Movement, And Messages (Agora Signaling) + +### Channels + +Each space has Signaling message channels. Their names are derived on the server, +and the server only gives them to people allowed to use them: + +``` +channel(space) = "ab-" + space.type + "-" + hmac(SPACE_CHANNEL_SECRET, space.id + ":" + space.channelEpoch)[0..24] +``` + +- The plaza has one channel. It carries plaza movement, presence, and public + messages. +- Each lot room and each user-created space has its own channel. +- Listed lot rooms use a name that anyone may learn. Unlisted and private spaces use + names that cannot be guessed and are revealed only by the enter response. + +### Signaling tokens and protection + +Agora Signaling tokens grant **login only**. Any logged-in client can subscribe or +publish to a message channel whose name it knows ([Agora: authentication +workflow](https://docs.agora.io/en/signaling/get-started/authentication-workflow)). +Agora's per-channel permission tokens (`Rtm2Permissions`) are undocumented in the +Signaling guides and "require Agora assistance". This design therefore does not depend +on them. Protection comes from these layers: + +1. **Login identity.** `POST /api/signaling/token` returns `{ appId, userId, token, + expiresAt, channels }`. Signed-in people log in as `a-` (their permanent account + ID; older profile-only sessions use `m-`), and guests as a random `g-`. Agora authenticates every publisher's user ID, so + receivers know who really sent each message. +2. **Channel names that cannot be guessed.** Unlisted and private channel names are + HMACs of a server secret, the space ID, and `channelEpoch`, with 96 bits of + randomness. Only the response to a successful entry contains them, and nothing on + the plaza or in discovery does. Removing someone increases `channelEpoch`, so the + room moves to a new channel and the removed person cannot follow. +3. **End-to-end payload encryption for unlisted and private spaces.** + - Every message and every presence state value on these channels is encrypted on + the client with AES-GCM (WebCrypto). + - The key is derived on the server per space and per `channelEpoch`, and given only + to entry-lease holders. + - Someone who learns a channel name without a lease therefore cannot read + messages, positions, or names, and their own messages fail to decrypt and are + dropped. + - A known remaining exposure: Agora presence can reveal the member IDs subscribed + to a channel to anyone who knows its name. Only names from the current epoch + work, and only people who were inside have ever had them. + - Encryption is done in the portal's own code rather than with the SDK's encryption + setting. The SDK applies one key to every channel a client uses, but the plaza and + each room need different keys. +4. **Receiver rules on every channel.** Clients drop messages and ignore presence state + from `g-` users, from users without presence on that channel, and from people on the + space's block list. The plaza block list comes with the lot summaries every 10 + seconds (below), and a room's block list comes with its entry response and token + renewals. +5. **Short tokens.** Tokens last 15 minutes. Renewal re-checks `can()`, the entry + lease, and account bans, so a banned or removed person's login ends within one + renewal period. + +Guests can therefore watch the plaza, but anything they publish is ignored. If Agora +later confirms per-channel permissions for this project, `AGORA_RTM_PERMISSIONS=true` +adds read and write restrictions to tokens as extra protection. It is off by default, +and nothing above depends on it. + +### Presence and movement + +- Clients use Signaling **Presence** on each subscribed channel. A person's presence + state holds `{ x, y, dir, name, avatar }`; `whoNow` gives the initial snapshot, + and presence events report joins, leaves, and state changes. +- Moving: click or tap chooses a target tile. The client finds a path with A* on the + map's grid, where decor items that block movement count as blocked tiles and publishes `{ t: "move", path, startedAt }` on the space's channel. + Every client animates the walk at a fixed speed, and the final tile goes into + presence state, so late joiners see where people stand. +- Keyboard movement (arrows and WASD) publishes only when a key is pressed or + released: `{ t: "walk", from, dir, startedAt }` and `{ t: "stop", at }`. Other + clients work out the steps in between, so holding a key sends no extra messages. +- Message budget: Signaling allows 60 messages per second per client in a message + channel. Clients publish movement at most 4 times per second and update presence + state only when someone stops. With 1000 people on the plaza, the expected total is + well under that. Clients draw and animate only avatars within about two screens of + their own view, and still list everyone in the panel. +- Large channels: `whoNow` results are read page by page. Clients handle presence + events arriving one at a time or batched together, since Agora batches them for + large channels. +- The portal does not validate movement. Receivers drop paths that cross blocked + tiles or jump too far and use the sender's last presence state instead. Movement is + cosmetic; access, capacity, and calls are enforced by the server. +- Guests subscribe to the plaza channel only. They have no presence state and are + never shown. + +### Entering and leaving rooms + +- Walking onto a lot's door tile in the plaza calls `POST /api/spaces//enter`. + For a user-created world, opening its URL does the same. +- The server checks `can(enter)` and capacity in one atomic store operation and + records an **entry lease** for the person. The response contains the space's + channel name, a renewed Signaling token that includes it, and whether the person + is the host and should set a topic (the first person in a lot room). +- The client unsubscribes from the plaza channel, subscribes to the room channel, and + switches the camera to the lot's interior, which is part of the same world map. + Leaving through the door calls `/leave` and returns them to the plaza at the door + tile. +- Leases last 60 seconds and are renewed by `POST /heartbeat` every 20 seconds while + the room is open. `navigator.sendBeacon` sends `/leave` when the page closes. An + expired lease frees the seat. The same account in two tabs holds one lease. +- If the room is full or access is refused, the person stays at the door and sees a + message. +- When the host's lease ends, the host role passes as described in section 1. When a + lot room's last lease ends, its topic, host, and decorations clear. + +### Plaza view of lot rooms + +Plaza clients read lot summaries (topic, host, occupancy, occupant names) from +`GET /api/spaces`. They refresh every 10 seconds and immediately after their own +enter or leave. Summaries come only from listed lots' leases. Nothing about unlisted +or private spaces is ever included. + +### Messages + +- **Public messages**: published on the plaza channel. They appear as speech bubbles + above avatars for 8 seconds and in the panel's message list. +- **Room messages**: published on the room's channel and shown in its panel. +- **No history.** Messages exist only for people subscribed when they are sent. The + portal never stores them, and nothing replays them to late joiners. +- Clients limit messages to 500 characters and 1 per second, with a short burst. They + also ignore messages over the limit, from `g-` users, or from people without + presence on the channel. +- Owners and admins can remove a person from a space (`POST .../members` or + `.../remove`). Removal ends the lease, refuses token renewal, increases + `channelEpoch`, and makes the client leave the RTC call and Signaling channel. + +### Team messages and DMs (Agora Chat) + +Signaling covers live, place-based talk. Agora Chat covers conversations that last +beyond a visit. + +- **Team**: the people who share a room or group, in listed lot rooms and in + unlisted or private spaces alike. Each room or space has one Agora Chat group: + - **Lot rooms (listed)**: the team is whoever is in the room now. The first entry + creates the group, and people are added when they enter and removed when they + leave or their lease expires. When the room empties, the group is dissolved and + its history goes with it, matching the topic, host, and decorations. + - **User-created spaces (unlisted or private)**: the team is the space's members + plus anyone currently inside. Members stay in the group whether present or not, + so the conversation continues between visits. Visitors who are not members are + added on entry and removed when they leave. The group lasts until the space is + deleted. + - The space's owner owns the group. For lot rooms the portal's system account owns + it, and the current host is made a group admin. +- **DM**: a one-to-one Chat conversation between two signed-in people who have + published profiles. Profiles and the panel's people list offer **Message**. +- **Identity**: the Chat username is the member ID in lowercase (`m-`). The + server registers it through the Chat REST API the first time the person needs Chat. + It stores only the returned Chat user UUID on the private account; no password is + kept. +- **Tokens**: `POST /api/chat/token` returns a short-lived user token + (`ChatTokenBuilder.buildUserToken`, 1 hour) for the signed-in person. Server REST + calls use an app token (`ChatTokenBuilder.buildAppToken`), cached until shortly + before it expires. +- **Membership sync**: the server is the source of truth for every team. + - Changes to the member list (adding, removing, accepting an invitation, deleting a + space) call the Chat group APIs in the same request. The change is kept only if + the Chat call succeeds or Chat is not configured, so a failed sync leaves both + sides as they were. + - Entering, leaving, and lease expiry update the group too, but these never block + entry. Failed calls go into a retry queue in the store, retried every 15 seconds. + The same 15-second sweeper ends expired leases, hands over the host role, and + clears empty lot rooms. + - Groups are private and members cannot invite through Chat. Invitations always go + through the portal and `can(invite)`. +- **Blocking**: a person can block another person in Chat, which stops DMs from them. + Deleting a profile removes the Chat user, which deletes their DMs and group + memberships at Agora. +- **Interface**: a Messages drawer, available on every Stoa page and from the account + page. It lists the person's DMs and their spaces' team conversations, with unread + counts. It is plain HTML outside the canvas and does not depend on the theme. +- **Configuration**: `AGORA_CHAT_APP_KEY` (`org#app`) and `AGORA_CHAT_REST_HOST`. + Chat reuses `AGORA_APP_ID` and `AGORA_APP_CERTIFICATE` for tokens. If these are not + set, Messages is hidden and team membership still works without a Chat group. + +### Scaling + +The server holds no real-time state: leases live in the store, and Signaling carries +all live traffic. More than one portal instance can therefore run behind a load +balancer once OAuth transactions move out of memory, which is unrelated to this spec. +Presence state already includes position, so proximity audio can later choose which +nearby people's RTC streams each client receives. + +## 3. RTC + +- Calls use Agora RTC. `calls.issue(room, person)` becomes `rtc.issue(space, person, role)`, + used by `POST /api/spaces//rtc-token`. +- The server issues a token only to a person who holds a current entry lease for the + space. Being on a roster or member list is not enough. +- The role comes from `can()`. In Stoa everyone present publishes. Pnyx will issue + subscriber-only tokens to its audience. +- The channel name is `agora-build-space-`. Existing user IDs, screen-share IDs, + and the per-tab identity scheme stay as they are. +- After someone leaves or is removed, renewal is refused. Tokens stay short-lived + (one hour at most), so access ends when the token expires. When the client learns it + was removed (a refused heartbeat or renewal), it leaves the channel at once. +- The plaza has no call in v1. Calls happen inside rooms. +- `call.js` is split into `rtc-client.js` (joining, devices, publishing, screen + share, and token renewal, with no UI) and a presentation layer. The theme decides + where video appears: over avatars or seats, in a strip, or in a grid. +- Call controls (microphone, camera, screen share, leave, chat) are native buttons + outside the canvas and look the same whatever the theme. Devices stay off until + the person chooses to join; joining to listen remains possible. + +## 4. World Engine And Themes + +``` +Map (worlds//map.json) -> Engine (world/*.js) -> Theme (themes//theme.json + assets) +semantic layout movement, camera, input art and styling only +``` + +### Maps + +Maps use a subset of Tiled JSON: orthogonal, fixed tile size, uncompressed tile +layers, and object layers. + +- The tile layers `ground` and `structure` use tiles whose `role` property is one of + the core roles: `floor`, `path`, `grass`, `water`, `wall`, `column`, `door`, + `table`, `seat`, `plant`, `decor`. Walkability comes from the role (`wall`, + `column`, `water`, `table`, `plant` block movement); a tile may override it. +- Object layers: + - `lot`: a rectangle with `lotId`, `slug`, `title`, `capacity`, `door` (tile), and + `interior` (a rectangle in the same map) + - `spawn`: where people appear + - `zone`: a named area, kept for future proximity features + - `interactable`: an object with a kind and a label +- The server loads the same file to know lots, doors, and capacities. The client + receives it from `GET /api/worlds/` and uses it for walkability and paths. +- v1 ships two maps: + - `plaza`: the public plaza, with about six lots of varied sizes + - `room`: the default map for user-created spaces, a single interior with seating + +### Themes + +A theme is data only and never contains code, so community themes can be added later +without running their authors' scripts. + +``` +themes//theme.json +{ + id, name, version, + roles: { floor: {...}, wall: {...}, ... }, // image or primitive per core role + avatar: { style, palette }, // how people are drawn + decor: { plant: { variants: [...] }, lamp: {...}, ... }, // art for every decor kind + ui: { "--paper": "...", "--ink": "...", "--font-display": "..." }, // CSS tokens + lighting: { ambient, tint }, + effects: ["dust" | "rain" | "leaves" | "neon-glow" ...], // fixed safe catalog + video: "over-avatar" | "strip" | "grid" +} +``` + +- `validateTheme()` runs at startup and in tests. Every core role and decor kind must be mapped, + asset paths must stay inside the theme's folder, only `png`, `webp`, and `svg` files + are allowed, the bundle has a size limit, and `ui` accepts only CSS tokens from an + allowlist. +- Built-in themes are reviewed source files. Before uploaded themes are allowed, SVG + needs sanitizing or rasterizing; that belongs to the later custom-themes spec. +- An owner chooses a theme for their space, and admins choose the plaza theme. A + viewer can switch their own display to Minimal for accessibility or performance; + this changes nothing for anyone else. +- v1 ships three built-in themes, with art drawn in code or as local SVG and no + external assets: + - **Agora** (default): marble, colonnades, and terracotta, matching the current + palette + - **Minimal**: flat and high contrast + - **Cyberpunk**: dark, neon, rain + +### Engine + +- Uses Canvas 2D, scaled for high-resolution screens, with a camera that follows the + person's own avatar. +- Supports click or tap to move and arrow/WASD keys. It draws a frame only while + something is moving or animating. +- Under `prefers-reduced-motion`, ambient effects are off and walks still move + without decorative motion. +- Rendering goes through a `Renderer` interface, so a WebGL renderer for Odeon can + replace it later without changing spaces, presence, or RTC. +- Files: `world/engine.js` (loop, camera, input), `world/map.js` (parsing, walkability, + A*), `world/renderer-canvas.js`, `world/themes.js` (loading and applying themes), and + `world/signaling.js` (Signaling login, channels, presence, renewal). `map.js` is + shared with the server. + +### Accessibility layer + +A DOM panel beside the canvas lists: + +- rooms (topic, occupancy, and a **Go to** button that walks there and enters) +- people present +- chat, with a form + +A live region announces arrivals, departures, and entry errors. Everything the canvas +offers is available through the panel with keyboard and screen readers. If the canvas +fails, the panel still works. + +## 4a. Discovery Hooks + +Stoa v1 connects the world to the discovery that already exists (people search with +intent, projects, offers, Radar). The full discovery engine has its own spec. + +- **Profile cards.** Selecting an avatar, on the canvas or in the panel's people list, + opens a card from the person's published profile: name, intent ("building"), + `lookingFor` ("needs"), skills, and projects. The card offers: + - **Message**: an Agora Chat DM + - **View profile** + - **Invite to my space**: an invitation to one of the person's own spaces + + Guests see the card without these actions. People without a published profile + appear by display name only, with no card. +- **Topic tags.** When a host sets a topic, they can add up to 5 tags (skills, + needs, or a project), chosen from the profile skill vocabulary or typed freely and + normalized. Tags appear on the room's plaza sign and in its lot summary. +- **Noticeboards.** `interactable` objects of kind `noticeboard` on the plaza open a + panel of discovery results. Each board has a fixed query in the map, such as + "Looking for collaborators", "Voice AI", or "Offers". + - Results come from the existing people, project, and offer data, ranked by match + with the viewer's own skills and needs when they have a profile. + - A result's **Go to** button walks to a live room when one matches. + - The themes draw the boards, which are a decor-style kind (`noticeboard`) that + every theme must map. +- **Live rooms in search.** `GET /api/spaces?q=` matches listed lot rooms by topic and + tags. The people search page shows "Live now" rooms next to people results, with a + link to `/stoa/room/`. Unlisted and private spaces are never searchable. +- **Activity.** "Opened a room about " and "joined " appear in the + existing activity feed for listed rooms only, using the existing privacy cleanup. + +## 5. Routes, API, Errors, And Testing + +### Pages + +| Route | Behavior | +| --- | --- | +| `/stoa/` | Plaza. Guests watch; signed-in people walk. | +| `/stoa/room/` | Plaza, starting at that lot's door | +| `/stoa/s/` | User-created world; `?invite=` accepts an invitation | +| `/meet/` | 308 redirect to `/stoa/s/` | + +All Stoa pages are served by `stoa.html`. When a person may not see a private space, +the server returns the same 404 as for a space that does not exist, and never reveals +its title. The site navigation's "Rooms" link becomes "Stoa". + +### API + +| Method and path | Purpose | +| --- | --- | +| `GET /api/spaces` | Listed spaces only, with topic, tags, and occupancy; `?q=` searches topic and tags | +| `GET /api/noticeboards/` | Results for a plaza noticeboard's query | +| `POST /api/spaces` | Create an unlisted or private space (subject to entitlement) | +| `GET/PATCH/DELETE /api/spaces/` | Read (`see`), edit (`edit`), delete (owner) | +| `POST /api/signaling/token` | Signaling token and channel names (guest or member) | +| `POST /api/chat/token` | Agora Chat user token (registers the Chat user on first use) | +| `POST /api/spaces//enter`, `/leave`, `/heartbeat` | Entry lease, with an atomic capacity check | +| `POST /api/spaces//topic` | Set the topic (`host`) | +| `POST /api/spaces//host` | Hand the host role to someone present (`host`), or take it back (owner) | +| `PUT /api/spaces//decor` | Replace the decoration arrangement (`decorate`) | +| `POST /api/spaces//remove` | Remove a person for the session (`moderate`) | +| `POST /api/spaces//rtc-token` | RTC token for a lease holder | +| `POST /api/spaces//invitations` | Expiring invitation with limited uses (`invite`) | +| `POST /api/spaces//members`, `DELETE .../members/` | Membership (`moderate`) | +| `GET /api/worlds/` | Map JSON | +| `GET /api/themes` | Built-in theme manifests | + +Invitation tokens are random, shown once, and stored only as hashes. + +### Errors + +Errors use the existing `AppError` and keep the friendly tone: + +| Situation | Status | +| --- | --- | +| Room is full | 409 | +| No access to a space the person may see | 403 | +| Space does not exist, or the person may not see it | 404 | +| Too many requests | 429 | +| RTC, Signaling, or Chat not configured | 503 | + +When Signaling reconnects, the client calls `whoNow` again and redraws from that +snapshot. If Signaling is unavailable, the panel still lets people enter rooms and +join calls; movement and messages pause with a visible notice. + +### Testing (Node test runner) + +- Permissions: `can()` across every visibility, access, and person type (guest, + account, published profile, member, invitation holder, owner, admin), including + the rejected `private` + `open`/`house` combination. +- Private isolation: `GET /api/spaces`, lot summaries, and guest or plaza tokens + never contain unlisted or private IDs, titles, occupants, or channel names. A + private space returns the same 404 as a missing one. +- Signaling protection: channel names and encryption keys for unlisted and private + spaces appear only in entry responses for lease holders. Both change when + `channelEpoch` increases and cannot be derived without the server secret. Renewal is + refused after leaving, removal, or a ban. +- Receiver rules (client module): drops messages that are from `g-` users, from + publishers without presence, or from blocked people, and messages that do not + decrypt. Encrypted payloads round-trip, and the wrong epoch's key fails. +- Plaza capacity: the 1001st walker is refused and offered their spaces, + invitations, and Start a space, which honors the space entitlement. People in lot + rooms do not count toward the plaza, and a lot room's URL still works while the + plaza is full. +- Capacity: concurrent `enter` requests never exceed capacity. Expired leases free + seats, and a lot room's topic, host, and decorations clear when it empties. +- Hosts: the first person into a lot becomes host. When the host leaves, the person + present longest becomes host. Concurrent entries produce exactly one host. The + owner is host whenever present and can take the role back. Only the host, owner, or + an admin can set the topic, decorate, or remove people. +- Decor: unknown kinds, items outside the room, items covering doors or spawn tiles, + arrangements that cut off a door, spawn, or seat, and more than 60 items are all + rejected. Lot decorations clear when the room empties; user-space decorations are + saved. +- Movement (client modules): A* avoids blocked tiles, and receivers reject paths + that cross blocked tiles or jump too far. +- Themes: every built-in theme passes `validateTheme()` and maps every core role and decor kind. A + bad path (`../`), a forbidden file type, a missing role, or a CSS token outside the + allowlist is rejected. +- Maps: the plaza's lots have reachable doors and interiors, and the spawn point is + walkable. +- RTC: a token is issued only to a lease holder, and renewal fails after leaving or + removal. +- Invitations: tokens expire, uses are counted, and tokens are stored hashed. +- Messages: the server has no endpoint or storage for message content. +- Discovery hooks: profile cards expose only published profile fields. Topic tags are + limited to 5 and normalized. Noticeboard results rank by viewer match and never + include unlisted or private spaces. `GET /api/spaces?q=` matches topics and tags of + listed rooms only. Activity entries are created only for listed rooms. +- Chat teams (with a fake Chat REST client): + - Lot rooms: the first entry creates the group, people are added on entry and + removed on leave or expiry, the room emptying dissolves the group, and the host + becomes group admin. + - User-created spaces: members stay in the group while away, and visitors are added + on entry and removed on leave. + - A failed member-list sync leaves portal membership unchanged. A failed sync on + entry or leave is queued and retried without blocking entry. Chat usernames are lowercase member IDs, and Chat tokens go only to the + signed-in person. +- Migration: meeting rooms become `unlisted`/`members` spaces with their rosters, the + migration is idempotent, and `/meet/` redirects. +- Manual checks: desktop and mobile widths, keyboard-only use, screen reader through + the panel, reduced motion, theme switching, two browsers walking the plaza, and + entering a lot from both sides. Repeat with real Agora RTC, Signaling, and Chat credentials, including a DM and a +team conversation between two accounts. + +## Decisions And Open Questions + +Decided: + +- Plaza capacity is 1000 people walking at once. When it is full, people are offered + unlisted spaces (section 1). +- A team is everyone who shares a room or group, in listed and unlisted spaces alike + (section 2, Agora Chat). +- The first person in a room becomes host and can decorate it (section 1). +- Live movement, presence, and public and room messages use Signaling and are never + stored. Team messages and DMs use Agora Chat. +- A lot host's removal lasts until the room empties. +- 13 decor kinds, with at most 60 items per space. +- User-created spaces: 3 on Basic and 20 on Premium. +- Resource discovery is the core purpose. Stoa v1 includes discovery hooks (section + 4a). The discovery engine and Events come next, before Pnyx and Odeon. +- Avatars: a theme-drawn character in the person's own color, showing their profile + photo or initials. A character editor comes later. + +Open: + +1. Optional: ask Agora whether per-channel Signaling permissions (`Rtm2Permissions`) + can be enabled for the project. Nothing depends on the answer. If yes, + `AGORA_RTM_PERMISSIONS=true` adds extra protection. From 805c68ff3b5a4b2280dd5a963659b9442aed796f Mon Sep 17 00:00:00 2001 From: Brent G Date: Wed, 7 Oct 2026 17:58:35 +0000 Subject: [PATCH 02/71] Add spaces state, actor lookup, and space entitlements MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🤖 Built with SMT --- scripts/plans.mjs | 2 +- scripts/serve.mjs | 2 +- scripts/store.mjs | 14 +++++++++++--- tests/platform.test.mjs | 2 +- tests/spaces-core.test.mjs | 39 ++++++++++++++++++++++++++++++++++++++ 5 files changed, 53 insertions(+), 6 deletions(-) create mode 100644 tests/spaces-core.test.mjs diff --git a/scripts/plans.mjs b/scripts/plans.mjs index e3c4c67..1b927df 100644 --- a/scripts/plans.mjs +++ b/scripts/plans.mjs @@ -13,5 +13,5 @@ export function effectivePlan(account, now = Date.now()) { export function entitlements(plan) { const premium = plan !== "basic"; - return { projects: premium ? 20 : 5, evalFlowsPerProject: premium ? 20 : 10, apiEvalFlows: premium ? 200 : 50, privateResources: premium, ownStorage: premium, publishMainline: ["principal", "fellow"].includes(plan) }; + return { projects: premium ? 20 : 5, evalFlowsPerProject: premium ? 20 : 10, apiEvalFlows: premium ? 200 : 50, privateResources: premium, ownStorage: premium, publishMainline: ["principal", "fellow"].includes(plan), spaces: premium ? 20 : 3 }; } diff --git a/scripts/serve.mjs b/scripts/serve.mjs index feee5b2..9cfa74c 100644 --- a/scripts/serve.mjs +++ b/scripts/serve.mjs @@ -337,7 +337,7 @@ export function createAppServer(directory = root, options = {}) { response.end(request.method === "HEAD" ? undefined : content); } catch (error) { if (response.headersSent) { response.end(); return; } - json(response, error.code === "ENOENT" ? 404 : error.status || 500, { error: error.code === "ENOENT" ? "This page was not found." : error.status ? error.message : "The house could not complete this request. Please try again." }); + json(response, error.code === "ENOENT" ? 404 : error.status || 500, { error: error.code === "ENOENT" ? "This page was not found." : error.status ? error.message : "The house could not complete this request. Please try again.", ...(error.status && error.details ? error.details : {}) }); } }); diff --git a/scripts/store.mjs b/scripts/store.mjs index accc609..084c228 100644 --- a/scripts/store.mjs +++ b/scripts/store.mjs @@ -4,7 +4,7 @@ import { createHash, randomBytes, randomUUID } from "node:crypto"; import { effectivePlan, entitlements } from "./plans.mjs"; export class AppError extends Error { - constructor(status, message) { super(message); this.status = status; } + constructor(status, message, details) { super(message); this.status = status; if (details) this.details = details; } } const hash = (value) => createHash("sha256").update(value).digest("hex"); const publicProfile = ({ sessionHash, accountId, ...person }) => person; @@ -40,7 +40,7 @@ export function createStore(seedFile, storageFile, { now = Date.now, persistence let queue = Promise.resolve(); const normalize = (state) => { state.accounts ||= []; state.sessions ||= []; state.authorizationCodes ||= []; state.grants ||= []; - state.people ||= []; state.rooms ||= []; state.radar ||= {}; + state.people ||= []; state.rooms ||= []; state.radar ||= {}; state.spaces ||= []; state.spaceLeases ||= []; return state; }; async function read() { @@ -48,7 +48,7 @@ export function createStore(seedFile, storageFile, { now = Date.now, persistence try { const state = JSON.parse(await readFile(storageFile, "utf8")); return normalize(state); - } catch (error) { if (error.code === "ENOENT") return { people: [], rooms: [], radar: {}, accounts: [], sessions: [], authorizationCodes: [], grants: [] }; throw error; } + } catch (error) { if (error.code === "ENOENT") return { people: [], rooms: [], radar: {}, accounts: [], sessions: [], authorizationCodes: [], grants: [], spaces: [], spaceLeases: [] }; throw error; } } function update(change) { // Keep concurrent joins and room updates inside one atomic read/write cycle. @@ -110,6 +110,14 @@ export function createStore(seedFile, storageFile, { now = Date.now, persistence }, async people() { return [...(await read()).people.map(publicProfile), ...JSON.parse(await readFile(seedFile, "utf8"))]; }, async me(token) { const person = personFor(await read(), token); return person ? publicProfile(person) : null; }, + async actor(token) { + if (!token) return null; + const state = await read(); + const account = accountFor(state, token); + const person = personFor(state, token); + if (!account && !person) return null; + return { id: account?.id || person.id, accountId: account?.id || null, memberId: person?.id || null, name: person?.name || account?.name || "Builder", avatar: person?.avatar || account?.avatar || "", hasProfile: Boolean(person), plan: effectivePlan(account) }; + }, async session(token) { const state = await read(); const person = personFor(state, token); diff --git a/tests/platform.test.mjs b/tests/platform.test.mjs index 8e40ce9..3d4007b 100644 --- a/tests/platform.test.mjs +++ b/tests/platform.test.mjs @@ -143,7 +143,7 @@ test("Vox-compatible entitlements depend on verified subscription state and peri for (const status of ["past_due", "unpaid", "canceled", "incomplete"]) assert.equal(effectivePlan({ subscription: { ...subscription, status } }, now), "basic"); assert.equal(effectivePlan({ subscription }, now + 2000000), "basic"); assert.equal(effectivePlan({ assignedPlan: "principal" }, now), "principal"); - assert.deepEqual(entitlements("basic"), { projects: 5, evalFlowsPerProject: 10, apiEvalFlows: 50, privateResources: false, ownStorage: false, publishMainline: false }); + assert.deepEqual(entitlements("basic"), { projects: 5, evalFlowsPerProject: 10, apiEvalFlows: 50, privateResources: false, ownStorage: false, publishMainline: false, spaces: 3 }); assert.equal(entitlements("premium").publishMainline, false); assert.equal(entitlements("fellow").publishMainline, true); }); diff --git a/tests/spaces-core.test.mjs b/tests/spaces-core.test.mjs new file mode 100644 index 0000000..5296927 --- /dev/null +++ b/tests/spaces-core.test.mjs @@ -0,0 +1,39 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { resolve } from "node:path"; +import { AppError, createStore } from "../scripts/store.mjs"; +import { entitlements } from "../scripts/plans.mjs"; + +const root = new URL("../", import.meta.url).pathname; +const profile = (name = "Test builder") => ({ name, bio: "Building voice agents with Rust.", intent: "Build a language practice voice agent with natural interruptions.", lookingFor: "Audio engineers", skills: ["Rust", "Voice AI"], location: "Remote", contact: "https://example.com/builder", monitor: false }); + +test("plans grant 3 spaces on Basic and 20 on every other plan", () => { + assert.equal(entitlements("basic").spaces, 3); + for (const plan of ["premium", "principal", "fellow"]) assert.equal(entitlements(plan).spaces, 20); +}); +test("errors can carry details for the client", () => { + const error = new AppError(409, "Full.", { full: true }); + assert.equal(error.status, 409); + assert.deepEqual(error.details, { full: true }); + assert.equal(new AppError(404, "Missing.").details, undefined); +}); +test("the store resolves the acting person for accounts, profiles, and guests", async () => { + const directory = await mkdtemp(resolve(tmpdir(), "spaces-actor-")); + try { + const store = createStore(resolve(root, "data/people.json"), resolve(directory, "state.json")); + assert.equal(await store.actor(""), null); + assert.equal(await store.actor("unknown"), null); + const { token: profileToken, person } = await store.join(profile("Profile only")); + assert.deepEqual(await store.actor(profileToken), { id: person.id, accountId: null, memberId: person.id, name: "Profile only", avatar: "", hasProfile: true, plan: "basic" }); + const login = await store.login({ provider: "github", issuer: "https://github.com", subject: "42", name: "Account only" }); + const actor = await store.actor(login.token); + assert.equal(actor.id, login.account.id); + assert.equal(actor.memberId, null); + assert.equal(actor.hasProfile, false); + assert.equal(actor.name, "Account only"); + const state = await store.snapshot(); + assert.deepEqual([state.spaces, state.spaceLeases], [[], []]); + } finally { await rm(directory, { recursive: true, force: true }); } +}); From 88db2b3db23e04b7cce844b167267f086886ce44 Mon Sep 17 00:00:00 2001 From: Brent G Date: Wed, 7 Oct 2026 18:00:54 +0000 Subject: [PATCH 03/71] Add shared map parsing and pathfinding MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🤖 Built with SMT --- tests/map.test.mjs | 64 ++++++++++++++++++++++++++++++++++++ world/map.js | 81 ++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 145 insertions(+) create mode 100644 tests/map.test.mjs create mode 100644 world/map.js diff --git a/tests/map.test.mjs b/tests/map.test.mjs new file mode 100644 index 0000000..c6fa14f --- /dev/null +++ b/tests/map.test.mjs @@ -0,0 +1,64 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { CORE_ROLES, findPath, parseMap, reachable, roleAt, validPath, walkable } from "../world/map.js"; + +const role = (id, name, extra = []) => ({ id, properties: [{ name: "role", type: "string", value: name }, ...extra] }); +const fixture = () => ({ + type: "map", orientation: "orthogonal", width: 5, height: 4, tilewidth: 32, tileheight: 32, + properties: [{ name: "id", type: "string", value: "tiny" }], + tilesets: [{ firstgid: 1, tiles: [role(0, "floor"), role(1, "wall"), role(2, "water", [{ name: "walkable", type: "bool", value: true }]), role(3, "seat")] }], + layers: [ + { type: "tilelayer", name: "ground", data: new Array(20).fill(1) }, + { type: "tilelayer", name: "structure", data: [0, 0, 2, 0, 0, 0, 0, 2, 0, 4, 0, 0, 2, 0, 3, 0, 0, 0, 0, 0] }, + { type: "objectgroup", name: "spawn", objects: [{ id: 1, name: "", x: 0, y: 0, width: 0, height: 0 }] }, + { type: "objectgroup", name: "lot", objects: [{ id: 2, name: "", x: 96, y: 0, width: 64, height: 96, properties: [{ name: "lotId", value: "a" }, { name: "slug", value: "a-room" }, { name: "title", value: "A" }, { name: "capacity", value: 4 }, { name: "doorX", value: 3 }, { name: "doorY", value: 3 }, { name: "entryX", value: 3 }, { name: "entryY", value: 2 }] }] }, + { type: "objectgroup", name: "interactable", objects: [{ id: 3, name: "", x: 32, y: 96, width: 0, height: 0, properties: [{ name: "id", value: "board" }, { name: "kind", value: "noticeboard" }, { name: "label", value: "Board" }, { name: "query", value: "voice" }] }] } + ] +}); + +test("core roles are fixed and ordered", () => { + assert.deepEqual(CORE_ROLES, ["floor", "path", "grass", "water", "wall", "column", "door", "table", "seat", "plant", "decor"]); +}); +test("maps parse roles, walkability overrides, lots, spawns, and interactables", () => { + const map = parseMap(fixture()); + assert.equal(map.id, "tiny"); + assert.deepEqual([map.width, map.height, map.tileSize], [5, 4, 32]); + assert.equal(roleAt(map, 2, 0), "wall"); + assert.equal(roleAt(map, 4, 2), "water"); + assert.equal(walkable(map, 2, 0), false); + assert.equal(walkable(map, 4, 2), true, "a tile property can make water walkable"); + assert.equal(walkable(map, 4, 1), true, "seats are walkable"); + assert.equal(walkable(map, -1, 0), false); + assert.equal(walkable(map, 0.5, 0), false); + assert.deepEqual(map.spawns, [{ x: 0, y: 0 }]); + assert.deepEqual(map.lots, [{ lotId: "a", slug: "a-room", title: "A", capacity: 4, door: { x: 3, y: 3 }, entry: { x: 3, y: 2 }, interior: { x: 3, y: 0, width: 2, height: 3 } }]); + assert.deepEqual(map.interactables, [{ id: "board", kind: "noticeboard", label: "Board", query: "voice", x: 1, y: 3 }]); +}); +test("unsupported maps are rejected", () => { + assert.throws(() => parseMap({ ...fixture(), orientation: "isometric" })); + const unknown = fixture(); unknown.layers[1].data[0] = 99; + assert.throws(() => parseMap(unknown), /Unknown tile/); + const short = fixture(); short.layers[0].data = [1]; + assert.throws(() => parseMap(short), /wrong size/); +}); +test("paths are shortest, avoid blocked tiles, and respect extra blockers", () => { + const map = parseMap(fixture()); + const path = findPath(map, { x: 0, y: 0 }, { x: 4, y: 0 }); + assert.equal(path.length, 11); + assert.deepEqual(path[0], { x: 0, y: 0 }); + assert.deepEqual(path.at(-1), { x: 4, y: 0 }); + assert.equal(validPath(map, path), true); + assert.equal(findPath(map, { x: 0, y: 0 }, { x: 2, y: 0 }), null); + assert.equal(findPath(map, { x: 0, y: 0 }, { x: 4, y: 0 }, new Set(["2,3"])), null); + const open = reachable(map, { x: 0, y: 0 }, new Set(["1,3"])); + assert.ok(open.has("0,3")); + assert.ok(!open.has("4,0")); +}); +test("received paths are rejected when they jump or cross blocked tiles", () => { + const map = parseMap(fixture()); + assert.equal(validPath(map, [{ x: 0, y: 0 }, { x: 0, y: 2 }]), false); + assert.equal(validPath(map, [{ x: 1, y: 0 }, { x: 2, y: 0 }]), false); + assert.equal(validPath(map, []), false); + assert.equal(validPath(map, "nope"), false); + assert.equal(validPath(map, new Array(401).fill({ x: 0, y: 0 })), false); +}); diff --git a/world/map.js b/world/map.js new file mode 100644 index 0000000..a8a6813 --- /dev/null +++ b/world/map.js @@ -0,0 +1,81 @@ +// Shared by the browser engine and the server: maps describe purpose (roles), never artwork. +export const CORE_ROLES = ["floor", "path", "grass", "water", "wall", "column", "door", "table", "seat", "plant", "decor"]; +const BLOCKING = new Set(["wall", "column", "water", "table", "plant"]); +const STEPS = [[1, 0], [-1, 0], [0, 1], [0, -1]]; +const props = (entry) => Object.fromEntries((entry?.properties || []).map((property) => [property.name, property.value])); + +export function parseMap(source) { + const { width, height, tilewidth: size } = source || {}; + if (source?.orientation !== "orthogonal" || !Number.isInteger(width) || !Number.isInteger(height) || width < 2 || height < 2 || width > 200 || height > 200 || !Number.isInteger(size) || size !== source.tileheight) throw new Error("Unsupported map."); + const tiles = new Map(); + for (const tileset of source.tilesets || []) for (const tile of tileset.tiles || []) tiles.set(tileset.firstgid + tile.id, props(tile)); + const roles = new Array(width * height).fill(null); + const blocked = new Uint8Array(width * height); + for (const name of ["ground", "structure"]) { + const data = source.layers.find((layer) => layer.type === "tilelayer" && layer.name === name)?.data; + if (!data) continue; + if (data.length !== width * height) throw new Error("Layer " + name + " has the wrong size."); + data.forEach((gid, index) => { + if (!gid) return; + const tile = tiles.get(gid); + if (!tile || !CORE_ROLES.includes(tile.role)) throw new Error("Unknown tile " + gid + "."); + roles[index] = tile.role; + blocked[index] = (tile.walkable === undefined ? BLOCKING.has(tile.role) : !tile.walkable) ? 1 : 0; + }); + } + roles.forEach((role, index) => { if (!role) blocked[index] = 1; }); + const objects = (name) => (source.layers.find((layer) => layer.type === "objectgroup" && layer.name === name)?.objects || []).map((object) => ({ name: object.name, ...props(object), x: Math.floor(object.x / size), y: Math.floor(object.y / size), width: Math.round((object.width || 0) / size), height: Math.round((object.height || 0) / size) })); + return { + id: props(source).id, width, height, tileSize: size, roles, blocked, + lots: objects("lot").map((lot) => ({ lotId: lot.lotId, slug: lot.slug, title: lot.title, capacity: lot.capacity, door: { x: lot.doorX, y: lot.doorY }, entry: { x: lot.entryX, y: lot.entryY }, interior: { x: lot.x, y: lot.y, width: lot.width, height: lot.height } })), + spawns: objects("spawn").map(({ x, y }) => ({ x, y })), + zones: objects("zone").map(({ name, x, y, width: w, height: h }) => ({ name, x, y, width: w, height: h })), + interactables: objects("interactable").map(({ id, kind, label, query, x, y }) => ({ id, kind, label, query, x, y })) + }; +} + +export const inside = (map, x, y) => Number.isInteger(x) && Number.isInteger(y) && x >= 0 && y >= 0 && x < map.width && y < map.height; +export const roleAt = (map, x, y) => inside(map, x, y) ? map.roles[y * map.width + x] : null; +export const walkable = (map, x, y, extra = new Set()) => inside(map, x, y) && !map.blocked[y * map.width + x] && !extra.has(x + "," + y); + +export function findPath(map, from, to, extra = new Set()) { + if (!inside(map, from.x, from.y) || !walkable(map, to.x, to.y, extra)) return null; + const key = (x, y) => y * map.width + x; + const start = key(from.x, from.y), goal = key(to.x, to.y); + const came = new Map(), cost = new Map([[start, 0]]); + const open = [[Math.abs(from.x - to.x) + Math.abs(from.y - to.y), start]]; + while (open.length) { + let best = 0; + for (let index = 1; index < open.length; index += 1) if (open[index][0] < open[best][0]) best = index; + const [, current] = open.splice(best, 1)[0]; + if (current === goal) { + const path = []; + for (let node = goal; node !== undefined; node = came.get(node)) path.unshift({ x: node % map.width, y: Math.floor(node / map.width) }); + return path; + } + const x = current % map.width, y = Math.floor(current / map.width); + for (const [dx, dy] of STEPS) { + const nx = x + dx, ny = y + dy; + if (!walkable(map, nx, ny, extra)) continue; + const next = key(nx, ny), score = cost.get(current) + 1; + if (score >= (cost.get(next) ?? Infinity)) continue; + cost.set(next, score); came.set(next, current); + open.push([score + Math.abs(nx - to.x) + Math.abs(ny - to.y), next]); + } + } + return null; +} + +export function reachable(map, start, extra = new Set()) { + const seen = new Set([start.x + "," + start.y]), queue = [start]; + while (queue.length) { + const { x, y } = queue.shift(); + for (const [dx, dy] of STEPS) { + const nx = x + dx, ny = y + dy, id = nx + "," + ny; + if (!seen.has(id) && walkable(map, nx, ny, extra)) { seen.add(id); queue.push({ x: nx, y: ny }); } + } + } + return seen; +} + +export const validPath = (map, path, extra = new Set()) => Array.isArray(path) && path.length > 0 && path.length <= 400 && path.every((step, index) => step && walkable(map, step.x, step.y, extra) && (index === 0 || Math.abs(step.x - path[index - 1].x) + Math.abs(step.y - path[index - 1].y) === 1)); From d17255d50c5a58e39589b49840288f5d0dbc6797 Mon Sep 17 00:00:00 2001 From: Brent G Date: Wed, 7 Oct 2026 18:03:41 +0000 Subject: [PATCH 04/71] =?UTF-8?q?Add=20the=20plaza=20and=20room=20worlds?= =?UTF-8?q?=20=F0=9F=A4=96=20Built=20with=20SMT=20?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package.json | 3 +- scripts/maps/build-maps.mjs | 84 +++++++++++++++++++++++++++++++++++++ tests/map.test.mjs | 33 +++++++++++++++ worlds/plaza/map.json | 1 + worlds/room/map.json | 1 + 5 files changed, 121 insertions(+), 1 deletion(-) create mode 100644 scripts/maps/build-maps.mjs create mode 100644 worlds/plaza/map.json create mode 100644 worlds/room/map.json diff --git a/package.json b/package.json index 2ca78e8..75e8e92 100644 --- a/package.json +++ b/package.json @@ -9,7 +9,8 @@ "start": "node scripts/serve.mjs", "build": "node scripts/build.mjs", "preview": "node scripts/serve.mjs --dir dist", - "test": "node --test tests/*.test.mjs" + "test": "node --test tests/*.test.mjs", + "maps": "node scripts/maps/build-maps.mjs" }, "engines": { "node": ">=20.12" diff --git a/scripts/maps/build-maps.mjs b/scripts/maps/build-maps.mjs new file mode 100644 index 0000000..87b540d --- /dev/null +++ b/scripts/maps/build-maps.mjs @@ -0,0 +1,84 @@ +// Generates the built-in Tiled-compatible worlds. Designers may replace the JSON with maps edited in Tiled. +import { mkdir, writeFile } from "node:fs/promises"; +import { resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { CORE_ROLES } from "../../world/map.js"; + +const SIZE = 32; +const prop = (values) => Object.entries(values).map(([name, value]) => ({ name, type: typeof value === "number" ? "int" : typeof value === "boolean" ? "bool" : "string", value })); +const blank = (width, height, role) => Array.from({ length: height }, () => new Array(width).fill(role)); +const fill = (grid, x, y, width, height, role) => { for (let row = y; row < y + height; row += 1) for (let column = x; column < x + width; column += 1) grid[row][column] = role; }; + +export function tiled(id, grid, objects) { + const height = grid.length, width = grid[0].length, gid = (role) => CORE_ROLES.indexOf(role) + 1; + const ground = [], structure = []; + for (const row of grid) for (const role of row) { + const base = ["water", "grass", "path"].includes(role) ? role : "floor"; + ground.push(gid(base)); structure.push(role === base ? 0 : gid(role)); + } + let next = 1; + const group = (name, list) => ({ type: "objectgroup", name, objects: list.map(({ x, y, width: w = 0, height: h = 0, name: label = "", ...rest }) => ({ id: next++, name: label, x: x * SIZE, y: y * SIZE, width: w * SIZE, height: h * SIZE, point: !w && !h, properties: prop(rest) })) }); + const layers = [{ type: "tilelayer", name: "ground", width, height, data: ground }, { type: "tilelayer", name: "structure", width, height, data: structure }, ...Object.entries(objects).map(([name, list]) => group(name, list))]; + return { type: "map", version: "1.10", tiledversion: "1.10.2", orientation: "orthogonal", renderorder: "right-down", width, height, tilewidth: SIZE, tileheight: SIZE, infinite: false, nextobjectid: next, properties: prop({ id }), tilesets: [{ firstgid: 1, name: "roles", tilewidth: SIZE, tileheight: SIZE, tilecount: CORE_ROLES.length, columns: 0, tiles: CORE_ROLES.map((role, index) => ({ id: index, properties: prop({ role }) })) }], layers }; +} + +export const LOTS = [ + { lotId: "agents", slug: "ai-agents", title: "AI agents", capacity: 8 }, + { lotId: "voice", slug: "voice-ai", title: "Voice AI", capacity: 8 }, + { lotId: "rtc", slug: "rtc-lab", title: "RTC lab", capacity: 6 }, + { lotId: "founders", slug: "founders-table", title: "Founders' table", capacity: 6 }, + { lotId: "oss", slug: "open-source", title: "Open source", capacity: 10 }, + { lotId: "lounge", slug: "lounge", title: "The lounge", capacity: 12 } +]; +// Four facades face down from the top edge of the plaza and two face up from the bottom. +const FACADES = [{ x: 2, y: 2, top: true }, { x: 10, y: 2, top: true }, { x: 27, y: 2, top: true }, { x: 35, y: 2, top: true }, { x: 6, y: 14, top: false }, { x: 29, y: 14, top: false }]; + +export function plaza() { + const grid = blank(44, 34, "wall"); + fill(grid, 1, 1, 42, 18, "grass"); + fill(grid, 1, 9, 42, 2, "path"); fill(grid, 21, 1, 2, 18, "path"); + fill(grid, 17, 6, 10, 8, "path"); fill(grid, 19, 8, 6, 4, "water"); + for (const [x, y] of [[17, 6], [26, 6], [17, 13], [26, 13]]) grid[y][x] = "column"; + for (const [x, y] of [[20, 7], [23, 7], [20, 12], [23, 12]]) grid[y][x] = "seat"; + for (const [x, y] of [[5, 8], [38, 8], [5, 11], [38, 11]]) grid[y][x] = "plant"; + const lots = LOTS.map((lot, index) => { + const facade = FACADES[index], door = { x: facade.x + 3, y: facade.top ? facade.y + 3 : facade.y }; + fill(grid, facade.x, facade.y, 7, 4, "wall"); + grid[door.y][door.x] = "door"; + const x0 = 1 + index * 7; + fill(grid, x0, 21, 6, 12, "floor"); + fill(grid, x0 + 2, 25, 2, 3, "table"); + for (let y = 25; y < 28; y += 1) { grid[y][x0 + 1] = "seat"; grid[y][x0 + 4] = "seat"; } + grid[21][x0] = "plant"; grid[21][x0 + 5] = "plant"; + grid[32][x0 + 2] = "door"; + return { ...lot, x: x0, y: 21, width: 6, height: 12, doorX: door.x, doorY: door.y, entryX: x0 + 2, entryY: 31 }; + }); + return tiled("plaza", grid, { + lot: lots, + spawn: [{ x: 21, y: 17 }, { x: 22, y: 17 }], + zone: [{ name: "plaza", x: 1, y: 1, width: 42, height: 18 }, { name: "fountain", x: 17, y: 6, width: 10, height: 8 }], + interactable: [ + { x: 12, y: 8, id: "collaborators", kind: "noticeboard", label: "Looking for collaborators", query: "collaborators" }, + { x: 31, y: 8, id: "voice-ai", kind: "noticeboard", label: "Voice AI", query: "voice ai" }, + { x: 25, y: 16, id: "offers", kind: "noticeboard", label: "Offers", query: "offers" } + ] + }); +} + +export function room() { + const grid = blank(20, 14, "wall"); + fill(grid, 1, 1, 18, 12, "floor"); + fill(grid, 8, 5, 4, 3, "table"); + for (let x = 8; x < 12; x += 1) { grid[4][x] = "seat"; grid[8][x] = "seat"; } + for (const [x, y] of [[1, 1], [18, 1], [1, 12], [18, 12]]) grid[y][x] = "plant"; + return tiled("room", grid, { spawn: [{ x: 9, y: 11 }, { x: 10, y: 11 }], zone: [{ name: "room", x: 1, y: 1, width: 18, height: 12 }] }); +} + +if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + const root = new URL("../../", import.meta.url); + for (const [id, map] of [["plaza", plaza()], ["room", room()]]) { + await mkdir(new URL("worlds/" + id + "/", root), { recursive: true }); + await writeFile(new URL("worlds/" + id + "/map.json", root), JSON.stringify(map) + "\n"); + } + console.log("Wrote worlds/plaza/map.json and worlds/room/map.json."); +} diff --git a/tests/map.test.mjs b/tests/map.test.mjs index c6fa14f..8e8c051 100644 --- a/tests/map.test.mjs +++ b/tests/map.test.mjs @@ -62,3 +62,36 @@ test("received paths are rejected when they jump or cross blocked tiles", () => assert.equal(validPath(map, "nope"), false); assert.equal(validPath(map, new Array(401).fill({ x: 0, y: 0 })), false); }); + +import { readFile } from "node:fs/promises"; +const world = async (id) => parseMap(JSON.parse(await readFile(new URL("../worlds/" + id + "/map.json", import.meta.url), "utf8"))); +const seatsIn = (map, area) => { const seats = []; for (let y = area.y; y < area.y + area.height; y += 1) for (let x = area.x; x < area.x + area.width; x += 1) if (roleAt(map, x, y) === "seat") seats.push({ x, y }); return seats; }; + +test("the plaza has six reachable lots whose interiors are separate and fully reachable", async () => { + const plaza = await world("plaza"); + assert.equal(plaza.id, "plaza"); + assert.deepEqual(plaza.lots.map((lot) => [lot.slug, lot.capacity]), [["ai-agents", 8], ["voice-ai", 8], ["rtc-lab", 6], ["founders-table", 6], ["open-source", 10], ["lounge", 12]]); + const [spawn] = plaza.spawns; + assert.ok(walkable(plaza, spawn.x, spawn.y)); + const outside = reachable(plaza, spawn); + for (const lot of plaza.lots) { + assert.equal(roleAt(plaza, lot.door.x, lot.door.y), "door", lot.slug); + assert.ok(outside.has(lot.door.x + "," + lot.door.y), lot.slug + " door is reachable"); + assert.ok(!outside.has(lot.entry.x + "," + lot.entry.y), lot.slug + " interior is separate"); + const inside = reachable(plaza, lot.entry); + const seats = seatsIn(plaza, lot.interior); + assert.equal(seats.length, 6, lot.slug); + for (const seat of seats) assert.ok(inside.has(seat.x + "," + seat.y), lot.slug + " seat"); + assert.ok(inside.has(lot.entry.x + "," + (lot.entry.y + 1)), lot.slug + " exit"); + assert.equal(roleAt(plaza, lot.entry.x, lot.entry.y + 1), "door"); + } + assert.deepEqual(plaza.interactables.map((entry) => [entry.id, entry.kind]), [["collaborators", "noticeboard"], ["voice-ai", "noticeboard"], ["offers", "noticeboard"]]); +}); +test("the room world seats everyone reachably", async () => { + const room = await world("room"); + assert.equal(room.id, "room"); + const open = reachable(room, room.spawns[0]); + const seats = seatsIn(room, { x: 0, y: 0, width: room.width, height: room.height }); + assert.equal(seats.length, 8); + for (const seat of seats) assert.ok(open.has(seat.x + "," + seat.y)); +}); diff --git a/worlds/plaza/map.json b/worlds/plaza/map.json new file mode 100644 index 0000000..f10addf --- /dev/null +++ b/worlds/plaza/map.json @@ -0,0 +1 @@ +{"type":"map","version":"1.10","tiledversion":"1.10.2","orientation":"orthogonal","renderorder":"right-down","width":44,"height":34,"tilewidth":32,"tileheight":32,"infinite":false,"nextobjectid":14,"properties":[{"name":"id","type":"string","value":"plaza"}],"tilesets":[{"firstgid":1,"name":"roles","tilewidth":32,"tileheight":32,"tilecount":11,"columns":0,"tiles":[{"id":0,"properties":[{"name":"role","type":"string","value":"floor"}]},{"id":1,"properties":[{"name":"role","type":"string","value":"path"}]},{"id":2,"properties":[{"name":"role","type":"string","value":"grass"}]},{"id":3,"properties":[{"name":"role","type":"string","value":"water"}]},{"id":4,"properties":[{"name":"role","type":"string","value":"wall"}]},{"id":5,"properties":[{"name":"role","type":"string","value":"column"}]},{"id":6,"properties":[{"name":"role","type":"string","value":"door"}]},{"id":7,"properties":[{"name":"role","type":"string","value":"table"}]},{"id":8,"properties":[{"name":"role","type":"string","value":"seat"}]},{"id":9,"properties":[{"name":"role","type":"string","value":"plant"}]},{"id":10,"properties":[{"name":"role","type":"string","value":"decor"}]}]}],"layers":[{"type":"tilelayer","name":"ground","width":44,"height":34,"data":[1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,2,2,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,1,1,3,1,1,1,1,1,1,1,3,1,1,1,1,1,1,1,3,3,3,3,2,2,3,3,3,3,1,1,1,1,1,1,1,3,1,1,1,1,1,1,1,3,1,1,3,1,1,1,1,1,1,1,3,1,1,1,1,1,1,1,3,3,3,3,2,2,3,3,3,3,1,1,1,1,1,1,1,3,1,1,1,1,1,1,1,3,1,1,3,1,1,1,1,1,1,1,3,1,1,1,1,1,1,1,3,3,3,3,2,2,3,3,3,3,1,1,1,1,1,1,1,3,1,1,1,1,1,1,1,3,1,1,3,1,1,1,1,1,1,1,3,1,1,1,1,1,1,1,3,3,3,3,2,2,3,3,3,3,1,1,1,1,1,1,1,3,1,1,1,1,1,1,1,3,1,1,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,1,2,2,2,2,2,2,2,2,1,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,1,1,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,2,2,2,1,2,2,1,2,2,2,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,1,1,3,3,3,3,1,3,3,3,3,3,3,3,3,3,3,3,2,2,4,4,4,4,4,4,2,2,3,3,3,3,3,3,3,3,3,3,3,1,3,3,3,3,1,1,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,4,4,4,4,4,4,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,1,1,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,4,4,4,4,4,4,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,1,1,3,3,3,3,1,3,3,3,3,3,3,3,3,3,3,3,2,2,4,4,4,4,4,4,2,2,3,3,3,3,3,3,3,3,3,3,3,1,3,3,3,3,1,1,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,2,2,2,1,2,2,1,2,2,2,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,1,1,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,1,2,2,2,2,2,2,2,2,1,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,1,1,3,3,3,3,3,1,1,1,1,1,1,1,3,3,3,3,3,3,3,3,2,2,3,3,3,3,3,3,1,1,1,1,1,1,1,3,3,3,3,3,3,3,1,1,3,3,3,3,3,1,1,1,1,1,1,1,3,3,3,3,3,3,3,3,2,2,3,3,3,3,3,3,1,1,1,1,1,1,1,3,3,3,3,3,3,3,1,1,3,3,3,3,3,1,1,1,1,1,1,1,3,3,3,3,3,3,3,3,2,2,3,3,3,3,3,3,1,1,1,1,1,1,1,3,3,3,3,3,3,3,1,1,3,3,3,3,3,1,1,1,1,1,1,1,3,3,3,3,3,3,3,3,2,2,3,3,3,3,3,3,1,1,1,1,1,1,1,3,3,3,3,3,3,3,1,1,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,2,2,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1]},{"type":"tilelayer","name":"structure","width":44,"height":34,"data":[5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,5,5,0,5,5,5,5,5,5,5,0,5,5,5,5,5,5,5,0,0,0,0,0,0,0,0,0,0,5,5,5,5,5,5,5,0,5,5,5,5,5,5,5,0,5,5,0,5,5,5,5,5,5,5,0,5,5,5,5,5,5,5,0,0,0,0,0,0,0,0,0,0,5,5,5,5,5,5,5,0,5,5,5,5,5,5,5,0,5,5,0,5,5,5,5,5,5,5,0,5,5,5,5,5,5,5,0,0,0,0,0,0,0,0,0,0,5,5,5,5,5,5,5,0,5,5,5,5,5,5,5,0,5,5,0,5,5,5,7,5,5,5,0,5,5,5,7,5,5,5,0,0,0,0,0,0,0,0,0,0,5,5,5,7,5,5,5,0,5,5,5,7,5,5,5,0,5,5,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,6,0,0,0,0,0,0,0,0,6,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,5,5,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,9,0,0,9,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,5,5,0,0,0,0,10,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,10,0,0,0,0,5,5,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,5,5,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,5,5,0,0,0,0,10,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,10,0,0,0,0,5,5,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,9,0,0,9,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,5,5,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,6,0,0,0,0,0,0,0,0,6,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,5,5,0,0,0,0,0,5,5,5,7,5,5,5,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,5,5,5,7,5,5,5,0,0,0,0,0,0,0,5,5,0,0,0,0,0,5,5,5,5,5,5,5,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,5,5,5,5,5,5,5,0,0,0,0,0,0,0,5,5,0,0,0,0,0,5,5,5,5,5,5,5,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,5,5,5,5,5,5,5,0,0,0,0,0,0,0,5,5,0,0,0,0,0,5,5,5,5,5,5,5,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,5,5,5,5,5,5,5,0,0,0,0,0,0,0,5,5,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,10,0,0,0,0,10,5,10,0,0,0,0,10,5,10,0,0,0,0,10,5,10,0,0,0,0,10,5,10,0,0,0,0,10,5,10,0,0,0,0,10,5,5,5,0,0,0,0,0,0,5,0,0,0,0,0,0,5,0,0,0,0,0,0,5,0,0,0,0,0,0,5,0,0,0,0,0,0,5,0,0,0,0,0,0,5,5,5,0,0,0,0,0,0,5,0,0,0,0,0,0,5,0,0,0,0,0,0,5,0,0,0,0,0,0,5,0,0,0,0,0,0,5,0,0,0,0,0,0,5,5,5,0,0,0,0,0,0,5,0,0,0,0,0,0,5,0,0,0,0,0,0,5,0,0,0,0,0,0,5,0,0,0,0,0,0,5,0,0,0,0,0,0,5,5,5,0,9,8,8,9,0,5,0,9,8,8,9,0,5,0,9,8,8,9,0,5,0,9,8,8,9,0,5,0,9,8,8,9,0,5,0,9,8,8,9,0,5,5,5,0,9,8,8,9,0,5,0,9,8,8,9,0,5,0,9,8,8,9,0,5,0,9,8,8,9,0,5,0,9,8,8,9,0,5,0,9,8,8,9,0,5,5,5,0,9,8,8,9,0,5,0,9,8,8,9,0,5,0,9,8,8,9,0,5,0,9,8,8,9,0,5,0,9,8,8,9,0,5,0,9,8,8,9,0,5,5,5,0,0,0,0,0,0,5,0,0,0,0,0,0,5,0,0,0,0,0,0,5,0,0,0,0,0,0,5,0,0,0,0,0,0,5,0,0,0,0,0,0,5,5,5,0,0,0,0,0,0,5,0,0,0,0,0,0,5,0,0,0,0,0,0,5,0,0,0,0,0,0,5,0,0,0,0,0,0,5,0,0,0,0,0,0,5,5,5,0,0,0,0,0,0,5,0,0,0,0,0,0,5,0,0,0,0,0,0,5,0,0,0,0,0,0,5,0,0,0,0,0,0,5,0,0,0,0,0,0,5,5,5,0,0,0,0,0,0,5,0,0,0,0,0,0,5,0,0,0,0,0,0,5,0,0,0,0,0,0,5,0,0,0,0,0,0,5,0,0,0,0,0,0,5,5,5,0,0,7,0,0,0,5,0,0,7,0,0,0,5,0,0,7,0,0,0,5,0,0,7,0,0,0,5,0,0,7,0,0,0,5,0,0,7,0,0,0,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5]},{"type":"objectgroup","name":"lot","objects":[{"id":1,"name":"","x":32,"y":672,"width":192,"height":384,"point":false,"properties":[{"name":"lotId","type":"string","value":"agents"},{"name":"slug","type":"string","value":"ai-agents"},{"name":"title","type":"string","value":"AI agents"},{"name":"capacity","type":"int","value":8},{"name":"doorX","type":"int","value":5},{"name":"doorY","type":"int","value":5},{"name":"entryX","type":"int","value":3},{"name":"entryY","type":"int","value":31}]},{"id":2,"name":"","x":256,"y":672,"width":192,"height":384,"point":false,"properties":[{"name":"lotId","type":"string","value":"voice"},{"name":"slug","type":"string","value":"voice-ai"},{"name":"title","type":"string","value":"Voice AI"},{"name":"capacity","type":"int","value":8},{"name":"doorX","type":"int","value":13},{"name":"doorY","type":"int","value":5},{"name":"entryX","type":"int","value":10},{"name":"entryY","type":"int","value":31}]},{"id":3,"name":"","x":480,"y":672,"width":192,"height":384,"point":false,"properties":[{"name":"lotId","type":"string","value":"rtc"},{"name":"slug","type":"string","value":"rtc-lab"},{"name":"title","type":"string","value":"RTC lab"},{"name":"capacity","type":"int","value":6},{"name":"doorX","type":"int","value":30},{"name":"doorY","type":"int","value":5},{"name":"entryX","type":"int","value":17},{"name":"entryY","type":"int","value":31}]},{"id":4,"name":"","x":704,"y":672,"width":192,"height":384,"point":false,"properties":[{"name":"lotId","type":"string","value":"founders"},{"name":"slug","type":"string","value":"founders-table"},{"name":"title","type":"string","value":"Founders' table"},{"name":"capacity","type":"int","value":6},{"name":"doorX","type":"int","value":38},{"name":"doorY","type":"int","value":5},{"name":"entryX","type":"int","value":24},{"name":"entryY","type":"int","value":31}]},{"id":5,"name":"","x":928,"y":672,"width":192,"height":384,"point":false,"properties":[{"name":"lotId","type":"string","value":"oss"},{"name":"slug","type":"string","value":"open-source"},{"name":"title","type":"string","value":"Open source"},{"name":"capacity","type":"int","value":10},{"name":"doorX","type":"int","value":9},{"name":"doorY","type":"int","value":14},{"name":"entryX","type":"int","value":31},{"name":"entryY","type":"int","value":31}]},{"id":6,"name":"","x":1152,"y":672,"width":192,"height":384,"point":false,"properties":[{"name":"lotId","type":"string","value":"lounge"},{"name":"slug","type":"string","value":"lounge"},{"name":"title","type":"string","value":"The lounge"},{"name":"capacity","type":"int","value":12},{"name":"doorX","type":"int","value":32},{"name":"doorY","type":"int","value":14},{"name":"entryX","type":"int","value":38},{"name":"entryY","type":"int","value":31}]}]},{"type":"objectgroup","name":"spawn","objects":[{"id":7,"name":"","x":672,"y":544,"width":0,"height":0,"point":true,"properties":[]},{"id":8,"name":"","x":704,"y":544,"width":0,"height":0,"point":true,"properties":[]}]},{"type":"objectgroup","name":"zone","objects":[{"id":9,"name":"plaza","x":32,"y":32,"width":1344,"height":576,"point":false,"properties":[]},{"id":10,"name":"fountain","x":544,"y":192,"width":320,"height":256,"point":false,"properties":[]}]},{"type":"objectgroup","name":"interactable","objects":[{"id":11,"name":"","x":384,"y":256,"width":0,"height":0,"point":true,"properties":[{"name":"id","type":"string","value":"collaborators"},{"name":"kind","type":"string","value":"noticeboard"},{"name":"label","type":"string","value":"Looking for collaborators"},{"name":"query","type":"string","value":"collaborators"}]},{"id":12,"name":"","x":992,"y":256,"width":0,"height":0,"point":true,"properties":[{"name":"id","type":"string","value":"voice-ai"},{"name":"kind","type":"string","value":"noticeboard"},{"name":"label","type":"string","value":"Voice AI"},{"name":"query","type":"string","value":"voice ai"}]},{"id":13,"name":"","x":800,"y":512,"width":0,"height":0,"point":true,"properties":[{"name":"id","type":"string","value":"offers"},{"name":"kind","type":"string","value":"noticeboard"},{"name":"label","type":"string","value":"Offers"},{"name":"query","type":"string","value":"offers"}]}]}]} diff --git a/worlds/room/map.json b/worlds/room/map.json new file mode 100644 index 0000000..80ca3e6 --- /dev/null +++ b/worlds/room/map.json @@ -0,0 +1 @@ +{"type":"map","version":"1.10","tiledversion":"1.10.2","orientation":"orthogonal","renderorder":"right-down","width":20,"height":14,"tilewidth":32,"tileheight":32,"infinite":false,"nextobjectid":4,"properties":[{"name":"id","type":"string","value":"room"}],"tilesets":[{"firstgid":1,"name":"roles","tilewidth":32,"tileheight":32,"tilecount":11,"columns":0,"tiles":[{"id":0,"properties":[{"name":"role","type":"string","value":"floor"}]},{"id":1,"properties":[{"name":"role","type":"string","value":"path"}]},{"id":2,"properties":[{"name":"role","type":"string","value":"grass"}]},{"id":3,"properties":[{"name":"role","type":"string","value":"water"}]},{"id":4,"properties":[{"name":"role","type":"string","value":"wall"}]},{"id":5,"properties":[{"name":"role","type":"string","value":"column"}]},{"id":6,"properties":[{"name":"role","type":"string","value":"door"}]},{"id":7,"properties":[{"name":"role","type":"string","value":"table"}]},{"id":8,"properties":[{"name":"role","type":"string","value":"seat"}]},{"id":9,"properties":[{"name":"role","type":"string","value":"plant"}]},{"id":10,"properties":[{"name":"role","type":"string","value":"decor"}]}]}],"layers":[{"type":"tilelayer","name":"ground","width":20,"height":14,"data":[1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1]},{"type":"tilelayer","name":"structure","width":20,"height":14,"data":[5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,10,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,10,5,5,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,5,5,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,5,5,0,0,0,0,0,0,0,9,9,9,9,0,0,0,0,0,0,0,5,5,0,0,0,0,0,0,0,8,8,8,8,0,0,0,0,0,0,0,5,5,0,0,0,0,0,0,0,8,8,8,8,0,0,0,0,0,0,0,5,5,0,0,0,0,0,0,0,8,8,8,8,0,0,0,0,0,0,0,5,5,0,0,0,0,0,0,0,9,9,9,9,0,0,0,0,0,0,0,5,5,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,5,5,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,5,5,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,5,5,10,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,10,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5]},{"type":"objectgroup","name":"spawn","objects":[{"id":1,"name":"","x":288,"y":352,"width":0,"height":0,"point":true,"properties":[]},{"id":2,"name":"","x":320,"y":352,"width":0,"height":0,"point":true,"properties":[]}]},{"type":"objectgroup","name":"zone","objects":[{"id":3,"name":"room","x":32,"y":32,"width":576,"height":384,"point":false,"properties":[]}]}]} From b85fa2f62aa25f42adf5bb48715a25bfbeb60c50 Mon Sep 17 00:00:00 2001 From: Brent G Date: Wed, 7 Oct 2026 18:05:45 +0000 Subject: [PATCH 05/71] Add space permissions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🤖 Built with SMT --- scripts/spaces/permissions.mjs | 25 ++++++++++++++++ tests/spaces-core.test.mjs | 52 ++++++++++++++++++++++++++++++++++ 2 files changed, 77 insertions(+) create mode 100644 scripts/spaces/permissions.mjs diff --git a/scripts/spaces/permissions.mjs b/scripts/spaces/permissions.mjs new file mode 100644 index 0000000..02b4d2d --- /dev/null +++ b/scripts/spaces/permissions.mjs @@ -0,0 +1,25 @@ +// One rule set for REST handlers and token issuing. Visibility decides who can learn a space exists; access decides who can enter. +export const VISIBILITY = ["listed", "unlisted", "private"]; +export const ACCESS = ["open", "house", "members"]; +export const validCombination = (visibility, access) => VISIBILITY.includes(visibility) && ACCESS.includes(access) && (visibility !== "private" || access === "members"); + +export function can(actor, action, space, context = {}) { + if (actor?.admin) return true; + const mine = (id) => Boolean(actor && id && actor.ids.includes(id)); + const owner = mine(space.ownerId); + const member = owner || (space.members || []).some(mine); + const blocked = (space.blocked || []).some(mine); + const present = Boolean(actor && context.present); + switch (action) { + case "see": return space.visibility !== "private" || member || Boolean(context.invited) || present; + case "enter": + if (!actor || blocked || !can(actor, "see", space, context)) return false; + if (space.access === "open") return true; + if (space.access === "house") return actor.hasProfile || member; + return member || Boolean(context.invited); + case "chat": case "speak": return present && !blocked; + case "host": case "decorate": case "moderate": return present && (mine(space.hostId) || owner); + case "edit": case "invite": case "delete": return owner; + default: return false; + } +} diff --git a/tests/spaces-core.test.mjs b/tests/spaces-core.test.mjs index 5296927..77f349f 100644 --- a/tests/spaces-core.test.mjs +++ b/tests/spaces-core.test.mjs @@ -37,3 +37,55 @@ test("the store resolves the acting person for accounts, profiles, and guests", assert.deepEqual([state.spaces, state.spaceLeases], [[], []]); } finally { await rm(directory, { recursive: true, force: true }); } }); + +import { ACCESS, VISIBILITY, can, validCombination } from "../scripts/spaces/permissions.mjs"; + +const actor = (id, extra = {}) => ({ id: "account:" + id, ids: ["account:" + id, ...(extra.memberId ? [extra.memberId] : [])], hasProfile: Boolean(extra.memberId), admin: false, ...extra }); +const owner = actor("owner"), member = actor("member"), stranger = actor("stranger", { memberId: "member:stranger" }), noProfile = actor("plain"), admin = actor("admin", { admin: true }); +const space = (visibility, access, extra = {}) => ({ visibility, access, ownerId: owner.id, members: [owner.id, member.id], blocked: [], hostId: null, ...extra }); + +test("visibility and access combine except private with open or house access", () => { + assert.deepEqual(VISIBILITY, ["listed", "unlisted", "private"]); + assert.deepEqual(ACCESS, ["open", "house", "members"]); + for (const visibility of VISIBILITY) for (const access of ACCESS) assert.equal(validCombination(visibility, access), visibility !== "private" || access === "members", visibility + "/" + access); + assert.equal(validCombination("secret", "open"), false); +}); +test("seeing a space depends only on visibility, membership, invitation, or presence", () => { + for (const visibility of ["listed", "unlisted"]) assert.equal(can(null, "see", space(visibility, "members")), true); + const hidden = space("private", "members"); + assert.equal(can(null, "see", hidden), false); + assert.equal(can(stranger, "see", hidden), false); + assert.equal(can(stranger, "see", hidden, { invited: true }), true); + assert.equal(can(member, "see", hidden), true); + assert.equal(can(owner, "see", hidden), true); + assert.equal(can(admin, "see", hidden), true); +}); +test("entering follows the access rule and never admits guests or blocked people", () => { + assert.equal(can(null, "enter", space("listed", "open")), false); + assert.equal(can(noProfile, "enter", space("listed", "open")), true); + assert.equal(can(noProfile, "enter", space("unlisted", "house")), false); + assert.equal(can(stranger, "enter", space("unlisted", "house")), true); + assert.equal(can(member, "enter", space("unlisted", "house")), true, "members may enter house spaces without a profile"); + assert.equal(can(stranger, "enter", space("unlisted", "members")), false); + assert.equal(can(stranger, "enter", space("unlisted", "members"), { invited: true }), true); + assert.equal(can(member, "enter", space("private", "members")), true); + assert.equal(can(stranger, "enter", space("listed", "open", { blocked: ["member:stranger"] })), false, "blocks match any of the person's IDs"); +}); +test("hosting powers belong to the present host or the present owner; editing belongs to the owner", () => { + const hosted = space("unlisted", "open", { hostId: member.id }); + for (const action of ["host", "decorate", "moderate"]) { + assert.equal(can(member, action, hosted, { present: true }), true, action); + assert.equal(can(member, action, hosted), false, action + " requires presence"); + assert.equal(can(owner, action, hosted, { present: true }), true, action + " owner"); + assert.equal(can(stranger, action, hosted, { present: true }), false, action + " stranger"); + } + for (const action of ["edit", "invite", "delete"]) { + assert.equal(can(owner, action, hosted), true, action); + assert.equal(can(member, action, hosted), false, action); + } + assert.equal(can(stranger, "chat", hosted, { present: true }), true); + assert.equal(can(stranger, "chat", hosted), false); + assert.equal(can(null, "speak", hosted, { present: true }), false); + assert.equal(can(admin, "delete", hosted), true); + assert.equal(can(owner, "fly", hosted), false); +}); From a162cfa59521b047f84ae4fff748502a54e0bd1a Mon Sep 17 00:00:00 2001 From: Brent G Date: Wed, 7 Oct 2026 18:08:04 +0000 Subject: [PATCH 06/71] Add the space model MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🤖 Built with SMT --- scripts/spaces/model.mjs | 50 ++++++++++++++++++++++++++++++++++++++ tests/spaces-core.test.mjs | 46 +++++++++++++++++++++++++++++++++++ 2 files changed, 96 insertions(+) create mode 100644 scripts/spaces/model.mjs diff --git a/scripts/spaces/model.mjs b/scripts/spaces/model.mjs new file mode 100644 index 0000000..dc64093 --- /dev/null +++ b/scripts/spaces/model.mjs @@ -0,0 +1,50 @@ +import { AppError } from "../store.mjs"; +import { validCombination } from "./permissions.mjs"; + +export const DECOR_KINDS = ["plant", "lamp", "rug", "sofa", "chair", "table", "whiteboard", "bookshelf", "screen", "banner", "poster", "statue", "fountain"]; +export const THEMES = ["agora", "minimal", "cyberpunk"]; +export const PLAZA_ID = "plaza"; +export const PLAZA_CAPACITY = 1000; +export const lotSpaceId = (slug) => "lot-" + slug; +const pick = (input, field, fallback) => input[field] === undefined ? fallback : input[field]; + +export function spaceInput(input, current = {}) { + const value = { title: pick(input, "title", current.title), purpose: pick(input, "purpose", current.purpose ?? ""), visibility: pick(input, "visibility", current.visibility ?? "unlisted"), access: pick(input, "access", current.access ?? "members"), capacity: pick(input, "capacity", current.capacity ?? 12), themeId: pick(input, "themeId", current.themeId ?? "agora") }; + if (typeof value.title !== "string" || value.title.trim().length < 2 || value.title.trim().length > 80) throw new AppError(422, "Add a title of 2 to 80 characters."); + if (typeof value.purpose !== "string" || value.purpose.trim().length > 300) throw new AppError(422, "Keep the purpose under 300 characters."); + if (!["unlisted", "private"].includes(value.visibility)) throw new AppError(422, "Your spaces are unlisted or private."); + if (!validCombination(value.visibility, value.access)) throw new AppError(422, "A private space admits members and invited people only."); + if (!Number.isInteger(value.capacity) || value.capacity < 2 || value.capacity > 50) throw new AppError(422, "Choose a capacity from 2 to 50."); + if (!THEMES.includes(value.themeId)) throw new AppError(422, "Choose an available theme."); + return { ...value, title: value.title.trim(), purpose: value.purpose.trim() }; +} + +export function normalizeTags(value = []) { + if (!Array.isArray(value) || value.length > 5 || value.some((tag) => typeof tag !== "string")) throw new AppError(422, "Add up to 5 tags."); + const tags = [...new Set(value.map((tag) => tag.trim().toLowerCase().replace(/\s+/g, " ")).filter(Boolean))]; + if (tags.some((tag) => tag.length > 30)) throw new AppError(422, "Keep each tag under 30 characters."); + return tags; +} + +const runtime = () => ({ invitations: [], blocked: [], channelEpoch: 0, hostId: null, topic: null, tags: [], decor: [], decorVersion: 0 }); + +export function newSpace(fields, { id, ownerId, now }) { + const at = new Date(now).toISOString(); + return { id, type: "stoa", worldId: "room", ...fields, ownerId, members: [ownerId], ...runtime(), createdAt: at, updatedAt: at }; +} + +export function systemSpaces(plazaMap, now, plazaCapacity = PLAZA_CAPACITY) { + const at = new Date(now).toISOString(); + const base = { type: "stoa", worldId: "plaza", themeId: "agora", visibility: "listed", access: "open", ownerId: null, members: [], ...runtime(), purpose: "", createdAt: at, updatedAt: at }; + return [{ ...base, id: PLAZA_ID, title: "The plaza", capacity: plazaCapacity }, ...plazaMap.lots.map((lot) => ({ ...base, id: lotSpaceId(lot.slug), slug: lot.slug, lot: { worldId: "plaza", lotId: lot.lotId }, title: lot.title, capacity: lot.capacity }))]; +} + +export function migratedRoom(room, now) { + const space = newSpace({ title: String(room.title || "Meeting room").slice(0, 80), purpose: String(room.intent || "").slice(0, 300), visibility: "unlisted", access: "members", capacity: 12, themeId: "agora" }, { id: room.id, ownerId: room.ownerId, now }); + return { ...space, members: [...new Set([room.ownerId, ...(room.participants || [])])], createdAt: room.createdAt || space.createdAt }; +} + +export function publicSpace(space, { occupants = [], manage = false } = {}) { + const { invitations, blocked, members, channelEpoch, ...rest } = space; + return { ...rest, occupancy: occupants.length, occupants, ...(manage ? { members, blocked, invitations: invitations.map(({ tokenHash, ...entry }) => entry) } : {}) }; +} diff --git a/tests/spaces-core.test.mjs b/tests/spaces-core.test.mjs index 77f349f..add9456 100644 --- a/tests/spaces-core.test.mjs +++ b/tests/spaces-core.test.mjs @@ -89,3 +89,49 @@ test("hosting powers belong to the present host or the present owner; editing be assert.equal(can(admin, "delete", hosted), true); assert.equal(can(owner, "fly", hosted), false); }); + +import { readFile } from "node:fs/promises"; +import { parseMap } from "../world/map.js"; +import { DECOR_KINDS, PLAZA_CAPACITY, PLAZA_ID, THEMES, lotSpaceId, migratedRoom, newSpace, normalizeTags, publicSpace, spaceInput, systemSpaces } from "../scripts/spaces/model.mjs"; + +const plazaMap = parseMap(JSON.parse(await readFile(new URL("../worlds/plaza/map.json", import.meta.url), "utf8"))); + +test("space input is validated with defaults and partial updates", () => { + assert.deepEqual(spaceInput({ title: " Demo night " }), { title: "Demo night", purpose: "", visibility: "unlisted", access: "members", capacity: 12, themeId: "agora" }); + const current = spaceInput({ title: "Team", visibility: "private", access: "members", capacity: 4, themeId: "cyberpunk" }); + assert.deepEqual(spaceInput({ capacity: 6 }, current), { ...current, capacity: 6 }); + for (const bad of [{ title: "x" }, { title: "Ok", visibility: "listed" }, { title: "Ok", visibility: "private", access: "open" }, { title: "Ok", capacity: 1 }, { title: "Ok", capacity: 51 }, { title: "Ok", capacity: 2.5 }, { title: "Ok", themeId: "neon" }, { title: "Ok", purpose: "p".repeat(301) }]) assert.throws(() => spaceInput(bad), { status: 422 }, JSON.stringify(bad)); +}); +test("tags are limited to five and normalized", () => { + assert.deepEqual(normalizeTags([" Voice AI ", "voice ai", "Rust"]), ["voice ai", "rust"]); + assert.deepEqual(normalizeTags(), []); + assert.throws(() => normalizeTags(["a", "b", "c", "d", "e", "f"]), { status: 422 }); + assert.throws(() => normalizeTags(["t".repeat(31)]), { status: 422 }); + assert.throws(() => normalizeTags("rust"), { status: 422 }); +}); +test("system spaces come from the plaza map and lots are listed, open, and FCFS", () => { + assert.equal(DECOR_KINDS.length, 13); + assert.deepEqual(THEMES, ["agora", "minimal", "cyberpunk"]); + const [plaza, ...lots] = systemSpaces(plazaMap, 0); + assert.equal(plaza.id, PLAZA_ID); + assert.equal(plaza.capacity, PLAZA_CAPACITY); + assert.equal(systemSpaces(plazaMap, 0, 2)[0].capacity, 2); + assert.equal(lots.length, 6); + assert.deepEqual(lots[0], { ...lots[0], id: lotSpaceId("ai-agents"), slug: "ai-agents", lot: { worldId: "plaza", lotId: "agents" }, visibility: "listed", access: "open", ownerId: null, capacity: 8, worldId: "plaza", topic: null, tags: [], decor: [] }); +}); +test("legacy meeting rooms become unlisted members-only spaces with their rosters", () => { + const room = { id: "fd616fdb-aa48-4c67-bd26-222222222222", title: "Demo", intent: "Plan the demo", ownerId: "member:a", participants: ["member:a", "member:b"], createdAt: "2026-01-01T00:00:00.000Z" }; + const space = migratedRoom(room, 0); + assert.deepEqual([space.id, space.visibility, space.access, space.ownerId, space.purpose, space.createdAt], [room.id, "unlisted", "members", "member:a", "Plan the demo", room.createdAt]); + assert.deepEqual(space.members, ["member:a", "member:b"]); +}); +test("public spaces hide invitation hashes and the channel epoch", () => { + const space = { ...newSpace(spaceInput({ title: "Team" }), { id: "s1", ownerId: "account:o", now: 0 }), invitations: [{ id: "i1", tokenHash: "secret", usesLeft: 1, expiresAt: 1 }], channelEpoch: 3 }; + const outside = publicSpace(space, { occupants: [{ id: "account:o", name: "O" }] }); + assert.equal(outside.occupancy, 1); + for (const field of ["invitations", "members", "blocked", "channelEpoch"]) assert.equal(field in outside, false, field); + const managed = publicSpace(space, { manage: true }); + assert.deepEqual(managed.invitations, [{ id: "i1", usesLeft: 1, expiresAt: 1 }]); + assert.deepEqual(managed.members, ["account:o"]); + assert.equal(JSON.stringify(managed).includes("secret"), false); +}); From 6f3825bcf17bcee0cef3ae7e0b15082680551cfa Mon Sep 17 00:00:00 2001 From: Brent G Date: Wed, 7 Oct 2026 18:10:15 +0000 Subject: [PATCH 07/71] Fix array instance sharing in systemSpaces MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🤖 Built with SMT --- scripts/spaces/model.mjs | 4 ++-- tests/spaces-core.test.mjs | 9 +++++++++ 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/scripts/spaces/model.mjs b/scripts/spaces/model.mjs index dc64093..ccea61c 100644 --- a/scripts/spaces/model.mjs +++ b/scripts/spaces/model.mjs @@ -35,8 +35,8 @@ export function newSpace(fields, { id, ownerId, now }) { export function systemSpaces(plazaMap, now, plazaCapacity = PLAZA_CAPACITY) { const at = new Date(now).toISOString(); - const base = { type: "stoa", worldId: "plaza", themeId: "agora", visibility: "listed", access: "open", ownerId: null, members: [], ...runtime(), purpose: "", createdAt: at, updatedAt: at }; - return [{ ...base, id: PLAZA_ID, title: "The plaza", capacity: plazaCapacity }, ...plazaMap.lots.map((lot) => ({ ...base, id: lotSpaceId(lot.slug), slug: lot.slug, lot: { worldId: "plaza", lotId: lot.lotId }, title: lot.title, capacity: lot.capacity }))]; + const makeBase = () => ({ type: "stoa", worldId: "plaza", themeId: "agora", visibility: "listed", access: "open", ownerId: null, members: [], ...runtime(), purpose: "", createdAt: at, updatedAt: at }); + return [{ ...makeBase(), id: PLAZA_ID, title: "The plaza", capacity: plazaCapacity }, ...plazaMap.lots.map((lot) => ({ ...makeBase(), id: lotSpaceId(lot.slug), slug: lot.slug, lot: { worldId: "plaza", lotId: lot.lotId }, title: lot.title, capacity: lot.capacity }))]; } export function migratedRoom(room, now) { diff --git a/tests/spaces-core.test.mjs b/tests/spaces-core.test.mjs index add9456..726ee44 100644 --- a/tests/spaces-core.test.mjs +++ b/tests/spaces-core.test.mjs @@ -135,3 +135,12 @@ test("public spaces hide invitation hashes and the channel epoch", () => { assert.deepEqual(managed.members, ["account:o"]); assert.equal(JSON.stringify(managed).includes("secret"), false); }); +test("system spaces have independent array instances", () => { + const [plaza, ...lots] = systemSpaces(plazaMap, 0); + plaza.decor.push("lamp"); + plaza.members.push("account:test"); + assert.deepEqual(lots[0].decor, []); + assert.deepEqual(lots[0].members, []); + assert.notEqual(plaza.decor, lots[0].decor); + assert.notEqual(plaza.members, lots[0].members); +}); From 54b0acb5b7a3996794f482d4c83103691904ef5c Mon Sep 17 00:00:00 2001 From: Brent G Date: Wed, 7 Oct 2026 18:12:43 +0000 Subject: [PATCH 08/71] Add entry leases, capacity, and hosting MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🤖 Built with SMT --- scripts/spaces/leases.mjs | 62 ++++++++++++++++++++++++++++++++++++++ tests/spaces-core.test.mjs | 62 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 124 insertions(+) create mode 100644 scripts/spaces/leases.mjs diff --git a/scripts/spaces/leases.mjs b/scripts/spaces/leases.mjs new file mode 100644 index 0000000..b328975 --- /dev/null +++ b/scripts/spaces/leases.mjs @@ -0,0 +1,62 @@ +import { AppError } from "../store.mjs"; +import { PLAZA_ID } from "./model.mjs"; + +// Leases count who is inside a space; capacity and hosting are decided only from active leases. +export const LEASE_MS = 60000; +const mine = (lease, id) => lease.actorId === id || lease.ids.includes(id); +export const leasesFor = (state, spaceId, now) => state.spaceLeases.filter((lease) => lease.spaceId === spaceId && lease.expiresAt > now); +export const leaseOf = (state, spaceId, actorId, now) => (actorId && leasesFor(state, spaceId, now).find((lease) => mine(lease, actorId))) || null; + +function settle(state, space, now, events) { + const present = leasesFor(state, space.id, now).sort((a, b) => a.enteredAt - b.enteredAt); + if (space.id !== PLAZA_ID) { + const keep = space.hostId && present.some((lease) => mine(lease, space.hostId)); + const next = keep ? space.hostId : (present.find((lease) => lease.ids.includes(space.ownerId)) || present[0])?.actorId || null; + if (next !== space.hostId) { space.hostId = next; events.push({ type: "host", spaceId: space.id, hostId: next }); } + } + if (!present.length && space.lot) { Object.assign(space, { topic: null, tags: [], decor: [], blocked: [] }); events.push({ type: "emptied", spaceId: space.id }); } +} + +export function enter(state, space, actor, now) { + const events = []; + const current = leaseOf(state, space.id, actor.id, now); + if (current) { current.expiresAt = now + LEASE_MS; return { lease: current, firstIn: false, events }; } + const present = leasesFor(state, space.id, now); + if (present.length >= space.capacity) throw new AppError(409, "This space is full right now."); + const lease = { spaceId: space.id, actorId: actor.id, ids: actor.ids, name: actor.name, enteredAt: now, expiresAt: now + LEASE_MS }; + state.spaceLeases.push(lease); + events.push({ type: "entered", spaceId: space.id, actorId: actor.id }); + const hostPresent = space.hostId && present.some((entry) => mine(entry, space.hostId)); + if (space.id !== PLAZA_ID && (actor.ids.includes(space.ownerId) || !hostPresent) && space.hostId !== actor.id) { space.hostId = actor.id; events.push({ type: "host", spaceId: space.id, hostId: actor.id }); } + return { lease, firstIn: present.length === 0, events }; +} + +export function leave(state, space, actorId, now) { + const events = []; + const gone = state.spaceLeases.filter((lease) => lease.spaceId === space.id && mine(lease, actorId)); + if (!gone.length) return events; + state.spaceLeases = state.spaceLeases.filter((lease) => !gone.includes(lease)); + for (const lease of gone) events.push({ type: "left", spaceId: space.id, actorId: lease.actorId }); + settle(state, space, now, events); + return events; +} + +export function heartbeat(state, space, actorId, now) { + const lease = leaseOf(state, space.id, actorId, now); + if (!lease) throw new AppError(410, "You are no longer in this space."); + lease.expiresAt = now + LEASE_MS; + return lease; +} + +export function sweep(state, now) { + const events = []; + const expired = state.spaceLeases.filter((lease) => lease.expiresAt <= now); + if (!expired.length) return events; + state.spaceLeases = state.spaceLeases.filter((lease) => lease.expiresAt > now); + for (const lease of expired) events.push({ type: "left", spaceId: lease.spaceId, actorId: lease.actorId }); + for (const id of new Set(expired.map((lease) => lease.spaceId))) { + const space = state.spaces.find((entry) => entry.id === id); + if (space) settle(state, space, now, events); + } + return events; +} diff --git a/tests/spaces-core.test.mjs b/tests/spaces-core.test.mjs index 726ee44..e707a8f 100644 --- a/tests/spaces-core.test.mjs +++ b/tests/spaces-core.test.mjs @@ -144,3 +144,65 @@ test("system spaces have independent array instances", () => { assert.notEqual(plaza.decor, lots[0].decor); assert.notEqual(plaza.members, lots[0].members); }); + +import { LEASE_MS, enter, heartbeat, leaseOf, leasesFor, leave, sweep } from "../scripts/spaces/leases.mjs"; + +const walker = (id) => ({ id: "account:" + id, ids: ["account:" + id, "member:" + id], name: id.toUpperCase() }); +const world = () => { const state = { spaces: systemSpaces(plazaMap, 0), spaceLeases: [] }; return { state, plaza: state.spaces[0], lot: state.spaces.find((space) => space.id === "lot-rtc-lab") }; }; + +test("the first person in hosts, capacity holds, and one person holds one lease", () => { + const { state, lot } = world(); + const first = enter(state, lot, walker("a"), 0); + assert.equal(first.firstIn, true); + assert.equal(lot.hostId, "account:a"); + assert.deepEqual(first.events, [{ type: "entered", spaceId: lot.id, actorId: "account:a" }, { type: "host", spaceId: lot.id, hostId: "account:a" }]); + const again = enter(state, lot, walker("a"), 5000); + assert.equal(again.firstIn, false); + assert.equal(leasesFor(state, lot.id, 5000).length, 1); + assert.equal(again.lease.expiresAt, 5000 + LEASE_MS); + assert.equal(leaseOf(state, lot.id, "member:a", 5000), again.lease, "leases match any of the person's IDs"); + for (const id of ["b", "c", "d", "e", "f"]) assert.equal(enter(state, lot, walker(id), 6000).firstIn, false); + assert.equal(lot.hostId, "account:a"); + assert.throws(() => enter(state, lot, walker("g"), 7000), { status: 409, message: "This space is full right now." }); +}); +test("hosting passes to the longest-present person, and the owner hosts whenever present", () => { + const { state } = world(); + const space = newSpace(spaceInput({ title: "Team", access: "open" }), { id: "s1", ownerId: "account:o", now: 0 }); + state.spaces.push(space); + enter(state, space, walker("a"), 0); + enter(state, space, walker("b"), 1000); + enter(state, space, walker("o"), 2000); + assert.equal(space.hostId, "account:o"); + assert.deepEqual(leave(state, space, "account:o", 3000), [{ type: "left", spaceId: "s1", actorId: "account:o" }, { type: "host", spaceId: "s1", hostId: "account:a" }]); + leave(state, space, "account:a", 4000); + assert.equal(space.hostId, "account:b"); + assert.deepEqual(leave(state, space, "account:nobody", 5000), []); +}); +test("an emptied lot clears its session, but a user space keeps its own", () => { + const { state, lot } = world(); + enter(state, lot, walker("a"), 0); + Object.assign(lot, { topic: "Demos", tags: ["rtc"], decor: [{ id: "p", kind: "plant", x: 1, y: 1 }], blocked: ["account:z"] }); + const events = leave(state, lot, "account:a", 1000); + assert.ok(events.some((event) => event.type === "emptied")); + assert.deepEqual([lot.topic, lot.tags, lot.decor, lot.blocked, lot.hostId], [null, [], [], [], null]); + const space = { ...newSpace(spaceInput({ title: "Team", access: "open" }), { id: "s2", ownerId: "account:o", now: 0 }), topic: "Keep" }; + state.spaces.push(space); + enter(state, space, walker("a"), 0); + leave(state, space, "account:a", 1000); + assert.equal(space.topic, "Keep"); +}); +test("expired leases are swept, freeing seats and handing over the host", () => { + const { state, lot } = world(); + enter(state, lot, walker("a"), 0); + enter(state, lot, walker("b"), 10000); + assert.deepEqual(sweep(state, 30000), []); + const events = sweep(state, LEASE_MS + 1); + assert.deepEqual(events, [{ type: "left", spaceId: lot.id, actorId: "account:a" }, { type: "host", spaceId: lot.id, hostId: "account:b" }]); + assert.equal(heartbeat(state, lot, "account:b", 65000).expiresAt, 65000 + LEASE_MS); + assert.throws(() => heartbeat(state, lot, "account:a", 65000), { status: 410 }); +}); +test("the plaza never has a host", () => { + const { state, plaza } = world(); + assert.deepEqual(enter(state, plaza, walker("a"), 0).events, [{ type: "entered", spaceId: "plaza", actorId: "account:a" }]); + assert.equal(plaza.hostId, null); +}); From 3c0d73b26b55422c692f659fb2bf55ffa330256b Mon Sep 17 00:00:00 2001 From: Brent G Date: Wed, 7 Oct 2026 18:15:36 +0000 Subject: [PATCH 09/71] Add decoration validation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🤖 Built with SMT --- scripts/spaces/decor.mjs | 38 ++++++++++++++++++++++++++++++++++++++ tests/spaces-core.test.mjs | 38 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 76 insertions(+) create mode 100644 scripts/spaces/decor.mjs diff --git a/scripts/spaces/decor.mjs b/scripts/spaces/decor.mjs new file mode 100644 index 0000000..66507d1 --- /dev/null +++ b/scripts/spaces/decor.mjs @@ -0,0 +1,38 @@ +import { AppError } from "../store.mjs"; +import { reachable, roleAt, walkable } from "../../world/map.js"; +import { DECOR_KINDS } from "./model.mjs"; + +// Decorations are semantic kinds; the theme draws them. Blocking kinds must never cut people off. +export const BLOCKING_DECOR = new Set(["sofa", "table", "whiteboard", "bookshelf", "statue", "fountain"]); +export const DECOR_LIMIT = 60; +const tilesIn = (map, area, role) => { const tiles = []; for (let y = area.y; y < area.y + area.height; y += 1) for (let x = area.x; x < area.x + area.width; x += 1) if (roleAt(map, x, y) === role) tiles.push({ x, y }); return tiles; }; + +function region(map, space) { + if (!space.lot) { + const area = { x: 0, y: 0, width: map.width, height: map.height }; + return { area, start: map.spawns[0], protect: [...map.spawns, ...tilesIn(map, area, "door")] }; + } + const lot = map.lots.find((entry) => entry.lotId === space.lot.lotId); + return { area: lot.interior, start: lot.entry, protect: [lot.entry, ...tilesIn(map, lot.interior, "door")] }; +} + +export function validateDecor(items, map, space) { + if (!Array.isArray(items) || items.length > DECOR_LIMIT) throw new AppError(422, "Place up to 60 decorations."); + const { area, start, protect } = region(map, space); + const protectedTiles = new Set(protect.map(({ x, y }) => x + "," + y)); + const used = new Set(); + const clean = items.map((item) => { + const { id, kind, x, y, rotation = 0, variant = 0 } = item && typeof item === "object" ? item : {}; + if (typeof id !== "string" || !/^[A-Za-z0-9_-]{1,40}$/.test(id)) throw new AppError(422, "Each decoration needs a short ID."); + if (!DECOR_KINDS.includes(kind)) throw new AppError(422, "Choose a known decoration."); + if (![0, 90, 180, 270].includes(rotation) || !Number.isInteger(variant) || variant < 0 || variant > 7) throw new AppError(422, "This decoration's rotation or style is not available."); + if (!walkable(map, x, y) || x < area.x || y < area.y || x >= area.x + area.width || y >= area.y + area.height) throw new AppError(422, "Place decorations on open floor inside the room."); + if (protectedTiles.has(x + "," + y)) throw new AppError(422, "Keep doors and entrances clear."); + if (used.has(x + "," + y)) throw new AppError(422, "Only one decoration fits on each tile."); + used.add(x + "," + y); + return { id, kind, x, y, rotation, variant }; + }); + const open = reachable(map, start, new Set(clean.filter((item) => BLOCKING_DECOR.has(item.kind)).map((item) => item.x + "," + item.y))); + if ([...protect, ...tilesIn(map, area, "seat")].some(({ x, y }) => !open.has(x + "," + y))) throw new AppError(422, "Keep every door, entrance, and seat reachable."); + return clean; +} diff --git a/tests/spaces-core.test.mjs b/tests/spaces-core.test.mjs index e707a8f..8eb6144 100644 --- a/tests/spaces-core.test.mjs +++ b/tests/spaces-core.test.mjs @@ -206,3 +206,41 @@ test("the plaza never has a host", () => { assert.deepEqual(enter(state, plaza, walker("a"), 0).events, [{ type: "entered", spaceId: "plaza", actorId: "account:a" }]); assert.equal(plaza.hostId, null); }); + +import { BLOCKING_DECOR, DECOR_LIMIT, validateDecor } from "../scripts/spaces/decor.mjs"; + +const roomMap = parseMap(JSON.parse(await readFile(new URL("../worlds/room/map.json", import.meta.url), "utf8"))); +const userSpace = { id: "s1" }; +const lotSpace = { id: "lot-rtc-lab", lot: { worldId: "plaza", lotId: "rtc" } }; +const rejects = (items, map, space, message) => assert.throws(() => validateDecor(items, map, space), { status: 422, message }); + +test("valid decorations are normalized", () => { + assert.equal(DECOR_LIMIT, 60); + assert.ok(BLOCKING_DECOR.has("sofa") && !BLOCKING_DECOR.has("plant")); + assert.deepEqual(validateDecor([{ id: "p1", kind: "plant", x: 3, y: 3 }, { id: "s1", kind: "sofa", x: 4, y: 3, rotation: 90, variant: 2, extra: "ignored" }], roomMap, userSpace), [{ id: "p1", kind: "plant", x: 3, y: 3, rotation: 0, variant: 0 }, { id: "s1", kind: "sofa", x: 4, y: 3, rotation: 90, variant: 2 }]); + assert.deepEqual(validateDecor([], roomMap, userSpace), []); +}); +test("decorations must be known, well-formed, on open floor, and inside the room", () => { + rejects(new Array(61).fill(0).map((_, index) => ({ id: "d" + index, kind: "plant", x: 2 + (index % 16), y: 2 + Math.floor(index / 16) })), roomMap, userSpace, "Place up to 60 decorations."); + rejects("plant", roomMap, userSpace, "Place up to 60 decorations."); + rejects([{ id: "bad id!", kind: "plant", x: 3, y: 3 }], roomMap, userSpace, "Each decoration needs a short ID."); + rejects([{ id: "d", kind: "dragon", x: 3, y: 3 }], roomMap, userSpace, "Choose a known decoration."); + rejects([{ id: "d", kind: "plant", x: 3, y: 3, rotation: 45 }], roomMap, userSpace, "This decoration's rotation or style is not available."); + rejects([{ id: "d", kind: "plant", x: 3, y: 3, variant: 8 }], roomMap, userSpace, "This decoration's rotation or style is not available."); + rejects([{ id: "d", kind: "plant", x: 0, y: 3 }], roomMap, userSpace, "Place decorations on open floor inside the room."); + rejects([{ id: "d", kind: "plant", x: 9, y: 6 }], roomMap, userSpace, "Place decorations on open floor inside the room."); + rejects([{ id: "d", kind: "plant", x: 9, y: 11 }], roomMap, userSpace, "Keep doors and entrances clear."); + rejects([{ id: "a", kind: "plant", x: 3, y: 3 }, { id: "b", kind: "lamp", x: 3, y: 3 }], roomMap, userSpace, "Only one decoration fits on each tile."); +}); +test("blocking decorations may not cut off seats, doors, or entrances", () => { + const walls = [[8, 11], [11, 11], [9, 10], [10, 10], [9, 12], [10, 12]].map(([x, y], index) => ({ id: "w" + index, kind: "statue", x, y })); + rejects(walls, roomMap, userSpace, "Keep every door, entrance, and seat reachable."); + assert.equal(validateDecor(walls.map((item) => ({ ...item, kind: "plant" })), roomMap, userSpace).length, 6, "non-blocking decorations never cut anything off"); + rejects([{ id: "d", kind: "sofa", x: 8, y: 4 }], roomMap, userSpace, "Keep every door, entrance, and seat reachable."); +}); +test("lot decorations stay inside the lot's interior and keep its doors clear", () => { + assert.equal(validateDecor([{ id: "p", kind: "plant", x: 15, y: 22 }], plazaMap, lotSpace).length, 1); + rejects([{ id: "p", kind: "plant", x: 22, y: 22 }], plazaMap, lotSpace, "Place decorations on open floor inside the room."); + rejects([{ id: "p", kind: "plant", x: 17, y: 31 }], plazaMap, lotSpace, "Keep doors and entrances clear."); + rejects([{ id: "p", kind: "plant", x: 17, y: 32 }], plazaMap, lotSpace, "Keep doors and entrances clear."); +}); From 32242180fb89d3b3dde81b5522322a0d4f12915e Mon Sep 17 00:00:00 2001 From: Brent G Date: Wed, 7 Oct 2026 18:18:18 +0000 Subject: [PATCH 10/71] Add Signaling channel names, space keys, and tokens MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🤖 Built with SMT --- scripts/spaces/signaling.mjs | 34 ++++++++++++++++++++++++++ tests/spaces-core.test.mjs | 46 ++++++++++++++++++++++++++++++++++++ 2 files changed, 80 insertions(+) create mode 100644 scripts/spaces/signaling.mjs diff --git a/scripts/spaces/signaling.mjs b/scripts/spaces/signaling.mjs new file mode 100644 index 0000000..a473ad4 --- /dev/null +++ b/scripts/spaces/signaling.mjs @@ -0,0 +1,34 @@ +import { createHmac, randomBytes } from "node:crypto"; +import agoraToken from "agora-token"; +import { AppError } from "../store.mjs"; + +// Signaling tokens grant login only. Isolation comes from secret-derived channel names and per-space keys; +// AGORA_RTM_PERMISSIONS=true adds Agora's per-channel permissions on top when Agora enables them. +export function signalingConfig(env = process.env) { + const appId = env.AGORA_APP_ID || "", certificate = env.AGORA_APP_CERTIFICATE || "", secret = env.SPACE_CHANNEL_SECRET || ""; + return { appId, certificate, secret, permissions: env.AGORA_RTM_PERMISSIONS === "true", ttl: 900, ready: /^[a-f0-9]{32}$/i.test(appId) && /^[a-f0-9]{32}$/i.test(certificate) && secret.length >= 32 }; +} + +const mac = (secret, value) => createHmac("sha256", secret).update(value).digest(); +export const channelName = (secret, space) => "ab-" + space.type + "-" + (space.visibility === "listed" ? space.slug || space.id : mac(secret, "channel:" + space.id + ":" + (space.channelEpoch || 0)).toString("hex").slice(0, 24)); +export const spaceKey = (secret, space) => space.visibility === "listed" ? null : mac(secret, "key:" + space.id + ":" + (space.channelEpoch || 0)).toString("base64"); +export const signalingUser = (actor) => actor ? actor.id.replace(/^account:/, "a-").replace(/^member:/, "m-") : "g-" + randomBytes(8).toString("hex"); + +export function createSignaling(config = signalingConfig(), { now = Date.now } = {}) { + const { RtmTokenBuilder, Rtm2Permissions } = agoraToken; + return { + ready: config.ready, + issue(userId, channels) { + if (!config.ready) throw new AppError(503, "Live movement and messages are not connected yet."); + let token; + if (config.permissions) { + const permissions = new Rtm2Permissions(); + permissions.add(Rtm2Permissions.kMessageChannels, Rtm2Permissions.kRead, channels.map((channel) => channel.name)); + const writable = channels.filter((channel) => channel.write).map((channel) => channel.name); + if (writable.length) permissions.add(Rtm2Permissions.kMessageChannels, Rtm2Permissions.kWrite, writable); + token = RtmTokenBuilder.buildTokenWithPermissions(config.appId, config.certificate, userId, permissions, config.ttl); + } else token = RtmTokenBuilder.buildToken(config.appId, config.certificate, userId, config.ttl); + return { appId: config.appId, userId, token, expiresAt: new Date(now() + config.ttl * 1000).toISOString() }; + } + }; +} diff --git a/tests/spaces-core.test.mjs b/tests/spaces-core.test.mjs index 8eb6144..a10eca1 100644 --- a/tests/spaces-core.test.mjs +++ b/tests/spaces-core.test.mjs @@ -244,3 +244,49 @@ test("lot decorations stay inside the lot's interior and keep its doors clear", rejects([{ id: "p", kind: "plant", x: 17, y: 31 }], plazaMap, lotSpace, "Keep doors and entrances clear."); rejects([{ id: "p", kind: "plant", x: 17, y: 32 }], plazaMap, lotSpace, "Keep doors and entrances clear."); }); + +import tokenTypes from "agora-token/src/AccessToken2.js"; +import { channelName, createSignaling, signalingConfig, signalingUser, spaceKey } from "../scripts/spaces/signaling.mjs"; + +const signalingEnv = { AGORA_APP_ID: "a".repeat(32), AGORA_APP_CERTIFICATE: "b".repeat(32), SPACE_CHANNEL_SECRET: "s".repeat(32) }; +const parse = (token) => { const parsed = new tokenTypes.AccessToken2(); assert.equal(parsed.from_string(token), true); assert.equal(parsed.verifySignature("b".repeat(32)), true); return parsed; }; + +test("Signaling needs Agora credentials and a long channel secret", () => { + const config = signalingConfig(signalingEnv); + assert.deepEqual([config.ready, config.ttl, config.permissions], [true, 900, false]); + assert.equal(signalingConfig({ ...signalingEnv, SPACE_CHANNEL_SECRET: "short" }).ready, false); + assert.equal(signalingConfig({}).ready, false); + assert.equal(signalingConfig({ ...signalingEnv, AGORA_RTM_PERMISSIONS: "true" }).permissions, true); + assert.throws(() => createSignaling(signalingConfig({})).issue("a-x", []), { status: 503 }); +}); +test("listed channels are public names; unlisted and private names and keys need the secret and change with the epoch", () => { + const secret = "s".repeat(32); + assert.equal(channelName(secret, { id: "plaza", type: "stoa", visibility: "listed" }), "ab-stoa-plaza"); + assert.equal(channelName(secret, { id: "lot-ai-agents", slug: "ai-agents", type: "stoa", visibility: "listed" }), "ab-stoa-ai-agents"); + assert.equal(spaceKey(secret, { id: "plaza", type: "stoa", visibility: "listed" }), null); + const hidden = { id: "8f9c4d02-0d56-4c79-9a39-111111111111", type: "stoa", visibility: "private", channelEpoch: 0 }; + const name = channelName(secret, hidden); + assert.match(name, /^ab-stoa-[a-f0-9]{24}$/); + assert.equal(name.includes(hidden.id), false); + assert.notEqual(channelName(secret, { ...hidden, channelEpoch: 1 }), name); + assert.notEqual(channelName("t".repeat(32), hidden), name); + const key = spaceKey(secret, hidden); + assert.equal(Buffer.from(key, "base64").length, 32); + assert.notEqual(spaceKey(secret, { ...hidden, channelEpoch: 1 }), key); +}); +test("Signaling user IDs come from the account, the profile, or a random guest ID", () => { + assert.equal(signalingUser({ id: "account:72a639ba-3a45-4afe-936b-111111111111" }), "a-72a639ba-3a45-4afe-936b-111111111111"); + assert.equal(signalingUser({ id: "member:72a639ba-3a45-4afe-936b-111111111111" }), "m-72a639ba-3a45-4afe-936b-111111111111"); + assert.match(signalingUser(null), /^g-[a-f0-9]{16}$/); + assert.notEqual(signalingUser(null), signalingUser(null)); +}); +test("Signaling tokens are signed for the user and add channel permissions only when enabled", () => { + const login = createSignaling(signalingConfig(signalingEnv), { now: () => 1700000000000 }).issue("a-x", [{ name: "ab-stoa-plaza", write: true }]); + assert.deepEqual([login.appId, login.userId, login.expiresAt], ["a".repeat(32), "a-x", new Date(1700000000000 + 900000).toISOString()]); + const parsed = parse(login.token); + assert.equal(parsed.expire, 900); + assert.equal(parsed.getServices(tokenTypes.kRtmServiceType)[0].__user_id.toString(), "a-x"); + const scoped = createSignaling(signalingConfig({ ...signalingEnv, AGORA_RTM_PERMISSIONS: "true" })).issue("g-1", [{ name: "ab-stoa-plaza", write: false }, { name: "ab-stoa-ai-agents", write: true }]); + const service = parse(scoped.token).getServices(tokenTypes.kRtm2ServiceType)[0]; + assert.deepEqual(service.__permissions.details, { 0: { 0: ["ab-stoa-plaza", "ab-stoa-ai-agents"], 1: ["ab-stoa-ai-agents"] } }); +}); From 03a6719c5db021cd68bbdc256e2f3ed968101403 Mon Sep 17 00:00:00 2001 From: Brent G Date: Wed, 7 Oct 2026 18:20:22 +0000 Subject: [PATCH 11/71] Issue RTC tokens for spaces MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🤖 Built with SMT --- scripts/calls.mjs | 22 +++++++++++++--------- tests/calls.test.mjs | 11 +++++++++++ 2 files changed, 24 insertions(+), 9 deletions(-) diff --git a/scripts/calls.mjs b/scripts/calls.mjs index e7513de..77c708f 100644 --- a/scripts/calls.mjs +++ b/scripts/calls.mjs @@ -10,19 +10,23 @@ export function agoraConfig(env = process.env) { } export function createAgoraCalls(config = agoraConfig(), { now = Date.now } = {}) { + function credentials(channel, prefix, input) { + if (!config.ready) throw new AppError(503, "Agora calls are not connected yet."); + // Each tab gets its own identity; renewals can only reuse this person's IDs. + const uid = input.uid === undefined ? prefix + randomBytes(6).toString("hex") : input.uid; + if (typeof uid !== "string" || !uid.startsWith(prefix) || !/^[a-f0-9]{12}$/.test(uid.slice(prefix.length))) throw new AppError(422, "This call identity does not belong to your profile."); + const screenUid = uid + "_screen"; + const sign = (account) => agoraToken.RtcTokenBuilder.buildTokenWithUserAccount(config.appId, config.certificate, channel, account, agoraToken.RtcRole.PUBLISHER, config.ttl, config.ttl); + return { provider: "agora", appId: config.appId, channel, uid, screenUid, token: sign(uid), screenToken: sign(screenUid), expiresAt: new Date(now() + config.ttl * 1000).toISOString() }; + } return { ready: config.ready, issue(room, person, input = {}) { if (!room.participants.includes(person.id)) throw new AppError(403, "Join this room's roster before entering the call."); - if (!config.ready) throw new AppError(503, "Agora calls are not connected yet."); - const prefix = person.id.replace(/^member:/, "") + "_"; - // Each tab gets its own identity; renewals can only reuse this member's IDs. - const uid = input.uid === undefined ? prefix + randomBytes(6).toString("hex") : input.uid; - if (typeof uid !== "string" || !uid.startsWith(prefix) || !/^[a-f0-9]{12}$/.test(uid.slice(prefix.length))) throw new AppError(422, "This call identity does not belong to your profile."); - const channel = "agora-build-" + room.id; - const screenUid = uid + "_screen"; - const sign = (account) => agoraToken.RtcTokenBuilder.buildTokenWithUserAccount(config.appId, config.certificate, channel, account, agoraToken.RtcRole.PUBLISHER, config.ttl, config.ttl); - return { provider: "agora", appId: config.appId, channel, uid, screenUid, token: sign(uid), screenToken: sign(screenUid), expiresAt: new Date(now() + config.ttl * 1000).toISOString() }; + return credentials("agora-build-" + room.id, person.id.replace(/^member:/, "") + "_", input); + }, + issueSpace(space, actor, input = {}) { + return credentials("agora-build-space-" + space.id, actor.id.replace(/^(account|member):/, "") + "_", input); } }; } diff --git a/tests/calls.test.mjs b/tests/calls.test.mjs index 7e4d853..7b71467 100644 --- a/tests/calls.test.mjs +++ b/tests/calls.test.mjs @@ -43,3 +43,14 @@ test("renewals reject other members, screen identities, and malformed identifier assert.throws(() => calls.issue(room, { id: "member:stranger" }), { status: 403 }); assert.throws(() => createAgoraCalls(agoraConfig({})).issue(room, person), { status: 503 }); }); +test("space calls use the space channel and an identity derived from the account", () => { + const calls = createAgoraCalls(config); + const actor = { id: "account:72a639ba-3a45-4afe-936b-333333333333" }; + const credentials = calls.issueSpace({ id: "lot-ai-agents" }, actor); + assert.equal(credentials.channel, "agora-build-space-lot-ai-agents"); + assert.match(credentials.uid, /^72a639ba-3a45-4afe-936b-333333333333_[a-f0-9]{12}$/); + assert.equal(credentials.screenUid, credentials.uid + "_screen"); + assert.equal(calls.issueSpace({ id: "lot-ai-agents" }, actor, { uid: credentials.uid }).uid, credentials.uid); + assert.throws(() => calls.issueSpace({ id: "lot-ai-agents" }, actor, { uid: "someone_123456789abc" }), { status: 422 }); + assert.throws(() => createAgoraCalls(agoraConfig({})).issueSpace({ id: "lot-ai-agents" }, actor), { status: 503 }); +}); From 970ccba5ca29edf094c07dfff488208294b99a87 Mon Sep 17 00:00:00 2001 From: Brent G Date: Wed, 7 Oct 2026 18:21:52 +0000 Subject: [PATCH 12/71] Add the spaces service MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🤖 Built with SMT --- scripts/spaces/service.mjs | 261 ++++++++++++++++++++++++++++++++++ tests/spaces-service.test.mjs | 212 +++++++++++++++++++++++++++ 2 files changed, 473 insertions(+) create mode 100644 scripts/spaces/service.mjs create mode 100644 tests/spaces-service.test.mjs diff --git a/scripts/spaces/service.mjs b/scripts/spaces/service.mjs new file mode 100644 index 0000000..08fcc32 --- /dev/null +++ b/scripts/spaces/service.mjs @@ -0,0 +1,261 @@ +import { createHash, randomBytes, randomUUID } from "node:crypto"; +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; +import { AppError } from "../store.mjs"; +import { entitlements } from "../plans.mjs"; +import { parseMap } from "../../world/map.js"; +import { can } from "./permissions.mjs"; +import { PLAZA_ID, migratedRoom, newSpace, normalizeTags, publicSpace, spaceInput, systemSpaces } from "./model.mjs"; +import { enter as enterLease, heartbeat as heartbeatLease, leaseOf, leasesFor, leave as leaveLease, sweep as sweepLeases } from "./leases.mjs"; +import { validateDecor } from "./decor.mjs"; +import { channelName, signalingUser, spaceKey } from "./signaling.mjs"; + +const hash = (value) => createHash("sha256").update(value).digest("hex"); +const ACTOR_ID = /^(member|account):[a-f0-9-]{36}$/; +const notFound = () => new AppError(404, "This space was not found."); + +export function loadWorlds(directory) { + return Object.fromEntries(["plaza", "room"].map((id) => { + const source = JSON.parse(readFileSync(resolve(directory, "worlds", id, "map.json"), "utf8")); + return [id, { source, map: parseMap(source) }]; + })); +} + +export function createSpaces({ store, worlds, signaling, calls, secret = "", admins = [], now = Date.now, plazaCapacity }) { + const listeners = []; + let prepared; + // System spaces follow the plaza map; legacy meeting rooms become unlisted members-only spaces once. + const ready = () => prepared ||= store.transaction((state) => { + for (const system of systemSpaces(worlds.plaza.map, now(), plazaCapacity)) { + const existing = state.spaces.find((space) => space.id === system.id); + if (!existing) state.spaces.push(system); + else Object.assign(existing, { title: system.title, capacity: system.capacity, slug: system.slug, lot: system.lot }); + } + for (const room of state.rooms) if (!state.spaces.some((space) => space.id === room.id)) state.spaces.push(migratedRoom(room, now())); + }).catch((error) => { prepared = undefined; throw error; }); + async function mutate(work) { + await ready(); + const events = []; + const result = await store.transaction((state) => { events.push(...sweepLeases(state, now())); return work(state, events); }); + if (events.length) for (const listener of listeners) listener(events); + return result; + } + async function actorFor(token) { + const actor = token ? await store.actor(token) : null; + return actor && { ...actor, ids: [actor.accountId, actor.memberId].filter(Boolean), admin: Boolean(actor.accountId && admins.includes(actor.accountId)) }; + } + const signedIn = (actor) => { if (!actor) throw new AppError(401, "Sign in to step into the Stoa."); return actor; }; + const find = (state, id) => { const space = state.spaces.find((entry) => entry.id === id); if (!space) throw notFound(); return space; }; + const system = (space) => Boolean(space.lot) || space.id === PLAZA_ID; + const invitationFor = (space, invite) => typeof invite === "string" && invite ? space.invitations.find((entry) => entry.tokenHash === hash(invite) && entry.expiresAt > now() && entry.usesLeft > 0) || null : null; + const present = (state, space, actor) => Boolean(actor && leaseOf(state, space.id, actor.id, now())); + const visible = (state, space, actor, invite) => can(actor, "see", space, { invited: Boolean(invitationFor(space, invite)), present: present(state, space, actor) }); + const findVisible = (state, id, actor, invite) => { const space = find(state, id); if (!visible(state, space, actor, invite)) throw notFound(); return space; }; + const occupants = (state, space) => leasesFor(state, space.id, now()).sort((a, b) => a.enteredAt - b.enteredAt).map((lease) => ({ id: lease.actorId, name: lease.name })); + const view = (state, space, actor) => publicSpace(space, { occupants: occupants(state, space), manage: can(actor, "edit", space) }); + const owned = (state, actor) => state.spaces.filter((space) => !system(space) && actor.ids.includes(space.ownerId)); + const offerFor = (state, actor) => ({ spaces: state.spaces.filter((space) => !system(space) && space.members.some((id) => actor.ids.includes(id))).map((space) => ({ id: space.id, title: space.title, visibility: space.visibility, path: "/stoa/s/" + space.id })), canCreate: owned(state, actor).length < entitlements(actor.plan).spaces }); + const access = (space) => ({ channel: channelName(secret, space), key: spaceKey(secret, space), blocked: space.blocked, hostId: space.hostId }); + const requirePresent = (state, space, actor) => { if (!present(state, space, actor)) throw new AppError(403, "Step into this space first."); }; + + const spaces = { + onEvents(listener) { listeners.push(listener); }, + async sweep() { await mutate(() => null); }, + async list({ q = "" } = {}) { + await ready(); + const state = await store.snapshot(); + const query = String(q).trim().toLowerCase().slice(0, 100); + const plaza = find(state, PLAZA_ID); + const rooms = state.spaces.filter((space) => space.visibility === "listed" && space.lot).map((space) => { + const people = occupants(state, space); + return { id: space.id, slug: space.slug, title: space.title, topic: space.topic, tags: space.tags, capacity: space.capacity, occupancy: people.length, occupants: people, hostName: people.find((entry) => entry.id === space.hostId)?.name || null, path: "/stoa/room/" + space.slug }; + }).filter((room) => !query || [room.title, room.topic || "", ...room.tags].some((value) => value.toLowerCase().includes(query))); + return { plaza: { capacity: plaza.capacity, occupancy: leasesFor(state, PLAZA_ID, now()).length, blocked: plaza.blocked }, rooms }; + }, + async create(token, input) { + const actor = signedIn(await actorFor(token)); + const fields = spaceInput(input || {}); + return mutate((state) => { + if (owned(state, actor).length >= entitlements(actor.plan).spaces) throw new AppError(409, "You have reached your plan's space limit.", { offer: offerFor(state, actor) }); + const space = newSpace(fields, { id: randomUUID(), ownerId: actor.id, now: now() }); + state.spaces.push(space); + return view(state, space, actor); + }); + }, + async get(token, id, invite) { + await ready(); + const actor = await actorFor(token); + const state = await store.snapshot(); + return view(state, findVisible(state, id, actor, invite), actor); + }, + async visible(token, id, invite) { + try { await spaces.get(token, id, invite); return true; } + catch (error) { if (error.status === 404) return false; throw error; } + }, + async update(token, id, input) { + const actor = signedIn(await actorFor(token)); + return mutate((state) => { + const space = findVisible(state, id, actor); + if (system(space)) throw new AppError(403, "The map sets the plaza and its rooms."); + if (!can(actor, "edit", space)) throw new AppError(403, "Only the owner can change this space."); + const fields = spaceInput(input || {}, space); + if (fields.visibility !== space.visibility) space.channelEpoch += 1; + Object.assign(space, fields, { updatedAt: new Date(now()).toISOString() }); + return view(state, space, actor); + }); + }, + async destroy(token, id) { + const actor = signedIn(await actorFor(token)); + return mutate((state, events) => { + const space = findVisible(state, id, actor); + if (system(space) || !can(actor, "delete", space)) throw new AppError(403, "Only the owner can delete this space."); + state.spaces = state.spaces.filter((entry) => entry !== space); + state.spaceLeases = state.spaceLeases.filter((lease) => lease.spaceId !== space.id); + events.push({ type: "deleted", spaceId: space.id, members: space.members }); + return { deleted: true }; + }); + }, + async enter(token, id, invite) { + const actor = signedIn(await actorFor(token)); + return mutate((state, events) => { + const space = find(state, id); + const invitation = invitationFor(space, invite); + const context = { invited: Boolean(invitation), present: present(state, space, actor) }; + if (!can(actor, "see", space, context)) throw notFound(); + if (!can(actor, "enter", space, context)) throw new AppError(403, space.blocked.some((entry) => actor.ids.includes(entry)) ? "You can't enter this space right now." : "This space is open to its members."); + if (invitation && !space.members.some((entry) => actor.ids.includes(entry))) { + space.members.push(actor.id); + invitation.usesLeft -= 1; + events.push({ type: "member-added", spaceId: space.id, actorId: actor.id }); + } + if (space.lot) events.push(...leaveLease(state, find(state, PLAZA_ID), actor.id, now())); + let result; + try { result = enterLease(state, space, actor, now()); } + catch (error) { + if (space.id === PLAZA_ID && error.status === 409) throw new AppError(409, "The plaza is full right now.", { full: true, offer: offerFor(state, actor) }); + throw error; + } + events.push(...result.events); + return { space: view(state, space, actor), firstIn: result.firstIn, ...access(space) }; + }); + }, + async leave(token, id) { + const actor = signedIn(await actorFor(token)); + return mutate((state, events) => { events.push(...leaveLease(state, find(state, id), actor.id, now())); return { left: true }; }); + }, + async heartbeat(token, id) { + const actor = signedIn(await actorFor(token)); + return mutate((state) => { const space = find(state, id); heartbeatLease(state, space, actor.id, now()); return access(space); }); + }, + async topic(token, id, input = {}) { + const actor = signedIn(await actorFor(token)); + const topic = typeof input.topic === "string" ? input.topic.trim() : ""; + if (topic.length < 2 || topic.length > 80) throw new AppError(422, "Add a topic of 2 to 80 characters."); + const tags = normalizeTags(input.tags); + return mutate((state) => { + const space = find(state, id); + requirePresent(state, space, actor); + if (space.id === PLAZA_ID || !can(actor, "host", space, { present: true })) throw new AppError(403, "Only the host can set the topic."); + Object.assign(space, { topic, tags }); + return view(state, space, actor); + }); + }, + async setHost(token, id, input = {}) { + const actor = signedIn(await actorFor(token)); + return mutate((state, events) => { + const space = find(state, id); + requirePresent(state, space, actor); + if (space.id === PLAZA_ID || !can(actor, "host", space, { present: true })) throw new AppError(403, "Only the host or owner can hand over hosting."); + const target = leaseOf(state, space.id, input.actorId, now()); + if (!target) throw new AppError(422, "Choose someone who is here."); + space.hostId = target.actorId; + events.push({ type: "host", spaceId: space.id, hostId: space.hostId }); + return { hostId: space.hostId }; + }); + }, + async decorate(token, id, items) { + const actor = signedIn(await actorFor(token)); + return mutate((state) => { + const space = find(state, id); + requirePresent(state, space, actor); + if (space.id === PLAZA_ID || !can(actor, "decorate", space, { present: true })) throw new AppError(403, "Only the host can decorate."); + space.decor = validateDecor(items, worlds[space.worldId].map, space); + space.decorVersion = (space.decorVersion || 0) + 1; + return { decor: space.decor, version: space.decorVersion }; + }); + }, + async removePerson(token, id, input = {}) { + const actor = signedIn(await actorFor(token)); + return mutate((state, events) => { + const space = find(state, id); + requirePresent(state, space, actor); + if (!can(actor, "moderate", space, { present: true })) throw new AppError(403, "Only the host can remove someone."); + const target = leaseOf(state, space.id, input.actorId, now()); + const ids = target?.ids || [input.actorId]; + if (!ACTOR_ID.test(input.actorId || "") || ids.includes(space.ownerId) || ids.some((entry) => actor.ids.includes(entry))) throw new AppError(422, "This person can't be removed."); + space.blocked = [...new Set([...space.blocked, ...ids])]; + if (space.visibility !== "listed") space.channelEpoch += 1; + events.push(...leaveLease(state, space, input.actorId, now()), { type: "removed", spaceId: space.id, actorId: input.actorId }); + return { removed: true, ...access(space) }; + }); + }, + async invite(token, id, input = {}) { + const actor = signedIn(await actorFor(token)); + const uses = input.uses ?? 10, hours = input.hours ?? 72; + if (!Number.isInteger(uses) || uses < 1 || uses > 100 || !Number.isInteger(hours) || hours < 1 || hours > 720) throw new AppError(422, "Choose 1 to 100 uses and 1 to 720 hours."); + const raw = randomBytes(24).toString("base64url"); + return mutate((state) => { + const space = findVisible(state, id, actor); + if (system(space) || !can(actor, "invite", space)) throw new AppError(403, "Only the owner can invite people."); + space.invitations = space.invitations.filter((entry) => entry.expiresAt > now() && entry.usesLeft > 0).slice(-49); + const invitation = { id: randomUUID(), tokenHash: hash(raw), usesLeft: uses, expiresAt: now() + hours * 3600000, createdBy: actor.id, createdAt: new Date(now()).toISOString() }; + space.invitations.push(invitation); + return { token: raw, path: "/stoa/s/" + space.id + "?invite=" + raw, usesLeft: uses, expiresAt: new Date(invitation.expiresAt).toISOString() }; + }); + }, + async addMember(token, id, memberId) { + const actor = signedIn(await actorFor(token)); + if (!ACTOR_ID.test(memberId || "")) throw new AppError(422, "Choose a builder to add."); + return mutate((state, events) => { + const space = findVisible(state, id, actor); + if (system(space) || !can(actor, "edit", space)) throw new AppError(403, "Only the owner can change members."); + space.blocked = space.blocked.filter((entry) => entry !== memberId); + if (!space.members.includes(memberId)) { space.members.push(memberId); events.push({ type: "member-added", spaceId: space.id, actorId: memberId }); } + return { members: space.members }; + }); + }, + async removeMember(token, id, memberId) { + const actor = signedIn(await actorFor(token)); + return mutate((state, events) => { + const space = findVisible(state, id, actor); + if (system(space) || !can(actor, "edit", space)) throw new AppError(403, "Only the owner can change members."); + if (memberId === space.ownerId) throw new AppError(422, "The owner stays a member."); + space.members = space.members.filter((entry) => entry !== memberId); + if (space.visibility !== "listed") space.channelEpoch += 1; + events.push(...leaveLease(state, space, memberId, now()), { type: "member-removed", spaceId: space.id, actorId: memberId }); + return { members: space.members }; + }); + }, + async signalingToken(token) { + await ready(); + const actor = await actorFor(token); + const state = await store.snapshot(); + const channels = [{ spaceId: PLAZA_ID, name: channelName(secret, find(state, PLAZA_ID)), write: Boolean(actor), key: null }]; + if (actor) for (const lease of state.spaceLeases.filter((entry) => entry.actorId === actor.id && entry.expiresAt > now() && entry.spaceId !== PLAZA_ID)) { + const space = state.spaces.find((entry) => entry.id === lease.spaceId); + if (space) channels.push({ spaceId: space.id, name: channelName(secret, space), write: true, key: spaceKey(secret, space) }); + } + return { ...signaling.issue(signalingUser(actor), channels), channels }; + }, + async rtcToken(token, id, input = {}) { + const actor = signedIn(await actorFor(token)); + await ready(); + const state = await store.snapshot(); + const space = find(state, id); + if (space.id === PLAZA_ID) throw new AppError(422, "Calls happen inside rooms."); + if (!present(state, space, actor)) throw new AppError(403, "Step into this space before joining its call."); + return calls.issueSpace(space, actor, input); + } + }; + return spaces; +} diff --git a/tests/spaces-service.test.mjs b/tests/spaces-service.test.mjs new file mode 100644 index 0000000..e31016c --- /dev/null +++ b/tests/spaces-service.test.mjs @@ -0,0 +1,212 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { resolve } from "node:path"; +import { createStore } from "../scripts/store.mjs"; +import { agoraConfig, createAgoraCalls } from "../scripts/calls.mjs"; +import { createSignaling, signalingConfig } from "../scripts/spaces/signaling.mjs"; +import { createSpaces, loadWorlds } from "../scripts/spaces/service.mjs"; + +const root = new URL("../", import.meta.url).pathname; +const worlds = loadWorlds(root); +const secret = "s".repeat(32); +const env = { AGORA_APP_ID: "a".repeat(32), AGORA_APP_CERTIFICATE: "b".repeat(32), SPACE_CHANNEL_SECRET: secret }; +const profile = (name) => ({ name, bio: "Building voice agents with Rust.", intent: "Build a language practice voice agent with natural interruptions.", lookingFor: "Audio engineers", skills: ["Rust", "Voice AI"], location: "Remote", contact: "https://example.com/builder", monitor: false }); +const missing = { status: 404, message: "This space was not found." }; + +async function setup(t, { plazaCapacity, before } = {}) { + const directory = await mkdtemp(resolve(tmpdir(), "spaces-service-")); + t.after(() => rm(directory, { recursive: true, force: true })); + const clock = { now: 1800000000000 }; + const now = () => clock.now; + const store = createStore(resolve(root, "data/people.json"), resolve(directory, "state.json"), { now }); + const person = async (name) => { const { token } = await store.join(profile(name)); return { token, id: (await store.actor(token)).id }; }; + const context = before ? await before(store, person) : {}; + const spaces = createSpaces({ store, worlds, secret, now, plazaCapacity, signaling: createSignaling(signalingConfig(env), { now }), calls: createAgoraCalls(agoraConfig(env), { now }) }); + const events = []; + spaces.onEvents((list) => events.push(...list)); + return { store, spaces, events, clock, person, ...context }; +} + +test("system spaces and legacy rooms are prepared once", async (t) => { + const { spaces, store, owner, room } = await setup(t, { before: async (store, person) => { const owner = await person("Owner"); return { owner, room: await store.createRoom(owner.token, { title: "Demo prep", intent: "Plan the demo day together." }) }; } }); + const listing = await spaces.list(); + assert.deepEqual(listing.rooms.map((entry) => entry.slug), ["ai-agents", "voice-ai", "rtc-lab", "founders-table", "open-source", "lounge"]); + assert.deepEqual(listing.plaza, { capacity: 1000, occupancy: 0, blocked: [] }); + const migrated = await spaces.get(owner.token, room.id); + assert.deepEqual([migrated.visibility, migrated.access, migrated.members, migrated.title], ["unlisted", "members", [owner.id], "Demo prep"]); + await spaces.list(); + assert.equal((await store.snapshot()).spaces.length, 8); +}); + +test("the first person in a lot hosts and sets the topic; hosting passes on; an empty lot resets", async (t) => { + const { spaces, events, clock, person } = await setup(t); + const [a, b, c] = [await person("Ada"), await person("Bo"), await person("Cy")]; + const first = await spaces.enter(a.token, "lot-ai-agents"); + assert.deepEqual([first.firstIn, first.hostId, first.channel, first.key], [true, a.id, "ab-stoa-ai-agents", null]); + clock.now += 1000; await spaces.enter(b.token, "lot-ai-agents"); + clock.now += 1000; await spaces.enter(c.token, "lot-ai-agents"); + await assert.rejects(spaces.topic(b.token, "lot-ai-agents", { topic: "Agents" }), { status: 403 }); + await assert.rejects(spaces.topic(a.token, "lot-ai-agents", { topic: "x" }), { status: 422 }); + const topical = await spaces.topic(a.token, "lot-ai-agents", { topic: "Agent evals", tags: ["Evals", "agents"] }); + assert.deepEqual([topical.topic, topical.tags], ["Agent evals", ["evals", "agents"]]); + assert.equal((await spaces.list({ q: "evals" })).rooms.length, 1); + assert.equal((await spaces.list({ q: "evals" })).rooms[0].hostName, "Ada"); + await spaces.leave(a.token, "lot-ai-agents"); + assert.equal((await spaces.get(c.token, "lot-ai-agents")).hostId, b.id); + assert.deepEqual(await spaces.setHost(b.token, "lot-ai-agents", { actorId: c.id }), { hostId: c.id }); + await assert.rejects(spaces.setHost(b.token, "lot-ai-agents", { actorId: b.id }), { status: 403 }); + await assert.rejects(spaces.setHost(c.token, "lot-ai-agents", { actorId: a.id }), { status: 422 }); + await spaces.leave(b.token, "lot-ai-agents"); + await spaces.leave(c.token, "lot-ai-agents"); + const empty = await spaces.get(null, "lot-ai-agents"); + assert.deepEqual([empty.topic, empty.tags, empty.hostId, empty.occupancy], [null, [], null, 0]); + assert.ok(events.some((event) => event.type === "emptied" && event.spaceId === "lot-ai-agents")); +}); + +test("expired leases free seats and hand over hosting", async (t) => { + const { spaces, clock, person } = await setup(t); + const [a, b] = [await person("Ada"), await person("Bo")]; + await spaces.enter(a.token, "lot-rtc-lab"); + clock.now += 61000; + const next = await spaces.enter(b.token, "lot-rtc-lab"); + assert.deepEqual([next.firstIn, next.hostId], [true, b.id]); + await assert.rejects(spaces.heartbeat(a.token, "lot-rtc-lab"), { status: 410 }); + assert.equal((await spaces.heartbeat(b.token, "lot-rtc-lab")).hostId, b.id); +}); + +test("a full plaza offers the person's unlisted spaces, and lots stay open", async (t) => { + const { spaces, person } = await setup(t, { plazaCapacity: 1 }); + const [a, b] = [await person("Ada"), await person("Bo")]; + await spaces.enter(a.token, "plaza"); + const own = await spaces.create(b.token, { title: "Bo's corner" }); + await assert.rejects(spaces.enter(b.token, "plaza"), (error) => { + assert.equal(error.status, 409); + assert.equal(error.message, "The plaza is full right now."); + assert.deepEqual(error.details, { full: true, offer: { spaces: [{ id: own.id, title: "Bo's corner", visibility: "unlisted", path: "/stoa/s/" + own.id }], canCreate: true } }); + return true; + }); + assert.equal((await spaces.enter(b.token, "lot-lounge")).firstIn, true); + await spaces.enter(a.token, "lot-voice-ai"); + assert.equal((await spaces.list()).plaza.occupancy, 0, "people inside a lot leave the plaza"); + assert.equal((await spaces.enter(b.token, "plaza")).space.id, "plaza"); +}); + +test("people can own as many spaces as their plan allows", async (t) => { + const { spaces, person } = await setup(t); + const a = await person("Ada"); + for (const title of ["One", "Two", "Three"]) await spaces.create(a.token, { title }); + await assert.rejects(spaces.create(a.token, { title: "Four" }), { status: 409 }); + await assert.rejects(spaces.create(null, { title: "Guest" }), { status: 401 }); +}); + +test("private spaces stay hidden from strangers and admit invitation holders", async (t) => { + const { spaces, events, clock, person } = await setup(t); + const [owner, guest, late, later] = [await person("Owner"), await person("Guest"), await person("Late"), await person("Later")]; + const space = await spaces.create(owner.token, { title: "Board room", visibility: "private" }); + await assert.rejects(spaces.get(guest.token, space.id), missing); + await assert.rejects(spaces.get(guest.token, "00000000-0000-4000-8000-000000000000"), missing); + await assert.rejects(spaces.enter(guest.token, space.id), missing); + await assert.rejects(spaces.invite(guest.token, space.id, {}), missing); + assert.equal(await spaces.visible(guest.token, space.id), false); + const invitation = await spaces.invite(owner.token, space.id, { uses: 1, hours: 1 }); + assert.equal(invitation.path, "/stoa/s/" + space.id + "?invite=" + invitation.token); + assert.equal(await spaces.visible(guest.token, space.id, invitation.token), true); + const entered = await spaces.enter(guest.token, space.id, invitation.token); + assert.match(entered.channel, /^ab-stoa-[a-f0-9]{24}$/); + assert.equal(Buffer.from(entered.key, "base64").length, 32); + assert.ok(events.some((event) => event.type === "member-added" && event.actorId === guest.id)); + assert.equal((await spaces.get(guest.token, space.id)).title, "Board room", "members keep seeing the space"); + await assert.rejects(spaces.invite(guest.token, space.id, {}), { status: 403 }); + await assert.rejects(spaces.enter(late.token, space.id, invitation.token), missing, "used invitations stop working"); + const expiring = await spaces.invite(owner.token, space.id, { uses: 5, hours: 1 }); + clock.now += 3600001; + await assert.rejects(spaces.enter(later.token, space.id, expiring.token), missing); + await assert.rejects(spaces.invite(owner.token, space.id, { uses: 0 }), { status: 422 }); + assert.equal(JSON.stringify(await spaces.get(owner.token, space.id)).includes(invitation.token), false, "invitation tokens are never stored in the clear"); +}); + +test("removing someone blocks them, renames hidden channels, and ends their heartbeat", async (t) => { + const { spaces, events, person } = await setup(t); + const [owner, visitor] = [await person("Owner"), await person("Visitor")]; + const space = await spaces.create(owner.token, { title: "Open studio", access: "open" }); + const before = await spaces.enter(owner.token, space.id); + await spaces.enter(visitor.token, space.id); + await assert.rejects(spaces.removePerson(visitor.token, space.id, { actorId: owner.id }), { status: 403 }); + await assert.rejects(spaces.removePerson(owner.token, space.id, { actorId: owner.id }), { status: 422 }); + const after = await spaces.removePerson(owner.token, space.id, { actorId: visitor.id }); + assert.notEqual(after.channel, before.channel); + assert.notEqual(after.key, before.key); + assert.ok(events.some((event) => event.type === "removed" && event.actorId === visitor.id)); + await assert.rejects(spaces.heartbeat(visitor.token, space.id), { status: 410 }); + await assert.rejects(spaces.enter(visitor.token, space.id), { status: 403 }); + await spaces.addMember(owner.token, space.id, visitor.id); + assert.equal((await spaces.enter(visitor.token, space.id)).channel, after.channel, "the owner can let them back in"); + const left = await spaces.removeMember(owner.token, space.id, visitor.id); + assert.deepEqual(left.members, [owner.id]); + assert.ok(events.some((event) => event.type === "member-removed" && event.actorId === visitor.id)); + await assert.rejects(spaces.removeMember(owner.token, space.id, owner.id), { status: 422 }); +}); + +test("owners edit and delete their spaces; the map's rooms cannot be edited", async (t) => { + const { spaces, events, person } = await setup(t); + const [owner, other] = [await person("Owner"), await person("Other")]; + const space = await spaces.create(owner.token, { title: "Studio" }); + assert.equal((await spaces.update(owner.token, space.id, { title: "Studio two", themeId: "cyberpunk" })).title, "Studio two"); + await assert.rejects(spaces.update(other.token, space.id, { title: "Mine" }), { status: 403 }); + await assert.rejects(spaces.update(owner.token, "lot-lounge", { title: "Mine" }), { status: 403 }); + await assert.rejects(spaces.destroy(other.token, space.id), { status: 403 }); + assert.deepEqual(await spaces.destroy(owner.token, space.id), { deleted: true }); + assert.ok(events.some((event) => event.type === "deleted" && event.spaceId === space.id)); + await assert.rejects(spaces.get(owner.token, space.id), missing); +}); + +test("only the host decorates, within the room's rules", async (t) => { + const { spaces, person } = await setup(t); + const [a, b] = [await person("Ada"), await person("Bo")]; + await spaces.enter(a.token, "lot-rtc-lab"); + await spaces.enter(b.token, "lot-rtc-lab"); + assert.deepEqual(await spaces.decorate(a.token, "lot-rtc-lab", [{ id: "p", kind: "plant", x: 15, y: 22 }]), { decor: [{ id: "p", kind: "plant", x: 15, y: 22, rotation: 0, variant: 0 }], version: 1 }); + await assert.rejects(spaces.decorate(b.token, "lot-rtc-lab", []), { status: 403 }); + await assert.rejects(spaces.decorate(a.token, "lot-rtc-lab", [{ id: "p", kind: "plant", x: 17, y: 32 }]), { status: 422 }); + await assert.rejects(spaces.decorate(a.token, "plaza", []), { status: 403 }); +}); + +test("Signaling tokens list only channels the person may use", async (t) => { + const { spaces, person } = await setup(t); + const [owner, stranger] = [await person("Owner"), await person("Stranger")]; + const hidden = await spaces.create(owner.token, { title: "Secret plans", visibility: "private" }); + const entered = await spaces.enter(owner.token, hidden.id); + const guest = await spaces.signalingToken(null); + assert.match(guest.userId, /^g-[a-f0-9]{16}$/); + assert.deepEqual(guest.channels, [{ spaceId: "plaza", name: "ab-stoa-plaza", write: false, key: null }]); + const own = await spaces.signalingToken(owner.token); + assert.equal(own.userId, owner.id.replace("member:", "m-")); + assert.deepEqual(own.channels.map((channel) => [channel.spaceId, channel.name, channel.write, channel.key]), [["plaza", "ab-stoa-plaza", true, null], [hidden.id, entered.channel, true, entered.key]]); + const outside = JSON.stringify([await spaces.list(), await spaces.signalingToken(stranger.token), guest]); + for (const value of [hidden.id, "Secret plans", entered.channel, entered.key]) assert.equal(outside.includes(value), false, value); +}); + +test("RTC tokens require being inside a room", async (t) => { + const { spaces, person } = await setup(t); + const a = await person("Ada"); + await assert.rejects(spaces.rtcToken(a.token, "lot-lounge", {}), { status: 403 }); + await spaces.enter(a.token, "lot-lounge"); + assert.equal((await spaces.rtcToken(a.token, "lot-lounge", {})).channel, "agora-build-space-lot-lounge"); + await spaces.enter(a.token, "plaza"); + await assert.rejects(spaces.rtcToken(a.token, "plaza", {}), { status: 422 }); +}); + +test("simultaneous entries never exceed capacity and produce one host", async (t) => { + const { spaces, person } = await setup(t); + const walkers = []; + for (let index = 0; index < 8; index += 1) walkers.push(await person("Walker " + index)); + const results = await Promise.allSettled(walkers.map((walker) => spaces.enter(walker.token, "lot-rtc-lab"))); + assert.equal(results.filter((result) => result.status === "fulfilled").length, 6); + assert.ok(results.filter((result) => result.status === "rejected").every((result) => result.reason.status === 409)); + assert.equal(results.filter((result) => result.status === "fulfilled" && result.value.firstIn).length, 1); + const room = await spaces.get(null, "lot-rtc-lab"); + assert.equal(room.occupancy, 6); + assert.ok(room.occupants.some((occupant) => occupant.id === room.hostId)); +}); From a700eba61b21b0a5b0dd7d33311d9945bb2abc79 Mon Sep 17 00:00:00 2001 From: Brent G Date: Wed, 7 Oct 2026 18:24:56 +0000 Subject: [PATCH 13/71] Hide private spaces consistently and evict on privacy change MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🤖 Built with SMT --- scripts/spaces/service.mjs | 20 ++++++++++++-------- tests/spaces-service.test.mjs | 30 ++++++++++++++++++++++++++++++ 2 files changed, 42 insertions(+), 8 deletions(-) diff --git a/scripts/spaces/service.mjs b/scripts/spaces/service.mjs index 08fcc32..fd4a327 100644 --- a/scripts/spaces/service.mjs +++ b/scripts/spaces/service.mjs @@ -94,13 +94,17 @@ export function createSpaces({ store, worlds, signaling, calls, secret = "", adm }, async update(token, id, input) { const actor = signedIn(await actorFor(token)); - return mutate((state) => { + return mutate((state, events) => { const space = findVisible(state, id, actor); if (system(space)) throw new AppError(403, "The map sets the plaza and its rooms."); if (!can(actor, "edit", space)) throw new AppError(403, "Only the owner can change this space."); const fields = spaceInput(input || {}, space); if (fields.visibility !== space.visibility) space.channelEpoch += 1; Object.assign(space, fields, { updatedAt: new Date(now()).toISOString() }); + for (const lease of leasesFor(state, space.id, now())) { + const holder = { id: lease.actorId, ids: lease.ids, hasProfile: lease.ids.some((entry) => entry.startsWith("member:")), admin: false }; + if (!can(holder, "enter", space)) events.push(...leaveLease(state, space, lease.actorId, now())); + } return view(state, space, actor); }); }, @@ -141,11 +145,11 @@ export function createSpaces({ store, worlds, signaling, calls, secret = "", adm }, async leave(token, id) { const actor = signedIn(await actorFor(token)); - return mutate((state, events) => { events.push(...leaveLease(state, find(state, id), actor.id, now())); return { left: true }; }); + return mutate((state, events) => { events.push(...leaveLease(state, findVisible(state, id, actor), actor.id, now())); return { left: true }; }); }, async heartbeat(token, id) { const actor = signedIn(await actorFor(token)); - return mutate((state) => { const space = find(state, id); heartbeatLease(state, space, actor.id, now()); return access(space); }); + return mutate((state) => { const space = findVisible(state, id, actor); if (present(state, space, actor) && !can(actor, "enter", space)) throw new AppError(410, "You are no longer in this space."); heartbeatLease(state, space, actor.id, now()); return access(space); }); }, async topic(token, id, input = {}) { const actor = signedIn(await actorFor(token)); @@ -153,7 +157,7 @@ export function createSpaces({ store, worlds, signaling, calls, secret = "", adm if (topic.length < 2 || topic.length > 80) throw new AppError(422, "Add a topic of 2 to 80 characters."); const tags = normalizeTags(input.tags); return mutate((state) => { - const space = find(state, id); + const space = findVisible(state, id, actor); requirePresent(state, space, actor); if (space.id === PLAZA_ID || !can(actor, "host", space, { present: true })) throw new AppError(403, "Only the host can set the topic."); Object.assign(space, { topic, tags }); @@ -163,7 +167,7 @@ export function createSpaces({ store, worlds, signaling, calls, secret = "", adm async setHost(token, id, input = {}) { const actor = signedIn(await actorFor(token)); return mutate((state, events) => { - const space = find(state, id); + const space = findVisible(state, id, actor); requirePresent(state, space, actor); if (space.id === PLAZA_ID || !can(actor, "host", space, { present: true })) throw new AppError(403, "Only the host or owner can hand over hosting."); const target = leaseOf(state, space.id, input.actorId, now()); @@ -176,7 +180,7 @@ export function createSpaces({ store, worlds, signaling, calls, secret = "", adm async decorate(token, id, items) { const actor = signedIn(await actorFor(token)); return mutate((state) => { - const space = find(state, id); + const space = findVisible(state, id, actor); requirePresent(state, space, actor); if (space.id === PLAZA_ID || !can(actor, "decorate", space, { present: true })) throw new AppError(403, "Only the host can decorate."); space.decor = validateDecor(items, worlds[space.worldId].map, space); @@ -187,7 +191,7 @@ export function createSpaces({ store, worlds, signaling, calls, secret = "", adm async removePerson(token, id, input = {}) { const actor = signedIn(await actorFor(token)); return mutate((state, events) => { - const space = find(state, id); + const space = findVisible(state, id, actor); requirePresent(state, space, actor); if (!can(actor, "moderate", space, { present: true })) throw new AppError(403, "Only the host can remove someone."); const target = leaseOf(state, space.id, input.actorId, now()); @@ -251,7 +255,7 @@ export function createSpaces({ store, worlds, signaling, calls, secret = "", adm const actor = signedIn(await actorFor(token)); await ready(); const state = await store.snapshot(); - const space = find(state, id); + const space = findVisible(state, id, actor); if (space.id === PLAZA_ID) throw new AppError(422, "Calls happen inside rooms."); if (!present(state, space, actor)) throw new AppError(403, "Step into this space before joining its call."); return calls.issueSpace(space, actor, input); diff --git a/tests/spaces-service.test.mjs b/tests/spaces-service.test.mjs index e31016c..f02daa3 100644 --- a/tests/spaces-service.test.mjs +++ b/tests/spaces-service.test.mjs @@ -210,3 +210,33 @@ test("simultaneous entries never exceed capacity and produce one host", async (t assert.equal(room.occupancy, 6); assert.ok(room.occupants.some((occupant) => occupant.id === room.hostId)); }); + +test("hidden private spaces answer every operation exactly like a missing one", async (t) => { + const { spaces, person } = await setup(t); + const [owner, stranger] = [await person("Owner"), await person("Stranger")]; + const space = await spaces.create(owner.token, { title: "Board room", visibility: "private" }); + const ghost = "00000000-0000-4000-8000-000000000000"; + const calls = (id) => [ + () => spaces.get(stranger.token, id), () => spaces.enter(stranger.token, id), () => spaces.invite(stranger.token, id, {}), + () => spaces.update(stranger.token, id, { title: "x y z" }), () => spaces.destroy(stranger.token, id), + () => spaces.addMember(stranger.token, id, owner.id), () => spaces.removeMember(stranger.token, id, owner.id), + () => spaces.leave(stranger.token, id), () => spaces.heartbeat(stranger.token, id), () => spaces.topic(stranger.token, id, { topic: "Topic here" }), + () => spaces.setHost(stranger.token, id, { actorId: owner.id }), () => spaces.decorate(stranger.token, id, []), + () => spaces.removePerson(stranger.token, id, { actorId: owner.id }), () => spaces.rtcToken(stranger.token, id, {}) + ]; + for (const call of calls(space.id)) await assert.rejects(call(), missing); + for (const call of calls(ghost)) await assert.rejects(call(), missing); +}); + +test("making a space private removes people who may no longer enter", async (t) => { + const { spaces, person } = await setup(t); + const [owner, stranger] = [await person("Owner"), await person("Stranger")]; + const space = await spaces.create(owner.token, { title: "Open studio", access: "open" }); + const before = await spaces.enter(owner.token, space.id); + await spaces.enter(stranger.token, space.id); + await spaces.update(owner.token, space.id, { visibility: "private", access: "members" }); + await assert.rejects(spaces.heartbeat(stranger.token, space.id), missing, "an evicted stranger sees the space as hidden, never as 410"); + const own = await spaces.heartbeat(owner.token, space.id); + assert.notEqual(own.channel, before.channel); + assert.equal((await spaces.signalingToken(stranger.token)).channels.some((channel) => channel.spaceId === space.id), false); +}); From 6c2d747c384ed6985ca1c2fb1b36f258b4933ce5 Mon Sep 17 00:00:00 2001 From: Brent G Date: Wed, 7 Oct 2026 18:27:02 +0000 Subject: [PATCH 14/71] Serve the spaces API and Stoa pages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🤖 Built with SMT --- .env.example | 8 +++ README.md | 4 ++ scripts/build.mjs | 5 +- scripts/serve.mjs | 21 ++++++- scripts/spaces/routes.mjs | 40 +++++++++++++ stoa.html | 17 ++++++ tests/server.test.mjs | 3 +- tests/spaces-api.test.mjs | 123 ++++++++++++++++++++++++++++++++++++++ 8 files changed, 216 insertions(+), 5 deletions(-) create mode 100644 scripts/spaces/routes.mjs create mode 100644 stoa.html create mode 100644 tests/spaces-api.test.mjs diff --git a/.env.example b/.env.example index 3c46878..b65a96a 100644 --- a/.env.example +++ b/.env.example @@ -78,3 +78,11 @@ MODEL_REQUESTS_PER_HOUR=60 AGORA_APP_ID= AGORA_APP_CERTIFICATE= AGORA_TOKEN_TTL_SECONDS=3600 +# Agora Signaling (RTM 2.x) carries movement, presence and live messages, using AGORA_APP_ID and AGORA_APP_CERTIFICATE. +# Enable Signaling for the project in Agora Console. +# At least 32 random characters. Derives private channel names and per-space keys; keep it secret and stable. +SPACE_CHANNEL_SECRET= +# Set to true only after Agora enables per-channel Signaling permissions for this project. +AGORA_RTM_PERMISSIONS=false +# Comma-separated portal account IDs (account:) with platform admin rights in spaces. +PLATFORM_ADMINS= diff --git a/README.md b/README.md index b8b206a..21d4ed6 100644 --- a/README.md +++ b/README.md @@ -107,6 +107,10 @@ Members join audio/video inside Agora.Build, choose whether to enable devices, t Use HTTPS for remote callers, or `http://localhost:3002` for local development. Browsers restrict media devices on plain remote HTTP. No Agora credentials are bundled in the site. Without valid configuration, rooms and invites remain available and calls show an unavailable state. Existing room IDs and rosters continue working, and old external call URLs are omitted from API responses. Removing a profile or room blocks new tokens and renewal; an already-issued Agora token remains valid until its short expiration. +## Stoa spaces + +The Stoa is a walkable plaza (`/stoa/`) with first-come rooms (`/stoa/room/`) and unlisted or private spaces (`/stoa/s/`). Calls use Agora RTC; movement, presence, and live messages use Agora Signaling. Enable Signaling for the Agora project and set `SPACE_CHANNEL_SECRET` to at least 32 random characters. The built-in maps in `worlds/` are generated by `npm run maps`. + ## The physical forge Demo days, hackathons, and whiteboard sessions are core community activities. The homepage offers a session-planning entry point for each. Create a room with your real intent, include a place and time in the session purpose, and share the invite with collaborators. Use the same workspace to prepare online and reconnect after meeting in person. These are planning tools; the site does not advertise invented events, venues, or attendees. diff --git a/scripts/build.mjs b/scripts/build.mjs index 7495a7a..1520a3e 100644 --- a/scripts/build.mjs +++ b/scripts/build.mjs @@ -4,10 +4,13 @@ import { createRequire } from "node:module"; const root = new URL("../", import.meta.url); const output = new URL("dist/", root); await mkdir(output, { recursive: true }); -await Promise.all(["index.html", "explore.html", "services.html", "radar.html", "meetings.html", "account.html", "account.js", "styles.css", "script.js", "people.js", "activity.js", "explore.js", "services.js", "radar.js", "meetings.js", "call.js", "package.json", "package-lock.json", ".env.example"].map((file) => copyFile(new URL(file, root), new URL(file, output)))); +await Promise.all(["index.html", "explore.html", "services.html", "radar.html", "meetings.html", "account.html", "account.js", "stoa.html", "styles.css", "script.js", "people.js", "activity.js", "explore.js", "services.js", "radar.js", "meetings.js", "call.js", "package.json", "package-lock.json", ".env.example"].map((file) => copyFile(new URL(file, root), new URL(file, output)))); await cp(new URL("assets/", root), new URL("assets/", output), { recursive: true }); await copyFile(createRequire(import.meta.url).resolve("agora-rtc-sdk-ng"), new URL("assets/agora-rtc.js", output)); await cp(new URL("data/", root), new URL("data/", output), { recursive: true }); await mkdir(new URL("scripts/", output), { recursive: true }); await Promise.all(["serve.mjs", "store.mjs", "models.mjs", "activity.mjs", "calls.mjs", "auth.mjs", "identity.mjs", "credits.mjs", "ledger.mjs", "billing.mjs", "plans.mjs", "persistence.mjs", "connections.mjs", "platform-admin.mjs"].map((file) => copyFile(new URL("scripts/" + file, root), new URL("scripts/" + file, output)))); +await cp(new URL("scripts/spaces/", root), new URL("scripts/spaces/", output), { recursive: true }); +await cp(new URL("world/", root), new URL("world/", output), { recursive: true }); +await cp(new URL("worlds/", root), new URL("worlds/", output), { recursive: true }); console.log("House app built in dist/. Run with: node dist/scripts/serve.mjs"); diff --git a/scripts/serve.mjs b/scripts/serve.mjs index 9cfa74c..2e89aa4 100644 --- a/scripts/serve.mjs +++ b/scripts/serve.mjs @@ -15,10 +15,13 @@ import { createLedger } from "./ledger.mjs"; import { createBilling } from "./billing.mjs"; import { createConnections } from "./connections.mjs"; import { createPostgresPersistence } from "./persistence.mjs"; +import { createSignaling, signalingConfig } from "./spaces/signaling.mjs"; +import { createSpaces, loadWorlds } from "./spaces/service.mjs"; +import { handleSpaces } from "./spaces/routes.mjs"; const root = fileURLToPath(new URL("../", import.meta.url)); const sdkFile = createRequire(import.meta.url).resolve("agora-rtc-sdk-ng"); -const publicFiles = new Set(["index.html", "explore.html", "services.html", "radar.html", "meetings.html", "account.html", "account.js", "styles.css", "script.js", "people.js", "activity.js", "explore.js", "services.js", "radar.js", "meetings.js", "call.js", "assets/agora-rtc.js", "assets/favicon.svg", "assets/guohai.jpg"]); +const publicFiles = new Set(["index.html", "explore.html", "services.html", "radar.html", "meetings.html", "account.html", "account.js", "stoa.html", "world/map.js", "styles.css", "script.js", "people.js", "activity.js", "explore.js", "services.js", "radar.js", "meetings.js", "call.js", "assets/agora-rtc.js", "assets/favicon.svg", "assets/guohai.jpg"]); const types = { ".html": "text/html; charset=utf-8", ".css": "text/css; charset=utf-8", ".js": "text/javascript; charset=utf-8", ".svg": "image/svg+xml", ".jpg": "image/jpeg" }; function json(response, status, body, headers = {}) { @@ -65,6 +68,9 @@ export function createAppServer(directory = root, options = {}) { const connections = options.connections || createConnections(store, auth); const models = options.models || createModelClient(modelConfig()); const activity = options.activity || createActivityFeed({ store, projectsFile: resolve(dataDirectory, "projects.json"), snapshotFile: resolve(dataDirectory, "activity.json") }); + const worlds = options.worlds || loadWorlds(directory); + const signaling = options.signaling || createSignaling(signalingConfig()); + const spaces = options.spaces || createSpaces({ store, worlds, signaling, calls, secret: options.channelSecret ?? signalingConfig().secret, admins: (process.env.PLATFORM_ADMINS || "").split(",").map((id) => id.trim()).filter(Boolean), plazaCapacity: options.plazaCapacity }); const windows = new Map(); const scanning = new Set(); const radarHours = Math.max(1, Number(process.env.RADAR_INTERVAL_HOURS) || 24); @@ -323,13 +329,19 @@ export function createAppServer(directory = root, options = {}) { if (!room) throw new AppError(404, "This room was not found."); limit("call:" + person.id, 30, 60000); json(response, 200, calls.issue(room, person, await body(request))); - } else throw new AppError(404, "This page or service was not found."); + } else { + const handled = await handleSpaces({ path, method: request.method, url: requestUrl, token, read: () => body(request), spaces, worlds, limit, ip: request.socket.remoteAddress }); + if (!handled) throw new AppError(404, "This page or service was not found."); + json(response, handled.status, handled.body); + } return; } if (!["GET", "HEAD"].includes(request.method)) { response.writeHead(405, { Allow: "GET, HEAD" }).end("Method not allowed"); return; } const meeting = /^\/meet\/([a-f0-9-]{36})$/.exec(path); if (meeting && !(await store.rooms()).some((room) => room.id === meeting[1])) { response.writeHead(404, { "Content-Type": "text/plain; charset=utf-8" }).end("This meeting room was not found."); return; } - const filename = meeting ? "meetings.html" : path === "/" ? "index.html" : path.slice(1); + const stoa = /^\/stoa(?:\/|\/room\/([a-z0-9-]{2,40})|\/s\/([a-f0-9-]{36}))$/.exec(path); + if (stoa && ((stoa[1] && !worlds.plaza.map.lots.some((lot) => lot.slug === stoa[1])) || (stoa[2] && !(await spaces.visible(token, stoa[2], requestUrl.searchParams.get("invite")))))) { response.writeHead(404, { "Content-Type": "text/plain; charset=utf-8" }).end("This space was not found."); return; } + const filename = stoa ? "stoa.html" : meeting ? "meetings.html" : path === "/" ? "index.html" : path.slice(1); if (!publicFiles.has(filename)) { response.writeHead(404, { "Content-Type": "text/plain; charset=utf-8" }).end("Not found"); return; } const file = resolve(directory, filename); const content = await readFile(filename === "assets/agora-rtc.js" && !existsSync(file) ? sdkFile : file); @@ -363,6 +375,9 @@ export function createAppServer(directory = root, options = {}) { const expiry = setInterval(() => { ledger.expire().catch(() => {}); }, 5 * 60000); expiry.unref(); server.on("close", () => { clearInterval(expiry); persistence?.close().catch(() => {}); }); + const spaceSweep = setInterval(() => { spaces.sweep().catch(() => {}); }, 15000); + spaceSweep.unref(); + server.on("close", () => clearInterval(spaceSweep)); return server; } diff --git a/scripts/spaces/routes.mjs b/scripts/spaces/routes.mjs new file mode 100644 index 0000000..7d0f133 --- /dev/null +++ b/scripts/spaces/routes.mjs @@ -0,0 +1,40 @@ +import { AppError } from "../store.mjs"; + +const SPACE = /^\/api\/spaces\/(plaza|lot-[a-z0-9-]{2,40}|[a-f0-9-]{36})(?:\/(enter|leave|heartbeat|topic|host|decor|remove|invitations|members|rtc-token)(?:\/((?:member|account):[a-f0-9-]{36}))?)?$/; + +// Maps HTTP requests to the spaces service. Returns null when the request is not a spaces route. +export async function handleSpaces({ path, method, url, token, read, spaces, worlds, limit, ip }) { + if (path === "/api/spaces" && method === "GET") return { status: 200, body: await spaces.list({ q: url.searchParams.get("q") || "" }) }; + if (path === "/api/spaces" && method === "POST") { limit("spaces:" + ip, 20, 3600000); return { status: 201, body: { space: await spaces.create(token, await read()) } }; } + if (path === "/api/signaling/token" && method === "POST") { limit("signaling:" + ip, 30, 60000); return { status: 200, body: await spaces.signalingToken(token) }; } + const world = /^\/api\/worlds\/([a-z]+)$/.exec(path); + if (world && method === "GET") { + if (!Object.hasOwn(worlds, world[1])) throw new AppError(404, "This world was not found."); + return { status: 200, body: worlds[world[1]].source }; + } + const match = SPACE.exec(path); + if (!match) return null; + const [, id, action, memberId] = match; + limit("space:" + ip, 240, 60000); + if (!action) { + if (method === "GET") return { status: 200, body: { space: await spaces.get(token, id, url.searchParams.get("invite")) } }; + if (method === "PUT") return { status: 200, body: { space: await spaces.update(token, id, await read()) } }; + if (method === "DELETE") return { status: 200, body: await spaces.destroy(token, id) }; + return null; + } + if (memberId) return action === "members" && method === "DELETE" ? { status: 200, body: await spaces.removeMember(token, id, memberId) } : null; + if (action === "decor" && method === "PUT") return { status: 200, body: await spaces.decorate(token, id, (await read()).items) }; + if (method !== "POST") return null; + // Leaving and heartbeats carry no body, so navigator.sendBeacon can call them. + if (action === "leave") return { status: 200, body: await spaces.leave(token, id) }; + if (action === "heartbeat") return { status: 200, body: await spaces.heartbeat(token, id) }; + const input = await read(); + if (action === "enter") return { status: 200, body: await spaces.enter(token, id, input.invite) }; + if (action === "topic") return { status: 200, body: { space: await spaces.topic(token, id, input) } }; + if (action === "host") return { status: 200, body: await spaces.setHost(token, id, input) }; + if (action === "remove") return { status: 200, body: await spaces.removePerson(token, id, input) }; + if (action === "invitations") return { status: 201, body: await spaces.invite(token, id, input) }; + if (action === "members") return { status: 200, body: await spaces.addMember(token, id, input.memberId) }; + if (action === "rtc-token") return { status: 200, body: await spaces.rtcToken(token, id, input) }; + return null; +} diff --git a/stoa.html b/stoa.html new file mode 100644 index 0000000..7e610ad --- /dev/null +++ b/stoa.html @@ -0,0 +1,17 @@ + + + + + + + Stoa | Agora Build + + + + + +
+

THE STOA

The plaza is being built.

Soon you can walk in, meet builders, start a topic, and work together.

+
+ + diff --git a/tests/server.test.mjs b/tests/server.test.mjs index 60b5be5..420134d 100644 --- a/tests/server.test.mjs +++ b/tests/server.test.mjs @@ -272,7 +272,7 @@ test("automatic radar scans only opted-in members and respects the scan interval test("production output includes six pages, platform backend, seed data, and no private state", async () => { await import("../scripts/build.mjs"); for (const file of ["account.html", "account.js", "scripts/identity.mjs", "scripts/ledger.mjs", "scripts/credits.mjs", "scripts/billing.mjs", "scripts/connections.mjs", "scripts/plans.mjs", "scripts/persistence.mjs", "scripts/platform-admin.mjs"]) assert.deepEqual(await readFile(resolve(root, "dist", file)), await readFile(resolve(root, file)), file); - const files = ["index.html", "explore.html", "services.html", "radar.html", "meetings.html", "styles.css", "script.js", "people.js", "activity.js", "explore.js", "services.js", "radar.js", "meetings.js", "call.js", "package.json", "package-lock.json", "assets/favicon.svg", "assets/guohai.jpg", "scripts/serve.mjs", "scripts/store.mjs", "scripts/models.mjs", "scripts/activity.mjs", "scripts/calls.mjs", "scripts/auth.mjs", "data/people.json", "data/projects.json", "data/offers.json", "data/activity.json"]; + const files = ["index.html", "explore.html", "services.html", "radar.html", "meetings.html", "styles.css", "script.js", "people.js", "activity.js", "explore.js", "services.js", "radar.js", "meetings.js", "call.js", "package.json", "package-lock.json", "assets/favicon.svg", "assets/guohai.jpg", "scripts/serve.mjs", "scripts/store.mjs", "scripts/models.mjs", "scripts/activity.mjs", "scripts/calls.mjs", "scripts/auth.mjs", "stoa.html", "world/map.js", "worlds/plaza/map.json", "worlds/room/map.json", "scripts/spaces/service.mjs", "scripts/spaces/routes.mjs", "data/people.json", "data/projects.json", "data/offers.json", "data/activity.json"]; for (const file of files) assert.deepEqual(await readFile(resolve(root, "dist", file)), await readFile(resolve(root, file)), file); for (const file of [".env", ".data/community.json"]) await assert.rejects(readFile(resolve(root, "dist", file)), { code: "ENOENT" }); const built = await start({}, resolve(root, "dist")); @@ -281,6 +281,7 @@ test("production output includes six pages, platform backend, seed data, and no assert.equal((await request("/account.html", { base: built.url })).status, 200); assert.equal((await request("/assets/agora-rtc.js", { base: built.url })).status, 200); assert.equal((await request("/call.js", { base: built.url })).status, 200); + assert.equal((await request("/stoa/", { base: built.url })).status, 200); assert.equal((await (await request("/api/projects", { base: built.url })).json()).projects.length, 16); } finally { await close(built.instance); } }); diff --git a/tests/spaces-api.test.mjs b/tests/spaces-api.test.mjs new file mode 100644 index 0000000..1fff4bb --- /dev/null +++ b/tests/spaces-api.test.mjs @@ -0,0 +1,123 @@ +import { after, before, test } from "node:test"; +import assert from "node:assert/strict"; +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { resolve } from "node:path"; +import { createAppServer } from "../scripts/serve.mjs"; +import { createModelClient, modelConfig } from "../scripts/models.mjs"; +import { agoraConfig, createAgoraCalls } from "../scripts/calls.mjs"; +import { authConfig, createAuth } from "../scripts/auth.mjs"; +import { createSignaling, signalingConfig } from "../scripts/spaces/signaling.mjs"; + +const root = new URL("../", import.meta.url).pathname; +const env = { AGORA_APP_ID: "a".repeat(32), AGORA_APP_CERTIFICATE: "b".repeat(32), SPACE_CHANNEL_SECRET: "s".repeat(32) }; +const profile = (name) => ({ name, bio: "Building voice agents with Rust.", intent: "Build a language practice voice agent with natural interruptions.", lookingFor: "Audio engineers", skills: ["Rust", "Voice AI"], location: "Remote", contact: "https://example.com/builder", monitor: false }); +const absent = "/00000000-0000-4000-8000-000000000000"; +let temporary; +const close = (instance) => new Promise((done) => { instance.close(done); instance.closeIdleConnections(); }); +async function start(options = {}) { + const instance = createAppServer(root, { monitor: false, storageFile: resolve(temporary, Math.random() + ".json"), models: createModelClient(modelConfig({})), calls: createAgoraCalls(agoraConfig(env)), auth: createAuth(authConfig({})), signaling: createSignaling(signalingConfig(env)), channelSecret: env.SPACE_CHANNEL_SECRET, ...options }); + await new Promise((done) => instance.listen(0, "127.0.0.1", done)); + return { instance, url: "http://127.0.0.1:" + instance.address().port }; +} +const request = (base, path, { cookie, method = "GET", data } = {}) => fetch(base + path, { method, headers: { ...(cookie ? { Cookie: cookie } : {}), ...(data !== undefined ? { "Content-Type": "application/json" } : {}) }, ...(data !== undefined ? { body: JSON.stringify(data) } : {}) }); +const post = (base, path, cookie, data = {}) => request(base, path, { cookie, method: "POST", data }); +async function join(base, name) { + const response = await post(base, "/api/profile", undefined, profile(name)); + assert.equal(response.status, 201); + return response.headers.get("set-cookie").split(";")[0]; +} +before(async () => { temporary = await mkdtemp(resolve(tmpdir(), "spaces-api-")); }); +after(() => rm(temporary, { recursive: true, force: true })); + +test("Stoa pages and worlds are served, and hidden spaces look exactly like missing ones", async () => { + const { instance, url } = await start(); + try { + for (const path of ["/stoa/", "/stoa/room/ai-agents"]) { + const response = await request(url, path); + assert.equal(response.status, 200, path); + assert.match(await response.text(), /Stoa/); + } + assert.equal((await request(url, "/stoa/room/nowhere")).status, 404); + const owner = await join(url, "Owner"), stranger = await join(url, "Stranger"); + const created = await post(url, "/api/spaces", owner, { title: "Board room", visibility: "private" }); + assert.equal(created.status, 201); + const { space } = await created.json(); + assert.equal((await request(url, "/stoa/s/" + space.id, { cookie: owner })).status, 200); + const hidden = await request(url, "/stoa/s/" + space.id, { cookie: stranger }); + const missing = await request(url, "/stoa/s" + absent, { cookie: stranger }); + assert.deepEqual([hidden.status, await hidden.text()], [missing.status, await missing.text()]); + const apiHidden = await request(url, "/api/spaces/" + space.id, { cookie: stranger }); + const apiMissing = await request(url, "/api/spaces" + absent, { cookie: stranger }); + assert.deepEqual([apiHidden.status, await apiHidden.json()], [404, await apiMissing.json()]); + const plaza = await (await request(url, "/api/worlds/plaza")).json(); + assert.deepEqual([plaza.width, plaza.height], [44, 34]); + assert.equal((await request(url, "/api/worlds/secret")).status, 404); + assert.equal(JSON.stringify(await (await request(url, "/api/spaces")).json()).includes(space.id), false); + assert.equal((await post(url, "/api/spaces", undefined, { title: "Guest" })).status, 401); + } finally { await close(instance); } +}); + +test("walking in, hosting, decorating, and calls work over HTTP", async () => { + const { instance, url } = await start(); + try { + const ada = await join(url, "Ada"), bo = await join(url, "Bo"); + assert.equal((await post(url, "/api/spaces/lot-voice-ai/enter")).status, 401); + const entered = await (await post(url, "/api/spaces/lot-voice-ai/enter", ada)).json(); + assert.deepEqual([entered.firstIn, entered.channel], [true, "ab-stoa-voice-ai"]); + await post(url, "/api/spaces/lot-voice-ai/enter", bo); + assert.equal((await post(url, "/api/spaces/lot-voice-ai/topic", bo, { topic: "Turn taking" })).status, 403); + const topic = await post(url, "/api/spaces/lot-voice-ai/topic", ada, { topic: "Turn taking", tags: ["latency"] }); + assert.equal((await topic.json()).space.topic, "Turn taking"); + const listing = await (await request(url, "/api/spaces?q=latency")).json(); + assert.deepEqual(listing.rooms.map((room) => [room.slug, room.occupancy, room.hostName]), [["voice-ai", 2, "Ada"]]); + const decor = await request(url, "/api/spaces/lot-voice-ai/decor", { cookie: ada, method: "PUT", data: { items: [{ id: "lamp", kind: "lamp", x: 8, y: 22 }] } }); + assert.equal((await decor.json()).version, 1); + const call = await (await post(url, "/api/spaces/lot-voice-ai/rtc-token", ada)).json(); + assert.equal(call.channel, "agora-build-space-lot-voice-ai"); + assert.equal((await request(url, "/api/spaces/lot-voice-ai/heartbeat", { cookie: ada, method: "POST" })).status, 200); + assert.equal((await request(url, "/api/spaces/lot-voice-ai/leave", { cookie: ada, method: "POST" })).status, 200); + assert.equal((await request(url, "/api/spaces/lot-voice-ai/heartbeat", { cookie: ada, method: "POST" })).status, 410); + const signaling = await (await post(url, "/api/signaling/token")).json(); + assert.match(signaling.userId, /^g-/); + } finally { await close(instance); } +}); + +test("a full plaza answers with an offer of other places", async () => { + const { instance, url } = await start({ plazaCapacity: 1 }); + try { + const ada = await join(url, "Ada"), bo = await join(url, "Bo"); + assert.equal((await post(url, "/api/spaces/plaza/enter", ada)).status, 200); + const full = await post(url, "/api/spaces/plaza/enter", bo); + assert.equal(full.status, 409); + assert.deepEqual(await full.json(), { error: "The plaza is full right now.", full: true, offer: { spaces: [], canCreate: true } }); + } finally { await close(instance); } +}); + +test("invitations, membership, and deletion work over HTTP", async () => { + const { instance, url } = await start(); + try { + const owner = await join(url, "Owner"), guest = await join(url, "Guest"); + const guestId = (await (await request(url, "/api/me", { cookie: guest })).json()).profile.id; + const { space } = await (await post(url, "/api/spaces", owner, { title: "Board room", visibility: "private" })).json(); + const invitation = await post(url, "/api/spaces/" + space.id + "/invitations", owner, { uses: 2, hours: 24 }); + assert.equal(invitation.status, 201); + const { token } = await invitation.json(); + assert.equal((await request(url, "/api/spaces/" + space.id + "?invite=" + token, { cookie: guest })).status, 200); + assert.equal((await post(url, "/api/spaces/" + space.id + "/enter", guest, { invite: token })).status, 200); + assert.equal((await request(url, "/api/spaces/" + space.id + "/members/" + guestId, { cookie: owner, method: "DELETE" })).status, 200); + assert.equal((await request(url, "/api/spaces/" + space.id, { cookie: guest })).status, 404); + assert.equal((await request(url, "/api/spaces/" + space.id, { cookie: owner, method: "DELETE" })).status, 200); + assert.equal((await request(url, "/api/spaces/" + space.id, { cookie: owner })).status, 404); + } finally { await close(instance); } +}); + +test("missing Signaling or call configuration is reported plainly", async () => { + const { instance, url } = await start({ signaling: createSignaling(signalingConfig({})), calls: createAgoraCalls(agoraConfig({})) }); + try { + assert.equal((await post(url, "/api/signaling/token")).status, 503); + const ada = await join(url, "Ada"); + assert.equal((await post(url, "/api/spaces/lot-lounge/enter", ada)).status, 200); + assert.equal((await post(url, "/api/spaces/lot-lounge/rtc-token", ada)).status, 503); + } finally { await close(instance); } +}); From 5cb35aefe69160d02d996fe557e462534fde68b0 Mon Sep 17 00:00:00 2001 From: Brent G Date: Wed, 7 Oct 2026 18:35:06 +0000 Subject: [PATCH 15/71] Fix review findings: rotate channel on access eviction, idle sweep, listener safety MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🤖 Built with SMT --- scripts/build.mjs | 4 +- scripts/spaces/routes.mjs | 3 +- scripts/spaces/service.mjs | 23 ++++++-- tests/spaces-api.test.mjs | 13 +++++ tests/spaces-service.test.mjs | 102 +++++++++++++++++++++++++++++++++- 5 files changed, 135 insertions(+), 10 deletions(-) diff --git a/scripts/build.mjs b/scripts/build.mjs index 1520a3e..5161d69 100644 --- a/scripts/build.mjs +++ b/scripts/build.mjs @@ -1,8 +1,10 @@ -import { mkdir, copyFile, cp } from "node:fs/promises"; +import { mkdir, copyFile, cp, rm } from "node:fs/promises"; import { createRequire } from "node:module"; const root = new URL("../", import.meta.url); const output = new URL("dist/", root); +// Start clean so stale private state under dist/ can never be packaged. +await rm(output, { recursive: true, force: true }); await mkdir(output, { recursive: true }); await Promise.all(["index.html", "explore.html", "services.html", "radar.html", "meetings.html", "account.html", "account.js", "stoa.html", "styles.css", "script.js", "people.js", "activity.js", "explore.js", "services.js", "radar.js", "meetings.js", "call.js", "package.json", "package-lock.json", ".env.example"].map((file) => copyFile(new URL(file, root), new URL(file, output)))); await cp(new URL("assets/", root), new URL("assets/", output), { recursive: true }); diff --git a/scripts/spaces/routes.mjs b/scripts/spaces/routes.mjs index 7d0f133..66cdc86 100644 --- a/scripts/spaces/routes.mjs +++ b/scripts/spaces/routes.mjs @@ -3,7 +3,8 @@ import { AppError } from "../store.mjs"; const SPACE = /^\/api\/spaces\/(plaza|lot-[a-z0-9-]{2,40}|[a-f0-9-]{36})(?:\/(enter|leave|heartbeat|topic|host|decor|remove|invitations|members|rtc-token)(?:\/((?:member|account):[a-f0-9-]{36}))?)?$/; // Maps HTTP requests to the spaces service. Returns null when the request is not a spaces route. -export async function handleSpaces({ path, method, url, token, read, spaces, worlds, limit, ip }) { +export async function handleSpaces({ path, method, url, token, read: readBody, spaces, worlds, limit, ip }) { + const read = async () => { const data = await readBody(); return data && typeof data === "object" && !Array.isArray(data) ? data : {}; }; if (path === "/api/spaces" && method === "GET") return { status: 200, body: await spaces.list({ q: url.searchParams.get("q") || "" }) }; if (path === "/api/spaces" && method === "POST") { limit("spaces:" + ip, 20, 3600000); return { status: 201, body: { space: await spaces.create(token, await read()) } }; } if (path === "/api/signaling/token" && method === "POST") { limit("signaling:" + ip, 30, 60000); return { status: 200, body: await spaces.signalingToken(token) }; } diff --git a/scripts/spaces/service.mjs b/scripts/spaces/service.mjs index fd4a327..7baa887 100644 --- a/scripts/spaces/service.mjs +++ b/scripts/spaces/service.mjs @@ -37,7 +37,10 @@ export function createSpaces({ store, worlds, signaling, calls, secret = "", adm await ready(); const events = []; const result = await store.transaction((state) => { events.push(...sweepLeases(state, now())); return work(state, events); }); - if (events.length) for (const listener of listeners) listener(events); + if (events.length) for (const listener of listeners) { + // The change is already committed, so a failing listener must never turn it into an error. + try { Promise.resolve(listener(events)).catch(() => {}); } catch { /* ignored */ } + } return result; } async function actorFor(token) { @@ -60,7 +63,11 @@ export function createSpaces({ store, worlds, signaling, calls, secret = "", adm const spaces = { onEvents(listener) { listeners.push(listener); }, - async sweep() { await mutate(() => null); }, + async sweep() { + const state = await store.snapshot(); + if (!state.spaceLeases.some((lease) => lease.expiresAt <= now())) return; + await mutate(() => null); + }, async list({ q = "" } = {}) { await ready(); const state = await store.snapshot(); @@ -99,12 +106,16 @@ export function createSpaces({ store, worlds, signaling, calls, secret = "", adm if (system(space)) throw new AppError(403, "The map sets the plaza and its rooms."); if (!can(actor, "edit", space)) throw new AppError(403, "Only the owner can change this space."); const fields = spaceInput(input || {}, space); - if (fields.visibility !== space.visibility) space.channelEpoch += 1; + const visibilityChanged = fields.visibility !== space.visibility; + if (visibilityChanged) space.channelEpoch += 1; Object.assign(space, fields, { updatedAt: new Date(now()).toISOString() }); + let evicted = false; for (const lease of leasesFor(state, space.id, now())) { const holder = { id: lease.actorId, ids: lease.ids, hasProfile: lease.ids.some((entry) => entry.startsWith("member:")), admin: false }; - if (!can(holder, "enter", space)) events.push(...leaveLease(state, space, lease.actorId, now())); + if (!can(holder, "enter", space)) { events.push(...leaveLease(state, space, lease.actorId, now())); evicted = true; } } + // Evicted people still know the old channel name and key, so rotate them unless the channel is public. + if (evicted && !visibilityChanged && space.visibility !== "listed") space.channelEpoch += 1; return view(state, space, actor); }); }, @@ -230,10 +241,12 @@ export function createSpaces({ store, worlds, signaling, calls, secret = "", adm }, async removeMember(token, id, memberId) { const actor = signedIn(await actorFor(token)); + if (!ACTOR_ID.test(memberId || "")) throw new AppError(422, "Choose a builder to remove."); return mutate((state, events) => { const space = findVisible(state, id, actor); if (system(space) || !can(actor, "edit", space)) throw new AppError(403, "Only the owner can change members."); if (memberId === space.ownerId) throw new AppError(422, "The owner stays a member."); + if (!space.members.includes(memberId)) return { members: space.members }; space.members = space.members.filter((entry) => entry !== memberId); if (space.visibility !== "listed") space.channelEpoch += 1; events.push(...leaveLease(state, space, memberId, now()), { type: "member-removed", spaceId: space.id, actorId: memberId }); @@ -245,7 +258,7 @@ export function createSpaces({ store, worlds, signaling, calls, secret = "", adm const actor = await actorFor(token); const state = await store.snapshot(); const channels = [{ spaceId: PLAZA_ID, name: channelName(secret, find(state, PLAZA_ID)), write: Boolean(actor), key: null }]; - if (actor) for (const lease of state.spaceLeases.filter((entry) => entry.actorId === actor.id && entry.expiresAt > now() && entry.spaceId !== PLAZA_ID)) { + if (actor) for (const lease of state.spaceLeases.filter((entry) => (actor.ids.includes(entry.actorId) || entry.ids.some((id) => actor.ids.includes(id))) && entry.expiresAt > now() && entry.spaceId !== PLAZA_ID)) { const space = state.spaces.find((entry) => entry.id === lease.spaceId); if (space) channels.push({ spaceId: space.id, name: channelName(secret, space), write: true, key: spaceKey(secret, space) }); } diff --git a/tests/spaces-api.test.mjs b/tests/spaces-api.test.mjs index 1fff4bb..9969751 100644 --- a/tests/spaces-api.test.mjs +++ b/tests/spaces-api.test.mjs @@ -121,3 +121,16 @@ test("missing Signaling or call configuration is reported plainly", async () => assert.equal((await post(url, "/api/spaces/lot-lounge/rtc-token", ada)).status, 503); } finally { await close(instance); } }); + +test("a JSON null body never causes a server error", async () => { + const { instance, url } = await start(); + try { + const cookie = await join(url, "Nina"); + for (const path of ["/api/spaces/plaza/enter", "/api/spaces/plaza/members", "/api/spaces/plaza/topic"]) { + const response = await request(url, path, { cookie, method: "POST", data: null }); + assert.ok(response.status < 500, path + " " + response.status); + } + assert.ok((await request(url, "/api/spaces/plaza/decor", { cookie, method: "PUT", data: null })).status < 500); + assert.ok((await request(url, "/api/spaces", { cookie, method: "POST", data: null })).status < 500); + } finally { await close(instance); } +}); diff --git a/tests/spaces-service.test.mjs b/tests/spaces-service.test.mjs index f02daa3..86c8a49 100644 --- a/tests/spaces-service.test.mjs +++ b/tests/spaces-service.test.mjs @@ -15,7 +15,7 @@ const env = { AGORA_APP_ID: "a".repeat(32), AGORA_APP_CERTIFICATE: "b".repeat(32 const profile = (name) => ({ name, bio: "Building voice agents with Rust.", intent: "Build a language practice voice agent with natural interruptions.", lookingFor: "Audio engineers", skills: ["Rust", "Voice AI"], location: "Remote", contact: "https://example.com/builder", monitor: false }); const missing = { status: 404, message: "This space was not found." }; -async function setup(t, { plazaCapacity, before } = {}) { +async function setup(t, { plazaCapacity, before, admins = [] } = {}) { const directory = await mkdtemp(resolve(tmpdir(), "spaces-service-")); t.after(() => rm(directory, { recursive: true, force: true })); const clock = { now: 1800000000000 }; @@ -23,10 +23,16 @@ async function setup(t, { plazaCapacity, before } = {}) { const store = createStore(resolve(root, "data/people.json"), resolve(directory, "state.json"), { now }); const person = async (name) => { const { token } = await store.join(profile(name)); return { token, id: (await store.actor(token)).id }; }; const context = before ? await before(store, person) : {}; - const spaces = createSpaces({ store, worlds, secret, now, plazaCapacity, signaling: createSignaling(signalingConfig(env), { now }), calls: createAgoraCalls(agoraConfig(env), { now }) }); + const spaces = createSpaces({ store, worlds, secret, now, plazaCapacity, admins, signaling: createSignaling(signalingConfig(env), { now }), calls: createAgoraCalls(agoraConfig(env), { now }) }); const events = []; spaces.onEvents((list) => events.push(...list)); - return { store, spaces, events, clock, person, ...context }; + const account = async (name, subject) => { + const login = await store.login({ provider: "github", issuer: "https://github.com", subject: String(subject), name }); + await store.join(profile(name), login.token); + const actor = await store.actor(login.token); + return { token: login.token, id: actor.id, accountId: actor.accountId, memberId: actor.memberId }; + }; + return { store, spaces, events, clock, person, account, ...context }; } test("system spaces and legacy rooms are prepared once", async (t) => { @@ -240,3 +246,93 @@ test("making a space private removes people who may no longer enter", async (t) assert.notEqual(own.channel, before.channel); assert.equal((await spaces.signalingToken(stranger.token)).channels.some((channel) => channel.spaceId === space.id), false); }); + +test("access tightening on an unlisted space rotates the channel and key", async (t) => { + const { spaces, person } = await setup(t); + const [owner, stranger] = [await person("Owner"), await person("Stranger")]; + const space = await spaces.create(owner.token, { title: "Side room", visibility: "unlisted", access: "open" }); + const before = await spaces.enter(owner.token, space.id); + await spaces.enter(stranger.token, space.id); + await spaces.update(owner.token, space.id, { access: "members" }); + const after = await spaces.heartbeat(owner.token, space.id); + assert.notEqual(after.channel, before.channel); + assert.notEqual(after.key, before.key); +}); + +test("account-backed actors own, enter, sign tokens, and are blocked by both ids", async (t) => { + const admins = []; + const { spaces, account } = await setup(t, { admins }); + const [owner, guest] = [await account("Acct Owner", 1), await account("Acct Guest", 2)]; + assert.match(owner.id, /^account:/); + const space = await spaces.create(owner.token, { title: "Account room", access: "open" }); + assert.equal(space.ownerId, owner.id); + assert.deepEqual(space.members, [owner.id]); + const entered = await spaces.enter(owner.token, space.id); + assert.equal(entered.hostId, owner.id); + assert.equal((await spaces.get(owner.token, space.id)).occupants[0].id, owner.id); + await spaces.enter(guest.token, space.id); + const signed = await spaces.signalingToken(owner.token); + assert.match(signed.userId, /^a-/); + assert.ok(signed.channels.some((channel) => channel.spaceId === space.id && channel.name === entered.channel)); + const rtc = await spaces.rtcToken(owner.token, space.id, {}); + assert.ok(rtc.uid.startsWith(owner.id.replace(/^account:/, "") + "_")); + const removed = await spaces.removePerson(owner.token, space.id, { actorId: guest.id }); + assert.deepEqual(removed.blocked, [guest.id, guest.memberId]); + assert.notEqual(removed.channel, entered.channel); + await assert.rejects(spaces.enter(guest.token, space.id), { status: 403 }); +}); + +test("a platform admin sees and enters a private space", async (t) => { + const admins = []; + const { spaces, person, account } = await setup(t, { admins }); + const owner = await person("Owner"); + const space = await spaces.create(owner.token, { title: "Board room", visibility: "private" }); + const admin = await account("Admin", 9); + await assert.rejects(spaces.get(admin.token, space.id), missing); + admins.push(admin.accountId); + assert.equal((await spaces.get(admin.token, space.id)).id, space.id); + assert.equal((await spaces.enter(admin.token, space.id)).space.id, space.id); +}); + +test("signalingToken matches leases through any of the actor ids", async (t) => { + const { spaces, store, account } = await setup(t); + const owner = await account("Acct Owner", 1); + const space = await spaces.create(owner.token, { title: "Id room", access: "open" }); + await spaces.enter(owner.token, space.id); + await store.transaction((state) => { state.spaceLeases[0].actorId = owner.memberId; }); + assert.ok((await spaces.signalingToken(owner.token)).channels.some((channel) => channel.spaceId === space.id)); +}); + +test("removeMember validates input and ignores non-members", async (t) => { + const { spaces, events, person } = await setup(t); + const [owner, other] = [await person("Owner"), await person("Other")]; + const space = await spaces.create(owner.token, { title: "Member room", visibility: "unlisted" }); + await assert.rejects(spaces.removeMember(owner.token, space.id, "nonsense"), { status: 422, message: "Choose a builder to remove." }); + const before = await spaces.enter(owner.token, space.id); + events.length = 0; + await spaces.removeMember(owner.token, space.id, other.id); + assert.equal(events.some((event) => event.type === "member-removed"), false); + assert.equal((await spaces.heartbeat(owner.token, space.id)).channel, before.channel); +}); + +test("a throwing or rejecting listener never fails a committed change", async (t) => { + const { spaces, person } = await setup(t); + spaces.onEvents(() => { throw new Error("boom"); }); + spaces.onEvents(async () => { throw new Error("late"); }); + const owner = await person("Owner"); + assert.equal((await spaces.enter(owner.token, "plaza")).space.id, "plaza"); +}); + +test("sweep without expired leases never writes", async (t) => { + const { spaces, store, person, clock } = await setup(t); + const owner = await person("Owner"); + await spaces.enter(owner.token, "plaza"); + const original = store.transaction; + let count = 0; + store.transaction = (...args) => { count += 1; return original(...args); }; + await spaces.sweep(); + assert.equal(count, 0); + clock.now += 120000; + await spaces.sweep(); + assert.equal(count, 1); +}); From d32491378a0912c4da3b7759317886ac9a87c2da Mon Sep 17 00:00:00 2001 From: Brent G Date: Wed, 7 Oct 2026 18:36:27 +0000 Subject: [PATCH 16/71] Record follow-ups for later Stoa plans MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🤖 Built with SMT --- .../plans/2026-10-07-spaces-core.md | 25 +++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/docs/superpowers/plans/2026-10-07-spaces-core.md b/docs/superpowers/plans/2026-10-07-spaces-core.md index 08c54ad..19cdc0f 100644 --- a/docs/superpowers/plans/2026-10-07-spaces-core.md +++ b/docs/superpowers/plans/2026-10-07-spaces-core.md @@ -2204,3 +2204,28 @@ git commit -m "Serve the spaces API and Stoa pages" -m "🤖 Built with SMT ` redirect and the meetings page retirement, client-side encryption and receiver rules (C), themes and the renderer (B), Chat (D), and noticeboard results and profile cards (E). + +## Notes for Later Plans (from the final review) + +- **Plan C (client):** + - Treat 404 and 410 from `heartbeat` as "you have left". + - Call `/api/signaling/token` after `enter`, because the enter response carries channel names and keys but no token. + - Update a space with `PUT /api/spaces/`; the spec says PATCH, so align the spec. + - Add a 308 redirect from `/stoa` to `/stoa/`. + - Key the spaces rate limits by actor rather than IP so heartbeats from a shared NAT don't exhaust them. + - Decide whether owners and admins may remove people without being present. +- **Plan D (Chat):** + - `spaces.onEvents` runs after commit and cannot veto a change, but the spec wants member-list changes kept only if Chat succeeds. Add a pre-commit hook or a compensating transaction for create, delete, add-member, remove-member and invitation acceptance. + - Rule on invitation acceptance inside `enter`, which must never block. + - Events carry only `actorId`; add `ids` so Chat usernames can be derived from member IDs. + - Add a `created` event. + - Ignore plaza `entered`/`left` events. +- **Plan E (discovery):** + - Occupants expose only `{ id, name }`. Add the member ID for profile cards and DMs. + - Make `publicSpace` an allowlist before adding new fields. +- **`/meet` redirect (Plan C):** + - Room migration runs once per process. Re-sync rooms or freeze room creation before redirecting `/meet/`. + - Migrated rooms count toward the 3-space Basic limit. +- **Scale:** + - Leases live in the single state document, so every heartbeat rewrites it. + - Move leases out of the aggregate, or batch heartbeats, before real plaza load. From dfa50916bdae459165339ed7a89cbc54c31c414a Mon Sep 17 00:00:00 2001 From: Brent G Date: Wed, 7 Oct 2026 19:35:38 +0000 Subject: [PATCH 17/71] Add the world engine and themes plan MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🤖 Built with SMT --- .../plans/2026-10-07-world-engine.md | 1440 +++++++++++++++++ 1 file changed, 1440 insertions(+) create mode 100644 docs/superpowers/plans/2026-10-07-world-engine.md diff --git a/docs/superpowers/plans/2026-10-07-world-engine.md b/docs/superpowers/plans/2026-10-07-world-engine.md new file mode 100644 index 0000000..06e8cdd --- /dev/null +++ b/docs/superpowers/plans/2026-10-07-world-engine.md @@ -0,0 +1,1440 @@ +# World Engine And Themes (Plan B of 5) Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Make the Stoa visible: a themed, walkable plaza at `/stoa/` where one person moves by click, tap, or keyboard, with three built-in themes, a Minimal view, and an accessible side panel. + +**Architecture:** There are three layers. Maps (`worlds/*.json`) say what each tile is for. Themes (`themes//theme.json`) are data only and say how each tile and decoration looks. The engine (`world/*.js`) handles movement, camera, and input, and draws through a swappable `Renderer` interface whose Canvas 2D implementation draws from a fixed catalog of patterns and shapes. Everything under `world/` is plain browser ESM; the pure parts are also imported by Node tests and the server. + +**Tech Stack:** Browser ES modules, Canvas 2D, the Node 20 built-in test runner, and no new dependencies. Visual checks use the installed `google-chrome --headless`. + +**Spec:** `docs/superpowers/specs/2026-10-07-stoa-spaces-design.md` (section 4, "World Engine And Themes") + +**Builds on Plan A:** `world/map.js` (`parseMap`, `findPath`, `walkable`, `reachable`), the worlds in `worlds/`, `GET /api/worlds/`, `GET /api/spaces`, `scripts/spaces/routes.mjs` (`handleSpaces`), `scripts/spaces/model.mjs` (`THEMES`), and the `stoa.html` shell. + +## Global Constraints + +- No new npm dependencies and no external assets (no image URLs or CDN fonts beyond the stylesheet's existing Google Fonts import). +- Themes are data only: no JavaScript, and no image files in this plan. Image roles come with the later custom-themes plan. +- Every theme must draw every core role (`CORE_ROLES`) and every themed kind (`THEMED_KINDS`, the 13 decor kinds plus `noticeboard`). +- Theme UI tokens are limited to `--paper`, `--surface`, `--ink`, `--muted`, `--line`, `--accent`, `--accent-dark`, `--green` (hex colors only) and `font` (one of `IBM Plex Sans`, `IBM Plex Mono`, `Georgia`, `system-ui`). +- Effects come only from the catalog `dust`, `rain`, `leaves`, `neon-glow`. With `prefers-reduced-motion`, nothing animates: no effects, no water motion, and no idle redraw loop. +- The canvas is never the only way to act. A DOM panel lists rooms with **Go to** buttons, the people present, and live status messages. +- Viewers may switch only their own display to Minimal. `?theme=` previews a built-in theme for the viewer only. +- Code style follows the repo: two-space indent, double quotes, semicolons, compact one-line style, `.mjs` for Node scripts, and `.js` ESM under `world/` and at the root for browser modules. +- Every commit message ends with the line `🤖 Built with SMT `. Do not add co-author trailers. Run `npm test` before each commit; it must pass completely. + +## File Structure + +| File | Responsibility | +| --- | --- | +| `world/kinds.js` (create) | `DECOR_KINDS`, `BLOCKING_DECOR`, `THEMED_KINDS`, shared by server and browser | +| `world/map.js` (modify) | Adds the `ground` role array | +| `world/themes.js` (create) | Theme contract, catalogs, `validateTheme()` | +| `themes/agora/theme.json`, `themes/minimal/theme.json`, `themes/cyberpunk/theme.json` (create) | Built-in themes | +| `scripts/spaces/themes.mjs` (create) | `loadThemes()` validates the built-ins at startup | +| `world/camera.js`, `world/motion.js` (create) | Camera math and walking interpolation | +| `world/renderer-canvas.js` (create) | Canvas 2D renderer: patterns, decor shapes, avatars, labels, lighting, effects | +| `world/engine.js` (create) | Movement, input, camera follow, and the draw loop | +| `stoa.html`, `stoa.js`, `styles.css` (modify or create) | The plaza page and its panel | +| `scripts/spaces/model.mjs`, `scripts/spaces/decor.mjs`, `scripts/spaces/routes.mjs`, `scripts/serve.mjs`, `scripts/build.mjs` (modify) | Wiring | +| `tests/map.test.mjs`, `tests/themes.test.mjs`, `tests/world-client.test.mjs`, `tests/world-renderer.test.mjs`, `tests/world-engine.test.mjs`, `tests/spaces-api.test.mjs`, `tests/server.test.mjs` | Tests | + +--- + +### Task 1: Shared kinds and the ground layer + +**Files:** +- Create: `world/kinds.js` +- Modify: `scripts/spaces/model.mjs:4`, `scripts/spaces/decor.mjs:3,6`, `world/map.js` (the tile-layer loop and the returned object), `scripts/maps/build-maps.mjs` (`tiled()` and `plaza()`) +- Regenerate: `worlds/plaza/map.json` (`npm run maps`) +- Test: `tests/map.test.mjs` (append) + +**Interfaces:** +- Produces from `world/kinds.js`: + - `DECOR_KINDS` (the same 13 kinds, in the same order) + - `BLOCKING_DECOR` (a Set of `sofa, table, whiteboard, bookshelf, statue, fountain`) + - `THEMED_KINDS` (`[...DECOR_KINDS, "noticeboard"]`) +- `model.mjs` and `decor.mjs` keep exporting `DECOR_KINDS` and `BLOCKING_DECOR`, so existing imports keep working. +- `parseMap(source).ground: (string|null)[]` holds the ground-layer role of each tile. `roles` still holds the topmost role. +- `tiled(id, grid, objects, under)` takes an optional 2D grid of ground roles. When it is given, structure tiles keep that ground instead of `floor`. The plaza passes one, so seats sit on the path and plants on grass. + +- [ ] **Step 1: Write the failing test** + +Append to `tests/map.test.mjs`: + +```js +import { BLOCKING_DECOR, DECOR_KINDS, THEMED_KINDS } from "../world/kinds.js"; +import { DECOR_KINDS as SERVER_KINDS } from "../scripts/spaces/model.mjs"; +import { BLOCKING_DECOR as SERVER_BLOCKING } from "../scripts/spaces/decor.mjs"; + +test("decoration kinds are shared by the browser and the server", () => { + assert.deepEqual(DECOR_KINDS, ["plant", "lamp", "rug", "sofa", "chair", "table", "whiteboard", "bookshelf", "screen", "banner", "poster", "statue", "fountain"]); + assert.deepEqual(THEMED_KINDS, [...DECOR_KINDS, "noticeboard"]); + assert.deepEqual([...BLOCKING_DECOR], ["sofa", "table", "whiteboard", "bookshelf", "statue", "fountain"]); + assert.equal(SERVER_KINDS, DECOR_KINDS); + assert.equal(SERVER_BLOCKING, BLOCKING_DECOR); +}); +test("maps keep the ground role beneath structures", async () => { + const plaza = await world("plaza"); + const at = (x, y) => [plaza.ground[y * plaza.width + x], plaza.roles[y * plaza.width + x]]; + assert.deepEqual(at(20, 7), ["path", "seat"]); + assert.deepEqual(at(5, 8), ["grass", "plant"]); + assert.deepEqual(at(0, 0), ["floor", "wall"]); + assert.deepEqual(at(21, 17), ["path", "path"]); + assert.equal(plaza.ground.length, plaza.width * plaza.height); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `node --test tests/map.test.mjs` +Expected: FAIL with `Cannot find module` for `world/kinds.js`. + +- [ ] **Step 3: Implement** + +Create `world/kinds.js`: + +```js +// Semantic kinds shared by maps, themes, the engine, and the server. Themes draw them; they never carry behavior. +export const DECOR_KINDS = ["plant", "lamp", "rug", "sofa", "chair", "table", "whiteboard", "bookshelf", "screen", "banner", "poster", "statue", "fountain"]; +export const BLOCKING_DECOR = new Set(["sofa", "table", "whiteboard", "bookshelf", "statue", "fountain"]); +export const THEMED_KINDS = [...DECOR_KINDS, "noticeboard"]; +``` + +In `scripts/spaces/model.mjs`, replace line 4 (`export const DECOR_KINDS = [...]`) with: + +```js +export { DECOR_KINDS } from "../../world/kinds.js"; +``` + +In `scripts/spaces/decor.mjs`, replace `import { DECOR_KINDS } from "./model.mjs";` with: + +```js +import { BLOCKING_DECOR, DECOR_KINDS } from "../../world/kinds.js"; +``` + +and replace the line `export const BLOCKING_DECOR = new Set([...]);` with: + +```js +export { BLOCKING_DECOR }; +``` + +In `scripts/maps/build-maps.mjs`: +1. Change the signature to `export function tiled(id, grid, objects, under = null) {`. +2. Change the ground loop to walk coordinates so it can read `under`: + +```js + grid.forEach((row, y) => row.forEach((role, x) => { + const base = ["water", "grass", "path"].includes(role) ? role : under ? under[y][x] : "floor"; + ground.push(gid(base)); structure.push(role === base ? 0 : gid(role)); + })); +``` + + This replaces the existing `for (const row of grid) for (const role of row) { ... }` loop. +3. In `plaza()`, directly after `const grid = blank(44, 34, "wall");`, build the ground grid with the same ground fills: + +```js + const under = blank(44, 34, "floor"); + fill(under, 1, 1, 42, 18, "grass"); + fill(under, 1, 9, 42, 2, "path"); fill(under, 21, 1, 2, 18, "path"); + fill(under, 17, 6, 10, 8, "path"); fill(under, 19, 8, 6, 4, "water"); +``` + +4. Change the plaza's `return tiled("plaza", grid, {` to pass `under` as the fourth argument: `return tiled("plaza", grid, { ...objects... }, under);`. Keep the object literal unchanged and add `, under` after its closing brace. +5. Run `npm run maps` to regenerate `worlds/plaza/map.json`. The room map does not change. + +In `world/map.js` `parseMap`: +1. Next to `const roles = ...`, add `const ground = new Array(width * height).fill(null);`. +2. Directly after `roles[index] = tile.role;`, add `if (name === "ground") ground[index] = tile.role;`. +3. In the returned object, change `roles, blocked,` to `roles, ground, blocked,`. + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `npm test` +Expected: PASS (all existing tests plus 2 new). + +- [ ] **Step 5: Commit** + +```bash +git add world/kinds.js world/map.js scripts/spaces/model.mjs scripts/spaces/decor.mjs scripts/maps/build-maps.mjs worlds/plaza/map.json tests/map.test.mjs +git commit -m "Share decoration kinds and keep the map's ground layer" -m "🤖 Built with SMT " +``` + +--- + +### Task 2: The theme contract + +**Files:** +- Create: `world/themes.js` +- Test: `tests/themes.test.mjs` + +**Interfaces:** +- Consumes: `CORE_ROLES` (`world/map.js`), `THEMED_KINDS` (Task 1). +- Produces: + - Constants: `PATTERNS`, `EFFECTS`, `UI_TOKENS`, `FONTS`, `AVATAR_STYLES`, `VIDEO_LAYOUTS`, `MANIFEST_LIMIT = 65536` + - `validateTheme(theme) -> string[]` (an empty array means valid) +- Theme manifest shape: + ``` + { id, name, version: 1, + roles: { : { pattern: , colors: [1..4 hex] } }, + decor: { : { colors: [1..4 hex], variants?: [[1..4 hex]] (at most 8) } }, + avatar: { style: "token"|"pawn", palette: [4..12 hex], outline: hex }, + ui: { : hex, font?: }, + lighting: { ambient: 0..1, tint: hex }, + effects: [], + video: "over-avatar"|"strip"|"grid" } + ``` + +- [ ] **Step 1: Write the failing test** + +Create `tests/themes.test.mjs`: + +```js +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { CORE_ROLES } from "../world/map.js"; +import { THEMED_KINDS } from "../world/kinds.js"; +import { AVATAR_STYLES, EFFECTS, FONTS, MANIFEST_LIMIT, PATTERNS, UI_TOKENS, VIDEO_LAYOUTS, validateTheme } from "../world/themes.js"; + +export function sampleTheme() { + return { + id: "sample", name: "Sample", version: 1, + roles: Object.fromEntries(CORE_ROLES.map((role) => [role, { pattern: "flat", colors: ["#112233"] }])), + decor: Object.fromEntries(THEMED_KINDS.map((kind) => [kind, { colors: ["#445566", "#778899"] }])), + avatar: { style: "token", palette: ["#111111", "#222222", "#333333", "#444444"], outline: "#000000" }, + ui: { "--paper": "#ffffff", "--ink": "#000000", font: "system-ui" }, + lighting: { ambient: 1, tint: "#ffffff" }, + effects: [], + video: "grid" + }; +} +const broken = (change) => { const theme = sampleTheme(); change(theme); return validateTheme(theme); }; + +test("the theme catalogs are fixed", () => { + assert.deepEqual(PATTERNS, ["flat", "tiles", "path", "grass", "water", "wall", "column", "door", "table", "seat", "plant", "decor", "neon"]); + assert.deepEqual(EFFECTS, ["dust", "rain", "leaves", "neon-glow"]); + assert.deepEqual(UI_TOKENS, ["--paper", "--surface", "--ink", "--muted", "--line", "--accent", "--accent-dark", "--green"]); + assert.deepEqual(FONTS, ["IBM Plex Sans", "IBM Plex Mono", "Georgia", "system-ui"]); + assert.deepEqual(AVATAR_STYLES, ["token", "pawn"]); + assert.deepEqual(VIDEO_LAYOUTS, ["over-avatar", "strip", "grid"]); + assert.equal(MANIFEST_LIMIT, 65536); +}); +test("a complete data-only theme is valid", () => { + assert.deepEqual(validateTheme(sampleTheme()), []); + assert.deepEqual(broken((theme) => { theme.decor.rug.variants = [["#010101"], ["#020202", "#030303"]]; theme.effects = ["rain", "neon-glow"]; }), []); +}); +test("every role and themed kind must be drawn", () => { + assert.match(broken((theme) => { delete theme.roles.water; }).join(" "), /water role/); + assert.match(broken((theme) => { delete theme.decor.noticeboard; }).join(" "), /noticeboard decoration/); +}); +test("themes cannot carry code, files, or unknown styling", () => { + assert.notDeepEqual(broken((theme) => { theme.roles.floor = { image: "floor.svg" }; }), []); + assert.notDeepEqual(broken((theme) => { theme.roles.floor.pattern = "script"; }), []); + assert.notDeepEqual(broken((theme) => { theme.roles.floor.colors = ["red"]; }), []); + assert.notDeepEqual(broken((theme) => { theme.roles.floor.colors = ["#111111", "#222222", "#333333", "#444444", "#555555"]; }), []); + assert.notDeepEqual(broken((theme) => { theme.ui["--paper"] = "url(https://evil.example)"; }), []); + assert.notDeepEqual(broken((theme) => { theme.ui["--position"] = "#ffffff"; }), []); + assert.notDeepEqual(broken((theme) => { theme.ui.font = "Comic Sans MS"; }), []); + assert.notDeepEqual(broken((theme) => { theme.effects = ["fireworks"]; }), []); + assert.notDeepEqual(broken((theme) => { theme.decor.rug.variants = new Array(9).fill(["#010101"]); }), []); + assert.notDeepEqual(broken((theme) => { theme.avatar.style = "robot"; }), []); + assert.notDeepEqual(broken((theme) => { theme.avatar.palette = ["#111111"]; }), []); + assert.notDeepEqual(broken((theme) => { theme.lighting.ambient = 2; }), []); + assert.notDeepEqual(broken((theme) => { theme.video = "theatre"; }), []); + assert.notDeepEqual(broken((theme) => { theme.version = 2; }), []); + assert.notDeepEqual(broken((theme) => { theme.id = "Bad Id"; }), []); + assert.notDeepEqual(broken((theme) => { theme.name = "x".repeat(70000); }), []); + assert.deepEqual(validateTheme(null), ["A theme must be an object."]); + assert.deepEqual(validateTheme([]), ["A theme must be an object."]); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `node --test tests/themes.test.mjs` +Expected: FAIL with `Cannot find module` for `world/themes.js`. + +- [ ] **Step 3: Implement** + +Create `world/themes.js`: + +```js +import { CORE_ROLES } from "./map.js"; +import { THEMED_KINDS } from "./kinds.js"; + +// Themes are data only: patterns, shapes, and effects come from fixed catalogs the renderer implements. +export const PATTERNS = ["flat", "tiles", "path", "grass", "water", "wall", "column", "door", "table", "seat", "plant", "decor", "neon"]; +export const EFFECTS = ["dust", "rain", "leaves", "neon-glow"]; +export const UI_TOKENS = ["--paper", "--surface", "--ink", "--muted", "--line", "--accent", "--accent-dark", "--green"]; +export const FONTS = ["IBM Plex Sans", "IBM Plex Mono", "Georgia", "system-ui"]; +export const AVATAR_STYLES = ["token", "pawn"]; +export const VIDEO_LAYOUTS = ["over-avatar", "strip", "grid"]; +export const MANIFEST_LIMIT = 65536; +const COLOR = /^#[0-9a-f]{6}$/i; + +export function validateTheme(theme) { + if (!theme || typeof theme !== "object" || Array.isArray(theme)) return ["A theme must be an object."]; + const errors = []; + const colors = (value, where, min = 1, max = 4) => { if (!Array.isArray(value) || value.length < min || value.length > max || !value.every((color) => typeof color === "string" && COLOR.test(color))) errors.push(where + " needs " + min + " to " + max + " colors like #a1b2c3."); }; + if (JSON.stringify(theme).length > MANIFEST_LIMIT) errors.push("The theme is larger than 64 KB."); + if (typeof theme.id !== "string" || !/^[a-z0-9-]{2,40}$/.test(theme.id)) errors.push("The theme needs an id of 2 to 40 lowercase letters, digits, or dashes."); + if (typeof theme.name !== "string" || theme.name.length < 2 || theme.name.length > 40) errors.push("The theme needs a name of 2 to 40 characters."); + if (theme.version !== 1) errors.push("Only theme version 1 is supported."); + for (const role of CORE_ROLES) { + const entry = theme.roles?.[role]; + if (!entry) { errors.push("The theme does not draw the " + role + " role."); continue; } + if (!PATTERNS.includes(entry.pattern)) errors.push("The " + role + " role uses an unknown pattern."); + colors(entry.colors, "The " + role + " role"); + } + for (const kind of THEMED_KINDS) { + const entry = theme.decor?.[kind]; + if (!entry) { errors.push("The theme does not draw the " + kind + " decoration."); continue; } + colors(entry.colors, "The " + kind + " decoration"); + if (entry.variants === undefined) continue; + if (!Array.isArray(entry.variants) || entry.variants.length > 8) errors.push("The " + kind + " decoration may have up to 8 variants."); + else entry.variants.forEach((variant, index) => colors(variant, "Variant " + index + " of " + kind)); + } + if (!AVATAR_STYLES.includes(theme.avatar?.style)) errors.push("Choose an avatar style: token or pawn."); + colors(theme.avatar?.palette, "The avatar palette", 4, 12); + colors([theme.avatar?.outline], "The avatar outline"); + for (const [token, value] of Object.entries(theme.ui || {})) { + if (token === "font") { if (!FONTS.includes(value)) errors.push("Choose an available font."); } + else if (!UI_TOKENS.includes(token)) errors.push("The ui token " + token + " is not allowed."); + else if (typeof value !== "string" || !COLOR.test(value)) errors.push("The ui token " + token + " must be a color like #a1b2c3."); + } + const ambient = theme.lighting?.ambient; + if (typeof ambient !== "number" || ambient < 0 || ambient > 1) errors.push("Lighting ambient must be between 0 and 1."); + colors([theme.lighting?.tint], "The lighting tint"); + if (!Array.isArray(theme.effects) || theme.effects.some((effect) => !EFFECTS.includes(effect))) errors.push("Effects must come from the built-in list."); + if (!VIDEO_LAYOUTS.includes(theme.video)) errors.push("Choose a video layout: over-avatar, strip, or grid."); + return errors; +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `node --test tests/themes.test.mjs` +Expected: PASS (4 tests). + +- [ ] **Step 5: Commit** + +```bash +git add world/themes.js tests/themes.test.mjs +git commit -m "Add the data-only theme contract" -m "🤖 Built with SMT " +``` + +--- + +### Task 3: Built-in themes and the themes API + +**Files:** +- Create: `themes/agora/theme.json`, `themes/minimal/theme.json`, `themes/cyberpunk/theme.json`, `scripts/spaces/themes.mjs` +- Modify: `scripts/spaces/routes.mjs` (the `handleSpaces` parameters and one new route), `scripts/serve.mjs` (import, wiring, the `handleSpaces` call), `scripts/build.mjs` (copy `themes/`), `tests/server.test.mjs` (production files) +- Test: `tests/themes.test.mjs` (append), `tests/spaces-api.test.mjs` (append) + +**Interfaces:** +- Consumes: `THEMES = ["agora","minimal","cyberpunk"]` (`model.mjs`), `validateTheme` (Task 2). +- Produces: + - `loadThemes(directory) -> Theme[]`, in `THEMES` order. Throws an `Error` naming the theme if any built-in is invalid or its id does not match its folder. + - `GET /api/themes -> { themes: Theme[] }` + - `createAppServer` accepts an optional `options.themes`. + +- [ ] **Step 1: Write the failing tests** + +Append to `tests/themes.test.mjs`: + +```js +import { mkdtemp, mkdir, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { resolve } from "node:path"; +import { loadThemes } from "../scripts/spaces/themes.mjs"; +import { THEMES } from "../scripts/spaces/model.mjs"; + +const root = new URL("../", import.meta.url).pathname; + +test("the three built-in themes are valid and look different", () => { + const themes = loadThemes(root); + assert.deepEqual(themes.map((theme) => theme.id), THEMES); + for (const theme of themes) assert.deepEqual(validateTheme(theme), [], theme.id); + const [agora, minimal, cyberpunk] = themes; + assert.notEqual(agora.roles.floor.colors[0], cyberpunk.roles.floor.colors[0]); + assert.deepEqual(minimal.effects, [], "Minimal has no effects"); + assert.ok(cyberpunk.effects.includes("rain") && cyberpunk.effects.includes("neon-glow")); + assert.equal(agora.ui["--accent"], "#ce4b24", "Agora keeps the portal's terracotta"); +}); +test("an invalid built-in theme stops the server from starting", async () => { + const directory = await mkdtemp(resolve(tmpdir(), "themes-")); + try { + for (const id of THEMES) { + await mkdir(resolve(directory, "themes", id), { recursive: true }); + await writeFile(resolve(directory, "themes", id, "theme.json"), JSON.stringify({ ...sampleTheme(), id })); + } + assert.equal(loadThemes(directory).length, 3); + await writeFile(resolve(directory, "themes", "minimal", "theme.json"), JSON.stringify({ ...sampleTheme(), id: "minimal", effects: ["fireworks"] })); + assert.throws(() => loadThemes(directory), /Theme minimal is invalid/); + await writeFile(resolve(directory, "themes", "minimal", "theme.json"), JSON.stringify({ ...sampleTheme(), id: "other" })); + assert.throws(() => loadThemes(directory), /Theme minimal is invalid/); + } finally { await rm(directory, { recursive: true, force: true }); } +}); +``` + +Append to `tests/spaces-api.test.mjs`: + +```js +test("the themes API returns the three built-in themes", async () => { + const { instance, url } = await start(); + try { + const response = await request(url, "/api/themes"); + assert.equal(response.status, 200); + assert.deepEqual((await response.json()).themes.map((theme) => theme.id), ["agora", "minimal", "cyberpunk"]); + } finally { await close(instance); } +}); +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `node --test tests/themes.test.mjs tests/spaces-api.test.mjs` +Expected: FAIL with `Cannot find module` for `scripts/spaces/themes.mjs`. The API test fails with 404. + +- [ ] **Step 3: Implement** + +Create `scripts/spaces/themes.mjs`: + +```js +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; +import { validateTheme } from "../../world/themes.js"; +import { THEMES } from "./model.mjs"; + +// Built-in themes are validated at startup so a broken theme can never reach a browser. +export function loadThemes(directory) { + return THEMES.map((id) => { + const theme = JSON.parse(readFileSync(resolve(directory, "themes", id, "theme.json"), "utf8")); + const errors = validateTheme(theme); + if (theme.id !== id) errors.push("Its id does not match its folder."); + if (errors.length) throw new Error("Theme " + id + " is invalid: " + errors.join(" ")); + return theme; + }); +} +``` + +Create `themes/agora/theme.json`: + +```json +{ + "id": "agora", + "name": "Agora", + "version": 1, + "roles": { + "floor": { "pattern": "tiles", "colors": ["#ebe5d5", "#dcd3bd"] }, + "path": { "pattern": "path", "colors": ["#ddd2b8", "#c9bc9c"] }, + "grass": { "pattern": "grass", "colors": ["#b9c29b", "#97a37a"] }, + "water": { "pattern": "water", "colors": ["#7ea6aa", "#a9c9cb"] }, + "wall": { "pattern": "wall", "colors": ["#d7cdb5", "#b9ad92", "#e8e0cc"] }, + "column": { "pattern": "column", "colors": ["#f4f1e8", "#cfc6b0"] }, + "door": { "pattern": "door", "colors": ["#ce4b24", "#7a2c14"] }, + "table": { "pattern": "table", "colors": ["#8a6a4f", "#6b503b"] }, + "seat": { "pattern": "seat", "colors": ["#b5552f", "#8a3f22"] }, + "plant": { "pattern": "plant", "colors": ["#526951", "#a0522d"] }, + "decor": { "pattern": "decor", "colors": ["#ce4b24"] } + }, + "decor": { + "plant": { "colors": ["#526951", "#a0522d"], "variants": [["#526951", "#a0522d"], ["#6f8a5a", "#8a5a3c"]] }, + "lamp": { "colors": ["#f2c46d", "#3b3a33"] }, + "rug": { "colors": ["#ce4b24", "#8f3a1d"], "variants": [["#ce4b24", "#8f3a1d"], ["#526951", "#384a37"]] }, + "sofa": { "colors": ["#b5552f", "#7f3a20"] }, + "chair": { "colors": ["#8a6a4f", "#5e4734"] }, + "table": { "colors": ["#8a6a4f", "#6b503b"] }, + "whiteboard": { "colors": ["#fffef9", "#6c746b"] }, + "bookshelf": { "colors": ["#ce4b24", "#5e4734", "#526951"] }, + "screen": { "colors": ["#7ea6aa", "#272d28"] }, + "banner": { "colors": ["#ce4b24", "#272d28"] }, + "poster": { "colors": ["#f5f4ee", "#272d28"] }, + "statue": { "colors": ["#f4f1e8", "#cfc6b0"] }, + "fountain": { "colors": ["#e8e0cc", "#7ea6aa"] }, + "noticeboard": { "colors": ["#c9a97a", "#5e4734", "#fffef9"] } + }, + "avatar": { "style": "token", "palette": ["#ce4b24", "#526951", "#3c5a78", "#8a6a4f", "#7a4f8a", "#c08a1f"], "outline": "#272d28" }, + "ui": { "--paper": "#f5f4ee", "--surface": "#fffef9", "--ink": "#272d28", "--muted": "#6c746b", "--line": "#dedfd3", "--accent": "#ce4b24", "--accent-dark": "#a93a1a", "--green": "#526951", "font": "IBM Plex Sans" }, + "lighting": { "ambient": 1, "tint": "#fff4dc" }, + "effects": ["dust"], + "video": "over-avatar" +} +``` + +Create `themes/minimal/theme.json`: + +```json +{ + "id": "minimal", + "name": "Minimal", + "version": 1, + "roles": { + "floor": { "pattern": "flat", "colors": ["#ffffff"] }, + "path": { "pattern": "flat", "colors": ["#eeeeee"] }, + "grass": { "pattern": "flat", "colors": ["#e3eee0"] }, + "water": { "pattern": "flat", "colors": ["#cfe3f5"] }, + "wall": { "pattern": "flat", "colors": ["#1a1a1a"] }, + "column": { "pattern": "flat", "colors": ["#555555"] }, + "door": { "pattern": "flat", "colors": ["#0057d9"] }, + "table": { "pattern": "flat", "colors": ["#8c8c8c"] }, + "seat": { "pattern": "seat", "colors": ["#4a4a4a", "#222222"] }, + "plant": { "pattern": "flat", "colors": ["#2e7d32"] }, + "decor": { "pattern": "flat", "colors": ["#0057d9"] } + }, + "decor": { + "plant": { "colors": ["#2e7d32", "#4a4a4a"] }, + "lamp": { "colors": ["#f9a825", "#222222"] }, + "rug": { "colors": ["#cccccc", "#999999"] }, + "sofa": { "colors": ["#4a4a4a", "#222222"] }, + "chair": { "colors": ["#4a4a4a", "#222222"] }, + "table": { "colors": ["#8c8c8c", "#5c5c5c"] }, + "whiteboard": { "colors": ["#ffffff", "#222222"] }, + "bookshelf": { "colors": ["#0057d9", "#222222", "#b3261e"] }, + "screen": { "colors": ["#cfe3f5", "#111111"] }, + "banner": { "colors": ["#0057d9", "#222222"] }, + "poster": { "colors": ["#ffffff", "#222222"] }, + "statue": { "colors": ["#bbbbbb", "#777777"] }, + "fountain": { "colors": ["#bbbbbb", "#cfe3f5"] }, + "noticeboard": { "colors": ["#ffffff", "#222222", "#0057d9"] } + }, + "avatar": { "style": "token", "palette": ["#0057d9", "#b3261e", "#1e7d32", "#6a1b9a", "#e65100", "#00695c"], "outline": "#000000" }, + "ui": { "--paper": "#ffffff", "--surface": "#ffffff", "--ink": "#111111", "--muted": "#4a4a4a", "--line": "#c8c8c8", "--accent": "#0057d9", "--accent-dark": "#003c99", "--green": "#1e7d32", "font": "system-ui" }, + "lighting": { "ambient": 1, "tint": "#ffffff" }, + "effects": [], + "video": "grid" +} +``` + +Create `themes/cyberpunk/theme.json`: + +```json +{ + "id": "cyberpunk", + "name": "Cyberpunk", + "version": 1, + "roles": { + "floor": { "pattern": "neon", "colors": ["#14151f", "#ff2bd6"] }, + "path": { "pattern": "neon", "colors": ["#1b1d2b", "#00e5ff"] }, + "grass": { "pattern": "tiles", "colors": ["#16202a", "#1f2e3a"] }, + "water": { "pattern": "water", "colors": ["#0b2a3a", "#00e5ff"] }, + "wall": { "pattern": "wall", "colors": ["#252838", "#11121a", "#3a3e57"] }, + "column": { "pattern": "column", "colors": ["#2e3250", "#00e5ff"] }, + "door": { "pattern": "door", "colors": ["#ff2bd6", "#5c0f4f"] }, + "table": { "pattern": "table", "colors": ["#2b2f45", "#1a1c2b"] }, + "seat": { "pattern": "seat", "colors": ["#00e5ff", "#007d8c"] }, + "plant": { "pattern": "plant", "colors": ["#39ff88", "#2b2f45"] }, + "decor": { "pattern": "decor", "colors": ["#ffd400"] } + }, + "decor": { + "plant": { "colors": ["#39ff88", "#2b2f45"] }, + "lamp": { "colors": ["#ff2bd6", "#2b2f45"] }, + "rug": { "colors": ["#3a1d5c", "#ff2bd6"] }, + "sofa": { "colors": ["#00e5ff", "#005c66"] }, + "chair": { "colors": ["#ff2bd6", "#5c0f4f"] }, + "table": { "colors": ["#2b2f45", "#1a1c2b"] }, + "whiteboard": { "colors": ["#0b2a3a", "#00e5ff"] }, + "bookshelf": { "colors": ["#ff2bd6", "#11121a", "#00e5ff"] }, + "screen": { "colors": ["#00e5ff", "#05060a"] }, + "banner": { "colors": ["#ffd400", "#11121a"] }, + "poster": { "colors": ["#ff2bd6", "#11121a"] }, + "statue": { "colors": ["#9d7bff", "#2e3250"] }, + "fountain": { "colors": ["#2e3250", "#00e5ff"] }, + "noticeboard": { "colors": ["#151724", "#00e5ff", "#ff2bd6"] } + }, + "avatar": { "style": "pawn", "palette": ["#ff2bd6", "#00e5ff", "#39ff88", "#ffd400", "#ff6b2b", "#9d7bff"], "outline": "#0b0c12" }, + "ui": { "--paper": "#0b0c12", "--surface": "#151724", "--ink": "#e8eaff", "--muted": "#9aa0c3", "--line": "#2a2d44", "--accent": "#ff2bd6", "--accent-dark": "#c21fa3", "--green": "#39ff88", "font": "IBM Plex Mono" }, + "lighting": { "ambient": 0.7, "tint": "#1a0b3d" }, + "effects": ["rain", "neon-glow"], + "video": "strip" +} +``` + +In `scripts/spaces/routes.mjs`: +1. Add `themes` to the destructured parameters: `({ path, method, url, token, read: readBody, spaces, worlds, themes, limit, ip })`. +2. Directly after the `/api/signaling/token` line, add: + +```js + if (path === "/api/themes" && method === "GET") return { status: 200, body: { themes } }; +``` + +In `scripts/serve.mjs`: +1. After the `handleSpaces` import, add `import { loadThemes } from "./spaces/themes.mjs";`. +2. After `const worlds = options.worlds || loadWorlds(directory);`, add `const themes = options.themes || loadThemes(directory);`. +3. In the `handleSpaces({ ... })` call, add `themes,` after `worlds,`. + +In `scripts/build.mjs`, after the line that copies `worlds/`, add: + +```js +await cp(new URL("themes/", root), new URL("themes/", output), { recursive: true }); +``` + +In `tests/server.test.mjs`, add `"themes/agora/theme.json"`, `"themes/minimal/theme.json"`, `"themes/cyberpunk/theme.json"`, and `"world/kinds.js"` to the production test's `files` array. + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `node --test tests/themes.test.mjs tests/spaces-api.test.mjs` +Expected: PASS. + +Run: `npm test` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add themes/ scripts/spaces/themes.mjs scripts/spaces/routes.mjs scripts/serve.mjs scripts/build.mjs tests/themes.test.mjs tests/spaces-api.test.mjs tests/server.test.mjs +git commit -m "Add the Agora, Minimal, and Cyberpunk themes" -m "🤖 Built with SMT " +``` + +--- + +### Task 4: Camera and motion + +**Files:** +- Create: `world/camera.js`, `world/motion.js` +- Test: `tests/world-client.test.mjs` + +**Interfaces:** +- Produces from `world/camera.js`. A camera is `{ x, y, zoom }`, where `x` and `y` are the world-pixel point at the viewport's top-left. + - `zoomFor(viewport, tileSize) -> number`: shows about 12 tiles across the viewport's shorter side, clamped to 0.6..2. + - `follow(target, viewport, world, zoom) -> camera`: centers the target, clamps to the world's edges, and centers a world smaller than the viewport. + - `screenToTile(camera, px, py, tileSize) -> { x, y }` + - `tileCenter(tile, tileSize) -> { x, y }` (world pixels) +- Produces from `world/motion.js`: + - `WALK_SPEED = 5` (tiles per second) + - `STEP` (direction to `[dx, dy]`) + - `KEYS` (key to direction, for the arrows and WASD in both cases) + - `direction(from, to)` + - `positionAt(walk, now, speed = WALK_SPEED) -> { x, y, dir, done }` (fractional tiles while walking; `walk = { path, startedAt, dir? }`) + +- [ ] **Step 1: Write the failing test** + +Create `tests/world-client.test.mjs`: + +```js +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { follow, screenToTile, tileCenter, zoomFor } from "../world/camera.js"; +import { KEYS, STEP, WALK_SPEED, direction, positionAt } from "../world/motion.js"; + +test("zoom shows about twelve tiles across the shorter side, within limits", () => { + assert.equal(zoomFor({ width: 768, height: 384 }, 32), 1); + assert.equal(zoomFor({ width: 100, height: 100 }, 32), 0.6); + assert.equal(zoomFor({ width: 4000, height: 4000 }, 32), 2); +}); +test("the camera follows the target, stops at the world's edges, and centers small worlds", () => { + const world = { width: 1408, height: 1088 }, viewport = { width: 640, height: 480 }; + assert.deepEqual(follow({ x: 700, y: 500 }, viewport, world, 1), { x: 380, y: 260, zoom: 1 }); + assert.deepEqual(follow({ x: 10, y: 10 }, viewport, world, 1), { x: 0, y: 0, zoom: 1 }); + assert.deepEqual(follow({ x: 1400, y: 1080 }, viewport, world, 1), { x: 768, y: 608, zoom: 1 }); + assert.deepEqual(follow({ x: 50, y: 50 }, viewport, { width: 320, height: 240 }, 1), { x: -160, y: -120, zoom: 1 }); + assert.deepEqual(follow({ x: 700, y: 500 }, viewport, world, 2), { x: 540, y: 380, zoom: 2 }); +}); +test("screen points map to tiles through the camera", () => { + const camera = { x: 380, y: 260, zoom: 2 }; + assert.deepEqual(screenToTile(camera, 0, 0, 32), { x: 11, y: 8 }); + assert.deepEqual(screenToTile(camera, 64, 32, 32), { x: 12, y: 8 }); + const center = tileCenter({ x: 12, y: 9 }, 32); + assert.deepEqual(center, { x: 400, y: 304 }); + assert.deepEqual(screenToTile({ x: 0, y: 0, zoom: 1 }, center.x, center.y, 32), { x: 12, y: 9 }); +}); +test("walks move at a fixed speed along the path and face the way they go", () => { + assert.equal(WALK_SPEED, 5); + const walk = { path: [{ x: 0, y: 0 }, { x: 1, y: 0 }, { x: 1, y: 1 }], startedAt: 1000 }; + assert.deepEqual(positionAt(walk, 1000), { x: 0, y: 0, dir: "right", done: false }); + assert.deepEqual(positionAt(walk, 1100), { x: 0.5, y: 0, dir: "right", done: false }); + assert.deepEqual(positionAt(walk, 1300), { x: 1, y: 0.5, dir: "down", done: false }); + assert.deepEqual(positionAt(walk, 1400), { x: 1, y: 1, dir: "down", done: true }); + assert.deepEqual(positionAt(walk, 500), { x: 0, y: 0, dir: "right", done: false }); + assert.deepEqual(positionAt({ path: [{ x: 3, y: 4 }], startedAt: 0 }, 99), { x: 3, y: 4, dir: "down", done: true }); + assert.deepEqual(positionAt({ path: [{ x: 3, y: 4 }], startedAt: 0, dir: "left" }, 99), { x: 3, y: 4, dir: "left", done: true }); +}); +test("arrows and WASD map to the four directions", () => { + assert.deepEqual(STEP, { up: [0, -1], down: [0, 1], left: [-1, 0], right: [1, 0] }); + for (const [key, dir] of [["ArrowUp", "up"], ["w", "up"], ["W", "up"], ["ArrowDown", "down"], ["s", "down"], ["ArrowLeft", "left"], ["a", "left"], ["ArrowRight", "right"], ["D", "right"]]) assert.equal(KEYS[key], dir, key); + assert.equal(KEYS.Enter, undefined); + assert.equal(direction({ x: 1, y: 1 }, { x: 1, y: 0 }), "up"); + assert.equal(direction({ x: 1, y: 1 }, { x: 0, y: 1 }), "left"); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `node --test tests/world-client.test.mjs` +Expected: FAIL with `Cannot find module` for `world/camera.js`. + +- [ ] **Step 3: Implement** + +Create `world/camera.js`: + +```js +// Camera math in world pixels. A camera's x/y is the world point drawn at the viewport's top-left corner. +export const zoomFor = (viewport, tileSize) => Math.max(0.6, Math.min(2, Math.min(viewport.width, viewport.height) / (12 * tileSize))); + +export function follow(target, viewport, world, zoom) { + const width = viewport.width / zoom, height = viewport.height / zoom; + const axis = (point, size, length) => length <= size ? -(size - length) / 2 : Math.max(0, Math.min(point - size / 2, length - size)); + return { x: axis(target.x, width, world.width), y: axis(target.y, height, world.height), zoom }; +} + +export const screenToTile = (camera, px, py, tileSize) => ({ x: Math.floor((camera.x + px / camera.zoom) / tileSize), y: Math.floor((camera.y + py / camera.zoom) / tileSize) }); +export const tileCenter = (tile, tileSize) => ({ x: (tile.x + 0.5) * tileSize, y: (tile.y + 0.5) * tileSize }); +``` + +Create `world/motion.js`: + +```js +// Walks are a path plus a start time; every client works out the same position, so nothing streams continuously. +export const WALK_SPEED = 5; +export const STEP = { up: [0, -1], down: [0, 1], left: [-1, 0], right: [1, 0] }; +export const KEYS = { ArrowUp: "up", ArrowDown: "down", ArrowLeft: "left", ArrowRight: "right", w: "up", s: "down", a: "left", d: "right", W: "up", S: "down", A: "left", D: "right" }; +export const direction = (from, to) => to.x > from.x ? "right" : to.x < from.x ? "left" : to.y < from.y ? "up" : "down"; + +export function positionAt(walk, now, speed = WALK_SPEED) { + const steps = walk.path.length - 1; + const travelled = Math.max(0, ((now - walk.startedAt) / 1000) * speed); + if (steps <= 0 || travelled >= steps) { + const last = walk.path[steps < 0 ? 0 : steps]; + return { x: last.x, y: last.y, dir: steps > 0 ? direction(walk.path[steps - 1], last) : walk.dir || "down", done: true }; + } + const index = Math.floor(travelled), part = travelled - index, from = walk.path[index], to = walk.path[index + 1]; + return { x: from.x + (to.x - from.x) * part, y: from.y + (to.y - from.y) * part, dir: direction(from, to), done: false }; +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `node --test tests/world-client.test.mjs` +Expected: PASS (5 tests). + +- [ ] **Step 5: Commit** + +```bash +git add world/camera.js world/motion.js tests/world-client.test.mjs +git commit -m "Add camera and walking motion" -m "🤖 Built with SMT " +``` + +--- +### Task 5: Canvas renderer + +**Files:** +- Create: `world/renderer-canvas.js` +- Test: `tests/world-renderer.test.mjs` + +**Interfaces:** +- Consumes: `THEMED_KINDS` (Task 1), a parsed map with `roles`, `ground`, `interactables`, `tileSize`, `width`, `height`, and a validated theme (Task 3). +- Produces from `world/renderer-canvas.js`: + - `PATTERN_DRAW`: one draw function per `PATTERNS` entry, in the same order. Signature `(ctx, x, y, size, colors, tile, time)`. + - `DECOR_DRAW`: one draw function per `THEMED_KINDS` entry, in the same order. Signature `(ctx, x, y, size, colors)`. + - `shade(hex, amount) -> hex`, `hashTile(x, y) -> uint32`, `hashText(text) -> uint32` + - `createCanvasRenderer(canvas, { map, theme }) -> { resize(width, height, ratio), setTheme(theme), draw(frame) }`. This is the `Renderer` interface; a WebGL renderer would implement the same three methods. + - `frame = { camera, time = 0, avatars = [{ id, name, x, y, dir, self? }], decor = [{ kind, x, y, rotation?, variant? }], labels = [{ x, y, text }], motion = true }`. With `motion: false`, water stays still and no effects are drawn. + +- [ ] **Step 1: Write the failing test** + +Create `tests/world-renderer.test.mjs`: + +```js +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; +import { parseMap } from "../world/map.js"; +import { THEMED_KINDS } from "../world/kinds.js"; +import { PATTERNS } from "../world/themes.js"; +import { DECOR_DRAW, PATTERN_DRAW, createCanvasRenderer, hashText, shade } from "../world/renderer-canvas.js"; +import { loadThemes } from "../scripts/spaces/themes.mjs"; + +const root = new URL("../", import.meta.url).pathname; +const plaza = parseMap(JSON.parse(await readFile(new URL("../worlds/plaza/map.json", import.meta.url), "utf8"))); +const [agora, minimal, cyberpunk] = loadThemes(root); +const whole = { x: 0, y: 0, zoom: 1 }; + +// A recording 2D context: every method call and property assignment is logged in order. +function fakeCanvas() { + const calls = [], values = {}; + const ctx = new Proxy(values, { + get: (target, name) => name in target ? target[name] : name === "measureText" ? () => ({ width: 20 }) : (...args) => { calls.push([name, ...args]); }, + set: (target, name, value) => { target[name] = value; calls.push(["set:" + String(name), value]); return true; } + }); + return { canvas: { width: 0, height: 0, getContext: () => ctx }, calls }; +} +const render = (theme, frame, size = [640, 480]) => { + const { canvas, calls } = fakeCanvas(); + const renderer = createCanvasRenderer(canvas, { map: plaza, theme }); + renderer.resize(size[0], size[1], 1); + renderer.draw({ camera: whole, ...frame }); + return { calls, canvas, renderer }; +}; + +test("the renderer implements every pattern and every themed kind", () => { + assert.deepEqual(Object.keys(PATTERN_DRAW), PATTERNS); + assert.deepEqual(Object.keys(DECOR_DRAW), THEMED_KINDS); +}); +test("colors shade within range and names hash deterministically", () => { + assert.equal(shade("#808080", 0.1), "#9a9a9a"); + assert.equal(shade("#ffffff", 0.5), "#ffffff"); + assert.equal(shade("#000000", -0.5), "#000000"); + assert.equal(hashText("Ada"), hashText("Ada")); + assert.notEqual(hashText("Ada"), hashText("Bo")); +}); +test("every built-in theme draws the whole plaza with people, decorations, and labels", () => { + for (const theme of [agora, minimal, cyberpunk]) { + const { canvas, calls } = fakeCanvas(); + const renderer = createCanvasRenderer(canvas, { map: plaza, theme }); + renderer.resize(1408, 1088, 2); + assert.deepEqual([canvas.width, canvas.height], [2816, 2176]); + renderer.draw({ camera: whole, time: 1234, avatars: [{ id: "a", name: "Ada Lovelace", x: 21, y: 17, dir: "down", self: true }, { id: "b", name: "Bo", x: 22.5, y: 17, dir: "left" }], decor: THEMED_KINDS.map((kind, index) => ({ kind, x: 2 + index, y: 24, rotation: 90, variant: 1 })), labels: [{ x: 5, y: 4, text: "AI agents" }] }); + const texts = calls.filter(([name]) => name === "fillText").map(([, text]) => text); + assert.ok(calls.filter(([name]) => name === "fillRect").length > plaza.width * plaza.height, theme.id); + assert.ok(texts.includes("Ada Lovelace") && texts.includes("Bo") && texts.includes("AI agents"), theme.id); + assert.equal(texts.includes("AL"), theme.avatar.style === "token", theme.id + " draws initials only on tokens"); + } +}); +test("only the tiles in view are drawn", () => { + const all = render(minimal, { motion: false }, [1408, 1088]).calls.filter(([name]) => name === "fillRect").length; + const { canvas, calls } = fakeCanvas(); + const renderer = createCanvasRenderer(canvas, { map: plaza, theme: minimal }); + renderer.resize(128, 128, 1); + renderer.draw({ camera: { x: 640, y: 480, zoom: 2 }, motion: false }); + assert.ok(calls.filter(([name]) => name === "fillRect").length < all / 20); +}); +test("without motion nothing depends on time and no effects are drawn", () => { + const still = render(cyberpunk, { time: 5000, motion: false }).calls; + assert.deepEqual(render(cyberpunk, { time: 9000, motion: false }).calls, still); + const moving = render(cyberpunk, { time: 5000 }).calls; + assert.notDeepEqual(render(cyberpunk, { time: 9000 }).calls, moving); + assert.ok(moving.filter(([name]) => name === "stroke").length > still.filter(([name]) => name === "stroke").length); +}); +test("dim themes are tinted by their lighting; bright themes are not", () => { + const tinted = render(cyberpunk, { motion: false }).calls; + const index = tinted.findIndex(([name, value]) => name === "set:globalAlpha" && Math.abs(value - 0.18) < 1e-9); + assert.ok(index > 0); + assert.deepEqual(tinted.slice(index).find(([name]) => name === "fillRect"), ["fillRect", 0, 0, 640, 480]); + assert.equal(render(agora, { motion: false }).calls.some(([name, value]) => name === "set:globalAlpha" && Math.abs(value - 0.18) < 1e-9), false); + const { canvas, calls } = fakeCanvas(); + const renderer = createCanvasRenderer(canvas, { map: plaza, theme: agora }); + renderer.resize(640, 480, 1); + renderer.setTheme(cyberpunk); + renderer.draw({ camera: whole, motion: false }); + assert.deepEqual(calls, tinted, "setTheme switches the look completely"); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `node --test tests/world-renderer.test.mjs` +Expected: FAIL with `Cannot find module` for `world/renderer-canvas.js`. + +- [ ] **Step 3: Implement** + +Create `world/renderer-canvas.js`: + +```js +// Canvas 2D renderer. It draws semantic roles and kinds from the theme's data and a fixed catalog of shapes, +// so a WebGL renderer can replace it later by implementing the same resize/setTheme/draw interface. +export const hashTile = (x, y) => (Math.imul(x + 1, 73856093) ^ Math.imul(y + 1, 19349663)) >>> 0; +export const hashText = (text) => [...String(text)].reduce((value, char) => (Math.imul(value, 31) + char.charCodeAt(0)) >>> 0, 7); +export function shade(hex, amount) { + const value = parseInt(hex.slice(1), 16), channel = (shift) => Math.max(0, Math.min(255, Math.round(((value >> shift) & 255) + amount * 255))); + return "#" + [16, 8, 0].map((shift) => channel(shift).toString(16).padStart(2, "0")).join(""); +} +const box = (ctx, color, x, y, width, height) => { ctx.fillStyle = color; ctx.fillRect(x, y, width, height); }; +const dot = (ctx, color, x, y, radius) => { ctx.fillStyle = color; ctx.beginPath(); ctx.arc(x, y, radius, 0, Math.PI * 2); ctx.fill(); }; + +export const PATTERN_DRAW = { + flat(ctx, x, y, s, [a]) { box(ctx, a, x, y, s, s); }, + tiles(ctx, x, y, s, [a, b = shade(a, -0.08)]) { box(ctx, a, x, y, s, s); ctx.strokeStyle = b; ctx.lineWidth = 1; ctx.strokeRect(x + 0.5, y + 0.5, s - 1, s - 1); }, + path(ctx, x, y, s, [a, b = shade(a, -0.1)], tile) { box(ctx, a, x, y, s, s); const h = hashTile(tile.x, tile.y); for (let i = 0; i < 3; i += 1) box(ctx, b, x + 3 + ((h >>> (i * 5)) % (s - 8)), y + 3 + ((h >>> (i * 7 + 3)) % (s - 8)), 4, 2); }, + grass(ctx, x, y, s, [a, b = shade(a, -0.12)], tile) { + box(ctx, a, x, y, s, s); ctx.strokeStyle = b; ctx.lineWidth = 1.2; + const h = hashTile(tile.x, tile.y); + for (let i = 0; i < 4; i += 1) { const px = x + 4 + ((h >>> (i * 4)) % (s - 8)), py = y + 6 + ((h >>> (i * 6 + 2)) % (s - 12)); ctx.beginPath(); ctx.moveTo(px, py + 5); ctx.lineTo(px + 1.5, py); ctx.stroke(); } + }, + water(ctx, x, y, s, [a, b = shade(a, 0.15)], tile, time) { + box(ctx, a, x, y, s, s); ctx.strokeStyle = b; ctx.lineWidth = 1.5; + const phase = time / 450 + tile.x + tile.y; + for (const row of [0.35, 0.72]) { ctx.beginPath(); for (let i = 0; i <= s; i += 4) ctx.lineTo(x + i, y + s * row + Math.sin(phase + i / 5) * 1.5); ctx.stroke(); } + }, + wall(ctx, x, y, s, [a, b = shade(a, -0.15), c = shade(a, 0.1)]) { box(ctx, a, x, y, s, s); box(ctx, c, x, y, s, 4); box(ctx, b, x, y + s / 3, s, 1); box(ctx, b, x, y + (2 * s) / 3, s, 1); box(ctx, b, x + s / 2, y + 4, 1, s / 3 - 4); }, + column(ctx, x, y, s, [a, b = shade(a, -0.2)]) { box(ctx, b, x + 5, y + s - 7, s - 10, 4); box(ctx, a, x + 9, y + 7, s - 18, s - 14); box(ctx, b, x + 6, y + 3, s - 12, 4); }, + door(ctx, x, y, s, [a, b = shade(a, -0.25)]) { box(ctx, b, x + 2, y, s - 4, s); box(ctx, a, x + 5, y + 3, s - 10, s - 3); }, + table(ctx, x, y, s, [a, b = shade(a, -0.2)]) { box(ctx, b, x + 1, y + 4, s - 2, s - 4); box(ctx, a, x + 1, y + 1, s - 2, s - 6); }, + seat(ctx, x, y, s, [a, b = shade(a, -0.2)]) { box(ctx, b, x + 8, y + 12, s - 16, s - 18); box(ctx, a, x + 8, y + 8, s - 16, s - 20); }, + plant(ctx, x, y, s, [a, b = "#8a5a3c"]) { box(ctx, b, x + 11, y + s - 11, s - 22, 9); dot(ctx, a, x + s / 2, y + s / 2 - 2, s / 4); dot(ctx, shade(a, 0.08), x + s / 2 - 5, y + s / 2 + 1, s / 6); dot(ctx, shade(a, -0.06), x + s / 2 + 5, y + s / 2, s / 6); }, + decor(ctx, x, y, s, [a]) { ctx.fillStyle = a; ctx.beginPath(); ctx.moveTo(x + s / 2, y + 6); ctx.lineTo(x + s - 6, y + s / 2); ctx.lineTo(x + s / 2, y + s - 6); ctx.lineTo(x + 6, y + s / 2); ctx.fill(); }, + neon(ctx, x, y, s, [a, b = "#00e5ff"]) { box(ctx, a, x, y, s, s); ctx.globalAlpha = 0.35; ctx.strokeStyle = b; ctx.lineWidth = 1; ctx.strokeRect(x + 0.5, y + 0.5, s - 1, s - 1); ctx.globalAlpha = 1; } +}; + +export const DECOR_DRAW = { + plant: (ctx, x, y, s, colors) => PATTERN_DRAW.plant(ctx, x, y, s, colors), + lamp(ctx, x, y, s, [a, b = shade(a, -0.3)]) { box(ctx, b, x + s / 2 - 1, y + 10, 2, s - 14); box(ctx, b, x + 10, y + s - 5, s - 20, 3); dot(ctx, a, x + s / 2, y + 9, 6); }, + rug(ctx, x, y, s, [a, b = shade(a, -0.15)]) { box(ctx, b, x + 2, y + 6, s - 4, s - 12); box(ctx, a, x + 5, y + 9, s - 10, s - 18); }, + sofa(ctx, x, y, s, [a, b = shade(a, -0.18)]) { box(ctx, b, x + 3, y + 8, s - 6, s - 14); box(ctx, a, x + 6, y + 13, s - 12, s - 22); }, + chair(ctx, x, y, s, [a, b = shade(a, -0.2)]) { box(ctx, b, x + 9, y + 7, s - 18, 6); box(ctx, a, x + 9, y + 13, s - 18, s - 22); }, + table: (ctx, x, y, s, colors) => PATTERN_DRAW.table(ctx, x, y, s, colors), + whiteboard(ctx, x, y, s, [a, b = shade(a, -0.4)]) { box(ctx, b, x + 3, y + 5, s - 6, s - 14); box(ctx, a, x + 5, y + 7, s - 10, s - 18); box(ctx, b, x + 8, y + s - 9, 2, 6); box(ctx, b, x + s - 10, y + s - 9, 2, 6); }, + bookshelf(ctx, x, y, s, [a, b = shade(a, -0.25), c = shade(a, 0.25)]) { box(ctx, b, x + 4, y + 3, s - 8, s - 6); for (let i = 0; i < 5; i += 1) { box(ctx, i % 2 ? a : c, x + 7 + i * 4, y + 6, 3, s / 2 - 6); box(ctx, i % 2 ? c : a, x + 7 + i * 4, y + s / 2 + 1, 3, s / 2 - 7); } }, + screen(ctx, x, y, s, [a, b = "#111318"]) { box(ctx, b, x + 3, y + 6, s - 6, s - 15); box(ctx, a, x + 5, y + 8, s - 10, s - 19); box(ctx, b, x + s / 2 - 2, y + s - 9, 4, 6); }, + banner(ctx, x, y, s, [a, b = shade(a, -0.3)]) { box(ctx, b, x + 9, y + 3, 2, s - 6); ctx.fillStyle = a; ctx.beginPath(); ctx.moveTo(x + 11, y + 4); ctx.lineTo(x + s - 7, y + 4); ctx.lineTo(x + s - 7, y + s / 2 + 4); ctx.lineTo(x + (s + 4) / 2, y + s / 2 - 1); ctx.lineTo(x + 11, y + s / 2 + 4); ctx.fill(); }, + poster(ctx, x, y, s, [a, b = shade(a, -0.35)]) { box(ctx, b, x + 7, y + 5, s - 14, s - 10); box(ctx, a, x + 9, y + 7, s - 18, s - 14); }, + statue(ctx, x, y, s, [a, b = shade(a, -0.2)]) { box(ctx, b, x + 7, y + s - 10, s - 14, 7); box(ctx, a, x + 12, y + 11, s - 24, s - 21); dot(ctx, a, x + s / 2, y + 9, 5); }, + fountain(ctx, x, y, s, [a, b = "#7fa3a8"]) { dot(ctx, a, x + s / 2, y + s / 2, s / 2 - 2); dot(ctx, b, x + s / 2, y + s / 2, s / 2 - 6); dot(ctx, a, x + s / 2, y + s / 2, 3); }, + noticeboard(ctx, x, y, s, [a, b = shade(a, -0.3), c = "#fffef9"]) { box(ctx, b, x + 7, y + s - 10, 3, 9); box(ctx, b, x + s - 10, y + s - 10, 3, 9); box(ctx, b, x + 3, y + 4, s - 6, s - 13); box(ctx, a, x + 5, y + 6, s - 10, s - 17); box(ctx, c, x + 8, y + 9, 6, 5); box(ctx, c, x + 17, y + 11, 6, 4); } +}; + +const initials = (name) => String(name || "?").split(/\s+/).filter(Boolean).map((part) => part[0]).slice(0, 2).join("").toUpperCase(); + +function label(ctx, text, cx, top, font, theme) { + ctx.font = "500 10px " + font; + const width = ctx.measureText(text).width + 8; + ctx.globalAlpha = 0.85; box(ctx, theme.ui?.["--ink"] || "#272d28", cx - width / 2, top, width, 14); ctx.globalAlpha = 1; + ctx.fillStyle = theme.ui?.["--surface"] || "#ffffff"; ctx.textAlign = "center"; ctx.textBaseline = "middle"; ctx.fillText(text, cx, top + 7.5); +} + +function drawAvatar(ctx, avatar, s, theme, font) { + const color = theme.avatar.palette[hashText(avatar.id) % theme.avatar.palette.length]; + const cx = (avatar.x + 0.5) * s, cy = (avatar.y + 0.5) * s; + ctx.lineWidth = 2; ctx.strokeStyle = theme.avatar.outline; + if (theme.avatar.style === "pawn") { + ctx.fillStyle = color; ctx.beginPath(); ctx.ellipse(cx, cy + 6, 9, 8, 0, 0, Math.PI * 2); ctx.fill(); ctx.stroke(); + dot(ctx, color, cx, cy - 6, 6); ctx.beginPath(); ctx.arc(cx, cy - 6, 6, 0, Math.PI * 2); ctx.stroke(); + } else { + dot(ctx, color, cx, cy, s * 0.36); ctx.beginPath(); ctx.arc(cx, cy, s * 0.36, 0, Math.PI * 2); ctx.stroke(); + ctx.fillStyle = "#ffffff"; ctx.font = "600 10px " + font; ctx.textAlign = "center"; ctx.textBaseline = "middle"; ctx.fillText(initials(avatar.name), cx, cy + 0.5); + } + if (avatar.self) { ctx.strokeStyle = theme.ui?.["--accent"] || theme.avatar.outline; ctx.lineWidth = 2; ctx.beginPath(); ctx.arc(cx, cy, s * 0.5, 0, Math.PI * 2); ctx.stroke(); } + label(ctx, avatar.name, cx, cy + s * 0.62, font, theme); +} + +function drawEffects(ctx, theme, width, height, time) { + for (const effect of theme.effects) { + if (effect === "dust") { + ctx.fillStyle = "rgba(255, 255, 255, 0.35)"; + for (let i = 0; i < 40; i += 1) { const h = hashTile(i, 7); ctx.fillRect(((h % 1000) / 1000) * width + Math.sin(time / 3000 + i) * 12, ((((h >>> 10) % 1000) / 1000) * height + (time / 80) * (((i % 3) + 1) * 0.2)) % height, 2, 2); } + } else if (effect === "rain") { + ctx.strokeStyle = "rgba(160, 220, 255, 0.35)"; ctx.lineWidth = 1; ctx.beginPath(); + for (let i = 0; i < 90; i += 1) { const h = hashTile(i, 3), x = ((((h % 1000) / 1000) * (width + 40) + time / 6) % (width + 40)) - 20, y = ((((h >>> 10) % 1000) / 1000) * height + time / 2.5) % height; ctx.moveTo(x, y); ctx.lineTo(x - 4, y + 12); } + ctx.stroke(); + } else if (effect === "leaves") { + ctx.fillStyle = theme.decor.plant.colors[0]; + for (let i = 0; i < 18; i += 1) { const h = hashTile(i, 11); ctx.beginPath(); ctx.ellipse(((((h % 1000) / 1000) * width + Math.sin(time / 900 + i) * 20) + width) % width, ((((h >>> 10) % 1000) / 1000) * height + time / 40) % height, 4, 2, (time / 500 + i) % Math.PI, 0, Math.PI * 2); ctx.fill(); } + } + } +} + +export function createCanvasRenderer(canvas, { map, theme }) { + const ctx = canvas.getContext("2d"), s = map.tileSize; + let current = theme, viewport = { width: 1, height: 1, ratio: 1 }; + function tile(role, x, y, time) { + const entry = current.roles[role]; + const glow = current.effects.includes("neon-glow") && (role === "door" || entry.pattern === "neon"); + if (glow) { ctx.shadowColor = current.roles.door.colors[0]; ctx.shadowBlur = 10; } + PATTERN_DRAW[entry.pattern](ctx, x * s, y * s, s, entry.colors, { x, y }, time); + if (glow) ctx.shadowBlur = 0; + } + function decorItem(item) { + const entry = current.decor[item.kind]; + if (!entry) return; + ctx.save(); ctx.translate((item.x + 0.5) * s, (item.y + 0.5) * s); ctx.rotate(((item.rotation || 0) * Math.PI) / 180); ctx.translate(-s / 2, -s / 2); + DECOR_DRAW[item.kind](ctx, 0, 0, s, entry.variants?.[item.variant || 0] || entry.colors); + ctx.restore(); + } + return { + resize(width, height, ratio = 1) { viewport = { width, height, ratio }; canvas.width = Math.round(width * ratio); canvas.height = Math.round(height * ratio); }, + setTheme(next) { current = next; }, + draw({ camera, time = 0, avatars = [], decor = [], labels = [], motion = true }) { + const { width, height, ratio } = viewport, font = current.ui?.font || "IBM Plex Sans", t = motion ? time : 0; + ctx.setTransform(ratio, 0, 0, ratio, 0, 0); + box(ctx, current.ui?.["--paper"] || "#111111", 0, 0, width, height); + ctx.save(); ctx.scale(camera.zoom, camera.zoom); ctx.translate(-camera.x, -camera.y); + const x0 = Math.max(0, Math.floor(camera.x / s)), y0 = Math.max(0, Math.floor(camera.y / s)); + const x1 = Math.min(map.width - 1, Math.floor((camera.x + width / camera.zoom) / s)), y1 = Math.min(map.height - 1, Math.floor((camera.y + height / camera.zoom) / s)); + for (let y = y0; y <= y1; y += 1) for (let x = x0; x <= x1; x += 1) { + const index = y * map.width + x, role = map.roles[index], ground = map.ground[index]; + if (ground && ground !== role) tile(ground, x, y, t); + if (role) tile(role, x, y, t); + } + for (const item of map.interactables) decorItem(item); + for (const item of decor) decorItem(item); + for (const entry of labels) label(ctx, entry.text, (entry.x + 0.5) * s, entry.y * s + 4, font, current); + for (const avatar of [...avatars].sort((a, b) => a.y - b.y)) drawAvatar(ctx, avatar, s, current, font); + ctx.restore(); + if (current.lighting.ambient < 1) { ctx.globalAlpha = (1 - current.lighting.ambient) * 0.6; box(ctx, current.lighting.tint, 0, 0, width, height); ctx.globalAlpha = 1; } + if (motion) drawEffects(ctx, current, width, height, time); + } + }; +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `node --test tests/world-renderer.test.mjs` +Expected: PASS (6 tests). + +- [ ] **Step 5: Commit** + +```bash +git add world/renderer-canvas.js tests/world-renderer.test.mjs +git commit -m "Add the Canvas world renderer" -m "🤖 Built with SMT " +``` + +--- + +### Task 6: The engine + +**Files:** +- Create: `world/engine.js` +- Test: `tests/world-engine.test.mjs` + +**Interfaces:** +- Consumes: `findPath`, `walkable` (map), `BLOCKING_DECOR` (kinds), `follow`, `screenToTile`, `zoomFor` (camera), `KEYS`, `STEP`, `positionAt` (motion), `createCanvasRenderer` (Task 5). +- Produces: `createEngine({ canvas, map, theme, start, reducedMotion = false, renderer?, now?, raf?, later?, cancel?, ratio? }) -> engine` with: + - `walkTo(tile) -> boolean` (false when the tile can't be reached) + - `position() -> { x, y }` (the current tile, rounded) + - `on("move" | "arrive", listener)`. `move` receives `{ path, startedAt }`; Plan C publishes it over Signaling. `arrive` receives `{ x, y }`. + - `setSelf({ name })`, `setTheme(theme)`, `setLabels(list)`, `setDecor(list)` + - `setOthers([{ id, name, walk }])` for Plan C + - `resize()`, `destroy()` + - Input: `pointerup` walks to the tile under the pointer. With the canvas focused, `keydown`/`keyup` for the arrows and WASD step one tile at a time while held. Repeated keydown events for the held key are ignored. Blocked steps only turn the avatar to face that way. + - Drawing: frames are requested only while someone is moving. When idle, a single 66 ms timer keeps ambient animation (effects or water) going unless `reducedMotion` is set. + - `now`, `raf`, `later`, `cancel` and `ratio` are injectable for tests and default to `performance.now`, `requestAnimationFrame`, `setTimeout`, `clearTimeout` and `devicePixelRatio`. + +- [ ] **Step 1: Write the failing test** + +Create `tests/world-engine.test.mjs`: + +```js +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; +import { parseMap } from "../world/map.js"; +import { createEngine } from "../world/engine.js"; +import { loadThemes } from "../scripts/spaces/themes.mjs"; + +const root = new URL("../", import.meta.url).pathname; +const plaza = parseMap(JSON.parse(await readFile(new URL("../worlds/plaza/map.json", import.meta.url), "utf8"))); +const [agora, minimal] = loadThemes(root); +const key = (name) => ({ key: name, preventDefault() {} }); + +function setup({ reducedMotion = false } = {}) { + let time = 1000; + const frames = [], timers = [], drawn = [], themes = [], handlers = {}, events = { move: [], arrive: [] }; + const canvas = { clientWidth: 640, clientHeight: 480, addEventListener: (type, listener) => { handlers[type] = listener; }, removeEventListener: (type) => { delete handlers[type]; }, getBoundingClientRect: () => ({ left: 0, top: 0 }) }; + const renderer = { resize() {}, setTheme: (theme) => themes.push(theme), draw: (frame) => drawn.push(frame) }; + const engine = createEngine({ canvas, map: plaza, theme: agora, start: plaza.spawns[0], reducedMotion, renderer, now: () => time, raf: (callback) => frames.push(callback), later: (callback) => { timers.push(callback); return timers.length; }, cancel() {}, ratio: () => 1 }); + engine.on("move", (value) => events.move.push(value)); + engine.on("arrive", (value) => events.arrive.push(value)); + // Runs only the frames already requested, so a walking avatar cannot loop forever inside one flush. + const flush = () => { for (const callback of frames.splice(0)) callback(); }; + flush(); + return { engine, handlers, drawn, themes, events, timers, flush, advance: (ms) => { time += ms; } }; +} + +test("walking to a tile follows a path and arrives", () => { + const { engine, events, flush, advance, drawn } = setup(); + assert.equal(engine.walkTo({ x: 0, y: 0 }), false, "walls can't be reached"); + assert.equal(engine.walkTo({ x: 21, y: 12 }), true); + const { path } = events.move[0]; + assert.deepEqual([path[0], path.at(-1)], [{ x: 21, y: 17 }, { x: 21, y: 12 }]); + advance(((path.length - 1) / 5) * 1000 + 1); flush(); + assert.deepEqual(events.arrive, [{ x: 21, y: 12 }]); + assert.deepEqual(engine.position(), { x: 21, y: 12 }); + const [me] = drawn.at(-1).avatars; + assert.deepEqual([me.self, me.x, me.y, me.name], [true, 21, 12, "You"]); +}); +test("holding a direction key keeps walking until it is released", () => { + const { handlers, events, flush, advance } = setup(); + handlers.keydown(key("ArrowUp")); + assert.deepEqual(events.move.at(-1).path, [{ x: 21, y: 17 }, { x: 21, y: 16 }]); + handlers.keydown(key("ArrowUp")); + assert.equal(events.move.length, 1, "key repeat does not restart the step"); + advance(201); flush(); + assert.deepEqual(events.move.at(-1).path, [{ x: 21, y: 16 }, { x: 21, y: 15 }]); + handlers.keyup(key("ArrowUp")); + advance(201); flush(); + assert.equal(events.move.length, 2); + handlers.keydown(key("Enter")); + assert.equal(events.move.length, 2, "other keys are ignored"); +}); +test("a blocked step only turns the walker", () => { + const { engine, handlers, events, flush, advance, drawn } = setup(); + engine.walkTo({ x: 1, y: 18 }); advance(10000); flush(); + const moves = events.move.length; + handlers.keydown(key("s")); flush(); + assert.equal(events.move.length, moves); + assert.equal(drawn.at(-1).avatars[0].dir, "down"); + assert.deepEqual(engine.position(), { x: 1, y: 18 }); +}); +test("clicking or tapping walks to the tile under the pointer", () => { + const { handlers, events, drawn } = setup(); + const { camera } = drawn.at(-1); + handlers.pointerup({ clientX: (23 * 32 + 16 - camera.x) * camera.zoom, clientY: (17 * 32 + 16 - camera.y) * camera.zoom }); + assert.deepEqual(events.move.at(-1).path.at(-1), { x: 23, y: 17 }); +}); +test("blocking decorations change the route", () => { + const { engine, events } = setup(); + engine.setDecor([{ kind: "statue", x: 21, y: 16 }, { kind: "plant", x: 22, y: 16 }]); + engine.walkTo({ x: 21, y: 15 }); + assert.equal(events.move.at(-1).path.some((step) => step.x === 21 && step.y === 16), false); + assert.equal(engine.walkTo({ x: 21, y: 16 }), false); +}); +test("idle redraws run only for ambient animation, one timer at a time, and never with reduced motion", () => { + const animated = setup(); + assert.equal(animated.timers.length, 1); + animated.engine.setLabels([{ x: 1, y: 1, text: "Hi" }]); animated.flush(); + assert.equal(animated.timers.length, 1, "a second idle timer is not stacked"); + assert.deepEqual(animated.drawn.at(-1).labels, [{ x: 1, y: 1, text: "Hi" }]); + animated.timers[0](); animated.flush(); + assert.equal(animated.timers.length, 2, "the idle loop continues after its timer fires"); + const still = setup({ reducedMotion: true }); + assert.equal(still.timers.length, 0); + assert.equal(still.drawn.at(-1).motion, false); +}); +test("themes, names, and people can change while walking; destroy removes input", () => { + const { engine, handlers, themes, drawn, flush } = setup(); + engine.setTheme(minimal); + engine.setSelf({ name: "Ada" }); + engine.setOthers([{ id: "bo", name: "Bo", walk: { path: [{ x: 22, y: 17 }], startedAt: 0 } }]); + flush(); + assert.deepEqual(themes, [minimal]); + assert.deepEqual(drawn.at(-1).avatars.map((avatar) => avatar.name), ["Ada", "Bo"]); + engine.destroy(); + assert.deepEqual(Object.keys(handlers), []); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `node --test tests/world-engine.test.mjs` +Expected: FAIL with `Cannot find module` for `world/engine.js`. + +- [ ] **Step 3: Implement** + +Create `world/engine.js`: + +```js +import { findPath, walkable } from "./map.js"; +import { BLOCKING_DECOR } from "./kinds.js"; +import { follow, screenToTile, zoomFor } from "./camera.js"; +import { KEYS, STEP, positionAt } from "./motion.js"; +import { createCanvasRenderer } from "./renderer-canvas.js"; + +// One person's movement, input, camera, and drawing. Other people arrive through setOthers; "move" events go out to them. +export function createEngine({ canvas, map, theme, start, reducedMotion = false, renderer = createCanvasRenderer(canvas, { map, theme }), now = () => performance.now(), raf = (callback) => requestAnimationFrame(callback), later = (callback, delay) => setTimeout(callback, delay), cancel = (handle) => clearTimeout(handle), ratio = () => globalThis.devicePixelRatio || 1 }) { + const listeners = { arrive: [], move: [] }; + const self = { id: "self", name: "You", walk: { path: [start], startedAt: now(), dir: "down" }, arrived: true }; + let current = theme, others = [], decor = [], labels = [], held = null, pending = false, timer = null; + let camera = { x: 0, y: 0, zoom: 1 }, viewport = { width: 1, height: 1 }; + const emit = (type, value) => { for (const listener of listeners[type]) listener(value); }; + const here = (time = now()) => positionAt(self.walk, time); + const blocked = () => new Set(decor.filter((item) => BLOCKING_DECOR.has(item.kind)).map((item) => item.x + "," + item.y)); + const ambient = () => !reducedMotion && (current.effects.some((effect) => effect !== "neon-glow") || map.roles.includes("water")); + const schedule = () => { if (!pending) { pending = true; raf(frame); } }; + + function frame() { + pending = false; + const time = now(), s = map.tileSize; + const reached = here(time); + if (reached.done && !self.arrived) { self.arrived = true; emit("arrive", { x: reached.x, y: reached.y }); if (held) step(held); } + const shown = here(time); + camera = follow({ x: (shown.x + 0.5) * s, y: (shown.y + 0.5) * s }, viewport, { width: map.width * s, height: map.height * s }, zoomFor(viewport, s)); + const crowd = others.map((other) => ({ id: other.id, name: other.name, ...positionAt(other.walk, time) })); + renderer.draw({ camera, time, avatars: [{ id: self.id, name: self.name, self: true, ...shown }, ...crowd], decor, labels, motion: !reducedMotion }); + if (!shown.done || crowd.some((other) => !other.done)) schedule(); + else if (ambient() && timer === null) timer = later(() => { timer = null; schedule(); }, 66); + } + + function walkTo(target) { + const time = now(), position = here(time); + const path = findPath(map, { x: Math.round(position.x), y: Math.round(position.y) }, target, blocked()); + if (!path) return false; + self.walk = { path, startedAt: time, dir: position.dir }; + self.arrived = false; + emit("move", { path, startedAt: time }); + schedule(); + return true; + } + + function step(dir) { + const position = here(); + if (!position.done) return; + const [dx, dy] = STEP[dir], target = { x: position.x + dx, y: position.y + dy }; + if (walkable(map, target.x, target.y, blocked())) walkTo(target); + else { self.walk = { path: [{ x: position.x, y: position.y }], startedAt: now(), dir }; schedule(); } + } + + const onPointer = (event) => { + const bounds = canvas.getBoundingClientRect(); + walkTo(screenToTile(camera, event.clientX - bounds.left, event.clientY - bounds.top, map.tileSize)); + }; + const onKeyDown = (event) => { + const dir = KEYS[event.key]; + if (!dir) return; + event.preventDefault(); + if (held === dir) return; + held = dir; + step(dir); + }; + const onKeyUp = (event) => { if (KEYS[event.key] === held) held = null; }; + const onBlur = () => { held = null; }; + const inputs = [["pointerup", onPointer], ["keydown", onKeyDown], ["keyup", onKeyUp], ["blur", onBlur]]; + for (const [type, listener] of inputs) canvas.addEventListener(type, listener); + + const engine = { + walkTo, + position() { const position = here(); return { x: Math.round(position.x), y: Math.round(position.y) }; }, + on(type, listener) { listeners[type].push(listener); }, + setSelf({ name }) { self.name = name; schedule(); }, + setTheme(next) { current = next; renderer.setTheme(next); schedule(); }, + setLabels(list) { labels = list; schedule(); }, + setDecor(list) { decor = list; schedule(); }, + setOthers(list) { others = list; schedule(); }, + resize() { viewport = { width: canvas.clientWidth || 1, height: canvas.clientHeight || 1 }; renderer.resize(viewport.width, viewport.height, ratio()); schedule(); }, + destroy() { for (const [type, listener] of inputs) canvas.removeEventListener(type, listener); if (timer !== null) cancel(timer); timer = null; } + }; + engine.resize(); + return engine; +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `node --test tests/world-engine.test.mjs` +Expected: PASS (7 tests). + +- [ ] **Step 5: Commit** + +```bash +git add world/engine.js tests/world-engine.test.mjs +git commit -m "Add the world engine: movement, input, camera, and drawing loop" -m "🤖 Built with SMT " +``` + +--- + +### Task 7: The walkable plaza page + +**Files:** +- Modify: `stoa.html` (replace the shell), `styles.css` (append), `scripts/serve.mjs` (`publicFiles`), `scripts/build.mjs` (root file list), `README.md` (the Stoa section), `tests/server.test.mjs` (production files) +- Create: `stoa.js` +- Test: `tests/spaces-api.test.mjs` (append) + +**Interfaces:** +- Consumes: `api`, `sessionReady`, `state` (`script.js`); `parseMap` (`world/map.js`); `createEngine` (Task 6); `GET /api/worlds/plaza`, `GET /api/themes`, `GET /api/spaces`. +- Produces: + - `/stoa/` shows the themed plaza. You walk by click, tap, or keyboard. + - The side panel lists every lot with its topic (or "Open") and occupancy, plus a **Go to** button that walks to the lot's door. + - Arriving at a door announces it in the live status. + - Lot titles (or topics) appear as signs above the doors. + - **Minimal view** toggles the viewer's own display and is remembered in `localStorage` (`stoa-minimal`). + - `?theme=` previews a built-in theme. + - Theme UI tokens are applied to `#stoa` only. + +- [ ] **Step 1: Write the failing test** + +Append to `tests/spaces-api.test.mjs`: + +```js +test("the plaza page loads its engine modules and every linked file", async () => { + const { instance, url } = await start(); + try { + const page = await request(url, "/stoa/"); + const html = await page.text(); + assert.match(html, / match[1]).filter((link) => !link.startsWith("https://"))); + for (const link of links) assert.equal((await fetch(new URL(link, url + "/stoa/"))).status, 200, link); + for (const module of ["/stoa.js", "/world/map.js", "/world/kinds.js", "/world/themes.js", "/world/camera.js", "/world/motion.js", "/world/renderer-canvas.js", "/world/engine.js"]) { + const response = await request(url, module); + assert.equal(response.status, 200, module); + assert.match(response.headers.get("content-type"), /text\/javascript/, module); + } + } finally { await close(instance); } +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `node --test tests/spaces-api.test.mjs` +Expected: FAIL. The shell page has no `stoa-canvas`, and `/stoa.js` returns 404. + +- [ ] **Step 3: Implement** + +**`stoa.html`:** replace the whole file. Copy `meetings.html` exactly, then make these changes: +1. `` becomes `Stoa | Agora Build`. +2. The `<meta name="description">` content becomes `The Stoa is Agora Build's walkable plaza for builders: meet people, start a topic, and work together.` +3. Replace `<script type="module" src="/meetings.js"></script>` with `<script type="module" src="/stoa.js"></script>`. Keep `/script.js`. +4. In the nav, remove `class="is-active" aria-current="page"` from the Rooms link so no item is marked current. +5. Replace the entire `<main id="main" ...>...</main>` element (in `meetings.html` it starts on the line with `<main id="main" class="main-content">` and ends with `</main>`) with: + +```html + <main id="main" class="main-content stoa-main"> + <section class="stoa" id="stoa" aria-labelledby="stoa-title"> + <div class="stoa-heading"><div><p class="kicker">THE STOA / PUBLIC PLAZA</p><h1 id="stoa-title">Walk into the plaza.</h1></div><button class="button button-secondary button-small" type="button" id="stoa-minimal" aria-pressed="false">Minimal view</button></div> + <div class="stoa-layout"> + <div class="stoa-stage"><canvas id="stoa-canvas" tabindex="0" aria-label="Plaza map" aria-describedby="stoa-help"></canvas><p class="stoa-help" id="stoa-help">Click or tap to walk. Select the map to use the arrow keys or WASD.</p></div> + <aside class="stoa-panel" aria-label="Around you"> + <p class="stoa-status" id="stoa-status" role="status" aria-live="polite">Opening the plaza...</p> + <h2 class="stoa-panel-title">Rooms</h2> + <ul class="stoa-rooms" id="stoa-rooms"></ul> + <h2 class="stoa-panel-title">Here now</h2> + <ul class="stoa-people" id="stoa-people"></ul> + </aside> + </div> + </section> + </main> +``` + +Keep the header, footer, all dialogs, and `<div id="toast" ...>` from `meetings.html` unchanged, because `script.js` needs them. + +Create `stoa.js`: + +```js +import { api, sessionReady, state } from "./script.js"; +import { parseMap } from "./world/map.js"; +import { createEngine } from "./world/engine.js"; + +const root = document.querySelector("#stoa"); +const canvas = document.querySelector("#stoa-canvas"); +const status = document.querySelector("#stoa-status"); +const roomList = document.querySelector("#stoa-rooms"); +const peopleList = document.querySelector("#stoa-people"); +const minimalToggle = document.querySelector("#stoa-minimal"); +const say = (message) => { status.textContent = message; }; +const node = (tag, text, className) => { const element = document.createElement(tag); element.textContent = text; if (className) element.className = className; return element; }; + +// Theme tokens restyle only the Stoa section; the site header and footer keep the portal's look. +function applyUi(theme) { + for (const [token, value] of Object.entries(theme.ui)) if (token.startsWith("--")) root.style.setProperty(token, value); + root.style.fontFamily = theme.ui.font ? "\"" + theme.ui.font + "\", sans-serif" : ""; +} + +try { + const [source, { themes }, listing] = await Promise.all([api("/api/worlds/plaza"), api("/api/themes"), api("/api/spaces")]); + const map = parseMap(source); + const byId = Object.fromEntries(themes.map((theme) => [theme.id, theme])); + const preview = byId[new URLSearchParams(location.search).get("theme")]; + const chosen = preview || byId.agora; + let minimal = localStorage.getItem("stoa-minimal") === "true"; + const active = () => minimal ? byId.minimal : chosen; + const engine = createEngine({ canvas, map, theme: active(), start: map.spawns[0], reducedMotion: matchMedia("(prefers-reduced-motion: reduce)").matches }); + applyUi(active()); + minimalToggle.setAttribute("aria-pressed", String(minimal)); + minimalToggle.addEventListener("click", () => { + minimal = !minimal; + localStorage.setItem("stoa-minimal", String(minimal)); + minimalToggle.setAttribute("aria-pressed", String(minimal)); + applyUi(active()); + engine.setTheme(active()); + }); + new ResizeObserver(() => engine.resize()).observe(canvas); + + const rooms = new Map(listing.rooms.map((room) => [room.slug, room])); + engine.setLabels(map.lots.map((lot) => ({ x: lot.door.x, y: lot.door.y - 1, text: rooms.get(lot.slug)?.topic || lot.title }))); + for (const lot of map.lots) { + const room = rooms.get(lot.slug) || { topic: null, occupancy: 0, capacity: lot.capacity }; + const entry = node("li", "", "stoa-room"); + const go = node("button", "Go to", "button button-secondary button-small"); + go.type = "button"; + go.setAttribute("aria-label", "Walk to " + lot.title); + go.addEventListener("click", () => { if (engine.walkTo(lot.door)) say("Walking to " + lot.title + "."); canvas.focus(); }); + entry.append(node("strong", lot.title), node("span", (room.topic || "Open") + " · " + room.occupancy + " of " + room.capacity + " here"), go); + roomList.append(entry); + } + const doors = new Map(map.lots.map((lot) => [lot.door.x + "," + lot.door.y, lot])); + engine.on("arrive", (tile) => { const lot = doors.get(tile.x + "," + tile.y); if (lot) say("You are at the door of " + lot.title + ". Stepping inside comes with the next update."); }); + + await sessionReady; + const name = state.profile?.name || state.account?.name || "You"; + engine.setSelf({ name }); + peopleList.replaceChildren(node("li", name + " (you)")); + say(preview ? "Previewing the " + preview.name + " theme. Only you see it." : "You're on the plaza. Walk to a room to see who's there."); +} catch (error) { + say(error.message || "The plaza could not open. Please try again."); +} +``` + +Append to `styles.css`: + +```css +.stoa-main { max-width: 1400px; } +.stoa { color: var(--ink); } +.stoa-heading { display: flex; justify-content: space-between; align-items: end; gap: 20px; flex-wrap: wrap; margin-bottom: 18px; } +.stoa-heading h1 { margin: 6px 0 0; font-size: clamp(30px, 4vw, 46px); font-weight: 500; letter-spacing: -1.5px; } +.stoa-heading [aria-pressed="true"] { background: var(--ink); color: var(--paper); } +.stoa-layout { display: grid; grid-template-columns: minmax(0, 1fr) 300px; gap: 18px; } +.stoa-stage { background: var(--paper); border: 1px solid var(--line); border-radius: 5px; overflow: hidden; } +#stoa-canvas { display: block; width: 100%; height: min(72vh, 720px); min-height: 420px; touch-action: none; cursor: pointer; } +#stoa-canvas:focus-visible { outline: 3px solid var(--accent); outline-offset: -3px; } +.stoa-help { margin: 0; padding: 9px 14px; font-size: 12px; color: var(--muted); border-top: 1px solid var(--line); background: var(--surface); } +.stoa-panel { padding: 20px; background: var(--surface); border: 1px solid var(--line); border-radius: 5px; align-self: start; } +.stoa-status { margin: 0 0 18px; font-size: 13px; color: var(--muted); } +.stoa-panel-title { margin: 22px 0 10px; font: 500 12px var(--mono); letter-spacing: 1px; text-transform: uppercase; color: var(--muted); } +.stoa-rooms, .stoa-people { list-style: none; margin: 0; padding: 0; } +.stoa-room { display: grid; grid-template-columns: 1fr auto; gap: 4px 10px; align-items: center; padding: 11px 0; border-bottom: 1px solid var(--line); } +.stoa-room strong { font-weight: 500; } +.stoa-room span { grid-column: 1; font-size: 12px; color: var(--muted); } +.stoa-room button { grid-row: 1 / span 2; grid-column: 2; } +.stoa-people li { font-size: 14px; padding: 6px 0; } +@media (max-width: 900px) { .stoa-layout { grid-template-columns: 1fr; } #stoa-canvas { height: 62vh; min-height: 340px; } } +``` + +In `scripts/serve.mjs`, add `"stoa.js"`, `"world/kinds.js"`, `"world/themes.js"`, `"world/camera.js"`, `"world/motion.js"`, `"world/renderer-canvas.js"`, and `"world/engine.js"` to `publicFiles`. + +In `scripts/build.mjs`, add `"stoa.js"` to the root file list after `"stoa.html"`. + +In `tests/server.test.mjs`, add `"stoa.js"` and `"world/engine.js"` to the production test's `files` array. + +In `README.md`, append to the `## Stoa spaces` section: + +```markdown +Built-in themes live in `themes/` (Agora, Minimal, Cyberpunk) and are validated at startup. Visitors can switch their own view to Minimal; `/stoa/?theme=cyberpunk` previews a theme. Movement works with click or tap and with the arrow keys or WASD; the side panel offers the same actions for keyboard and screen reader users. +``` + +- [ ] **Step 4: Run tests and check it in a real browser** + +Run: `npm test` +Expected: PASS. + +Start a server on a free port with throwaway state, then capture screenshots: + +```bash +STOA_DATA=$(mktemp -d) +node -e 'import("./scripts/serve.mjs").then(({ createAppServer }) => createAppServer(process.cwd(), { storageFile: process.argv[1] + "/state.json", monitor: false }).listen(4381, "127.0.0.1"))' "$STOA_DATA" & +sleep 2 +for view in "1280,900 desktop" "390,844 mobile"; do set -- $view; google-chrome --headless=new --disable-gpu --hide-scrollbars --window-size=$1 --virtual-time-budget=4000 --screenshot=/tmp/stoa-$2.png http://127.0.0.1:4381/stoa/; done +google-chrome --headless=new --disable-gpu --hide-scrollbars --window-size=1280,900 --virtual-time-budget=4000 --screenshot=/tmp/stoa-cyberpunk.png "http://127.0.0.1:4381/stoa/?theme=cyberpunk" +kill %1; rm -rf "$STOA_DATA" +``` + +Expected: the three PNG files exist. Read each one and confirm: +- The plaza is drawn: paths, the fountain, six facades with door signs, and an avatar at the bottom center. +- The room list is filled. +- Mobile stacks the panel under the map. +- The Cyberpunk preview is dark and neon. + +- [ ] **Step 5: Commit** + +```bash +git add stoa.html stoa.js styles.css scripts/serve.mjs scripts/build.mjs README.md tests/spaces-api.test.mjs tests/server.test.mjs +git commit -m "Open the walkable plaza page" -m "🤖 Built with SMT <smt@agora.build>" +``` + +--- + +## Spec Coverage (Plan B) + +| Spec requirement (section 4) | Task | +| --- | --- | +| Maps hold semantic roles; theme supplies art; server and client share `map.js` | 1, 2, 3 | +| Themes are data only, validated (roles and decor kinds, CSS token allowlist, size), with a fixed effects catalog | 2, 3 | +| Three built-in themes (Agora, Minimal, Cyberpunk) drawn in code, no external assets | 3, 5 | +| Viewer can switch to Minimal for their own display | 7 | +| Canvas 2D engine, high-resolution aware, camera follows, click/tap and arrows/WASD | 4, 5, 6 | +| Draws only while moving or animating; reduced motion stops effects and animation | 5, 6 | +| Renderer interface swappable for WebGL | 5 | +| Decorations drawn by theme from semantic kinds; blocking decorations shape paths | 1, 5, 6 | +| Accessibility panel: rooms with Go to, people, live status | 7 | + +Deferred to Plan C: other people over Signaling, entering rooms, the host topic and decor modes, calls, private-space encryption, the Stoa navigation link, and the `/meet` redirect. From 649e6eba98effe44773845abff2ab537dd0ec3a3 Mon Sep 17 00:00:00 2001 From: Brent G <deliberatekids@gmail.com> Date: Wed, 7 Oct 2026 19:43:03 +0000 Subject: [PATCH 18/71] Share decoration kinds and keep the map's ground layer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🤖 Built with SMT <smt@agora.build> --- scripts/maps/build-maps.mjs | 14 +++++++++----- scripts/spaces/decor.mjs | 4 ++-- scripts/spaces/model.mjs | 2 +- tests/map.test.mjs | 21 +++++++++++++++++++++ world/kinds.js | 4 ++++ world/map.js | 4 +++- worlds/plaza/map.json | 2 +- 7 files changed, 41 insertions(+), 10 deletions(-) create mode 100644 world/kinds.js diff --git a/scripts/maps/build-maps.mjs b/scripts/maps/build-maps.mjs index 87b540d..129c116 100644 --- a/scripts/maps/build-maps.mjs +++ b/scripts/maps/build-maps.mjs @@ -9,13 +9,13 @@ const prop = (values) => Object.entries(values).map(([name, value]) => ({ name, const blank = (width, height, role) => Array.from({ length: height }, () => new Array(width).fill(role)); const fill = (grid, x, y, width, height, role) => { for (let row = y; row < y + height; row += 1) for (let column = x; column < x + width; column += 1) grid[row][column] = role; }; -export function tiled(id, grid, objects) { +export function tiled(id, grid, objects, under = null) { const height = grid.length, width = grid[0].length, gid = (role) => CORE_ROLES.indexOf(role) + 1; const ground = [], structure = []; - for (const row of grid) for (const role of row) { - const base = ["water", "grass", "path"].includes(role) ? role : "floor"; + grid.forEach((row, y) => row.forEach((role, x) => { + const base = ["water", "grass", "path"].includes(role) ? role : under ? under[y][x] : "floor"; ground.push(gid(base)); structure.push(role === base ? 0 : gid(role)); - } + })); let next = 1; const group = (name, list) => ({ type: "objectgroup", name, objects: list.map(({ x, y, width: w = 0, height: h = 0, name: label = "", ...rest }) => ({ id: next++, name: label, x: x * SIZE, y: y * SIZE, width: w * SIZE, height: h * SIZE, point: !w && !h, properties: prop(rest) })) }); const layers = [{ type: "tilelayer", name: "ground", width, height, data: ground }, { type: "tilelayer", name: "structure", width, height, data: structure }, ...Object.entries(objects).map(([name, list]) => group(name, list))]; @@ -38,6 +38,10 @@ export function plaza() { fill(grid, 1, 1, 42, 18, "grass"); fill(grid, 1, 9, 42, 2, "path"); fill(grid, 21, 1, 2, 18, "path"); fill(grid, 17, 6, 10, 8, "path"); fill(grid, 19, 8, 6, 4, "water"); + const under = blank(44, 34, "floor"); + fill(under, 1, 1, 42, 18, "grass"); + fill(under, 1, 9, 42, 2, "path"); fill(under, 21, 1, 2, 18, "path"); + fill(under, 17, 6, 10, 8, "path"); fill(under, 19, 8, 6, 4, "water"); for (const [x, y] of [[17, 6], [26, 6], [17, 13], [26, 13]]) grid[y][x] = "column"; for (const [x, y] of [[20, 7], [23, 7], [20, 12], [23, 12]]) grid[y][x] = "seat"; for (const [x, y] of [[5, 8], [38, 8], [5, 11], [38, 11]]) grid[y][x] = "plant"; @@ -62,7 +66,7 @@ export function plaza() { { x: 31, y: 8, id: "voice-ai", kind: "noticeboard", label: "Voice AI", query: "voice ai" }, { x: 25, y: 16, id: "offers", kind: "noticeboard", label: "Offers", query: "offers" } ] - }); + }, under); } export function room() { diff --git a/scripts/spaces/decor.mjs b/scripts/spaces/decor.mjs index 66507d1..9473fe3 100644 --- a/scripts/spaces/decor.mjs +++ b/scripts/spaces/decor.mjs @@ -1,9 +1,9 @@ import { AppError } from "../store.mjs"; import { reachable, roleAt, walkable } from "../../world/map.js"; -import { DECOR_KINDS } from "./model.mjs"; +import { BLOCKING_DECOR, DECOR_KINDS } from "../../world/kinds.js"; // Decorations are semantic kinds; the theme draws them. Blocking kinds must never cut people off. -export const BLOCKING_DECOR = new Set(["sofa", "table", "whiteboard", "bookshelf", "statue", "fountain"]); +export { BLOCKING_DECOR }; export const DECOR_LIMIT = 60; const tilesIn = (map, area, role) => { const tiles = []; for (let y = area.y; y < area.y + area.height; y += 1) for (let x = area.x; x < area.x + area.width; x += 1) if (roleAt(map, x, y) === role) tiles.push({ x, y }); return tiles; }; diff --git a/scripts/spaces/model.mjs b/scripts/spaces/model.mjs index ccea61c..78e2f50 100644 --- a/scripts/spaces/model.mjs +++ b/scripts/spaces/model.mjs @@ -1,7 +1,7 @@ import { AppError } from "../store.mjs"; import { validCombination } from "./permissions.mjs"; -export const DECOR_KINDS = ["plant", "lamp", "rug", "sofa", "chair", "table", "whiteboard", "bookshelf", "screen", "banner", "poster", "statue", "fountain"]; +export { DECOR_KINDS } from "../../world/kinds.js"; export const THEMES = ["agora", "minimal", "cyberpunk"]; export const PLAZA_ID = "plaza"; export const PLAZA_CAPACITY = 1000; diff --git a/tests/map.test.mjs b/tests/map.test.mjs index 8e8c051..bca2435 100644 --- a/tests/map.test.mjs +++ b/tests/map.test.mjs @@ -95,3 +95,24 @@ test("the room world seats everyone reachably", async () => { assert.equal(seats.length, 8); for (const seat of seats) assert.ok(open.has(seat.x + "," + seat.y)); }); + +import { BLOCKING_DECOR, DECOR_KINDS, THEMED_KINDS } from "../world/kinds.js"; +import { DECOR_KINDS as SERVER_KINDS } from "../scripts/spaces/model.mjs"; +import { BLOCKING_DECOR as SERVER_BLOCKING } from "../scripts/spaces/decor.mjs"; + +test("decoration kinds are shared by the browser and the server", () => { + assert.deepEqual(DECOR_KINDS, ["plant", "lamp", "rug", "sofa", "chair", "table", "whiteboard", "bookshelf", "screen", "banner", "poster", "statue", "fountain"]); + assert.deepEqual(THEMED_KINDS, [...DECOR_KINDS, "noticeboard"]); + assert.deepEqual([...BLOCKING_DECOR], ["sofa", "table", "whiteboard", "bookshelf", "statue", "fountain"]); + assert.equal(SERVER_KINDS, DECOR_KINDS); + assert.equal(SERVER_BLOCKING, BLOCKING_DECOR); +}); +test("maps keep the ground role beneath structures", async () => { + const plaza = await world("plaza"); + const at = (x, y) => [plaza.ground[y * plaza.width + x], plaza.roles[y * plaza.width + x]]; + assert.deepEqual(at(20, 7), ["path", "seat"]); + assert.deepEqual(at(5, 8), ["grass", "plant"]); + assert.deepEqual(at(0, 0), ["floor", "wall"]); + assert.deepEqual(at(21, 17), ["path", "path"]); + assert.equal(plaza.ground.length, plaza.width * plaza.height); +}); diff --git a/world/kinds.js b/world/kinds.js new file mode 100644 index 0000000..afdad41 --- /dev/null +++ b/world/kinds.js @@ -0,0 +1,4 @@ +// Semantic kinds shared by maps, themes, the engine, and the server. Themes draw them; they never carry behavior. +export const DECOR_KINDS = ["plant", "lamp", "rug", "sofa", "chair", "table", "whiteboard", "bookshelf", "screen", "banner", "poster", "statue", "fountain"]; +export const BLOCKING_DECOR = new Set(["sofa", "table", "whiteboard", "bookshelf", "statue", "fountain"]); +export const THEMED_KINDS = [...DECOR_KINDS, "noticeboard"]; diff --git a/world/map.js b/world/map.js index a8a6813..e5011fd 100644 --- a/world/map.js +++ b/world/map.js @@ -10,6 +10,7 @@ export function parseMap(source) { const tiles = new Map(); for (const tileset of source.tilesets || []) for (const tile of tileset.tiles || []) tiles.set(tileset.firstgid + tile.id, props(tile)); const roles = new Array(width * height).fill(null); + const ground = new Array(width * height).fill(null); const blocked = new Uint8Array(width * height); for (const name of ["ground", "structure"]) { const data = source.layers.find((layer) => layer.type === "tilelayer" && layer.name === name)?.data; @@ -20,13 +21,14 @@ export function parseMap(source) { const tile = tiles.get(gid); if (!tile || !CORE_ROLES.includes(tile.role)) throw new Error("Unknown tile " + gid + "."); roles[index] = tile.role; + if (name === "ground") ground[index] = tile.role; blocked[index] = (tile.walkable === undefined ? BLOCKING.has(tile.role) : !tile.walkable) ? 1 : 0; }); } roles.forEach((role, index) => { if (!role) blocked[index] = 1; }); const objects = (name) => (source.layers.find((layer) => layer.type === "objectgroup" && layer.name === name)?.objects || []).map((object) => ({ name: object.name, ...props(object), x: Math.floor(object.x / size), y: Math.floor(object.y / size), width: Math.round((object.width || 0) / size), height: Math.round((object.height || 0) / size) })); return { - id: props(source).id, width, height, tileSize: size, roles, blocked, + id: props(source).id, width, height, tileSize: size, roles, ground, blocked, lots: objects("lot").map((lot) => ({ lotId: lot.lotId, slug: lot.slug, title: lot.title, capacity: lot.capacity, door: { x: lot.doorX, y: lot.doorY }, entry: { x: lot.entryX, y: lot.entryY }, interior: { x: lot.x, y: lot.y, width: lot.width, height: lot.height } })), spawns: objects("spawn").map(({ x, y }) => ({ x, y })), zones: objects("zone").map(({ name, x, y, width: w, height: h }) => ({ name, x, y, width: w, height: h })), diff --git a/worlds/plaza/map.json b/worlds/plaza/map.json index f10addf..1ad2243 100644 --- a/worlds/plaza/map.json +++ b/worlds/plaza/map.json @@ -1 +1 @@ -{"type":"map","version":"1.10","tiledversion":"1.10.2","orientation":"orthogonal","renderorder":"right-down","width":44,"height":34,"tilewidth":32,"tileheight":32,"infinite":false,"nextobjectid":14,"properties":[{"name":"id","type":"string","value":"plaza"}],"tilesets":[{"firstgid":1,"name":"roles","tilewidth":32,"tileheight":32,"tilecount":11,"columns":0,"tiles":[{"id":0,"properties":[{"name":"role","type":"string","value":"floor"}]},{"id":1,"properties":[{"name":"role","type":"string","value":"path"}]},{"id":2,"properties":[{"name":"role","type":"string","value":"grass"}]},{"id":3,"properties":[{"name":"role","type":"string","value":"water"}]},{"id":4,"properties":[{"name":"role","type":"string","value":"wall"}]},{"id":5,"properties":[{"name":"role","type":"string","value":"column"}]},{"id":6,"properties":[{"name":"role","type":"string","value":"door"}]},{"id":7,"properties":[{"name":"role","type":"string","value":"table"}]},{"id":8,"properties":[{"name":"role","type":"string","value":"seat"}]},{"id":9,"properties":[{"name":"role","type":"string","value":"plant"}]},{"id":10,"properties":[{"name":"role","type":"string","value":"decor"}]}]}],"layers":[{"type":"tilelayer","name":"ground","width":44,"height":34,"data":[1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,2,2,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,1,1,3,1,1,1,1,1,1,1,3,1,1,1,1,1,1,1,3,3,3,3,2,2,3,3,3,3,1,1,1,1,1,1,1,3,1,1,1,1,1,1,1,3,1,1,3,1,1,1,1,1,1,1,3,1,1,1,1,1,1,1,3,3,3,3,2,2,3,3,3,3,1,1,1,1,1,1,1,3,1,1,1,1,1,1,1,3,1,1,3,1,1,1,1,1,1,1,3,1,1,1,1,1,1,1,3,3,3,3,2,2,3,3,3,3,1,1,1,1,1,1,1,3,1,1,1,1,1,1,1,3,1,1,3,1,1,1,1,1,1,1,3,1,1,1,1,1,1,1,3,3,3,3,2,2,3,3,3,3,1,1,1,1,1,1,1,3,1,1,1,1,1,1,1,3,1,1,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,1,2,2,2,2,2,2,2,2,1,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,1,1,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,2,2,2,1,2,2,1,2,2,2,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,1,1,3,3,3,3,1,3,3,3,3,3,3,3,3,3,3,3,2,2,4,4,4,4,4,4,2,2,3,3,3,3,3,3,3,3,3,3,3,1,3,3,3,3,1,1,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,4,4,4,4,4,4,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,1,1,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,4,4,4,4,4,4,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,1,1,3,3,3,3,1,3,3,3,3,3,3,3,3,3,3,3,2,2,4,4,4,4,4,4,2,2,3,3,3,3,3,3,3,3,3,3,3,1,3,3,3,3,1,1,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,2,2,2,1,2,2,1,2,2,2,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,1,1,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,1,2,2,2,2,2,2,2,2,1,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,1,1,3,3,3,3,3,1,1,1,1,1,1,1,3,3,3,3,3,3,3,3,2,2,3,3,3,3,3,3,1,1,1,1,1,1,1,3,3,3,3,3,3,3,1,1,3,3,3,3,3,1,1,1,1,1,1,1,3,3,3,3,3,3,3,3,2,2,3,3,3,3,3,3,1,1,1,1,1,1,1,3,3,3,3,3,3,3,1,1,3,3,3,3,3,1,1,1,1,1,1,1,3,3,3,3,3,3,3,3,2,2,3,3,3,3,3,3,1,1,1,1,1,1,1,3,3,3,3,3,3,3,1,1,3,3,3,3,3,1,1,1,1,1,1,1,3,3,3,3,3,3,3,3,2,2,3,3,3,3,3,3,1,1,1,1,1,1,1,3,3,3,3,3,3,3,1,1,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,2,2,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1]},{"type":"tilelayer","name":"structure","width":44,"height":34,"data":[5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,5,5,0,5,5,5,5,5,5,5,0,5,5,5,5,5,5,5,0,0,0,0,0,0,0,0,0,0,5,5,5,5,5,5,5,0,5,5,5,5,5,5,5,0,5,5,0,5,5,5,5,5,5,5,0,5,5,5,5,5,5,5,0,0,0,0,0,0,0,0,0,0,5,5,5,5,5,5,5,0,5,5,5,5,5,5,5,0,5,5,0,5,5,5,5,5,5,5,0,5,5,5,5,5,5,5,0,0,0,0,0,0,0,0,0,0,5,5,5,5,5,5,5,0,5,5,5,5,5,5,5,0,5,5,0,5,5,5,7,5,5,5,0,5,5,5,7,5,5,5,0,0,0,0,0,0,0,0,0,0,5,5,5,7,5,5,5,0,5,5,5,7,5,5,5,0,5,5,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,6,0,0,0,0,0,0,0,0,6,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,5,5,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,9,0,0,9,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,5,5,0,0,0,0,10,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,10,0,0,0,0,5,5,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,5,5,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,5,5,0,0,0,0,10,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,10,0,0,0,0,5,5,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,9,0,0,9,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,5,5,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,6,0,0,0,0,0,0,0,0,6,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,5,5,0,0,0,0,0,5,5,5,7,5,5,5,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,5,5,5,7,5,5,5,0,0,0,0,0,0,0,5,5,0,0,0,0,0,5,5,5,5,5,5,5,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,5,5,5,5,5,5,5,0,0,0,0,0,0,0,5,5,0,0,0,0,0,5,5,5,5,5,5,5,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,5,5,5,5,5,5,5,0,0,0,0,0,0,0,5,5,0,0,0,0,0,5,5,5,5,5,5,5,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,5,5,5,5,5,5,5,0,0,0,0,0,0,0,5,5,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,10,0,0,0,0,10,5,10,0,0,0,0,10,5,10,0,0,0,0,10,5,10,0,0,0,0,10,5,10,0,0,0,0,10,5,10,0,0,0,0,10,5,5,5,0,0,0,0,0,0,5,0,0,0,0,0,0,5,0,0,0,0,0,0,5,0,0,0,0,0,0,5,0,0,0,0,0,0,5,0,0,0,0,0,0,5,5,5,0,0,0,0,0,0,5,0,0,0,0,0,0,5,0,0,0,0,0,0,5,0,0,0,0,0,0,5,0,0,0,0,0,0,5,0,0,0,0,0,0,5,5,5,0,0,0,0,0,0,5,0,0,0,0,0,0,5,0,0,0,0,0,0,5,0,0,0,0,0,0,5,0,0,0,0,0,0,5,0,0,0,0,0,0,5,5,5,0,9,8,8,9,0,5,0,9,8,8,9,0,5,0,9,8,8,9,0,5,0,9,8,8,9,0,5,0,9,8,8,9,0,5,0,9,8,8,9,0,5,5,5,0,9,8,8,9,0,5,0,9,8,8,9,0,5,0,9,8,8,9,0,5,0,9,8,8,9,0,5,0,9,8,8,9,0,5,0,9,8,8,9,0,5,5,5,0,9,8,8,9,0,5,0,9,8,8,9,0,5,0,9,8,8,9,0,5,0,9,8,8,9,0,5,0,9,8,8,9,0,5,0,9,8,8,9,0,5,5,5,0,0,0,0,0,0,5,0,0,0,0,0,0,5,0,0,0,0,0,0,5,0,0,0,0,0,0,5,0,0,0,0,0,0,5,0,0,0,0,0,0,5,5,5,0,0,0,0,0,0,5,0,0,0,0,0,0,5,0,0,0,0,0,0,5,0,0,0,0,0,0,5,0,0,0,0,0,0,5,0,0,0,0,0,0,5,5,5,0,0,0,0,0,0,5,0,0,0,0,0,0,5,0,0,0,0,0,0,5,0,0,0,0,0,0,5,0,0,0,0,0,0,5,0,0,0,0,0,0,5,5,5,0,0,0,0,0,0,5,0,0,0,0,0,0,5,0,0,0,0,0,0,5,0,0,0,0,0,0,5,0,0,0,0,0,0,5,0,0,0,0,0,0,5,5,5,0,0,7,0,0,0,5,0,0,7,0,0,0,5,0,0,7,0,0,0,5,0,0,7,0,0,0,5,0,0,7,0,0,0,5,0,0,7,0,0,0,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5]},{"type":"objectgroup","name":"lot","objects":[{"id":1,"name":"","x":32,"y":672,"width":192,"height":384,"point":false,"properties":[{"name":"lotId","type":"string","value":"agents"},{"name":"slug","type":"string","value":"ai-agents"},{"name":"title","type":"string","value":"AI agents"},{"name":"capacity","type":"int","value":8},{"name":"doorX","type":"int","value":5},{"name":"doorY","type":"int","value":5},{"name":"entryX","type":"int","value":3},{"name":"entryY","type":"int","value":31}]},{"id":2,"name":"","x":256,"y":672,"width":192,"height":384,"point":false,"properties":[{"name":"lotId","type":"string","value":"voice"},{"name":"slug","type":"string","value":"voice-ai"},{"name":"title","type":"string","value":"Voice AI"},{"name":"capacity","type":"int","value":8},{"name":"doorX","type":"int","value":13},{"name":"doorY","type":"int","value":5},{"name":"entryX","type":"int","value":10},{"name":"entryY","type":"int","value":31}]},{"id":3,"name":"","x":480,"y":672,"width":192,"height":384,"point":false,"properties":[{"name":"lotId","type":"string","value":"rtc"},{"name":"slug","type":"string","value":"rtc-lab"},{"name":"title","type":"string","value":"RTC lab"},{"name":"capacity","type":"int","value":6},{"name":"doorX","type":"int","value":30},{"name":"doorY","type":"int","value":5},{"name":"entryX","type":"int","value":17},{"name":"entryY","type":"int","value":31}]},{"id":4,"name":"","x":704,"y":672,"width":192,"height":384,"point":false,"properties":[{"name":"lotId","type":"string","value":"founders"},{"name":"slug","type":"string","value":"founders-table"},{"name":"title","type":"string","value":"Founders' table"},{"name":"capacity","type":"int","value":6},{"name":"doorX","type":"int","value":38},{"name":"doorY","type":"int","value":5},{"name":"entryX","type":"int","value":24},{"name":"entryY","type":"int","value":31}]},{"id":5,"name":"","x":928,"y":672,"width":192,"height":384,"point":false,"properties":[{"name":"lotId","type":"string","value":"oss"},{"name":"slug","type":"string","value":"open-source"},{"name":"title","type":"string","value":"Open source"},{"name":"capacity","type":"int","value":10},{"name":"doorX","type":"int","value":9},{"name":"doorY","type":"int","value":14},{"name":"entryX","type":"int","value":31},{"name":"entryY","type":"int","value":31}]},{"id":6,"name":"","x":1152,"y":672,"width":192,"height":384,"point":false,"properties":[{"name":"lotId","type":"string","value":"lounge"},{"name":"slug","type":"string","value":"lounge"},{"name":"title","type":"string","value":"The lounge"},{"name":"capacity","type":"int","value":12},{"name":"doorX","type":"int","value":32},{"name":"doorY","type":"int","value":14},{"name":"entryX","type":"int","value":38},{"name":"entryY","type":"int","value":31}]}]},{"type":"objectgroup","name":"spawn","objects":[{"id":7,"name":"","x":672,"y":544,"width":0,"height":0,"point":true,"properties":[]},{"id":8,"name":"","x":704,"y":544,"width":0,"height":0,"point":true,"properties":[]}]},{"type":"objectgroup","name":"zone","objects":[{"id":9,"name":"plaza","x":32,"y":32,"width":1344,"height":576,"point":false,"properties":[]},{"id":10,"name":"fountain","x":544,"y":192,"width":320,"height":256,"point":false,"properties":[]}]},{"type":"objectgroup","name":"interactable","objects":[{"id":11,"name":"","x":384,"y":256,"width":0,"height":0,"point":true,"properties":[{"name":"id","type":"string","value":"collaborators"},{"name":"kind","type":"string","value":"noticeboard"},{"name":"label","type":"string","value":"Looking for collaborators"},{"name":"query","type":"string","value":"collaborators"}]},{"id":12,"name":"","x":992,"y":256,"width":0,"height":0,"point":true,"properties":[{"name":"id","type":"string","value":"voice-ai"},{"name":"kind","type":"string","value":"noticeboard"},{"name":"label","type":"string","value":"Voice AI"},{"name":"query","type":"string","value":"voice ai"}]},{"id":13,"name":"","x":800,"y":512,"width":0,"height":0,"point":true,"properties":[{"name":"id","type":"string","value":"offers"},{"name":"kind","type":"string","value":"noticeboard"},{"name":"label","type":"string","value":"Offers"},{"name":"query","type":"string","value":"offers"}]}]}]} +{"type":"map","version":"1.10","tiledversion":"1.10.2","orientation":"orthogonal","renderorder":"right-down","width":44,"height":34,"tilewidth":32,"tileheight":32,"infinite":false,"nextobjectid":14,"properties":[{"name":"id","type":"string","value":"plaza"}],"tilesets":[{"firstgid":1,"name":"roles","tilewidth":32,"tileheight":32,"tilecount":11,"columns":0,"tiles":[{"id":0,"properties":[{"name":"role","type":"string","value":"floor"}]},{"id":1,"properties":[{"name":"role","type":"string","value":"path"}]},{"id":2,"properties":[{"name":"role","type":"string","value":"grass"}]},{"id":3,"properties":[{"name":"role","type":"string","value":"water"}]},{"id":4,"properties":[{"name":"role","type":"string","value":"wall"}]},{"id":5,"properties":[{"name":"role","type":"string","value":"column"}]},{"id":6,"properties":[{"name":"role","type":"string","value":"door"}]},{"id":7,"properties":[{"name":"role","type":"string","value":"table"}]},{"id":8,"properties":[{"name":"role","type":"string","value":"seat"}]},{"id":9,"properties":[{"name":"role","type":"string","value":"plant"}]},{"id":10,"properties":[{"name":"role","type":"string","value":"decor"}]}]}],"layers":[{"type":"tilelayer","name":"ground","width":44,"height":34,"data":[1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,2,2,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,1,1,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,2,2,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,1,1,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,2,2,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,1,1,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,2,2,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,1,1,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,2,2,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,1,1,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,2,2,2,2,2,2,2,2,2,2,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,1,1,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,2,2,2,2,2,2,2,2,2,2,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,1,1,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,2,2,4,4,4,4,4,4,2,2,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,1,1,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,4,4,4,4,4,4,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,1,1,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,4,4,4,4,4,4,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,1,1,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,2,2,4,4,4,4,4,4,2,2,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,1,1,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,2,2,2,2,2,2,2,2,2,2,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,1,1,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,2,2,2,2,2,2,2,2,2,2,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,1,1,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,2,2,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,1,1,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,2,2,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,1,1,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,2,2,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,1,1,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,2,2,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,1,1,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,2,2,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1]},{"type":"tilelayer","name":"structure","width":44,"height":34,"data":[5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,5,5,0,5,5,5,5,5,5,5,0,5,5,5,5,5,5,5,0,0,0,0,0,0,0,0,0,0,5,5,5,5,5,5,5,0,5,5,5,5,5,5,5,0,5,5,0,5,5,5,5,5,5,5,0,5,5,5,5,5,5,5,0,0,0,0,0,0,0,0,0,0,5,5,5,5,5,5,5,0,5,5,5,5,5,5,5,0,5,5,0,5,5,5,5,5,5,5,0,5,5,5,5,5,5,5,0,0,0,0,0,0,0,0,0,0,5,5,5,5,5,5,5,0,5,5,5,5,5,5,5,0,5,5,0,5,5,5,7,5,5,5,0,5,5,5,7,5,5,5,0,0,0,0,0,0,0,0,0,0,5,5,5,7,5,5,5,0,5,5,5,7,5,5,5,0,5,5,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,6,0,0,0,0,0,0,0,0,6,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,5,5,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,9,0,0,9,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,5,5,0,0,0,0,10,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,10,0,0,0,0,5,5,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,5,5,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,5,5,0,0,0,0,10,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,10,0,0,0,0,5,5,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,9,0,0,9,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,5,5,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,6,0,0,0,0,0,0,0,0,6,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,5,5,0,0,0,0,0,5,5,5,7,5,5,5,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,5,5,5,7,5,5,5,0,0,0,0,0,0,0,5,5,0,0,0,0,0,5,5,5,5,5,5,5,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,5,5,5,5,5,5,5,0,0,0,0,0,0,0,5,5,0,0,0,0,0,5,5,5,5,5,5,5,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,5,5,5,5,5,5,5,0,0,0,0,0,0,0,5,5,0,0,0,0,0,5,5,5,5,5,5,5,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,5,5,5,5,5,5,5,0,0,0,0,0,0,0,5,5,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,10,0,0,0,0,10,5,10,0,0,0,0,10,5,10,0,0,0,0,10,5,10,0,0,0,0,10,5,10,0,0,0,0,10,5,10,0,0,0,0,10,5,5,5,0,0,0,0,0,0,5,0,0,0,0,0,0,5,0,0,0,0,0,0,5,0,0,0,0,0,0,5,0,0,0,0,0,0,5,0,0,0,0,0,0,5,5,5,0,0,0,0,0,0,5,0,0,0,0,0,0,5,0,0,0,0,0,0,5,0,0,0,0,0,0,5,0,0,0,0,0,0,5,0,0,0,0,0,0,5,5,5,0,0,0,0,0,0,5,0,0,0,0,0,0,5,0,0,0,0,0,0,5,0,0,0,0,0,0,5,0,0,0,0,0,0,5,0,0,0,0,0,0,5,5,5,0,9,8,8,9,0,5,0,9,8,8,9,0,5,0,9,8,8,9,0,5,0,9,8,8,9,0,5,0,9,8,8,9,0,5,0,9,8,8,9,0,5,5,5,0,9,8,8,9,0,5,0,9,8,8,9,0,5,0,9,8,8,9,0,5,0,9,8,8,9,0,5,0,9,8,8,9,0,5,0,9,8,8,9,0,5,5,5,0,9,8,8,9,0,5,0,9,8,8,9,0,5,0,9,8,8,9,0,5,0,9,8,8,9,0,5,0,9,8,8,9,0,5,0,9,8,8,9,0,5,5,5,0,0,0,0,0,0,5,0,0,0,0,0,0,5,0,0,0,0,0,0,5,0,0,0,0,0,0,5,0,0,0,0,0,0,5,0,0,0,0,0,0,5,5,5,0,0,0,0,0,0,5,0,0,0,0,0,0,5,0,0,0,0,0,0,5,0,0,0,0,0,0,5,0,0,0,0,0,0,5,0,0,0,0,0,0,5,5,5,0,0,0,0,0,0,5,0,0,0,0,0,0,5,0,0,0,0,0,0,5,0,0,0,0,0,0,5,0,0,0,0,0,0,5,0,0,0,0,0,0,5,5,5,0,0,0,0,0,0,5,0,0,0,0,0,0,5,0,0,0,0,0,0,5,0,0,0,0,0,0,5,0,0,0,0,0,0,5,0,0,0,0,0,0,5,5,5,0,0,7,0,0,0,5,0,0,7,0,0,0,5,0,0,7,0,0,0,5,0,0,7,0,0,0,5,0,0,7,0,0,0,5,0,0,7,0,0,0,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5]},{"type":"objectgroup","name":"lot","objects":[{"id":1,"name":"","x":32,"y":672,"width":192,"height":384,"point":false,"properties":[{"name":"lotId","type":"string","value":"agents"},{"name":"slug","type":"string","value":"ai-agents"},{"name":"title","type":"string","value":"AI agents"},{"name":"capacity","type":"int","value":8},{"name":"doorX","type":"int","value":5},{"name":"doorY","type":"int","value":5},{"name":"entryX","type":"int","value":3},{"name":"entryY","type":"int","value":31}]},{"id":2,"name":"","x":256,"y":672,"width":192,"height":384,"point":false,"properties":[{"name":"lotId","type":"string","value":"voice"},{"name":"slug","type":"string","value":"voice-ai"},{"name":"title","type":"string","value":"Voice AI"},{"name":"capacity","type":"int","value":8},{"name":"doorX","type":"int","value":13},{"name":"doorY","type":"int","value":5},{"name":"entryX","type":"int","value":10},{"name":"entryY","type":"int","value":31}]},{"id":3,"name":"","x":480,"y":672,"width":192,"height":384,"point":false,"properties":[{"name":"lotId","type":"string","value":"rtc"},{"name":"slug","type":"string","value":"rtc-lab"},{"name":"title","type":"string","value":"RTC lab"},{"name":"capacity","type":"int","value":6},{"name":"doorX","type":"int","value":30},{"name":"doorY","type":"int","value":5},{"name":"entryX","type":"int","value":17},{"name":"entryY","type":"int","value":31}]},{"id":4,"name":"","x":704,"y":672,"width":192,"height":384,"point":false,"properties":[{"name":"lotId","type":"string","value":"founders"},{"name":"slug","type":"string","value":"founders-table"},{"name":"title","type":"string","value":"Founders' table"},{"name":"capacity","type":"int","value":6},{"name":"doorX","type":"int","value":38},{"name":"doorY","type":"int","value":5},{"name":"entryX","type":"int","value":24},{"name":"entryY","type":"int","value":31}]},{"id":5,"name":"","x":928,"y":672,"width":192,"height":384,"point":false,"properties":[{"name":"lotId","type":"string","value":"oss"},{"name":"slug","type":"string","value":"open-source"},{"name":"title","type":"string","value":"Open source"},{"name":"capacity","type":"int","value":10},{"name":"doorX","type":"int","value":9},{"name":"doorY","type":"int","value":14},{"name":"entryX","type":"int","value":31},{"name":"entryY","type":"int","value":31}]},{"id":6,"name":"","x":1152,"y":672,"width":192,"height":384,"point":false,"properties":[{"name":"lotId","type":"string","value":"lounge"},{"name":"slug","type":"string","value":"lounge"},{"name":"title","type":"string","value":"The lounge"},{"name":"capacity","type":"int","value":12},{"name":"doorX","type":"int","value":32},{"name":"doorY","type":"int","value":14},{"name":"entryX","type":"int","value":38},{"name":"entryY","type":"int","value":31}]}]},{"type":"objectgroup","name":"spawn","objects":[{"id":7,"name":"","x":672,"y":544,"width":0,"height":0,"point":true,"properties":[]},{"id":8,"name":"","x":704,"y":544,"width":0,"height":0,"point":true,"properties":[]}]},{"type":"objectgroup","name":"zone","objects":[{"id":9,"name":"plaza","x":32,"y":32,"width":1344,"height":576,"point":false,"properties":[]},{"id":10,"name":"fountain","x":544,"y":192,"width":320,"height":256,"point":false,"properties":[]}]},{"type":"objectgroup","name":"interactable","objects":[{"id":11,"name":"","x":384,"y":256,"width":0,"height":0,"point":true,"properties":[{"name":"id","type":"string","value":"collaborators"},{"name":"kind","type":"string","value":"noticeboard"},{"name":"label","type":"string","value":"Looking for collaborators"},{"name":"query","type":"string","value":"collaborators"}]},{"id":12,"name":"","x":992,"y":256,"width":0,"height":0,"point":true,"properties":[{"name":"id","type":"string","value":"voice-ai"},{"name":"kind","type":"string","value":"noticeboard"},{"name":"label","type":"string","value":"Voice AI"},{"name":"query","type":"string","value":"voice ai"}]},{"id":13,"name":"","x":800,"y":512,"width":0,"height":0,"point":true,"properties":[{"name":"id","type":"string","value":"offers"},{"name":"kind","type":"string","value":"noticeboard"},{"name":"label","type":"string","value":"Offers"},{"name":"query","type":"string","value":"offers"}]}]}]} From fa22d50404de3f2de606349cb66ce7a7c4fd1653 Mon Sep 17 00:00:00 2001 From: Brent G <deliberatekids@gmail.com> Date: Wed, 7 Oct 2026 19:44:59 +0000 Subject: [PATCH 19/71] Add the data-only theme contract MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🤖 Built with SMT <smt@agora.build> --- tests/themes.test.mjs | 57 +++++++++++++++++++++++++++++++++++++++++++ world/themes.js | 50 +++++++++++++++++++++++++++++++++++++ 2 files changed, 107 insertions(+) create mode 100644 tests/themes.test.mjs create mode 100644 world/themes.js diff --git a/tests/themes.test.mjs b/tests/themes.test.mjs new file mode 100644 index 0000000..b37d48a --- /dev/null +++ b/tests/themes.test.mjs @@ -0,0 +1,57 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { CORE_ROLES } from "../world/map.js"; +import { THEMED_KINDS } from "../world/kinds.js"; +import { AVATAR_STYLES, EFFECTS, FONTS, MANIFEST_LIMIT, PATTERNS, UI_TOKENS, VIDEO_LAYOUTS, validateTheme } from "../world/themes.js"; + +export function sampleTheme() { + return { + id: "sample", name: "Sample", version: 1, + roles: Object.fromEntries(CORE_ROLES.map((role) => [role, { pattern: "flat", colors: ["#112233"] }])), + decor: Object.fromEntries(THEMED_KINDS.map((kind) => [kind, { colors: ["#445566", "#778899"] }])), + avatar: { style: "token", palette: ["#111111", "#222222", "#333333", "#444444"], outline: "#000000" }, + ui: { "--paper": "#ffffff", "--ink": "#000000", font: "system-ui" }, + lighting: { ambient: 1, tint: "#ffffff" }, + effects: [], + video: "grid" + }; +} +const broken = (change) => { const theme = sampleTheme(); change(theme); return validateTheme(theme); }; + +test("the theme catalogs are fixed", () => { + assert.deepEqual(PATTERNS, ["flat", "tiles", "path", "grass", "water", "wall", "column", "door", "table", "seat", "plant", "decor", "neon"]); + assert.deepEqual(EFFECTS, ["dust", "rain", "leaves", "neon-glow"]); + assert.deepEqual(UI_TOKENS, ["--paper", "--surface", "--ink", "--muted", "--line", "--accent", "--accent-dark", "--green"]); + assert.deepEqual(FONTS, ["IBM Plex Sans", "IBM Plex Mono", "Georgia", "system-ui"]); + assert.deepEqual(AVATAR_STYLES, ["token", "pawn"]); + assert.deepEqual(VIDEO_LAYOUTS, ["over-avatar", "strip", "grid"]); + assert.equal(MANIFEST_LIMIT, 65536); +}); +test("a complete data-only theme is valid", () => { + assert.deepEqual(validateTheme(sampleTheme()), []); + assert.deepEqual(broken((theme) => { theme.decor.rug.variants = [["#010101"], ["#020202", "#030303"]]; theme.effects = ["rain", "neon-glow"]; }), []); +}); +test("every role and themed kind must be drawn", () => { + assert.match(broken((theme) => { delete theme.roles.water; }).join(" "), /water role/); + assert.match(broken((theme) => { delete theme.decor.noticeboard; }).join(" "), /noticeboard decoration/); +}); +test("themes cannot carry code, files, or unknown styling", () => { + assert.notDeepEqual(broken((theme) => { theme.roles.floor = { image: "floor.svg" }; }), []); + assert.notDeepEqual(broken((theme) => { theme.roles.floor.pattern = "script"; }), []); + assert.notDeepEqual(broken((theme) => { theme.roles.floor.colors = ["red"]; }), []); + assert.notDeepEqual(broken((theme) => { theme.roles.floor.colors = ["#111111", "#222222", "#333333", "#444444", "#555555"]; }), []); + assert.notDeepEqual(broken((theme) => { theme.ui["--paper"] = "url(https://evil.example)"; }), []); + assert.notDeepEqual(broken((theme) => { theme.ui["--position"] = "#ffffff"; }), []); + assert.notDeepEqual(broken((theme) => { theme.ui.font = "Comic Sans MS"; }), []); + assert.notDeepEqual(broken((theme) => { theme.effects = ["fireworks"]; }), []); + assert.notDeepEqual(broken((theme) => { theme.decor.rug.variants = new Array(9).fill(["#010101"]); }), []); + assert.notDeepEqual(broken((theme) => { theme.avatar.style = "robot"; }), []); + assert.notDeepEqual(broken((theme) => { theme.avatar.palette = ["#111111"]; }), []); + assert.notDeepEqual(broken((theme) => { theme.lighting.ambient = 2; }), []); + assert.notDeepEqual(broken((theme) => { theme.video = "theatre"; }), []); + assert.notDeepEqual(broken((theme) => { theme.version = 2; }), []); + assert.notDeepEqual(broken((theme) => { theme.id = "Bad Id"; }), []); + assert.notDeepEqual(broken((theme) => { theme.name = "x".repeat(70000); }), []); + assert.deepEqual(validateTheme(null), ["A theme must be an object."]); + assert.deepEqual(validateTheme([]), ["A theme must be an object."]); +}); diff --git a/world/themes.js b/world/themes.js new file mode 100644 index 0000000..b1ac590 --- /dev/null +++ b/world/themes.js @@ -0,0 +1,50 @@ +import { CORE_ROLES } from "./map.js"; +import { THEMED_KINDS } from "./kinds.js"; + +// Themes are data only: patterns, shapes, and effects come from fixed catalogs the renderer implements. +export const PATTERNS = ["flat", "tiles", "path", "grass", "water", "wall", "column", "door", "table", "seat", "plant", "decor", "neon"]; +export const EFFECTS = ["dust", "rain", "leaves", "neon-glow"]; +export const UI_TOKENS = ["--paper", "--surface", "--ink", "--muted", "--line", "--accent", "--accent-dark", "--green"]; +export const FONTS = ["IBM Plex Sans", "IBM Plex Mono", "Georgia", "system-ui"]; +export const AVATAR_STYLES = ["token", "pawn"]; +export const VIDEO_LAYOUTS = ["over-avatar", "strip", "grid"]; +export const MANIFEST_LIMIT = 65536; +const COLOR = /^#[0-9a-f]{6}$/i; + +export function validateTheme(theme) { + if (!theme || typeof theme !== "object" || Array.isArray(theme)) return ["A theme must be an object."]; + const errors = []; + const colors = (value, where, min = 1, max = 4) => { if (!Array.isArray(value) || value.length < min || value.length > max || !value.every((color) => typeof color === "string" && COLOR.test(color))) errors.push(where + " needs " + min + " to " + max + " colors like #a1b2c3."); }; + if (JSON.stringify(theme).length > MANIFEST_LIMIT) errors.push("The theme is larger than 64 KB."); + if (typeof theme.id !== "string" || !/^[a-z0-9-]{2,40}$/.test(theme.id)) errors.push("The theme needs an id of 2 to 40 lowercase letters, digits, or dashes."); + if (typeof theme.name !== "string" || theme.name.length < 2 || theme.name.length > 40) errors.push("The theme needs a name of 2 to 40 characters."); + if (theme.version !== 1) errors.push("Only theme version 1 is supported."); + for (const role of CORE_ROLES) { + const entry = theme.roles?.[role]; + if (!entry) { errors.push("The theme does not draw the " + role + " role."); continue; } + if (!PATTERNS.includes(entry.pattern)) errors.push("The " + role + " role uses an unknown pattern."); + colors(entry.colors, "The " + role + " role"); + } + for (const kind of THEMED_KINDS) { + const entry = theme.decor?.[kind]; + if (!entry) { errors.push("The theme does not draw the " + kind + " decoration."); continue; } + colors(entry.colors, "The " + kind + " decoration"); + if (entry.variants === undefined) continue; + if (!Array.isArray(entry.variants) || entry.variants.length > 8) errors.push("The " + kind + " decoration may have up to 8 variants."); + else entry.variants.forEach((variant, index) => colors(variant, "Variant " + index + " of " + kind)); + } + if (!AVATAR_STYLES.includes(theme.avatar?.style)) errors.push("Choose an avatar style: token or pawn."); + colors(theme.avatar?.palette, "The avatar palette", 4, 12); + colors([theme.avatar?.outline], "The avatar outline"); + for (const [token, value] of Object.entries(theme.ui || {})) { + if (token === "font") { if (!FONTS.includes(value)) errors.push("Choose an available font."); } + else if (!UI_TOKENS.includes(token)) errors.push("The ui token " + token + " is not allowed."); + else if (typeof value !== "string" || !COLOR.test(value)) errors.push("The ui token " + token + " must be a color like #a1b2c3."); + } + const ambient = theme.lighting?.ambient; + if (typeof ambient !== "number" || ambient < 0 || ambient > 1) errors.push("Lighting ambient must be between 0 and 1."); + colors([theme.lighting?.tint], "The lighting tint"); + if (!Array.isArray(theme.effects) || theme.effects.some((effect) => !EFFECTS.includes(effect))) errors.push("Effects must come from the built-in list."); + if (!VIDEO_LAYOUTS.includes(theme.video)) errors.push("Choose a video layout: over-avatar, strip, or grid."); + return errors; +} From 84d7f2e0b0e1a3a96bab55bfadd5eb38569a7957 Mon Sep 17 00:00:00 2001 From: Brent G <deliberatekids@gmail.com> Date: Wed, 7 Oct 2026 19:47:16 +0000 Subject: [PATCH 20/71] Reject unknown fields in theme validation and use Number.isFinite MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🤖 Built with SMT <smt@agora.build> --- tests/themes.test.mjs | 9 +++++++++ world/themes.js | 14 +++++++++++++- 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/tests/themes.test.mjs b/tests/themes.test.mjs index b37d48a..b23d471 100644 --- a/tests/themes.test.mjs +++ b/tests/themes.test.mjs @@ -55,3 +55,12 @@ test("themes cannot carry code, files, or unknown styling", () => { assert.deepEqual(validateTheme(null), ["A theme must be an object."]); assert.deepEqual(validateTheme([]), ["A theme must be an object."]); }); +test("themes reject unknown fields", () => { + assert.ok(broken((theme) => { theme.roles.floor.image = "floor.svg"; }).some((msg) => msg.includes("unknown field"))); + assert.ok(broken((theme) => { theme.script = "alert('hi')"; }).some((msg) => msg.includes("unknown field"))); + assert.ok(broken((theme) => { theme.roles.lava = { pattern: "flat", colors: ["#111111"] }; }).some((msg) => msg.includes("unknown field"))); + assert.deepEqual(validateTheme(sampleTheme()), []); +}); +test("lighting.ambient rejects NaN", () => { + assert.notDeepEqual(broken((theme) => { theme.lighting.ambient = NaN; }), []); +}); diff --git a/world/themes.js b/world/themes.js index b1ac590..e8340ee 100644 --- a/world/themes.js +++ b/world/themes.js @@ -15,25 +15,36 @@ export function validateTheme(theme) { if (!theme || typeof theme !== "object" || Array.isArray(theme)) return ["A theme must be an object."]; const errors = []; const colors = (value, where, min = 1, max = 4) => { if (!Array.isArray(value) || value.length < min || value.length > max || !value.every((color) => typeof color === "string" && COLOR.test(color))) errors.push(where + " needs " + min + " to " + max + " colors like #a1b2c3."); }; + const checkKeys = (obj, allowed, where) => { if (obj) for (const key of Object.keys(obj)) if (!allowed.includes(key)) errors.push("The theme has an unknown field: " + where + "." + key + "."); }; if (JSON.stringify(theme).length > MANIFEST_LIMIT) errors.push("The theme is larger than 64 KB."); if (typeof theme.id !== "string" || !/^[a-z0-9-]{2,40}$/.test(theme.id)) errors.push("The theme needs an id of 2 to 40 lowercase letters, digits, or dashes."); if (typeof theme.name !== "string" || theme.name.length < 2 || theme.name.length > 40) errors.push("The theme needs a name of 2 to 40 characters."); if (theme.version !== 1) errors.push("Only theme version 1 is supported."); + checkKeys(theme, ["id", "name", "version", "roles", "decor", "avatar", "ui", "lighting", "effects", "video"], ""); for (const role of CORE_ROLES) { const entry = theme.roles?.[role]; if (!entry) { errors.push("The theme does not draw the " + role + " role."); continue; } + checkKeys(entry, ["pattern", "colors"], "roles." + role); if (!PATTERNS.includes(entry.pattern)) errors.push("The " + role + " role uses an unknown pattern."); colors(entry.colors, "The " + role + " role"); } + for (const roleKey of Object.keys(theme.roles || {})) { + if (!CORE_ROLES.includes(roleKey)) errors.push("The theme has an unknown field: roles." + roleKey + "."); + } for (const kind of THEMED_KINDS) { const entry = theme.decor?.[kind]; if (!entry) { errors.push("The theme does not draw the " + kind + " decoration."); continue; } + checkKeys(entry, ["colors", "variants"], "decor." + kind); colors(entry.colors, "The " + kind + " decoration"); if (entry.variants === undefined) continue; if (!Array.isArray(entry.variants) || entry.variants.length > 8) errors.push("The " + kind + " decoration may have up to 8 variants."); else entry.variants.forEach((variant, index) => colors(variant, "Variant " + index + " of " + kind)); } + for (const kindKey of Object.keys(theme.decor || {})) { + if (!THEMED_KINDS.includes(kindKey)) errors.push("The theme has an unknown field: decor." + kindKey + "."); + } if (!AVATAR_STYLES.includes(theme.avatar?.style)) errors.push("Choose an avatar style: token or pawn."); + checkKeys(theme.avatar, ["style", "palette", "outline"], "avatar"); colors(theme.avatar?.palette, "The avatar palette", 4, 12); colors([theme.avatar?.outline], "The avatar outline"); for (const [token, value] of Object.entries(theme.ui || {})) { @@ -41,8 +52,9 @@ export function validateTheme(theme) { else if (!UI_TOKENS.includes(token)) errors.push("The ui token " + token + " is not allowed."); else if (typeof value !== "string" || !COLOR.test(value)) errors.push("The ui token " + token + " must be a color like #a1b2c3."); } + checkKeys(theme.lighting, ["ambient", "tint"], "lighting"); const ambient = theme.lighting?.ambient; - if (typeof ambient !== "number" || ambient < 0 || ambient > 1) errors.push("Lighting ambient must be between 0 and 1."); + if (!Number.isFinite(ambient) || ambient < 0 || ambient > 1) errors.push("Lighting ambient must be between 0 and 1."); colors([theme.lighting?.tint], "The lighting tint"); if (!Array.isArray(theme.effects) || theme.effects.some((effect) => !EFFECTS.includes(effect))) errors.push("Effects must come from the built-in list."); if (!VIDEO_LAYOUTS.includes(theme.video)) errors.push("Choose a video layout: over-avatar, strip, or grid."); From 5b491aaa482129c1f91f19c3afc22d59954b545a Mon Sep 17 00:00:00 2001 From: Brent G <deliberatekids@gmail.com> Date: Wed, 7 Oct 2026 19:51:42 +0000 Subject: [PATCH 21/71] =?UTF-8?q?Add=20the=20Agora,=20Minimal,=20and=20Cyb?= =?UTF-8?q?erpunk=20themes=20=F0=9F=A4=96=20Built=20with=20SMT=20<smt@agor?= =?UTF-8?q?a.build>?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- scripts/build.mjs | 1 + scripts/serve.mjs | 4 +++- scripts/spaces/routes.mjs | 3 ++- scripts/spaces/themes.mjs | 15 ++++++++++++++ tests/server.test.mjs | 2 +- tests/spaces-api.test.mjs | 9 +++++++++ tests/themes.test.mjs | 33 +++++++++++++++++++++++++++++++ themes/agora/theme.json | 39 +++++++++++++++++++++++++++++++++++++ themes/cyberpunk/theme.json | 39 +++++++++++++++++++++++++++++++++++++ themes/minimal/theme.json | 39 +++++++++++++++++++++++++++++++++++++ 10 files changed, 181 insertions(+), 3 deletions(-) create mode 100644 scripts/spaces/themes.mjs create mode 100644 themes/agora/theme.json create mode 100644 themes/cyberpunk/theme.json create mode 100644 themes/minimal/theme.json diff --git a/scripts/build.mjs b/scripts/build.mjs index 5161d69..ec13f09 100644 --- a/scripts/build.mjs +++ b/scripts/build.mjs @@ -15,4 +15,5 @@ await Promise.all(["serve.mjs", "store.mjs", "models.mjs", "activity.mjs", "call await cp(new URL("scripts/spaces/", root), new URL("scripts/spaces/", output), { recursive: true }); await cp(new URL("world/", root), new URL("world/", output), { recursive: true }); await cp(new URL("worlds/", root), new URL("worlds/", output), { recursive: true }); +await cp(new URL("themes/", root), new URL("themes/", output), { recursive: true }); console.log("House app built in dist/. Run with: node dist/scripts/serve.mjs"); diff --git a/scripts/serve.mjs b/scripts/serve.mjs index 2e89aa4..514e964 100644 --- a/scripts/serve.mjs +++ b/scripts/serve.mjs @@ -18,6 +18,7 @@ import { createPostgresPersistence } from "./persistence.mjs"; import { createSignaling, signalingConfig } from "./spaces/signaling.mjs"; import { createSpaces, loadWorlds } from "./spaces/service.mjs"; import { handleSpaces } from "./spaces/routes.mjs"; +import { loadThemes } from "./spaces/themes.mjs"; const root = fileURLToPath(new URL("../", import.meta.url)); const sdkFile = createRequire(import.meta.url).resolve("agora-rtc-sdk-ng"); @@ -69,6 +70,7 @@ export function createAppServer(directory = root, options = {}) { const models = options.models || createModelClient(modelConfig()); const activity = options.activity || createActivityFeed({ store, projectsFile: resolve(dataDirectory, "projects.json"), snapshotFile: resolve(dataDirectory, "activity.json") }); const worlds = options.worlds || loadWorlds(directory); + const themes = options.themes || loadThemes(directory); const signaling = options.signaling || createSignaling(signalingConfig()); const spaces = options.spaces || createSpaces({ store, worlds, signaling, calls, secret: options.channelSecret ?? signalingConfig().secret, admins: (process.env.PLATFORM_ADMINS || "").split(",").map((id) => id.trim()).filter(Boolean), plazaCapacity: options.plazaCapacity }); const windows = new Map(); @@ -330,7 +332,7 @@ export function createAppServer(directory = root, options = {}) { limit("call:" + person.id, 30, 60000); json(response, 200, calls.issue(room, person, await body(request))); } else { - const handled = await handleSpaces({ path, method: request.method, url: requestUrl, token, read: () => body(request), spaces, worlds, limit, ip: request.socket.remoteAddress }); + const handled = await handleSpaces({ path, method: request.method, url: requestUrl, token, read: () => body(request), spaces, worlds, themes, limit, ip: request.socket.remoteAddress }); if (!handled) throw new AppError(404, "This page or service was not found."); json(response, handled.status, handled.body); } diff --git a/scripts/spaces/routes.mjs b/scripts/spaces/routes.mjs index 66cdc86..e204a07 100644 --- a/scripts/spaces/routes.mjs +++ b/scripts/spaces/routes.mjs @@ -3,11 +3,12 @@ import { AppError } from "../store.mjs"; const SPACE = /^\/api\/spaces\/(plaza|lot-[a-z0-9-]{2,40}|[a-f0-9-]{36})(?:\/(enter|leave|heartbeat|topic|host|decor|remove|invitations|members|rtc-token)(?:\/((?:member|account):[a-f0-9-]{36}))?)?$/; // Maps HTTP requests to the spaces service. Returns null when the request is not a spaces route. -export async function handleSpaces({ path, method, url, token, read: readBody, spaces, worlds, limit, ip }) { +export async function handleSpaces({ path, method, url, token, read: readBody, spaces, worlds, themes, limit, ip }) { const read = async () => { const data = await readBody(); return data && typeof data === "object" && !Array.isArray(data) ? data : {}; }; if (path === "/api/spaces" && method === "GET") return { status: 200, body: await spaces.list({ q: url.searchParams.get("q") || "" }) }; if (path === "/api/spaces" && method === "POST") { limit("spaces:" + ip, 20, 3600000); return { status: 201, body: { space: await spaces.create(token, await read()) } }; } if (path === "/api/signaling/token" && method === "POST") { limit("signaling:" + ip, 30, 60000); return { status: 200, body: await spaces.signalingToken(token) }; } + if (path === "/api/themes" && method === "GET") return { status: 200, body: { themes } }; const world = /^\/api\/worlds\/([a-z]+)$/.exec(path); if (world && method === "GET") { if (!Object.hasOwn(worlds, world[1])) throw new AppError(404, "This world was not found."); diff --git a/scripts/spaces/themes.mjs b/scripts/spaces/themes.mjs new file mode 100644 index 0000000..38ac821 --- /dev/null +++ b/scripts/spaces/themes.mjs @@ -0,0 +1,15 @@ +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; +import { validateTheme } from "../../world/themes.js"; +import { THEMES } from "./model.mjs"; + +// Built-in themes are validated at startup so a broken theme can never reach a browser. +export function loadThemes(directory) { + return THEMES.map((id) => { + const theme = JSON.parse(readFileSync(resolve(directory, "themes", id, "theme.json"), "utf8")); + const errors = validateTheme(theme); + if (theme.id !== id) errors.push("Its id does not match its folder."); + if (errors.length) throw new Error("Theme " + id + " is invalid: " + errors.join(" ")); + return theme; + }); +} diff --git a/tests/server.test.mjs b/tests/server.test.mjs index 420134d..78c7bca 100644 --- a/tests/server.test.mjs +++ b/tests/server.test.mjs @@ -272,7 +272,7 @@ test("automatic radar scans only opted-in members and respects the scan interval test("production output includes six pages, platform backend, seed data, and no private state", async () => { await import("../scripts/build.mjs"); for (const file of ["account.html", "account.js", "scripts/identity.mjs", "scripts/ledger.mjs", "scripts/credits.mjs", "scripts/billing.mjs", "scripts/connections.mjs", "scripts/plans.mjs", "scripts/persistence.mjs", "scripts/platform-admin.mjs"]) assert.deepEqual(await readFile(resolve(root, "dist", file)), await readFile(resolve(root, file)), file); - const files = ["index.html", "explore.html", "services.html", "radar.html", "meetings.html", "styles.css", "script.js", "people.js", "activity.js", "explore.js", "services.js", "radar.js", "meetings.js", "call.js", "package.json", "package-lock.json", "assets/favicon.svg", "assets/guohai.jpg", "scripts/serve.mjs", "scripts/store.mjs", "scripts/models.mjs", "scripts/activity.mjs", "scripts/calls.mjs", "scripts/auth.mjs", "stoa.html", "world/map.js", "worlds/plaza/map.json", "worlds/room/map.json", "scripts/spaces/service.mjs", "scripts/spaces/routes.mjs", "data/people.json", "data/projects.json", "data/offers.json", "data/activity.json"]; + const files = ["index.html", "explore.html", "services.html", "radar.html", "meetings.html", "styles.css", "script.js", "people.js", "activity.js", "explore.js", "services.js", "radar.js", "meetings.js", "call.js", "package.json", "package-lock.json", "assets/favicon.svg", "assets/guohai.jpg", "scripts/serve.mjs", "scripts/store.mjs", "scripts/models.mjs", "scripts/activity.mjs", "scripts/calls.mjs", "scripts/auth.mjs", "stoa.html", "world/map.js", "world/kinds.js", "worlds/plaza/map.json", "worlds/room/map.json", "scripts/spaces/service.mjs", "scripts/spaces/routes.mjs", "data/people.json", "data/projects.json", "data/offers.json", "data/activity.json", "themes/agora/theme.json", "themes/minimal/theme.json", "themes/cyberpunk/theme.json"]; for (const file of files) assert.deepEqual(await readFile(resolve(root, "dist", file)), await readFile(resolve(root, file)), file); for (const file of [".env", ".data/community.json"]) await assert.rejects(readFile(resolve(root, "dist", file)), { code: "ENOENT" }); const built = await start({}, resolve(root, "dist")); diff --git a/tests/spaces-api.test.mjs b/tests/spaces-api.test.mjs index 9969751..d4b5056 100644 --- a/tests/spaces-api.test.mjs +++ b/tests/spaces-api.test.mjs @@ -134,3 +134,12 @@ test("a JSON null body never causes a server error", async () => { assert.ok((await request(url, "/api/spaces", { cookie, method: "POST", data: null })).status < 500); } finally { await close(instance); } }); + +test("the themes API returns the three built-in themes", async () => { + const { instance, url } = await start(); + try { + const response = await request(url, "/api/themes"); + assert.equal(response.status, 200); + assert.deepEqual((await response.json()).themes.map((theme) => theme.id), ["agora", "minimal", "cyberpunk"]); + } finally { await close(instance); } +}); diff --git a/tests/themes.test.mjs b/tests/themes.test.mjs index b23d471..64cdd8a 100644 --- a/tests/themes.test.mjs +++ b/tests/themes.test.mjs @@ -64,3 +64,36 @@ test("themes reject unknown fields", () => { test("lighting.ambient rejects NaN", () => { assert.notDeepEqual(broken((theme) => { theme.lighting.ambient = NaN; }), []); }); + +import { mkdtemp, mkdir, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { resolve } from "node:path"; +import { loadThemes } from "../scripts/spaces/themes.mjs"; +import { THEMES } from "../scripts/spaces/model.mjs"; + +const root = new URL("../", import.meta.url).pathname; + +test("the three built-in themes are valid and look different", () => { + const themes = loadThemes(root); + assert.deepEqual(themes.map((theme) => theme.id), THEMES); + for (const theme of themes) assert.deepEqual(validateTheme(theme), [], theme.id); + const [agora, minimal, cyberpunk] = themes; + assert.notEqual(agora.roles.floor.colors[0], cyberpunk.roles.floor.colors[0]); + assert.deepEqual(minimal.effects, [], "Minimal has no effects"); + assert.ok(cyberpunk.effects.includes("rain") && cyberpunk.effects.includes("neon-glow")); + assert.equal(agora.ui["--accent"], "#ce4b24", "Agora keeps the portal's terracotta"); +}); +test("an invalid built-in theme stops the server from starting", async () => { + const directory = await mkdtemp(resolve(tmpdir(), "themes-")); + try { + for (const id of THEMES) { + await mkdir(resolve(directory, "themes", id), { recursive: true }); + await writeFile(resolve(directory, "themes", id, "theme.json"), JSON.stringify({ ...sampleTheme(), id })); + } + assert.equal(loadThemes(directory).length, 3); + await writeFile(resolve(directory, "themes", "minimal", "theme.json"), JSON.stringify({ ...sampleTheme(), id: "minimal", effects: ["fireworks"] })); + assert.throws(() => loadThemes(directory), /Theme minimal is invalid/); + await writeFile(resolve(directory, "themes", "minimal", "theme.json"), JSON.stringify({ ...sampleTheme(), id: "other" })); + assert.throws(() => loadThemes(directory), /Theme minimal is invalid/); + } finally { await rm(directory, { recursive: true, force: true }); } +}); diff --git a/themes/agora/theme.json b/themes/agora/theme.json new file mode 100644 index 0000000..af494cb --- /dev/null +++ b/themes/agora/theme.json @@ -0,0 +1,39 @@ +{ + "id": "agora", + "name": "Agora", + "version": 1, + "roles": { + "floor": { "pattern": "tiles", "colors": ["#ebe5d5", "#dcd3bd"] }, + "path": { "pattern": "path", "colors": ["#ddd2b8", "#c9bc9c"] }, + "grass": { "pattern": "grass", "colors": ["#b9c29b", "#97a37a"] }, + "water": { "pattern": "water", "colors": ["#7ea6aa", "#a9c9cb"] }, + "wall": { "pattern": "wall", "colors": ["#d7cdb5", "#b9ad92", "#e8e0cc"] }, + "column": { "pattern": "column", "colors": ["#f4f1e8", "#cfc6b0"] }, + "door": { "pattern": "door", "colors": ["#ce4b24", "#7a2c14"] }, + "table": { "pattern": "table", "colors": ["#8a6a4f", "#6b503b"] }, + "seat": { "pattern": "seat", "colors": ["#b5552f", "#8a3f22"] }, + "plant": { "pattern": "plant", "colors": ["#526951", "#a0522d"] }, + "decor": { "pattern": "decor", "colors": ["#ce4b24"] } + }, + "decor": { + "plant": { "colors": ["#526951", "#a0522d"], "variants": [["#526951", "#a0522d"], ["#6f8a5a", "#8a5a3c"]] }, + "lamp": { "colors": ["#f2c46d", "#3b3a33"] }, + "rug": { "colors": ["#ce4b24", "#8f3a1d"], "variants": [["#ce4b24", "#8f3a1d"], ["#526951", "#384a37"]] }, + "sofa": { "colors": ["#b5552f", "#7f3a20"] }, + "chair": { "colors": ["#8a6a4f", "#5e4734"] }, + "table": { "colors": ["#8a6a4f", "#6b503b"] }, + "whiteboard": { "colors": ["#fffef9", "#6c746b"] }, + "bookshelf": { "colors": ["#ce4b24", "#5e4734", "#526951"] }, + "screen": { "colors": ["#7ea6aa", "#272d28"] }, + "banner": { "colors": ["#ce4b24", "#272d28"] }, + "poster": { "colors": ["#f5f4ee", "#272d28"] }, + "statue": { "colors": ["#f4f1e8", "#cfc6b0"] }, + "fountain": { "colors": ["#e8e0cc", "#7ea6aa"] }, + "noticeboard": { "colors": ["#c9a97a", "#5e4734", "#fffef9"] } + }, + "avatar": { "style": "token", "palette": ["#ce4b24", "#526951", "#3c5a78", "#8a6a4f", "#7a4f8a", "#c08a1f"], "outline": "#272d28" }, + "ui": { "--paper": "#f5f4ee", "--surface": "#fffef9", "--ink": "#272d28", "--muted": "#6c746b", "--line": "#dedfd3", "--accent": "#ce4b24", "--accent-dark": "#a93a1a", "--green": "#526951", "font": "IBM Plex Sans" }, + "lighting": { "ambient": 1, "tint": "#fff4dc" }, + "effects": ["dust"], + "video": "over-avatar" +} diff --git a/themes/cyberpunk/theme.json b/themes/cyberpunk/theme.json new file mode 100644 index 0000000..628e02a --- /dev/null +++ b/themes/cyberpunk/theme.json @@ -0,0 +1,39 @@ +{ + "id": "cyberpunk", + "name": "Cyberpunk", + "version": 1, + "roles": { + "floor": { "pattern": "neon", "colors": ["#14151f", "#ff2bd6"] }, + "path": { "pattern": "neon", "colors": ["#1b1d2b", "#00e5ff"] }, + "grass": { "pattern": "tiles", "colors": ["#16202a", "#1f2e3a"] }, + "water": { "pattern": "water", "colors": ["#0b2a3a", "#00e5ff"] }, + "wall": { "pattern": "wall", "colors": ["#252838", "#11121a", "#3a3e57"] }, + "column": { "pattern": "column", "colors": ["#2e3250", "#00e5ff"] }, + "door": { "pattern": "door", "colors": ["#ff2bd6", "#5c0f4f"] }, + "table": { "pattern": "table", "colors": ["#2b2f45", "#1a1c2b"] }, + "seat": { "pattern": "seat", "colors": ["#00e5ff", "#007d8c"] }, + "plant": { "pattern": "plant", "colors": ["#39ff88", "#2b2f45"] }, + "decor": { "pattern": "decor", "colors": ["#ffd400"] } + }, + "decor": { + "plant": { "colors": ["#39ff88", "#2b2f45"] }, + "lamp": { "colors": ["#ff2bd6", "#2b2f45"] }, + "rug": { "colors": ["#3a1d5c", "#ff2bd6"] }, + "sofa": { "colors": ["#00e5ff", "#005c66"] }, + "chair": { "colors": ["#ff2bd6", "#5c0f4f"] }, + "table": { "colors": ["#2b2f45", "#1a1c2b"] }, + "whiteboard": { "colors": ["#0b2a3a", "#00e5ff"] }, + "bookshelf": { "colors": ["#ff2bd6", "#11121a", "#00e5ff"] }, + "screen": { "colors": ["#00e5ff", "#05060a"] }, + "banner": { "colors": ["#ffd400", "#11121a"] }, + "poster": { "colors": ["#ff2bd6", "#11121a"] }, + "statue": { "colors": ["#9d7bff", "#2e3250"] }, + "fountain": { "colors": ["#2e3250", "#00e5ff"] }, + "noticeboard": { "colors": ["#151724", "#00e5ff", "#ff2bd6"] } + }, + "avatar": { "style": "pawn", "palette": ["#ff2bd6", "#00e5ff", "#39ff88", "#ffd400", "#ff6b2b", "#9d7bff"], "outline": "#0b0c12" }, + "ui": { "--paper": "#0b0c12", "--surface": "#151724", "--ink": "#e8eaff", "--muted": "#9aa0c3", "--line": "#2a2d44", "--accent": "#ff2bd6", "--accent-dark": "#c21fa3", "--green": "#39ff88", "font": "IBM Plex Mono" }, + "lighting": { "ambient": 0.7, "tint": "#1a0b3d" }, + "effects": ["rain", "neon-glow"], + "video": "strip" +} diff --git a/themes/minimal/theme.json b/themes/minimal/theme.json new file mode 100644 index 0000000..1e7daa5 --- /dev/null +++ b/themes/minimal/theme.json @@ -0,0 +1,39 @@ +{ + "id": "minimal", + "name": "Minimal", + "version": 1, + "roles": { + "floor": { "pattern": "flat", "colors": ["#ffffff"] }, + "path": { "pattern": "flat", "colors": ["#eeeeee"] }, + "grass": { "pattern": "flat", "colors": ["#e3eee0"] }, + "water": { "pattern": "flat", "colors": ["#cfe3f5"] }, + "wall": { "pattern": "flat", "colors": ["#1a1a1a"] }, + "column": { "pattern": "flat", "colors": ["#555555"] }, + "door": { "pattern": "flat", "colors": ["#0057d9"] }, + "table": { "pattern": "flat", "colors": ["#8c8c8c"] }, + "seat": { "pattern": "seat", "colors": ["#4a4a4a", "#222222"] }, + "plant": { "pattern": "flat", "colors": ["#2e7d32"] }, + "decor": { "pattern": "flat", "colors": ["#0057d9"] } + }, + "decor": { + "plant": { "colors": ["#2e7d32", "#4a4a4a"] }, + "lamp": { "colors": ["#f9a825", "#222222"] }, + "rug": { "colors": ["#cccccc", "#999999"] }, + "sofa": { "colors": ["#4a4a4a", "#222222"] }, + "chair": { "colors": ["#4a4a4a", "#222222"] }, + "table": { "colors": ["#8c8c8c", "#5c5c5c"] }, + "whiteboard": { "colors": ["#ffffff", "#222222"] }, + "bookshelf": { "colors": ["#0057d9", "#222222", "#b3261e"] }, + "screen": { "colors": ["#cfe3f5", "#111111"] }, + "banner": { "colors": ["#0057d9", "#222222"] }, + "poster": { "colors": ["#ffffff", "#222222"] }, + "statue": { "colors": ["#bbbbbb", "#777777"] }, + "fountain": { "colors": ["#bbbbbb", "#cfe3f5"] }, + "noticeboard": { "colors": ["#ffffff", "#222222", "#0057d9"] } + }, + "avatar": { "style": "token", "palette": ["#0057d9", "#b3261e", "#1e7d32", "#6a1b9a", "#e65100", "#00695c"], "outline": "#000000" }, + "ui": { "--paper": "#ffffff", "--surface": "#ffffff", "--ink": "#111111", "--muted": "#4a4a4a", "--line": "#c8c8c8", "--accent": "#0057d9", "--accent-dark": "#003c99", "--green": "#1e7d32", "font": "system-ui" }, + "lighting": { "ambient": 1, "tint": "#ffffff" }, + "effects": [], + "video": "grid" +} From 76028cd4b93187d146fcf1f8c2bec1208a0d55b1 Mon Sep 17 00:00:00 2001 From: Brent G <deliberatekids@gmail.com> Date: Wed, 7 Oct 2026 19:53:52 +0000 Subject: [PATCH 22/71] Add camera and walking motion MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🤖 Built with SMT <smt@agora.build> --- tests/world-client.test.mjs | 44 +++++++++++++++++++++++++++++++++++++ world/camera.js | 11 ++++++++++ world/motion.js | 16 ++++++++++++++ 3 files changed, 71 insertions(+) create mode 100644 tests/world-client.test.mjs create mode 100644 world/camera.js create mode 100644 world/motion.js diff --git a/tests/world-client.test.mjs b/tests/world-client.test.mjs new file mode 100644 index 0000000..ce7f488 --- /dev/null +++ b/tests/world-client.test.mjs @@ -0,0 +1,44 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { follow, screenToTile, tileCenter, zoomFor } from "../world/camera.js"; +import { KEYS, STEP, WALK_SPEED, direction, positionAt } from "../world/motion.js"; + +test("zoom shows about twelve tiles across the shorter side, within limits", () => { + assert.equal(zoomFor({ width: 768, height: 384 }, 32), 1); + assert.equal(zoomFor({ width: 100, height: 100 }, 32), 0.6); + assert.equal(zoomFor({ width: 4000, height: 4000 }, 32), 2); +}); +test("the camera follows the target, stops at the world's edges, and centers small worlds", () => { + const world = { width: 1408, height: 1088 }, viewport = { width: 640, height: 480 }; + assert.deepEqual(follow({ x: 700, y: 500 }, viewport, world, 1), { x: 380, y: 260, zoom: 1 }); + assert.deepEqual(follow({ x: 10, y: 10 }, viewport, world, 1), { x: 0, y: 0, zoom: 1 }); + assert.deepEqual(follow({ x: 1400, y: 1080 }, viewport, world, 1), { x: 768, y: 608, zoom: 1 }); + assert.deepEqual(follow({ x: 50, y: 50 }, viewport, { width: 320, height: 240 }, 1), { x: -160, y: -120, zoom: 1 }); + assert.deepEqual(follow({ x: 700, y: 500 }, viewport, world, 2), { x: 540, y: 380, zoom: 2 }); +}); +test("screen points map to tiles through the camera", () => { + const camera = { x: 380, y: 260, zoom: 2 }; + assert.deepEqual(screenToTile(camera, 0, 0, 32), { x: 11, y: 8 }); + assert.deepEqual(screenToTile(camera, 64, 32, 32), { x: 12, y: 8 }); + const center = tileCenter({ x: 12, y: 9 }, 32); + assert.deepEqual(center, { x: 400, y: 304 }); + assert.deepEqual(screenToTile({ x: 0, y: 0, zoom: 1 }, center.x, center.y, 32), { x: 12, y: 9 }); +}); +test("walks move at a fixed speed along the path and face the way they go", () => { + assert.equal(WALK_SPEED, 5); + const walk = { path: [{ x: 0, y: 0 }, { x: 1, y: 0 }, { x: 1, y: 1 }], startedAt: 1000 }; + assert.deepEqual(positionAt(walk, 1000), { x: 0, y: 0, dir: "right", done: false }); + assert.deepEqual(positionAt(walk, 1100), { x: 0.5, y: 0, dir: "right", done: false }); + assert.deepEqual(positionAt(walk, 1300), { x: 1, y: 0.5, dir: "down", done: false }); + assert.deepEqual(positionAt(walk, 1400), { x: 1, y: 1, dir: "down", done: true }); + assert.deepEqual(positionAt(walk, 500), { x: 0, y: 0, dir: "right", done: false }); + assert.deepEqual(positionAt({ path: [{ x: 3, y: 4 }], startedAt: 0 }, 99), { x: 3, y: 4, dir: "down", done: true }); + assert.deepEqual(positionAt({ path: [{ x: 3, y: 4 }], startedAt: 0, dir: "left" }, 99), { x: 3, y: 4, dir: "left", done: true }); +}); +test("arrows and WASD map to the four directions", () => { + assert.deepEqual(STEP, { up: [0, -1], down: [0, 1], left: [-1, 0], right: [1, 0] }); + for (const [key, dir] of [["ArrowUp", "up"], ["w", "up"], ["W", "up"], ["ArrowDown", "down"], ["s", "down"], ["ArrowLeft", "left"], ["a", "left"], ["ArrowRight", "right"], ["D", "right"]]) assert.equal(KEYS[key], dir, key); + assert.equal(KEYS.Enter, undefined); + assert.equal(direction({ x: 1, y: 1 }, { x: 1, y: 0 }), "up"); + assert.equal(direction({ x: 1, y: 1 }, { x: 0, y: 1 }), "left"); +}); diff --git a/world/camera.js b/world/camera.js new file mode 100644 index 0000000..b18010a --- /dev/null +++ b/world/camera.js @@ -0,0 +1,11 @@ +// Camera math in world pixels. A camera's x/y is the world point drawn at the viewport's top-left corner. +export const zoomFor = (viewport, tileSize) => Math.max(0.6, Math.min(2, Math.min(viewport.width, viewport.height) / (12 * tileSize))); + +export function follow(target, viewport, world, zoom) { + const width = viewport.width / zoom, height = viewport.height / zoom; + const axis = (point, size, length) => length <= size ? -(size - length) / 2 : Math.max(0, Math.min(point - size / 2, length - size)); + return { x: axis(target.x, width, world.width), y: axis(target.y, height, world.height), zoom }; +} + +export const screenToTile = (camera, px, py, tileSize) => ({ x: Math.floor((camera.x + px / camera.zoom) / tileSize), y: Math.floor((camera.y + py / camera.zoom) / tileSize) }); +export const tileCenter = (tile, tileSize) => ({ x: (tile.x + 0.5) * tileSize, y: (tile.y + 0.5) * tileSize }); diff --git a/world/motion.js b/world/motion.js new file mode 100644 index 0000000..ac6783d --- /dev/null +++ b/world/motion.js @@ -0,0 +1,16 @@ +// Walks are a path plus a start time; every client works out the same position, so nothing streams continuously. +export const WALK_SPEED = 5; +export const STEP = { up: [0, -1], down: [0, 1], left: [-1, 0], right: [1, 0] }; +export const KEYS = { ArrowUp: "up", ArrowDown: "down", ArrowLeft: "left", ArrowRight: "right", w: "up", s: "down", a: "left", d: "right", W: "up", S: "down", A: "left", D: "right" }; +export const direction = (from, to) => to.x > from.x ? "right" : to.x < from.x ? "left" : to.y < from.y ? "up" : "down"; + +export function positionAt(walk, now, speed = WALK_SPEED) { + const steps = walk.path.length - 1; + const travelled = Math.max(0, ((now - walk.startedAt) / 1000) * speed); + if (steps <= 0 || travelled >= steps) { + const last = walk.path[steps < 0 ? 0 : steps]; + return { x: last.x, y: last.y, dir: steps > 0 ? direction(walk.path[steps - 1], last) : walk.dir || "down", done: true }; + } + const index = Math.floor(travelled), part = travelled - index, from = walk.path[index], to = walk.path[index + 1]; + return { x: from.x + (to.x - from.x) * part, y: from.y + (to.y - from.y) * part, dir: direction(from, to), done: false }; +} From 4c4fd33eafc3dacb2247c3943e3e65ef0af5cd7c Mon Sep 17 00:00:00 2001 From: Brent G <deliberatekids@gmail.com> Date: Wed, 7 Oct 2026 19:55:50 +0000 Subject: [PATCH 23/71] Add the Canvas world renderer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🤖 Built with SMT <smt@agora.build> --- tests/world-renderer.test.mjs | 83 +++++++++++++++++++++ world/renderer-canvas.js | 134 ++++++++++++++++++++++++++++++++++ 2 files changed, 217 insertions(+) create mode 100644 tests/world-renderer.test.mjs create mode 100644 world/renderer-canvas.js diff --git a/tests/world-renderer.test.mjs b/tests/world-renderer.test.mjs new file mode 100644 index 0000000..81c11d6 --- /dev/null +++ b/tests/world-renderer.test.mjs @@ -0,0 +1,83 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; +import { parseMap } from "../world/map.js"; +import { THEMED_KINDS } from "../world/kinds.js"; +import { PATTERNS } from "../world/themes.js"; +import { DECOR_DRAW, PATTERN_DRAW, createCanvasRenderer, hashText, shade } from "../world/renderer-canvas.js"; +import { loadThemes } from "../scripts/spaces/themes.mjs"; + +const root = new URL("../", import.meta.url).pathname; +const plaza = parseMap(JSON.parse(await readFile(new URL("../worlds/plaza/map.json", import.meta.url), "utf8"))); +const [agora, minimal, cyberpunk] = loadThemes(root); +const whole = { x: 0, y: 0, zoom: 1 }; + +// A recording 2D context: every method call and property assignment is logged in order. +function fakeCanvas() { + const calls = [], values = {}; + const ctx = new Proxy(values, { + get: (target, name) => name in target ? target[name] : name === "measureText" ? () => ({ width: 20 }) : (...args) => { calls.push([name, ...args]); }, + set: (target, name, value) => { target[name] = value; calls.push(["set:" + String(name), value]); return true; } + }); + return { canvas: { width: 0, height: 0, getContext: () => ctx }, calls }; +} +const render = (theme, frame, size = [640, 480]) => { + const { canvas, calls } = fakeCanvas(); + const renderer = createCanvasRenderer(canvas, { map: plaza, theme }); + renderer.resize(size[0], size[1], 1); + renderer.draw({ camera: whole, ...frame }); + return { calls, canvas, renderer }; +}; + +test("the renderer implements every pattern and every themed kind", () => { + assert.deepEqual(Object.keys(PATTERN_DRAW), PATTERNS); + assert.deepEqual(Object.keys(DECOR_DRAW), THEMED_KINDS); +}); +test("colors shade within range and names hash deterministically", () => { + assert.equal(shade("#808080", 0.1), "#9a9a9a"); + assert.equal(shade("#ffffff", 0.5), "#ffffff"); + assert.equal(shade("#000000", -0.5), "#000000"); + assert.equal(hashText("Ada"), hashText("Ada")); + assert.notEqual(hashText("Ada"), hashText("Bo")); +}); +test("every built-in theme draws the whole plaza with people, decorations, and labels", () => { + for (const theme of [agora, minimal, cyberpunk]) { + const { canvas, calls } = fakeCanvas(); + const renderer = createCanvasRenderer(canvas, { map: plaza, theme }); + renderer.resize(1408, 1088, 2); + assert.deepEqual([canvas.width, canvas.height], [2816, 2176]); + renderer.draw({ camera: whole, time: 1234, avatars: [{ id: "a", name: "Ada Lovelace", x: 21, y: 17, dir: "down", self: true }, { id: "b", name: "Bo", x: 22.5, y: 17, dir: "left" }], decor: THEMED_KINDS.map((kind, index) => ({ kind, x: 2 + index, y: 24, rotation: 90, variant: 1 })), labels: [{ x: 5, y: 4, text: "AI agents" }] }); + const texts = calls.filter(([name]) => name === "fillText").map(([, text]) => text); + assert.ok(calls.filter(([name]) => name === "fillRect").length > plaza.width * plaza.height, theme.id); + assert.ok(texts.includes("Ada Lovelace") && texts.includes("Bo") && texts.includes("AI agents"), theme.id); + assert.equal(texts.includes("AL"), theme.avatar.style === "token", theme.id + " draws initials only on tokens"); + } +}); +test("only the tiles in view are drawn", () => { + const all = render(minimal, { motion: false }, [1408, 1088]).calls.filter(([name]) => name === "fillRect").length; + const { canvas, calls } = fakeCanvas(); + const renderer = createCanvasRenderer(canvas, { map: plaza, theme: minimal }); + renderer.resize(128, 128, 1); + renderer.draw({ camera: { x: 640, y: 480, zoom: 2 }, motion: false }); + assert.ok(calls.filter(([name]) => name === "fillRect").length < all / 20); +}); +test("without motion nothing depends on time and no effects are drawn", () => { + const still = render(cyberpunk, { time: 5000, motion: false }).calls; + assert.deepEqual(render(cyberpunk, { time: 9000, motion: false }).calls, still); + const moving = render(cyberpunk, { time: 5000 }).calls; + assert.notDeepEqual(render(cyberpunk, { time: 9000 }).calls, moving); + assert.ok(moving.filter(([name]) => name === "stroke").length > still.filter(([name]) => name === "stroke").length); +}); +test("dim themes are tinted by their lighting; bright themes are not", () => { + const tinted = render(cyberpunk, { motion: false }).calls; + const index = tinted.findIndex(([name, value]) => name === "set:globalAlpha" && Math.abs(value - 0.18) < 1e-9); + assert.ok(index > 0); + assert.deepEqual(tinted.slice(index).find(([name]) => name === "fillRect"), ["fillRect", 0, 0, 640, 480]); + assert.equal(render(agora, { motion: false }).calls.some(([name, value]) => name === "set:globalAlpha" && Math.abs(value - 0.18) < 1e-9), false); + const { canvas, calls } = fakeCanvas(); + const renderer = createCanvasRenderer(canvas, { map: plaza, theme: agora }); + renderer.resize(640, 480, 1); + renderer.setTheme(cyberpunk); + renderer.draw({ camera: whole, motion: false }); + assert.deepEqual(calls, tinted, "setTheme switches the look completely"); +}); diff --git a/world/renderer-canvas.js b/world/renderer-canvas.js new file mode 100644 index 0000000..e1987f9 --- /dev/null +++ b/world/renderer-canvas.js @@ -0,0 +1,134 @@ +// Canvas 2D renderer. It draws semantic roles and kinds from the theme's data and a fixed catalog of shapes, +// so a WebGL renderer can replace it later by implementing the same resize/setTheme/draw interface. +export const hashTile = (x, y) => (Math.imul(x + 1, 73856093) ^ Math.imul(y + 1, 19349663)) >>> 0; +export const hashText = (text) => [...String(text)].reduce((value, char) => (Math.imul(value, 31) + char.charCodeAt(0)) >>> 0, 7); +export function shade(hex, amount) { + const value = parseInt(hex.slice(1), 16), channel = (shift) => Math.max(0, Math.min(255, Math.round(((value >> shift) & 255) + amount * 255))); + return "#" + [16, 8, 0].map((shift) => channel(shift).toString(16).padStart(2, "0")).join(""); +} +const box = (ctx, color, x, y, width, height) => { ctx.fillStyle = color; ctx.fillRect(x, y, width, height); }; +const dot = (ctx, color, x, y, radius) => { ctx.fillStyle = color; ctx.beginPath(); ctx.arc(x, y, radius, 0, Math.PI * 2); ctx.fill(); }; + +export const PATTERN_DRAW = { + flat(ctx, x, y, s, [a]) { box(ctx, a, x, y, s, s); }, + tiles(ctx, x, y, s, [a, b = shade(a, -0.08)]) { box(ctx, a, x, y, s, s); ctx.strokeStyle = b; ctx.lineWidth = 1; ctx.strokeRect(x + 0.5, y + 0.5, s - 1, s - 1); }, + path(ctx, x, y, s, [a, b = shade(a, -0.1)], tile) { box(ctx, a, x, y, s, s); const h = hashTile(tile.x, tile.y); for (let i = 0; i < 3; i += 1) box(ctx, b, x + 3 + ((h >>> (i * 5)) % (s - 8)), y + 3 + ((h >>> (i * 7 + 3)) % (s - 8)), 4, 2); }, + grass(ctx, x, y, s, [a, b = shade(a, -0.12)], tile) { + box(ctx, a, x, y, s, s); ctx.strokeStyle = b; ctx.lineWidth = 1.2; + const h = hashTile(tile.x, tile.y); + for (let i = 0; i < 4; i += 1) { const px = x + 4 + ((h >>> (i * 4)) % (s - 8)), py = y + 6 + ((h >>> (i * 6 + 2)) % (s - 12)); ctx.beginPath(); ctx.moveTo(px, py + 5); ctx.lineTo(px + 1.5, py); ctx.stroke(); } + }, + water(ctx, x, y, s, [a, b = shade(a, 0.15)], tile, time) { + box(ctx, a, x, y, s, s); ctx.strokeStyle = b; ctx.lineWidth = 1.5; + const phase = time / 450 + tile.x + tile.y; + for (const row of [0.35, 0.72]) { ctx.beginPath(); for (let i = 0; i <= s; i += 4) ctx.lineTo(x + i, y + s * row + Math.sin(phase + i / 5) * 1.5); ctx.stroke(); } + }, + wall(ctx, x, y, s, [a, b = shade(a, -0.15), c = shade(a, 0.1)]) { box(ctx, a, x, y, s, s); box(ctx, c, x, y, s, 4); box(ctx, b, x, y + s / 3, s, 1); box(ctx, b, x, y + (2 * s) / 3, s, 1); box(ctx, b, x + s / 2, y + 4, 1, s / 3 - 4); }, + column(ctx, x, y, s, [a, b = shade(a, -0.2)]) { box(ctx, b, x + 5, y + s - 7, s - 10, 4); box(ctx, a, x + 9, y + 7, s - 18, s - 14); box(ctx, b, x + 6, y + 3, s - 12, 4); }, + door(ctx, x, y, s, [a, b = shade(a, -0.25)]) { box(ctx, b, x + 2, y, s - 4, s); box(ctx, a, x + 5, y + 3, s - 10, s - 3); }, + table(ctx, x, y, s, [a, b = shade(a, -0.2)]) { box(ctx, b, x + 1, y + 4, s - 2, s - 4); box(ctx, a, x + 1, y + 1, s - 2, s - 6); }, + seat(ctx, x, y, s, [a, b = shade(a, -0.2)]) { box(ctx, b, x + 8, y + 12, s - 16, s - 18); box(ctx, a, x + 8, y + 8, s - 16, s - 20); }, + plant(ctx, x, y, s, [a, b = "#8a5a3c"]) { box(ctx, b, x + 11, y + s - 11, s - 22, 9); dot(ctx, a, x + s / 2, y + s / 2 - 2, s / 4); dot(ctx, shade(a, 0.08), x + s / 2 - 5, y + s / 2 + 1, s / 6); dot(ctx, shade(a, -0.06), x + s / 2 + 5, y + s / 2, s / 6); }, + decor(ctx, x, y, s, [a]) { ctx.fillStyle = a; ctx.beginPath(); ctx.moveTo(x + s / 2, y + 6); ctx.lineTo(x + s - 6, y + s / 2); ctx.lineTo(x + s / 2, y + s - 6); ctx.lineTo(x + 6, y + s / 2); ctx.fill(); }, + neon(ctx, x, y, s, [a, b = "#00e5ff"]) { box(ctx, a, x, y, s, s); ctx.globalAlpha = 0.35; ctx.strokeStyle = b; ctx.lineWidth = 1; ctx.strokeRect(x + 0.5, y + 0.5, s - 1, s - 1); ctx.globalAlpha = 1; } +}; + +export const DECOR_DRAW = { + plant: (ctx, x, y, s, colors) => PATTERN_DRAW.plant(ctx, x, y, s, colors), + lamp(ctx, x, y, s, [a, b = shade(a, -0.3)]) { box(ctx, b, x + s / 2 - 1, y + 10, 2, s - 14); box(ctx, b, x + 10, y + s - 5, s - 20, 3); dot(ctx, a, x + s / 2, y + 9, 6); }, + rug(ctx, x, y, s, [a, b = shade(a, -0.15)]) { box(ctx, b, x + 2, y + 6, s - 4, s - 12); box(ctx, a, x + 5, y + 9, s - 10, s - 18); }, + sofa(ctx, x, y, s, [a, b = shade(a, -0.18)]) { box(ctx, b, x + 3, y + 8, s - 6, s - 14); box(ctx, a, x + 6, y + 13, s - 12, s - 22); }, + chair(ctx, x, y, s, [a, b = shade(a, -0.2)]) { box(ctx, b, x + 9, y + 7, s - 18, 6); box(ctx, a, x + 9, y + 13, s - 18, s - 22); }, + table: (ctx, x, y, s, colors) => PATTERN_DRAW.table(ctx, x, y, s, colors), + whiteboard(ctx, x, y, s, [a, b = shade(a, -0.4)]) { box(ctx, b, x + 3, y + 5, s - 6, s - 14); box(ctx, a, x + 5, y + 7, s - 10, s - 18); box(ctx, b, x + 8, y + s - 9, 2, 6); box(ctx, b, x + s - 10, y + s - 9, 2, 6); }, + bookshelf(ctx, x, y, s, [a, b = shade(a, -0.25), c = shade(a, 0.25)]) { box(ctx, b, x + 4, y + 3, s - 8, s - 6); for (let i = 0; i < 5; i += 1) { box(ctx, i % 2 ? a : c, x + 7 + i * 4, y + 6, 3, s / 2 - 6); box(ctx, i % 2 ? c : a, x + 7 + i * 4, y + s / 2 + 1, 3, s / 2 - 7); } }, + screen(ctx, x, y, s, [a, b = "#111318"]) { box(ctx, b, x + 3, y + 6, s - 6, s - 15); box(ctx, a, x + 5, y + 8, s - 10, s - 19); box(ctx, b, x + s / 2 - 2, y + s - 9, 4, 6); }, + banner(ctx, x, y, s, [a, b = shade(a, -0.3)]) { box(ctx, b, x + 9, y + 3, 2, s - 6); ctx.fillStyle = a; ctx.beginPath(); ctx.moveTo(x + 11, y + 4); ctx.lineTo(x + s - 7, y + 4); ctx.lineTo(x + s - 7, y + s / 2 + 4); ctx.lineTo(x + (s + 4) / 2, y + s / 2 - 1); ctx.lineTo(x + 11, y + s / 2 + 4); ctx.fill(); }, + poster(ctx, x, y, s, [a, b = shade(a, -0.35)]) { box(ctx, b, x + 7, y + 5, s - 14, s - 10); box(ctx, a, x + 9, y + 7, s - 18, s - 14); }, + statue(ctx, x, y, s, [a, b = shade(a, -0.2)]) { box(ctx, b, x + 7, y + s - 10, s - 14, 7); box(ctx, a, x + 12, y + 11, s - 24, s - 21); dot(ctx, a, x + s / 2, y + 9, 5); }, + fountain(ctx, x, y, s, [a, b = "#7fa3a8"]) { dot(ctx, a, x + s / 2, y + s / 2, s / 2 - 2); dot(ctx, b, x + s / 2, y + s / 2, s / 2 - 6); dot(ctx, a, x + s / 2, y + s / 2, 3); }, + noticeboard(ctx, x, y, s, [a, b = shade(a, -0.3), c = "#fffef9"]) { box(ctx, b, x + 7, y + s - 10, 3, 9); box(ctx, b, x + s - 10, y + s - 10, 3, 9); box(ctx, b, x + 3, y + 4, s - 6, s - 13); box(ctx, a, x + 5, y + 6, s - 10, s - 17); box(ctx, c, x + 8, y + 9, 6, 5); box(ctx, c, x + 17, y + 11, 6, 4); } +}; + +const initials = (name) => String(name || "?").split(/\s+/).filter(Boolean).map((part) => part[0]).slice(0, 2).join("").toUpperCase(); + +function label(ctx, text, cx, top, font, theme) { + ctx.font = "500 10px " + font; + const width = ctx.measureText(text).width + 8; + ctx.globalAlpha = 0.85; box(ctx, theme.ui?.["--ink"] || "#272d28", cx - width / 2, top, width, 14); ctx.globalAlpha = 1; + ctx.fillStyle = theme.ui?.["--surface"] || "#ffffff"; ctx.textAlign = "center"; ctx.textBaseline = "middle"; ctx.fillText(text, cx, top + 7.5); +} + +function drawAvatar(ctx, avatar, s, theme, font) { + const color = theme.avatar.palette[hashText(avatar.id) % theme.avatar.palette.length]; + const cx = (avatar.x + 0.5) * s, cy = (avatar.y + 0.5) * s; + ctx.lineWidth = 2; ctx.strokeStyle = theme.avatar.outline; + if (theme.avatar.style === "pawn") { + ctx.fillStyle = color; ctx.beginPath(); ctx.ellipse(cx, cy + 6, 9, 8, 0, 0, Math.PI * 2); ctx.fill(); ctx.stroke(); + dot(ctx, color, cx, cy - 6, 6); ctx.beginPath(); ctx.arc(cx, cy - 6, 6, 0, Math.PI * 2); ctx.stroke(); + } else { + dot(ctx, color, cx, cy, s * 0.36); ctx.beginPath(); ctx.arc(cx, cy, s * 0.36, 0, Math.PI * 2); ctx.stroke(); + ctx.fillStyle = "#ffffff"; ctx.font = "600 10px " + font; ctx.textAlign = "center"; ctx.textBaseline = "middle"; ctx.fillText(initials(avatar.name), cx, cy + 0.5); + } + if (avatar.self) { ctx.strokeStyle = theme.ui?.["--accent"] || theme.avatar.outline; ctx.lineWidth = 2; ctx.beginPath(); ctx.arc(cx, cy, s * 0.5, 0, Math.PI * 2); ctx.stroke(); } + label(ctx, avatar.name, cx, cy + s * 0.62, font, theme); +} + +function drawEffects(ctx, theme, width, height, time) { + for (const effect of theme.effects) { + if (effect === "dust") { + ctx.fillStyle = "rgba(255, 255, 255, 0.35)"; + for (let i = 0; i < 40; i += 1) { const h = hashTile(i, 7); ctx.fillRect(((h % 1000) / 1000) * width + Math.sin(time / 3000 + i) * 12, ((((h >>> 10) % 1000) / 1000) * height + (time / 80) * (((i % 3) + 1) * 0.2)) % height, 2, 2); } + } else if (effect === "rain") { + ctx.strokeStyle = "rgba(160, 220, 255, 0.35)"; ctx.lineWidth = 1; ctx.beginPath(); + for (let i = 0; i < 90; i += 1) { const h = hashTile(i, 3), x = ((((h % 1000) / 1000) * (width + 40) + time / 6) % (width + 40)) - 20, y = ((((h >>> 10) % 1000) / 1000) * height + time / 2.5) % height; ctx.moveTo(x, y); ctx.lineTo(x - 4, y + 12); } + ctx.stroke(); + } else if (effect === "leaves") { + ctx.fillStyle = theme.decor.plant.colors[0]; + for (let i = 0; i < 18; i += 1) { const h = hashTile(i, 11); ctx.beginPath(); ctx.ellipse(((((h % 1000) / 1000) * width + Math.sin(time / 900 + i) * 20) + width) % width, ((((h >>> 10) % 1000) / 1000) * height + time / 40) % height, 4, 2, (time / 500 + i) % Math.PI, 0, Math.PI * 2); ctx.fill(); } + } + } +} + +export function createCanvasRenderer(canvas, { map, theme }) { + const ctx = canvas.getContext("2d"), s = map.tileSize; + let current = theme, viewport = { width: 1, height: 1, ratio: 1 }; + function tile(role, x, y, time) { + const entry = current.roles[role]; + const glow = current.effects.includes("neon-glow") && (role === "door" || entry.pattern === "neon"); + if (glow) { ctx.shadowColor = current.roles.door.colors[0]; ctx.shadowBlur = 10; } + PATTERN_DRAW[entry.pattern](ctx, x * s, y * s, s, entry.colors, { x, y }, time); + if (glow) ctx.shadowBlur = 0; + } + function decorItem(item) { + const entry = current.decor[item.kind]; + if (!entry) return; + ctx.save(); ctx.translate((item.x + 0.5) * s, (item.y + 0.5) * s); ctx.rotate(((item.rotation || 0) * Math.PI) / 180); ctx.translate(-s / 2, -s / 2); + DECOR_DRAW[item.kind](ctx, 0, 0, s, entry.variants?.[item.variant || 0] || entry.colors); + ctx.restore(); + } + return { + resize(width, height, ratio = 1) { viewport = { width, height, ratio }; canvas.width = Math.round(width * ratio); canvas.height = Math.round(height * ratio); }, + setTheme(next) { current = next; }, + draw({ camera, time = 0, avatars = [], decor = [], labels = [], motion = true }) { + const { width, height, ratio } = viewport, font = current.ui?.font || "IBM Plex Sans", t = motion ? time : 0; + ctx.setTransform(ratio, 0, 0, ratio, 0, 0); + box(ctx, current.ui?.["--paper"] || "#111111", 0, 0, width, height); + ctx.save(); ctx.scale(camera.zoom, camera.zoom); ctx.translate(-camera.x, -camera.y); + const x0 = Math.max(0, Math.floor(camera.x / s)), y0 = Math.max(0, Math.floor(camera.y / s)); + const x1 = Math.min(map.width - 1, Math.floor((camera.x + width / camera.zoom) / s)), y1 = Math.min(map.height - 1, Math.floor((camera.y + height / camera.zoom) / s)); + for (let y = y0; y <= y1; y += 1) for (let x = x0; x <= x1; x += 1) { + const index = y * map.width + x, role = map.roles[index], ground = map.ground[index]; + if (ground && ground !== role) tile(ground, x, y, t); + if (role) tile(role, x, y, t); + } + for (const item of map.interactables) decorItem(item); + for (const item of decor) decorItem(item); + for (const entry of labels) label(ctx, entry.text, (entry.x + 0.5) * s, entry.y * s + 4, font, current); + for (const avatar of [...avatars].sort((a, b) => a.y - b.y)) drawAvatar(ctx, avatar, s, current, font); + ctx.restore(); + if (current.lighting.ambient < 1) { ctx.globalAlpha = (1 - current.lighting.ambient) * 0.6; box(ctx, current.lighting.tint, 0, 0, width, height); ctx.globalAlpha = 1; } + if (motion) drawEffects(ctx, current, width, height, time); + } + }; +} From 17fe175f9457c6fad8a9c9845261e8627e21303c Mon Sep 17 00:00:00 2001 From: Brent G <deliberatekids@gmail.com> Date: Wed, 7 Oct 2026 19:57:35 +0000 Subject: [PATCH 24/71] Add the world engine: movement, input, camera, and drawing loop MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🤖 Built with SMT <smt@agora.build> --- tests/world-engine.test.mjs | 97 +++++++++++++++++++++++++++++++++++++ world/engine.js | 82 +++++++++++++++++++++++++++++++ 2 files changed, 179 insertions(+) create mode 100644 tests/world-engine.test.mjs create mode 100644 world/engine.js diff --git a/tests/world-engine.test.mjs b/tests/world-engine.test.mjs new file mode 100644 index 0000000..b9abdfe --- /dev/null +++ b/tests/world-engine.test.mjs @@ -0,0 +1,97 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; +import { parseMap } from "../world/map.js"; +import { createEngine } from "../world/engine.js"; +import { loadThemes } from "../scripts/spaces/themes.mjs"; + +const root = new URL("../", import.meta.url).pathname; +const plaza = parseMap(JSON.parse(await readFile(new URL("../worlds/plaza/map.json", import.meta.url), "utf8"))); +const [agora, minimal] = loadThemes(root); +const key = (name) => ({ key: name, preventDefault() {} }); + +function setup({ reducedMotion = false } = {}) { + let time = 1000; + const frames = [], timers = [], drawn = [], themes = [], handlers = {}, events = { move: [], arrive: [] }; + const canvas = { clientWidth: 640, clientHeight: 480, addEventListener: (type, listener) => { handlers[type] = listener; }, removeEventListener: (type) => { delete handlers[type]; }, getBoundingClientRect: () => ({ left: 0, top: 0 }) }; + const renderer = { resize() {}, setTheme: (theme) => themes.push(theme), draw: (frame) => drawn.push(frame) }; + const engine = createEngine({ canvas, map: plaza, theme: agora, start: plaza.spawns[0], reducedMotion, renderer, now: () => time, raf: (callback) => frames.push(callback), later: (callback) => { timers.push(callback); return timers.length; }, cancel() {}, ratio: () => 1 }); + engine.on("move", (value) => events.move.push(value)); + engine.on("arrive", (value) => events.arrive.push(value)); + // Runs only the frames already requested, so a walking avatar cannot loop forever inside one flush. + const flush = () => { for (const callback of frames.splice(0)) callback(); }; + flush(); + return { engine, handlers, drawn, themes, events, timers, flush, advance: (ms) => { time += ms; } }; +} + +test("walking to a tile follows a path and arrives", () => { + const { engine, events, flush, advance, drawn } = setup(); + assert.equal(engine.walkTo({ x: 0, y: 0 }), false, "walls can't be reached"); + assert.equal(engine.walkTo({ x: 21, y: 12 }), true); + const { path } = events.move[0]; + assert.deepEqual([path[0], path.at(-1)], [{ x: 21, y: 17 }, { x: 21, y: 12 }]); + advance(((path.length - 1) / 5) * 1000 + 1); flush(); + assert.deepEqual(events.arrive, [{ x: 21, y: 12 }]); + assert.deepEqual(engine.position(), { x: 21, y: 12 }); + const [me] = drawn.at(-1).avatars; + assert.deepEqual([me.self, me.x, me.y, me.name], [true, 21, 12, "You"]); +}); +test("holding a direction key keeps walking until it is released", () => { + const { handlers, events, flush, advance } = setup(); + handlers.keydown(key("ArrowUp")); + assert.deepEqual(events.move.at(-1).path, [{ x: 21, y: 17 }, { x: 21, y: 16 }]); + handlers.keydown(key("ArrowUp")); + assert.equal(events.move.length, 1, "key repeat does not restart the step"); + advance(201); flush(); + assert.deepEqual(events.move.at(-1).path, [{ x: 21, y: 16 }, { x: 21, y: 15 }]); + handlers.keyup(key("ArrowUp")); + advance(201); flush(); + assert.equal(events.move.length, 2); + handlers.keydown(key("Enter")); + assert.equal(events.move.length, 2, "other keys are ignored"); +}); +test("a blocked step only turns the walker", () => { + const { engine, handlers, events, flush, advance, drawn } = setup(); + engine.walkTo({ x: 1, y: 18 }); advance(10000); flush(); + const moves = events.move.length; + handlers.keydown(key("s")); flush(); + assert.equal(events.move.length, moves); + assert.equal(drawn.at(-1).avatars[0].dir, "down"); + assert.deepEqual(engine.position(), { x: 1, y: 18 }); +}); +test("clicking or tapping walks to the tile under the pointer", () => { + const { handlers, events, drawn } = setup(); + const { camera } = drawn.at(-1); + handlers.pointerup({ clientX: (23 * 32 + 16 - camera.x) * camera.zoom, clientY: (17 * 32 + 16 - camera.y) * camera.zoom }); + assert.deepEqual(events.move.at(-1).path.at(-1), { x: 23, y: 17 }); +}); +test("blocking decorations change the route", () => { + const { engine, events } = setup(); + engine.setDecor([{ kind: "statue", x: 21, y: 16 }, { kind: "plant", x: 22, y: 16 }]); + engine.walkTo({ x: 21, y: 15 }); + assert.equal(events.move.at(-1).path.some((step) => step.x === 21 && step.y === 16), false); + assert.equal(engine.walkTo({ x: 21, y: 16 }), false); +}); +test("idle redraws run only for ambient animation, one timer at a time, and never with reduced motion", () => { + const animated = setup(); + assert.equal(animated.timers.length, 1); + animated.engine.setLabels([{ x: 1, y: 1, text: "Hi" }]); animated.flush(); + assert.equal(animated.timers.length, 1, "a second idle timer is not stacked"); + assert.deepEqual(animated.drawn.at(-1).labels, [{ x: 1, y: 1, text: "Hi" }]); + animated.timers[0](); animated.flush(); + assert.equal(animated.timers.length, 2, "the idle loop continues after its timer fires"); + const still = setup({ reducedMotion: true }); + assert.equal(still.timers.length, 0); + assert.equal(still.drawn.at(-1).motion, false); +}); +test("themes, names, and people can change while walking; destroy removes input", () => { + const { engine, handlers, themes, drawn, flush } = setup(); + engine.setTheme(minimal); + engine.setSelf({ name: "Ada" }); + engine.setOthers([{ id: "bo", name: "Bo", walk: { path: [{ x: 22, y: 17 }], startedAt: 0 } }]); + flush(); + assert.deepEqual(themes, [minimal]); + assert.deepEqual(drawn.at(-1).avatars.map((avatar) => avatar.name), ["Ada", "Bo"]); + engine.destroy(); + assert.deepEqual(Object.keys(handlers), []); +}); diff --git a/world/engine.js b/world/engine.js new file mode 100644 index 0000000..7896c82 --- /dev/null +++ b/world/engine.js @@ -0,0 +1,82 @@ +import { findPath, walkable } from "./map.js"; +import { BLOCKING_DECOR } from "./kinds.js"; +import { follow, screenToTile, zoomFor } from "./camera.js"; +import { KEYS, STEP, positionAt } from "./motion.js"; +import { createCanvasRenderer } from "./renderer-canvas.js"; + +// One person's movement, input, camera, and drawing. Other people arrive through setOthers; "move" events go out to them. +export function createEngine({ canvas, map, theme, start, reducedMotion = false, renderer = createCanvasRenderer(canvas, { map, theme }), now = () => performance.now(), raf = (callback) => requestAnimationFrame(callback), later = (callback, delay) => setTimeout(callback, delay), cancel = (handle) => clearTimeout(handle), ratio = () => globalThis.devicePixelRatio || 1 }) { + const listeners = { arrive: [], move: [] }; + const self = { id: "self", name: "You", walk: { path: [start], startedAt: now(), dir: "down" }, arrived: true }; + let current = theme, others = [], decor = [], labels = [], held = null, pending = false, timer = null; + let camera = { x: 0, y: 0, zoom: 1 }, viewport = { width: 1, height: 1 }; + const emit = (type, value) => { for (const listener of listeners[type]) listener(value); }; + const here = (time = now()) => positionAt(self.walk, time); + const blocked = () => new Set(decor.filter((item) => BLOCKING_DECOR.has(item.kind)).map((item) => item.x + "," + item.y)); + const ambient = () => !reducedMotion && (current.effects.some((effect) => effect !== "neon-glow") || map.roles.includes("water")); + const schedule = () => { if (!pending) { pending = true; raf(frame); } }; + + function frame() { + pending = false; + const time = now(), s = map.tileSize; + const reached = here(time); + if (reached.done && !self.arrived) { self.arrived = true; emit("arrive", { x: reached.x, y: reached.y }); if (held) step(held); } + const shown = here(time); + camera = follow({ x: (shown.x + 0.5) * s, y: (shown.y + 0.5) * s }, viewport, { width: map.width * s, height: map.height * s }, zoomFor(viewport, s)); + const crowd = others.map((other) => ({ id: other.id, name: other.name, ...positionAt(other.walk, time) })); + renderer.draw({ camera, time, avatars: [{ id: self.id, name: self.name, self: true, ...shown }, ...crowd], decor, labels, motion: !reducedMotion }); + if (!shown.done || crowd.some((other) => !other.done)) schedule(); + else if (ambient() && timer === null) timer = later(() => { timer = null; schedule(); }, 66); + } + + function walkTo(target) { + const time = now(), position = here(time); + const path = findPath(map, { x: Math.round(position.x), y: Math.round(position.y) }, target, blocked()); + if (!path) return false; + self.walk = { path, startedAt: time, dir: position.dir }; + self.arrived = false; + emit("move", { path, startedAt: time }); + schedule(); + return true; + } + + function step(dir) { + const position = here(); + if (!position.done) return; + const [dx, dy] = STEP[dir], target = { x: position.x + dx, y: position.y + dy }; + if (walkable(map, target.x, target.y, blocked())) walkTo(target); + else { self.walk = { path: [{ x: position.x, y: position.y }], startedAt: now(), dir }; schedule(); } + } + + const onPointer = (event) => { + const bounds = canvas.getBoundingClientRect(); + walkTo(screenToTile(camera, event.clientX - bounds.left, event.clientY - bounds.top, map.tileSize)); + }; + const onKeyDown = (event) => { + const dir = KEYS[event.key]; + if (!dir) return; + event.preventDefault(); + if (held === dir) return; + held = dir; + step(dir); + }; + const onKeyUp = (event) => { if (KEYS[event.key] === held) held = null; }; + const onBlur = () => { held = null; }; + const inputs = [["pointerup", onPointer], ["keydown", onKeyDown], ["keyup", onKeyUp], ["blur", onBlur]]; + for (const [type, listener] of inputs) canvas.addEventListener(type, listener); + + const engine = { + walkTo, + position() { const position = here(); return { x: Math.round(position.x), y: Math.round(position.y) }; }, + on(type, listener) { listeners[type].push(listener); }, + setSelf({ name }) { self.name = name; schedule(); }, + setTheme(next) { current = next; renderer.setTheme(next); schedule(); }, + setLabels(list) { labels = list; schedule(); }, + setDecor(list) { decor = list; schedule(); }, + setOthers(list) { others = list; schedule(); }, + resize() { viewport = { width: canvas.clientWidth || 1, height: canvas.clientHeight || 1 }; renderer.resize(viewport.width, viewport.height, ratio()); schedule(); }, + destroy() { for (const [type, listener] of inputs) canvas.removeEventListener(type, listener); if (timer !== null) cancel(timer); timer = null; } + }; + engine.resize(); + return engine; +} From 4f8d5206c65fac9a2a2cefa0bbfd4872c9dc8f88 Mon Sep 17 00:00:00 2001 From: Brent G <deliberatekids@gmail.com> Date: Wed, 7 Oct 2026 19:59:30 +0000 Subject: [PATCH 25/71] Fix engine review findings: destroy, wall clock, mid-walk redirect, input guards MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🤖 Built with SMT <smt@agora.build> --- tests/world-engine.test.mjs | 44 ++++++++++++++++++++++++++++++++++++- world/engine.js | 39 +++++++++++++++++++++----------- 2 files changed, 69 insertions(+), 14 deletions(-) diff --git a/tests/world-engine.test.mjs b/tests/world-engine.test.mjs index b9abdfe..2c9d641 100644 --- a/tests/world-engine.test.mjs +++ b/tests/world-engine.test.mjs @@ -21,7 +21,7 @@ function setup({ reducedMotion = false } = {}) { // Runs only the frames already requested, so a walking avatar cannot loop forever inside one flush. const flush = () => { for (const callback of frames.splice(0)) callback(); }; flush(); - return { engine, handlers, drawn, themes, events, timers, flush, advance: (ms) => { time += ms; } }; + return { engine, handlers, drawn, themes, events, timers, frames, flush, advance: (ms) => { time += ms; } }; } test("walking to a tile follows a path and arrives", () => { @@ -95,3 +95,45 @@ test("themes, names, and people can change while walking; destroy removes input" engine.destroy(); assert.deepEqual(Object.keys(handlers), []); }); +test("destroy stops the loop", () => { + const { engine, frames, timers, drawn, flush } = setup(); + engine.walkTo({ x: 21, y: 12 }); + engine.destroy(); + const [drawnBefore, timersBefore] = [drawn.length, timers.length]; + engine.setLabels([{ x: 1, y: 1, text: "Hi" }]); flush(); + for (const callback of timers) callback(); + flush(); + assert.equal(drawn.length, drawnBefore); + assert.equal(timers.length, timersBefore); + assert.equal(frames.length, 0); +}); +test("walking to a new tile mid-walk keeps the avatar where it is", () => { + const { engine, events, drawn, flush, advance } = setup(); + engine.walkTo({ x: 21, y: 12 }); + const first = events.move[0]; + advance(300); flush(); + const before = drawn.at(-1).avatars[0]; + assert.equal(engine.walkTo({ x: 23, y: 17 }), true); + flush(); + const after = drawn.at(-1).avatars[0]; + assert.deepEqual([after.x, after.y], [before.x, before.y]); + const { path, startedAt } = events.move.at(-1); + assert.deepEqual(path[0], first.path[1]); + assert.deepEqual(path.at(-1), { x: 23, y: 17 }); + assert.equal(startedAt, 1000 + 300 - 100); +}); +test("modified keys and secondary buttons are ignored, and listeners can be removed", () => { + const { engine, handlers, events } = setup(); + let prevented = false; + handlers.keydown({ key: "ArrowLeft", ctrlKey: true, preventDefault() { prevented = true; } }); + assert.equal(prevented, false); + assert.equal(events.move.length, 0); + handlers.pointerup({ button: 2, clientX: 0, clientY: 0 }); + assert.equal(events.move.length, 0); + const seen = []; + const off = engine.on("move", (value) => seen.push(value)); + engine.walkTo({ x: 21, y: 12 }); + off(); + engine.walkTo({ x: 23, y: 17 }); + assert.equal(seen.length, 1); +}); diff --git a/world/engine.js b/world/engine.js index 7896c82..0188fdd 100644 --- a/world/engine.js +++ b/world/engine.js @@ -1,23 +1,25 @@ import { findPath, walkable } from "./map.js"; import { BLOCKING_DECOR } from "./kinds.js"; import { follow, screenToTile, zoomFor } from "./camera.js"; -import { KEYS, STEP, positionAt } from "./motion.js"; +import { KEYS, STEP, WALK_SPEED, positionAt } from "./motion.js"; import { createCanvasRenderer } from "./renderer-canvas.js"; +// Times are wall-clock (Date.now) because move.startedAt is published, so peers share the clock. // One person's movement, input, camera, and drawing. Other people arrive through setOthers; "move" events go out to them. -export function createEngine({ canvas, map, theme, start, reducedMotion = false, renderer = createCanvasRenderer(canvas, { map, theme }), now = () => performance.now(), raf = (callback) => requestAnimationFrame(callback), later = (callback, delay) => setTimeout(callback, delay), cancel = (handle) => clearTimeout(handle), ratio = () => globalThis.devicePixelRatio || 1 }) { +export function createEngine({ canvas, map, theme, start, reducedMotion = false, renderer = createCanvasRenderer(canvas, { map, theme }), now = () => Date.now(), raf = (callback) => requestAnimationFrame(callback), later = (callback, delay) => setTimeout(callback, delay), cancel = (handle) => clearTimeout(handle), ratio = () => globalThis.devicePixelRatio || 1 }) { const listeners = { arrive: [], move: [] }; const self = { id: "self", name: "You", walk: { path: [start], startedAt: now(), dir: "down" }, arrived: true }; - let current = theme, others = [], decor = [], labels = [], held = null, pending = false, timer = null; + let current = theme, others = [], decor = [], labels = [], held = null, pending = false, timer = null, destroyed = false; let camera = { x: 0, y: 0, zoom: 1 }, viewport = { width: 1, height: 1 }; const emit = (type, value) => { for (const listener of listeners[type]) listener(value); }; const here = (time = now()) => positionAt(self.walk, time); const blocked = () => new Set(decor.filter((item) => BLOCKING_DECOR.has(item.kind)).map((item) => item.x + "," + item.y)); const ambient = () => !reducedMotion && (current.effects.some((effect) => effect !== "neon-glow") || map.roles.includes("water")); - const schedule = () => { if (!pending) { pending = true; raf(frame); } }; + const schedule = () => { if (!destroyed && !pending) { pending = true; raf(frame); } }; function frame() { pending = false; + if (destroyed) return; const time = now(), s = map.tileSize; const reached = here(time); if (reached.done && !self.arrived) { self.arrived = true; emit("arrive", { x: reached.x, y: reached.y }); if (held) step(held); } @@ -30,12 +32,22 @@ export function createEngine({ canvas, map, theme, start, reducedMotion = false, } function walkTo(target) { - const time = now(), position = here(time); - const path = findPath(map, { x: Math.round(position.x), y: Math.round(position.y) }, target, blocked()); - if (!path) return false; - self.walk = { path, startedAt: time, dir: position.dir }; + const time = now(), position = here(time), walk = self.walk; + let route; + if (position.done) { + const path = findPath(map, { x: Math.round(position.x), y: Math.round(position.y) }, target, blocked()); + if (!path) return false; + route = { path, startedAt: time }; + } else { + // Mid-walk: keep the segment in progress so the avatar does not snap to a tile. + const travelled = ((time - walk.startedAt) / 1000) * WALK_SPEED, index = Math.floor(travelled), part = travelled - index; + const ahead = findPath(map, walk.path[index + 1], target, blocked()); + if (!ahead) return false; + route = { path: [walk.path[index], ...ahead], startedAt: time - (part * 1000) / WALK_SPEED }; + } + self.walk = { ...route, dir: position.dir }; self.arrived = false; - emit("move", { path, startedAt: time }); + emit("move", route); schedule(); return true; } @@ -49,18 +61,19 @@ export function createEngine({ canvas, map, theme, start, reducedMotion = false, } const onPointer = (event) => { + if (event.button) return; const bounds = canvas.getBoundingClientRect(); walkTo(screenToTile(camera, event.clientX - bounds.left, event.clientY - bounds.top, map.tileSize)); }; const onKeyDown = (event) => { const dir = KEYS[event.key]; - if (!dir) return; + if (!dir || event.ctrlKey || event.metaKey || event.altKey) return; event.preventDefault(); if (held === dir) return; held = dir; step(dir); }; - const onKeyUp = (event) => { if (KEYS[event.key] === held) held = null; }; + const onKeyUp = (event) => { if (!event.ctrlKey && !event.metaKey && !event.altKey && KEYS[event.key] === held) held = null; }; const onBlur = () => { held = null; }; const inputs = [["pointerup", onPointer], ["keydown", onKeyDown], ["keyup", onKeyUp], ["blur", onBlur]]; for (const [type, listener] of inputs) canvas.addEventListener(type, listener); @@ -68,14 +81,14 @@ export function createEngine({ canvas, map, theme, start, reducedMotion = false, const engine = { walkTo, position() { const position = here(); return { x: Math.round(position.x), y: Math.round(position.y) }; }, - on(type, listener) { listeners[type].push(listener); }, + on(type, listener) { listeners[type].push(listener); return () => { listeners[type] = listeners[type].filter((item) => item !== listener); }; }, setSelf({ name }) { self.name = name; schedule(); }, setTheme(next) { current = next; renderer.setTheme(next); schedule(); }, setLabels(list) { labels = list; schedule(); }, setDecor(list) { decor = list; schedule(); }, setOthers(list) { others = list; schedule(); }, resize() { viewport = { width: canvas.clientWidth || 1, height: canvas.clientHeight || 1 }; renderer.resize(viewport.width, viewport.height, ratio()); schedule(); }, - destroy() { for (const [type, listener] of inputs) canvas.removeEventListener(type, listener); if (timer !== null) cancel(timer); timer = null; } + destroy() { destroyed = true; for (const [type, listener] of inputs) canvas.removeEventListener(type, listener); if (timer !== null) cancel(timer); timer = null; } }; engine.resize(); return engine; From 7bc9315f287c77558ca4b16c8b5cc77c7b1e2f07 Mon Sep 17 00:00:00 2001 From: Brent G <deliberatekids@gmail.com> Date: Wed, 7 Oct 2026 20:01:45 +0000 Subject: [PATCH 26/71] Open the walkable plaza page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🤖 Built with SMT <smt@agora.build> --- README.md | 2 ++ scripts/build.mjs | 2 +- scripts/serve.mjs | 2 +- stoa.html | 51 ++++++++++++++++++++++++++++++-- stoa.js | 62 +++++++++++++++++++++++++++++++++++++++ styles.css | 20 +++++++++++++ tests/server.test.mjs | 2 +- tests/spaces-api.test.mjs | 18 ++++++++++++ 8 files changed, 153 insertions(+), 6 deletions(-) create mode 100644 stoa.js diff --git a/README.md b/README.md index 21d4ed6..66b79cc 100644 --- a/README.md +++ b/README.md @@ -111,6 +111,8 @@ Use HTTPS for remote callers, or `http://localhost:3002` for local development. The Stoa is a walkable plaza (`/stoa/`) with first-come rooms (`/stoa/room/<slug>`) and unlisted or private spaces (`/stoa/s/<id>`). Calls use Agora RTC; movement, presence, and live messages use Agora Signaling. Enable Signaling for the Agora project and set `SPACE_CHANNEL_SECRET` to at least 32 random characters. The built-in maps in `worlds/` are generated by `npm run maps`. +Built-in themes live in `themes/` (Agora, Minimal, Cyberpunk) and are validated at startup. Visitors can switch their own view to Minimal; `/stoa/?theme=cyberpunk` previews a theme. Movement works with click or tap and with the arrow keys or WASD; the side panel offers the same actions for keyboard and screen reader users. + ## The physical forge Demo days, hackathons, and whiteboard sessions are core community activities. The homepage offers a session-planning entry point for each. Create a room with your real intent, include a place and time in the session purpose, and share the invite with collaborators. Use the same workspace to prepare online and reconnect after meeting in person. These are planning tools; the site does not advertise invented events, venues, or attendees. diff --git a/scripts/build.mjs b/scripts/build.mjs index ec13f09..2148aac 100644 --- a/scripts/build.mjs +++ b/scripts/build.mjs @@ -6,7 +6,7 @@ const output = new URL("dist/", root); // Start clean so stale private state under dist/ can never be packaged. await rm(output, { recursive: true, force: true }); await mkdir(output, { recursive: true }); -await Promise.all(["index.html", "explore.html", "services.html", "radar.html", "meetings.html", "account.html", "account.js", "stoa.html", "styles.css", "script.js", "people.js", "activity.js", "explore.js", "services.js", "radar.js", "meetings.js", "call.js", "package.json", "package-lock.json", ".env.example"].map((file) => copyFile(new URL(file, root), new URL(file, output)))); +await Promise.all(["index.html", "explore.html", "services.html", "radar.html", "meetings.html", "account.html", "account.js", "stoa.html", "stoa.js", "styles.css", "script.js", "people.js", "activity.js", "explore.js", "services.js", "radar.js", "meetings.js", "call.js", "package.json", "package-lock.json", ".env.example"].map((file) => copyFile(new URL(file, root), new URL(file, output)))); await cp(new URL("assets/", root), new URL("assets/", output), { recursive: true }); await copyFile(createRequire(import.meta.url).resolve("agora-rtc-sdk-ng"), new URL("assets/agora-rtc.js", output)); await cp(new URL("data/", root), new URL("data/", output), { recursive: true }); diff --git a/scripts/serve.mjs b/scripts/serve.mjs index 514e964..7df840b 100644 --- a/scripts/serve.mjs +++ b/scripts/serve.mjs @@ -22,7 +22,7 @@ import { loadThemes } from "./spaces/themes.mjs"; const root = fileURLToPath(new URL("../", import.meta.url)); const sdkFile = createRequire(import.meta.url).resolve("agora-rtc-sdk-ng"); -const publicFiles = new Set(["index.html", "explore.html", "services.html", "radar.html", "meetings.html", "account.html", "account.js", "stoa.html", "world/map.js", "styles.css", "script.js", "people.js", "activity.js", "explore.js", "services.js", "radar.js", "meetings.js", "call.js", "assets/agora-rtc.js", "assets/favicon.svg", "assets/guohai.jpg"]); +const publicFiles = new Set(["index.html", "explore.html", "services.html", "radar.html", "meetings.html", "account.html", "account.js", "stoa.html", "stoa.js", "world/map.js", "world/kinds.js", "world/themes.js", "world/camera.js", "world/motion.js", "world/renderer-canvas.js", "world/engine.js", "styles.css", "script.js", "people.js", "activity.js", "explore.js", "services.js", "radar.js", "meetings.js", "call.js", "assets/agora-rtc.js", "assets/favicon.svg", "assets/guohai.jpg"]); const types = { ".html": "text/html; charset=utf-8", ".css": "text/css; charset=utf-8", ".js": "text/javascript; charset=utf-8", ".svg": "image/svg+xml", ".jpg": "image/jpeg" }; function json(response, status, body, headers = {}) { diff --git a/stoa.html b/stoa.html index 7e610ad..f6c0905 100644 --- a/stoa.html +++ b/stoa.html @@ -7,11 +7,56 @@ <title>Stoa | Agora Build + -
-

THE STOA

The plaza is being built.

Soon you can walk in, meet builders, start a topic, and work together.

-
+
+ +
+
+

THE STOA / PUBLIC PLAZA

Walk into the plaza.

+
+

Click or tap to walk. Select the map to use the arrow keys or WASD.

+ +
+
+
+ +
+ +
YOUR PLACE IN THE HOUSE
+

Pull up a chair.

+

Good connections start with honest intent. Your profile and participation in rooms are public. Sign in to keep access across devices, or continue with this browser.

+
+
+ + + + + + +

Your profile and room activity are shared with the foundry. Model search may use its public text. Save your intent here; keep personal or sensitive details out.

+ +
+
+
+
BUILDER PROFILE
+ +
MAKE ROOM FOR A CONVERSATION
+

Meet around a purpose.

Create a group room, share the invite, and open a video call together.

+

Room names, purposes, and rosters are public. Audio, video, and screen sharing stay in this workspace, powered by Agora.

+
+
diff --git a/stoa.js b/stoa.js new file mode 100644 index 0000000..26211c9 --- /dev/null +++ b/stoa.js @@ -0,0 +1,62 @@ +import { api, sessionReady, state } from "./script.js"; +import { parseMap } from "./world/map.js"; +import { createEngine } from "./world/engine.js"; + +const root = document.querySelector("#stoa"); +const canvas = document.querySelector("#stoa-canvas"); +const status = document.querySelector("#stoa-status"); +const roomList = document.querySelector("#stoa-rooms"); +const peopleList = document.querySelector("#stoa-people"); +const minimalToggle = document.querySelector("#stoa-minimal"); +const say = (message) => { status.textContent = message; }; +const node = (tag, text, className) => { const element = document.createElement(tag); element.textContent = text; if (className) element.className = className; return element; }; + +// Theme tokens restyle only the Stoa section; the site header and footer keep the portal's look. +function applyUi(theme) { + for (const [token, value] of Object.entries(theme.ui)) if (token.startsWith("--")) root.style.setProperty(token, value); + root.style.fontFamily = theme.ui.font ? "\"" + theme.ui.font + "\", sans-serif" : ""; +} + +try { + const [source, { themes }, listing] = await Promise.all([api("/api/worlds/plaza"), api("/api/themes"), api("/api/spaces")]); + const map = parseMap(source); + const byId = Object.fromEntries(themes.map((theme) => [theme.id, theme])); + const preview = byId[new URLSearchParams(location.search).get("theme")]; + const chosen = preview || byId.agora; + let minimal = localStorage.getItem("stoa-minimal") === "true"; + const active = () => minimal ? byId.minimal : chosen; + const engine = createEngine({ canvas, map, theme: active(), start: map.spawns[0], reducedMotion: matchMedia("(prefers-reduced-motion: reduce)").matches }); + applyUi(active()); + minimalToggle.setAttribute("aria-pressed", String(minimal)); + minimalToggle.addEventListener("click", () => { + minimal = !minimal; + localStorage.setItem("stoa-minimal", String(minimal)); + minimalToggle.setAttribute("aria-pressed", String(minimal)); + applyUi(active()); + engine.setTheme(active()); + }); + new ResizeObserver(() => engine.resize()).observe(canvas); + + const rooms = new Map(listing.rooms.map((room) => [room.slug, room])); + engine.setLabels(map.lots.map((lot) => ({ x: lot.door.x, y: lot.door.y - 1, text: rooms.get(lot.slug)?.topic || lot.title }))); + for (const lot of map.lots) { + const room = rooms.get(lot.slug) || { topic: null, occupancy: 0, capacity: lot.capacity }; + const entry = node("li", "", "stoa-room"); + const go = node("button", "Go to", "button button-secondary button-small"); + go.type = "button"; + go.setAttribute("aria-label", "Walk to " + lot.title); + go.addEventListener("click", () => { if (engine.walkTo(lot.door)) say("Walking to " + lot.title + "."); canvas.focus(); }); + entry.append(node("strong", lot.title), node("span", (room.topic || "Open") + " · " + room.occupancy + " of " + room.capacity + " here"), go); + roomList.append(entry); + } + const doors = new Map(map.lots.map((lot) => [lot.door.x + "," + lot.door.y, lot])); + engine.on("arrive", (tile) => { const lot = doors.get(tile.x + "," + tile.y); if (lot) say("You are at the door of " + lot.title + ". Stepping inside comes with the next update."); }); + + await sessionReady; + const name = state.profile?.name || state.account?.name || "You"; + engine.setSelf({ name }); + peopleList.replaceChildren(node("li", name + " (you)")); + say(preview ? "Previewing the " + preview.name + " theme. Only you see it." : "You're on the plaza. Walk to a room to see who's there."); +} catch (error) { + say(error.message || "The plaza could not open. Please try again."); +} diff --git a/styles.css b/styles.css index ef841f2..85a758b 100644 --- a/styles.css +++ b/styles.css @@ -545,3 +545,23 @@ dialog h2 { font-size: 30px; line-height: 1.25; font-weight: 500; letter-spacing html { scroll-behavior: auto; } *, *::before, *::after { animation: none !important; transition: none !important; } } +.stoa-main { max-width: 1400px; } +.stoa { color: var(--ink); background: var(--paper); padding: 18px; border-radius: 5px; } +.stoa-heading { display: flex; justify-content: space-between; align-items: end; gap: 20px; flex-wrap: wrap; margin-bottom: 18px; } +.stoa-heading h1 { margin: 6px 0 0; font-size: clamp(30px, 4vw, 46px); font-weight: 500; letter-spacing: -1.5px; } +.stoa-heading [aria-pressed="true"] { background: var(--ink); color: var(--paper); } +.stoa-layout { display: grid; grid-template-columns: minmax(0, 1fr) 300px; gap: 18px; } +.stoa-stage { background: var(--paper); border: 1px solid var(--line); border-radius: 5px; overflow: hidden; } +#stoa-canvas { display: block; width: 100%; height: min(72vh, 720px); min-height: 420px; touch-action: none; cursor: pointer; } +#stoa-canvas:focus-visible { outline: 3px solid var(--accent); outline-offset: -3px; } +.stoa-help { margin: 0; padding: 9px 14px; font-size: 12px; color: var(--muted); border-top: 1px solid var(--line); background: var(--surface); } +.stoa-panel { padding: 20px; background: var(--surface); border: 1px solid var(--line); border-radius: 5px; align-self: start; } +.stoa-status { margin: 0 0 18px; font-size: 13px; color: var(--muted); } +.stoa-panel-title { margin: 22px 0 10px; font: 500 12px var(--mono); letter-spacing: 1px; text-transform: uppercase; color: var(--muted); } +.stoa-rooms, .stoa-people { list-style: none; margin: 0; padding: 0; } +.stoa-room { display: grid; grid-template-columns: 1fr auto; gap: 4px 10px; align-items: center; padding: 11px 0; border-bottom: 1px solid var(--line); } +.stoa-room strong { font-weight: 500; } +.stoa-room span { grid-column: 1; font-size: 12px; color: var(--muted); } +.stoa-room button { grid-row: 1 / span 2; grid-column: 2; } +.stoa-people li { font-size: 14px; padding: 6px 0; } +@media (max-width: 900px) { .stoa-layout { grid-template-columns: 1fr; } #stoa-canvas { height: 62vh; min-height: 340px; } } diff --git a/tests/server.test.mjs b/tests/server.test.mjs index 78c7bca..857560d 100644 --- a/tests/server.test.mjs +++ b/tests/server.test.mjs @@ -272,7 +272,7 @@ test("automatic radar scans only opted-in members and respects the scan interval test("production output includes six pages, platform backend, seed data, and no private state", async () => { await import("../scripts/build.mjs"); for (const file of ["account.html", "account.js", "scripts/identity.mjs", "scripts/ledger.mjs", "scripts/credits.mjs", "scripts/billing.mjs", "scripts/connections.mjs", "scripts/plans.mjs", "scripts/persistence.mjs", "scripts/platform-admin.mjs"]) assert.deepEqual(await readFile(resolve(root, "dist", file)), await readFile(resolve(root, file)), file); - const files = ["index.html", "explore.html", "services.html", "radar.html", "meetings.html", "styles.css", "script.js", "people.js", "activity.js", "explore.js", "services.js", "radar.js", "meetings.js", "call.js", "package.json", "package-lock.json", "assets/favicon.svg", "assets/guohai.jpg", "scripts/serve.mjs", "scripts/store.mjs", "scripts/models.mjs", "scripts/activity.mjs", "scripts/calls.mjs", "scripts/auth.mjs", "stoa.html", "world/map.js", "world/kinds.js", "worlds/plaza/map.json", "worlds/room/map.json", "scripts/spaces/service.mjs", "scripts/spaces/routes.mjs", "data/people.json", "data/projects.json", "data/offers.json", "data/activity.json", "themes/agora/theme.json", "themes/minimal/theme.json", "themes/cyberpunk/theme.json"]; + const files = ["index.html", "explore.html", "services.html", "radar.html", "meetings.html", "styles.css", "script.js", "people.js", "activity.js", "explore.js", "services.js", "radar.js", "meetings.js", "call.js", "package.json", "package-lock.json", "assets/favicon.svg", "assets/guohai.jpg", "scripts/serve.mjs", "scripts/store.mjs", "scripts/models.mjs", "scripts/activity.mjs", "scripts/calls.mjs", "scripts/auth.mjs", "stoa.html", "stoa.js", "world/engine.js", "world/map.js", "world/kinds.js", "worlds/plaza/map.json", "worlds/room/map.json", "scripts/spaces/service.mjs", "scripts/spaces/routes.mjs", "data/people.json", "data/projects.json", "data/offers.json", "data/activity.json", "themes/agora/theme.json", "themes/minimal/theme.json", "themes/cyberpunk/theme.json"]; for (const file of files) assert.deepEqual(await readFile(resolve(root, "dist", file)), await readFile(resolve(root, file)), file); for (const file of [".env", ".data/community.json"]) await assert.rejects(readFile(resolve(root, "dist", file)), { code: "ENOENT" }); const built = await start({}, resolve(root, "dist")); diff --git a/tests/spaces-api.test.mjs b/tests/spaces-api.test.mjs index d4b5056..0a42023 100644 --- a/tests/spaces-api.test.mjs +++ b/tests/spaces-api.test.mjs @@ -143,3 +143,21 @@ test("the themes API returns the three built-in themes", async () => { assert.deepEqual((await response.json()).themes.map((theme) => theme.id), ["agora", "minimal", "cyberpunk"]); } finally { await close(instance); } }); + +test("the plaza page loads its engine modules and every linked file", async () => { + const { instance, url } = await start(); + try { + const page = await request(url, "/stoa/"); + const html = await page.text(); + assert.match(html, / match[1]).filter((link) => !link.startsWith("https://"))); + for (const link of links) assert.equal((await fetch(new URL(link, url + "/stoa/"))).status, 200, link); + for (const module of ["/stoa.js", "/world/map.js", "/world/kinds.js", "/world/themes.js", "/world/camera.js", "/world/motion.js", "/world/renderer-canvas.js", "/world/engine.js"]) { + const response = await request(url, module); + assert.equal(response.status, 200, module); + assert.match(response.headers.get("content-type"), /text\/javascript/, module); + } + } finally { await close(instance); } +}); From b36cf8134355b964adc0ee99494b05422d9ffe27 Mon Sep 17 00:00:00 2001 From: Brent G Date: Wed, 7 Oct 2026 20:04:47 +0000 Subject: [PATCH 27/71] Frame the plaza: wider camera, bounds, and review fixes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🤖 Built with SMT --- stoa.js | 14 ++++++++++---- styles.css | 4 +++- tests/world-client.test.mjs | 4 +++- tests/world-engine.test.mjs | 14 ++++++++++++++ tests/world-renderer.test.mjs | 8 ++++++++ world/camera.js | 9 +++++---- world/engine.js | 8 +++++--- world/renderer-canvas.js | 7 ++++--- 8 files changed, 52 insertions(+), 16 deletions(-) diff --git a/stoa.js b/stoa.js index 26211c9..e44820a 100644 --- a/stoa.js +++ b/stoa.js @@ -8,6 +8,8 @@ const status = document.querySelector("#stoa-status"); const roomList = document.querySelector("#stoa-rooms"); const peopleList = document.querySelector("#stoa-people"); const minimalToggle = document.querySelector("#stoa-minimal"); +let remembered = null; +const stored = { get: () => { try { return localStorage.getItem("stoa-minimal"); } catch { return remembered; } }, set: (value) => { remembered = value; try { localStorage.setItem("stoa-minimal", value); } catch { /* storage unavailable; keep it for this visit */ } } }; const say = (message) => { status.textContent = message; }; const node = (tag, text, className) => { const element = document.createElement(tag); element.textContent = text; if (className) element.className = className; return element; }; @@ -23,14 +25,16 @@ try { const byId = Object.fromEntries(themes.map((theme) => [theme.id, theme])); const preview = byId[new URLSearchParams(location.search).get("theme")]; const chosen = preview || byId.agora; - let minimal = localStorage.getItem("stoa-minimal") === "true"; + let minimal = stored.get() === "true"; const active = () => minimal ? byId.minimal : chosen; const engine = createEngine({ canvas, map, theme: active(), start: map.spawns[0], reducedMotion: matchMedia("(prefers-reduced-motion: reduce)").matches }); + const plaza = map.zones.find((zone) => zone.name === "plaza"); + if (plaza) engine.setBounds({ x: Math.max(0, plaza.x - 1), y: Math.max(0, plaza.y - 1), width: Math.min(map.width, plaza.x + plaza.width + 1) - Math.max(0, plaza.x - 1), height: Math.min(map.height, plaza.y + plaza.height + 1) - Math.max(0, plaza.y - 1) }); applyUi(active()); minimalToggle.setAttribute("aria-pressed", String(minimal)); minimalToggle.addEventListener("click", () => { minimal = !minimal; - localStorage.setItem("stoa-minimal", String(minimal)); + stored.set(String(minimal)); minimalToggle.setAttribute("aria-pressed", String(minimal)); applyUi(active()); engine.setTheme(active()); @@ -45,7 +49,9 @@ try { const go = node("button", "Go to", "button button-secondary button-small"); go.type = "button"; go.setAttribute("aria-label", "Walk to " + lot.title); - go.addEventListener("click", () => { if (engine.walkTo(lot.door)) say("Walking to " + lot.title + "."); canvas.focus(); }); + go.addEventListener("click", () => { if (engine.walkTo(lot.door)) say("Walking to " + lot.title + "."); + else { const at = engine.position(); say(at.x === lot.door.x && at.y === lot.door.y ? "You're already at the door of " + lot.title + "." : lot.title + " can't be reached right now."); } + canvas.focus(); }); entry.append(node("strong", lot.title), node("span", (room.topic || "Open") + " · " + room.occupancy + " of " + room.capacity + " here"), go); roomList.append(entry); } @@ -56,7 +62,7 @@ try { const name = state.profile?.name || state.account?.name || "You"; engine.setSelf({ name }); peopleList.replaceChildren(node("li", name + " (you)")); - say(preview ? "Previewing the " + preview.name + " theme. Only you see it." : "You're on the plaza. Walk to a room to see who's there."); + say(minimal ? "Minimal view is on." : preview ? "Previewing the " + preview.name + " theme. Only you see it." : "You're on the plaza. Walk to a room to see who's there."); } catch (error) { say(error.message || "The plaza could not open. Please try again."); } diff --git a/styles.css b/styles.css index 85a758b..647d530 100644 --- a/styles.css +++ b/styles.css @@ -549,7 +549,9 @@ dialog h2 { font-size: 30px; line-height: 1.25; font-weight: 500; letter-spacing .stoa { color: var(--ink); background: var(--paper); padding: 18px; border-radius: 5px; } .stoa-heading { display: flex; justify-content: space-between; align-items: end; gap: 20px; flex-wrap: wrap; margin-bottom: 18px; } .stoa-heading h1 { margin: 6px 0 0; font-size: clamp(30px, 4vw, 46px); font-weight: 500; letter-spacing: -1.5px; } -.stoa-heading [aria-pressed="true"] { background: var(--ink); color: var(--paper); } +.stoa .button-secondary { border-color: var(--line); color: var(--ink); } +.stoa .button-secondary:hover:not(:disabled) { background: var(--surface); border-color: var(--line); color: var(--ink); } +.stoa [aria-pressed="true"], .stoa [aria-pressed="true"]:hover:not(:disabled) { background: var(--ink); color: var(--paper); } .stoa-layout { display: grid; grid-template-columns: minmax(0, 1fr) 300px; gap: 18px; } .stoa-stage { background: var(--paper); border: 1px solid var(--line); border-radius: 5px; overflow: hidden; } #stoa-canvas { display: block; width: 100%; height: min(72vh, 720px); min-height: 420px; touch-action: none; cursor: pointer; } diff --git a/tests/world-client.test.mjs b/tests/world-client.test.mjs index ce7f488..a7ef69a 100644 --- a/tests/world-client.test.mjs +++ b/tests/world-client.test.mjs @@ -4,7 +4,7 @@ import { follow, screenToTile, tileCenter, zoomFor } from "../world/camera.js"; import { KEYS, STEP, WALK_SPEED, direction, positionAt } from "../world/motion.js"; test("zoom shows about twelve tiles across the shorter side, within limits", () => { - assert.equal(zoomFor({ width: 768, height: 384 }, 32), 1); + assert.equal(zoomFor({ width: 1152, height: 576 }, 32), 1); assert.equal(zoomFor({ width: 100, height: 100 }, 32), 0.6); assert.equal(zoomFor({ width: 4000, height: 4000 }, 32), 2); }); @@ -14,6 +14,8 @@ test("the camera follows the target, stops at the world's edges, and centers sma assert.deepEqual(follow({ x: 10, y: 10 }, viewport, world, 1), { x: 0, y: 0, zoom: 1 }); assert.deepEqual(follow({ x: 1400, y: 1080 }, viewport, world, 1), { x: 768, y: 608, zoom: 1 }); assert.deepEqual(follow({ x: 50, y: 50 }, viewport, { width: 320, height: 240 }, 1), { x: -160, y: -120, zoom: 1 }); + assert.deepEqual(follow({ x: 700, y: 500 }, viewport, { x: 100, y: 50, width: 600, height: 500 }, 1), { x: 80, y: 70, zoom: 1 }); + assert.deepEqual(follow({ x: 50, y: 50 }, viewport, { x: 100, y: 50, width: 320, height: 240 }, 1), { x: -60, y: -70, zoom: 1 }); assert.deepEqual(follow({ x: 700, y: 500 }, viewport, world, 2), { x: 540, y: 380, zoom: 2 }); }); test("screen points map to tiles through the camera", () => { diff --git a/tests/world-engine.test.mjs b/tests/world-engine.test.mjs index 2c9d641..756b287 100644 --- a/tests/world-engine.test.mjs +++ b/tests/world-engine.test.mjs @@ -137,3 +137,17 @@ test("modified keys and secondary buttons are ignored, and listeners can be remo engine.walkTo({ x: 23, y: 17 }); assert.equal(seen.length, 1); }); + +test("bounds keep the camera and the frame inside the plaza", () => { + const { engine, drawn, flush } = setup(); + const rect = { x: 0, y: 0, width: 44, height: 20 }; + engine.setBounds(rect); + engine.walkTo({ x: 21, y: 17 }); + flush(); + const frame = drawn.at(-1); + assert.deepEqual(frame.bounds, rect); + assert.ok(frame.camera.y + 480 / frame.camera.zoom <= 20 * 32 + 1e-9); + engine.setBounds(null); + flush(); + assert.equal(drawn.at(-1).bounds, null); +}); diff --git a/tests/world-renderer.test.mjs b/tests/world-renderer.test.mjs index 81c11d6..0fcd169 100644 --- a/tests/world-renderer.test.mjs +++ b/tests/world-renderer.test.mjs @@ -81,3 +81,11 @@ test("dim themes are tinted by their lighting; bright themes are not", () => { renderer.draw({ camera: whole, motion: false }); assert.deepEqual(calls, tinted, "setTheme switches the look completely"); }); + +test("tiles outside the bounds are not drawn", () => { + const rects = (frame) => render(minimal, { motion: false, ...frame }, [1408, 1088]).calls.filter(([name]) => name === "fillRect"); + const all = rects({}), bounded = rects({ bounds: { x: 0, y: 0, width: 44, height: 20 } }); + assert.ok(all.some(([, , y]) => y >= 20 * 32), "the whole map draws interiors"); + assert.ok(bounded.length > 0 && bounded.length < all.length); + assert.ok(!bounded.some(([, , y]) => y >= 20 * 32)); +}); diff --git a/world/camera.js b/world/camera.js index b18010a..b02e23d 100644 --- a/world/camera.js +++ b/world/camera.js @@ -1,10 +1,11 @@ // Camera math in world pixels. A camera's x/y is the world point drawn at the viewport's top-left corner. -export const zoomFor = (viewport, tileSize) => Math.max(0.6, Math.min(2, Math.min(viewport.width, viewport.height) / (12 * tileSize))); +export const zoomFor = (viewport, tileSize) => Math.max(0.6, Math.min(2, Math.min(viewport.width, viewport.height) / (18 * tileSize))); -export function follow(target, viewport, world, zoom) { +// `area` is the part of the world the camera may show, {x = 0, y = 0, width, height} in world pixels. +export function follow(target, viewport, area, zoom) { const width = viewport.width / zoom, height = viewport.height / zoom; - const axis = (point, size, length) => length <= size ? -(size - length) / 2 : Math.max(0, Math.min(point - size / 2, length - size)); - return { x: axis(target.x, width, world.width), y: axis(target.y, height, world.height), zoom }; + const axis = (point, size, origin, length) => length <= size ? origin - (size - length) / 2 : Math.max(origin, Math.min(point - size / 2, origin + length - size)); + return { x: axis(target.x, width, area.x || 0, area.width), y: axis(target.y, height, area.y || 0, area.height), zoom }; } export const screenToTile = (camera, px, py, tileSize) => ({ x: Math.floor((camera.x + px / camera.zoom) / tileSize), y: Math.floor((camera.y + py / camera.zoom) / tileSize) }); diff --git a/world/engine.js b/world/engine.js index 0188fdd..9f5f3f3 100644 --- a/world/engine.js +++ b/world/engine.js @@ -10,7 +10,7 @@ export function createEngine({ canvas, map, theme, start, reducedMotion = false, const listeners = { arrive: [], move: [] }; const self = { id: "self", name: "You", walk: { path: [start], startedAt: now(), dir: "down" }, arrived: true }; let current = theme, others = [], decor = [], labels = [], held = null, pending = false, timer = null, destroyed = false; - let camera = { x: 0, y: 0, zoom: 1 }, viewport = { width: 1, height: 1 }; + let bounds = null, camera = { x: 0, y: 0, zoom: 1 }, viewport = { width: 1, height: 1 }; const emit = (type, value) => { for (const listener of listeners[type]) listener(value); }; const here = (time = now()) => positionAt(self.walk, time); const blocked = () => new Set(decor.filter((item) => BLOCKING_DECOR.has(item.kind)).map((item) => item.x + "," + item.y)); @@ -21,12 +21,13 @@ export function createEngine({ canvas, map, theme, start, reducedMotion = false, pending = false; if (destroyed) return; const time = now(), s = map.tileSize; + const area = bounds || { x: 0, y: 0, width: map.width, height: map.height }; const reached = here(time); if (reached.done && !self.arrived) { self.arrived = true; emit("arrive", { x: reached.x, y: reached.y }); if (held) step(held); } const shown = here(time); - camera = follow({ x: (shown.x + 0.5) * s, y: (shown.y + 0.5) * s }, viewport, { width: map.width * s, height: map.height * s }, zoomFor(viewport, s)); + camera = follow({ x: (shown.x + 0.5) * s, y: (shown.y + 0.5) * s }, viewport, { x: area.x * s, y: area.y * s, width: area.width * s, height: area.height * s }, zoomFor(viewport, s)); const crowd = others.map((other) => ({ id: other.id, name: other.name, ...positionAt(other.walk, time) })); - renderer.draw({ camera, time, avatars: [{ id: self.id, name: self.name, self: true, ...shown }, ...crowd], decor, labels, motion: !reducedMotion }); + renderer.draw({ camera, time, avatars: [{ id: self.id, name: self.name, self: true, ...shown }, ...crowd], decor, labels, bounds, motion: !reducedMotion }); if (!shown.done || crowd.some((other) => !other.done)) schedule(); else if (ambient() && timer === null) timer = later(() => { timer = null; schedule(); }, 66); } @@ -84,6 +85,7 @@ export function createEngine({ canvas, map, theme, start, reducedMotion = false, on(type, listener) { listeners[type].push(listener); return () => { listeners[type] = listeners[type].filter((item) => item !== listener); }; }, setSelf({ name }) { self.name = name; schedule(); }, setTheme(next) { current = next; renderer.setTheme(next); schedule(); }, + setBounds(rect) { bounds = rect || null; schedule(); }, setLabels(list) { labels = list; schedule(); }, setDecor(list) { decor = list; schedule(); }, setOthers(list) { others = list; schedule(); }, diff --git a/world/renderer-canvas.js b/world/renderer-canvas.js index e1987f9..a828ed5 100644 --- a/world/renderer-canvas.js +++ b/world/renderer-canvas.js @@ -110,13 +110,14 @@ export function createCanvasRenderer(canvas, { map, theme }) { return { resize(width, height, ratio = 1) { viewport = { width, height, ratio }; canvas.width = Math.round(width * ratio); canvas.height = Math.round(height * ratio); }, setTheme(next) { current = next; }, - draw({ camera, time = 0, avatars = [], decor = [], labels = [], motion = true }) { + draw({ camera, time = 0, avatars = [], decor = [], labels = [], bounds = null, motion = true }) { const { width, height, ratio } = viewport, font = current.ui?.font || "IBM Plex Sans", t = motion ? time : 0; ctx.setTransform(ratio, 0, 0, ratio, 0, 0); box(ctx, current.ui?.["--paper"] || "#111111", 0, 0, width, height); ctx.save(); ctx.scale(camera.zoom, camera.zoom); ctx.translate(-camera.x, -camera.y); - const x0 = Math.max(0, Math.floor(camera.x / s)), y0 = Math.max(0, Math.floor(camera.y / s)); - const x1 = Math.min(map.width - 1, Math.floor((camera.x + width / camera.zoom) / s)), y1 = Math.min(map.height - 1, Math.floor((camera.y + height / camera.zoom) / s)); + const area = bounds || { x: 0, y: 0, width: map.width, height: map.height }; + const x0 = Math.max(area.x, Math.floor(camera.x / s)), y0 = Math.max(area.y, Math.floor(camera.y / s)); + const x1 = Math.min(area.x + area.width - 1, Math.floor((camera.x + width / camera.zoom) / s)), y1 = Math.min(area.y + area.height - 1, Math.floor((camera.y + height / camera.zoom) / s)); for (let y = y0; y <= y1; y += 1) for (let x = x0; x <= x1; x += 1) { const index = y * map.width + x, role = map.roles[index], ground = map.ground[index]; if (ground && ground !== role) tile(ground, x, y, t); From 0210721fbcd81eb5f9d75bbf8aeb8c3665a12286 Mon Sep 17 00:00:00 2001 From: Brent G Date: Wed, 7 Oct 2026 20:12:15 +0000 Subject: [PATCH 28/71] Fix final review findings: idle redraw, draw cost, resilient room panel, engine hardening MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🤖 Built with SMT --- stoa.js | 93 ++++++++++++++++++++++------------- tests/world-client.test.mjs | 2 +- tests/world-engine.test.mjs | 57 ++++++++++++++++++++- tests/world-renderer.test.mjs | 23 +++++++++ world/engine.js | 15 +++--- world/renderer-canvas.js | 8 +-- 6 files changed, 151 insertions(+), 47 deletions(-) diff --git a/stoa.js b/stoa.js index e44820a..63854ec 100644 --- a/stoa.js +++ b/stoa.js @@ -14,55 +14,78 @@ const say = (message) => { status.textContent = message; }; const node = (tag, text, className) => { const element = document.createElement(tag); element.textContent = text; if (className) element.className = className; return element; }; // Theme tokens restyle only the Stoa section; the site header and footer keep the portal's look. +const fontStack = (name) => name ? (/\s/.test(name) ? "\"" + name + "\"" : name) + ", sans-serif" : ""; function applyUi(theme) { for (const [token, value] of Object.entries(theme.ui)) if (token.startsWith("--")) root.style.setProperty(token, value); - root.style.fontFamily = theme.ui.font ? "\"" + theme.ui.font + "\", sans-serif" : ""; + root.style.fontFamily = fontStack(theme.ui.font); } -try { - const [source, { themes }, listing] = await Promise.all([api("/api/worlds/plaza"), api("/api/themes"), api("/api/spaces")]); - const map = parseMap(source); - const byId = Object.fromEntries(themes.map((theme) => [theme.id, theme])); - const preview = byId[new URLSearchParams(location.search).get("theme")]; - const chosen = preview || byId.agora; - let minimal = stored.get() === "true"; - const active = () => minimal ? byId.minimal : chosen; - const engine = createEngine({ canvas, map, theme: active(), start: map.spawns[0], reducedMotion: matchMedia("(prefers-reduced-motion: reduce)").matches }); - const plaza = map.zones.find((zone) => zone.name === "plaza"); - if (plaza) engine.setBounds({ x: Math.max(0, plaza.x - 1), y: Math.max(0, plaza.y - 1), width: Math.min(map.width, plaza.x + plaza.width + 1) - Math.max(0, plaza.x - 1), height: Math.min(map.height, plaza.y + plaza.height + 1) - Math.max(0, plaza.y - 1) }); - applyUi(active()); - minimalToggle.setAttribute("aria-pressed", String(minimal)); - minimalToggle.addEventListener("click", () => { - minimal = !minimal; - stored.set(String(minimal)); - minimalToggle.setAttribute("aria-pressed", String(minimal)); - applyUi(active()); - engine.setTheme(active()); - }); - new ResizeObserver(() => engine.resize()).observe(canvas); +const [worldResult, themesResult, spacesResult] = await Promise.allSettled([api("/api/worlds/plaza"), api("/api/themes"), api("/api/spaces")]); +const problems = []; +const failure = (result, what) => { if (result.status === "rejected") problems.push(what + " could not load" + (result.reason?.message ? " (" + result.reason.message + ")" : "") + "."); }; +failure(worldResult, "The plaza map"); failure(themesResult, "Themes"); failure(spacesResult, "The room list"); + +let map = null, engine = null, minimal = false, preview = null; +const rooms = new Map((spacesResult.value?.rooms || []).map((room) => [room.slug, room])); +const door = (lot) => lot.door.x + ", " + lot.door.y; - const rooms = new Map(listing.rooms.map((room) => [room.slug, room])); - engine.setLabels(map.lots.map((lot) => ({ x: lot.door.x, y: lot.door.y - 1, text: rooms.get(lot.slug)?.topic || lot.title }))); +try { if (worldResult.status === "fulfilled") map = parseMap(worldResult.value); } catch (error) { problems.push("The plaza map is not valid (" + error.message + ")."); } + +// The room list needs only the map and /api/spaces, so it is built whether or not the canvas works. +function listRooms() { for (const lot of map.lots) { const room = rooms.get(lot.slug) || { topic: null, occupancy: 0, capacity: lot.capacity }; const entry = node("li", "", "stoa-room"); const go = node("button", "Go to", "button button-secondary button-small"); go.type = "button"; go.setAttribute("aria-label", "Walk to " + lot.title); - go.addEventListener("click", () => { if (engine.walkTo(lot.door)) say("Walking to " + lot.title + "."); + go.addEventListener("click", () => { + if (!engine) say("The plaza view isn't available, so " + lot.title + " can't be walked to. Its door is at tile " + door(lot) + "."); + else if (engine.walkTo(lot.door)) say("Walking to " + lot.title + "."); else { const at = engine.position(); say(at.x === lot.door.x && at.y === lot.door.y ? "You're already at the door of " + lot.title + "." : lot.title + " can't be reached right now."); } - canvas.focus(); }); + canvas.focus(); + }); entry.append(node("strong", lot.title), node("span", (room.topic || "Open") + " · " + room.occupancy + " of " + room.capacity + " here"), go); roomList.append(entry); } - const doors = new Map(map.lots.map((lot) => [lot.door.x + "," + lot.door.y, lot])); - engine.on("arrive", (tile) => { const lot = doors.get(tile.x + "," + tile.y); if (lot) say("You are at the door of " + lot.title + ". Stepping inside comes with the next update."); }); +} +if (map) listRooms(); - await sessionReady; - const name = state.profile?.name || state.account?.name || "You"; - engine.setSelf({ name }); - peopleList.replaceChildren(node("li", name + " (you)")); - say(minimal ? "Minimal view is on." : preview ? "Previewing the " + preview.name + " theme. Only you see it." : "You're on the plaza. Walk to a room to see who's there."); -} catch (error) { - say(error.message || "The plaza could not open. Please try again."); +if (map && themesResult.status === "fulfilled") { + try { + const byId = new Map(themesResult.value.themes.map((theme) => [theme.id, theme])); + const wanted = new URLSearchParams(location.search).get("theme"); + preview = wanted !== null && byId.has(wanted) && wanted !== "agora" ? byId.get(wanted) : null; + const chosen = preview || byId.get("agora"); + minimal = stored.get() === "true"; + const active = () => minimal ? byId.get("minimal") : chosen; + engine = createEngine({ canvas, map, theme: active(), start: map.spawns[0], reducedMotion: matchMedia("(prefers-reduced-motion: reduce)").matches }); + const plaza = map.zones.find((zone) => zone.name === "plaza"); + if (plaza) engine.setBounds({ x: Math.max(0, plaza.x - 1), y: Math.max(0, plaza.y - 1), width: Math.min(map.width, plaza.x + plaza.width + 1) - Math.max(0, plaza.x - 1), height: Math.min(map.height, plaza.y + plaza.height + 1) - Math.max(0, plaza.y - 1) }); + applyUi(active()); + minimalToggle.setAttribute("aria-pressed", String(minimal)); + minimalToggle.addEventListener("click", () => { + minimal = !minimal; + stored.set(String(minimal)); + minimalToggle.setAttribute("aria-pressed", String(minimal)); + applyUi(active()); + engine.setTheme(active()); + }); + new ResizeObserver(() => engine.resize()).observe(canvas); + // Canvas text uses the web font only once it has loaded, so draw again then. + document.fonts?.ready.then(() => engine?.resize()); + engine.setLabels(map.lots.map((lot) => ({ x: lot.door.x, y: lot.door.y - 1, text: rooms.get(lot.slug)?.topic || lot.title }))); + const doors = new Map(map.lots.map((lot) => [lot.door.x + "," + lot.door.y, lot])); + engine.on("arrive", (tile) => { const lot = doors.get(tile.x + "," + tile.y); if (lot) say("You are at the door of " + lot.title + ". Stepping inside comes with the next update."); }); + } catch (error) { + engine = null; + problems.push("The plaza view could not start (" + (error.message || "unknown error") + ")."); + } } + +await sessionReady; +const name = state.profile?.name || state.account?.name || "You"; +if (engine) engine.setSelf({ id: state.account?.id || state.profile?.id, name }); +peopleList.replaceChildren(node("li", name + " (you)")); +if (problems.length) say(problems.join(" ") + (map && !engine ? " The rooms are listed below." : "")); +else say(minimal ? "Minimal view is on." : preview ? "Previewing the " + preview.name + " theme. Only you see it." : "You're on the plaza. Walk to a room to see who's there."); diff --git a/tests/world-client.test.mjs b/tests/world-client.test.mjs index a7ef69a..8f3cbb7 100644 --- a/tests/world-client.test.mjs +++ b/tests/world-client.test.mjs @@ -3,7 +3,7 @@ import assert from "node:assert/strict"; import { follow, screenToTile, tileCenter, zoomFor } from "../world/camera.js"; import { KEYS, STEP, WALK_SPEED, direction, positionAt } from "../world/motion.js"; -test("zoom shows about twelve tiles across the shorter side, within limits", () => { +test("zoom shows about eighteen tiles across the shorter side, within limits", () => { assert.equal(zoomFor({ width: 1152, height: 576 }, 32), 1); assert.equal(zoomFor({ width: 100, height: 100 }, 32), 0.6); assert.equal(zoomFor({ width: 4000, height: 4000 }, 32), 2); diff --git a/tests/world-engine.test.mjs b/tests/world-engine.test.mjs index 756b287..7d17bf3 100644 --- a/tests/world-engine.test.mjs +++ b/tests/world-engine.test.mjs @@ -10,12 +10,12 @@ const plaza = parseMap(JSON.parse(await readFile(new URL("../worlds/plaza/map.js const [agora, minimal] = loadThemes(root); const key = (name) => ({ key: name, preventDefault() {} }); -function setup({ reducedMotion = false } = {}) { +function setup({ reducedMotion = false, theme = agora } = {}) { let time = 1000; const frames = [], timers = [], drawn = [], themes = [], handlers = {}, events = { move: [], arrive: [] }; const canvas = { clientWidth: 640, clientHeight: 480, addEventListener: (type, listener) => { handlers[type] = listener; }, removeEventListener: (type) => { delete handlers[type]; }, getBoundingClientRect: () => ({ left: 0, top: 0 }) }; const renderer = { resize() {}, setTheme: (theme) => themes.push(theme), draw: (frame) => drawn.push(frame) }; - const engine = createEngine({ canvas, map: plaza, theme: agora, start: plaza.spawns[0], reducedMotion, renderer, now: () => time, raf: (callback) => frames.push(callback), later: (callback) => { timers.push(callback); return timers.length; }, cancel() {}, ratio: () => 1 }); + const engine = createEngine({ canvas, map: plaza, theme, start: plaza.spawns[0], reducedMotion, renderer, now: () => time, raf: (callback) => frames.push(callback), later: (callback) => { timers.push(callback); return timers.length; }, cancel() {}, ratio: () => 1 }); engine.on("move", (value) => events.move.push(value)); engine.on("arrive", (value) => events.arrive.push(value)); // Runs only the frames already requested, so a walking avatar cannot loop forever inside one flush. @@ -151,3 +151,56 @@ test("bounds keep the camera and the frame inside the plaza", () => { flush(); assert.equal(drawn.at(-1).bounds, null); }); + +test("a theme that draws water flat does not keep an idle loop running", () => { + assert.ok(plaza.roles.includes("water"), "the plaza has water"); + assert.equal(setup({ theme: minimal }).timers.length, 0, "Minimal is still when idle"); + assert.equal(setup().timers.length, 1, "animated water keeps ambient redraws"); +}); +test("releasing a key always releases the hold, even with modifiers", () => { + const { handlers, events, flush, advance } = setup(); + handlers.keydown(key("ArrowUp")); + handlers.keyup({ key: "ArrowUp", metaKey: true }); + advance(201); flush(); + assert.equal(events.move.length, 1); +}); +test("self identity decides the avatar id", () => { + const { engine, drawn, flush } = setup(); + engine.setSelf({ id: "account:x", name: "Ada" }); flush(); + assert.equal(drawn.at(-1).avatars[0].id, "account:x"); + engine.setSelf({ name: "Ada" }); flush(); + assert.equal(drawn.at(-1).avatars[0].id, "account:x", "an omitted id keeps the current one"); +}); +test("bad peer walks are ignored and never throw while drawing", () => { + const { engine, drawn, flush } = setup(); + engine.setOthers([ + { id: "empty", name: "E", walk: { path: [], startedAt: 0 } }, + { id: "none", name: "N" }, + null, + { id: "wall", name: "W", walk: { path: [{ x: 0, y: 0 }], startedAt: 0 } }, + { id: "time", name: "T", walk: { path: [{ x: 22, y: 17 }], startedAt: "x" } }, + { id: "frac", name: "F", walk: { path: [{ x: 22.5, y: 17 }], startedAt: 0 } }, + { id: "ok", name: "Bo", walk: { path: [{ x: 22, y: 17 }], startedAt: 0 } } + ]); + assert.doesNotThrow(() => flush()); + assert.deepEqual(drawn.at(-1).avatars.map((avatar) => avatar.name), ["You", "Bo"]); +}); +test("unknown engine events throw a clear error", () => { + const { engine } = setup(); + assert.throws(() => engine.on("__proto__", () => {}), /Unknown engine event: __proto__/); + assert.throws(() => engine.on("nope", () => {}), /Unknown engine event: nope/); +}); +test("walking to the tile you stand on does nothing", () => { + const { engine, events } = setup(); + assert.equal(engine.walkTo({ x: 21, y: 17 }), false); + assert.equal(events.move.length, 0); +}); +test("an arrival not yet announced is announced before the next walk starts", () => { + const { engine, events, advance } = setup(); + engine.walkTo({ x: 21, y: 12 }); + advance(10000); + assert.equal(events.arrive.length, 0); + assert.equal(engine.walkTo({ x: 23, y: 17 }), true); + assert.deepEqual(events.arrive, [{ x: 21, y: 12 }]); + assert.equal(events.move.length, 2); +}); diff --git a/tests/world-renderer.test.mjs b/tests/world-renderer.test.mjs index 0fcd169..8654fb6 100644 --- a/tests/world-renderer.test.mjs +++ b/tests/world-renderer.test.mjs @@ -89,3 +89,26 @@ test("tiles outside the bounds are not drawn", () => { assert.ok(bounded.length > 0 && bounded.length < all.length); assert.ok(!bounded.some(([, , y]) => y >= 20 * 32)); }); + +// Fill colors of the whole-tile rectangles drawn at tile (x, y). +const originRects = (calls, x, y) => { let style = null; const out = []; for (const [name, a, b, c, d] of calls) { if (name === "set:fillStyle") style = a; else if (name === "fillRect" && a === x * 32 && b === y * 32 && c === 32 && d === 32) out.push(style); } return out; }; +const tileView = (x, y) => ({ motion: false, camera: { x: x * 32, y: y * 32, zoom: 1 } }); +test("a tile that fills itself is not drawn over its ground", () => { + const index = plaza.roles.findIndex((role, i) => role === "wall" && plaza.ground[i] && plaza.ground[i] !== "wall"); + assert.ok(index >= 0); + const x = index % plaza.width, y = Math.floor(index / plaza.width); + const painted = originRects(render(agora, tileView(x, y)).calls, x, y); + assert.deepEqual(painted, [agora.roles.wall.colors[0]], "only the wall is painted: " + painted); +}); +test("ground still shows under shapes that do not fill the tile", () => { + const index = plaza.roles.findIndex((role, i) => role && !["flat", "tiles", "path", "grass", "water", "wall", "neon"].includes(agora.roles[role].pattern) && plaza.ground[i] && plaza.ground[i] !== role); + assert.ok(index >= 0, "the plaza has a column, door, or similar over ground"); + const x = index % plaza.width, y = Math.floor(index / plaza.width); + assert.ok(originRects(render(agora, tileView(x, y)).calls, x, y).includes(agora.roles[plaza.ground[index]].colors[0])); +}); +test("unknown decoration kinds are skipped and fonts have a fallback", () => { + const { calls } = render(agora, { motion: false, decor: [{ kind: "__proto__", x: 3, y: 3 }, { kind: "constructor", x: 4, y: 3 }, { kind: "toString", x: 5, y: 3 }], avatars: [{ id: "a", name: "Ada", x: 3, y: 3 }] }); + const fonts = calls.filter(([name]) => name === "set:font").map(([, value]) => value); + assert.ok(fonts.length > 0 && fonts.every((font) => font.endsWith(", sans-serif")), fonts.join("|")); + assert.ok(fonts.some((font) => font.includes("\"IBM Plex Sans\""))); +}); diff --git a/world/engine.js b/world/engine.js index 9f5f3f3..976c1a1 100644 --- a/world/engine.js +++ b/world/engine.js @@ -1,4 +1,4 @@ -import { findPath, walkable } from "./map.js"; +import { findPath, validPath, walkable } from "./map.js"; import { BLOCKING_DECOR } from "./kinds.js"; import { follow, screenToTile, zoomFor } from "./camera.js"; import { KEYS, STEP, WALK_SPEED, positionAt } from "./motion.js"; @@ -14,7 +14,8 @@ export function createEngine({ canvas, map, theme, start, reducedMotion = false, const emit = (type, value) => { for (const listener of listeners[type]) listener(value); }; const here = (time = now()) => positionAt(self.walk, time); const blocked = () => new Set(decor.filter((item) => BLOCKING_DECOR.has(item.kind)).map((item) => item.x + "," + item.y)); - const ambient = () => !reducedMotion && (current.effects.some((effect) => effect !== "neon-glow") || map.roles.includes("water")); + const ambient = () => !reducedMotion && (current.effects.some((effect) => effect !== "neon-glow") || (map.roles.includes("water") && current.roles.water?.pattern === "water")); + const sound = (other) => Boolean(other) && Boolean(other.walk) && Number.isFinite(other.walk.startedAt) && Array.isArray(other.walk.path) && other.walk.path.every((step) => step && Number.isInteger(step.x) && Number.isInteger(step.y)) && validPath(map, other.walk.path); const schedule = () => { if (!destroyed && !pending) { pending = true; raf(frame); } }; function frame() { @@ -35,7 +36,9 @@ export function createEngine({ canvas, map, theme, start, reducedMotion = false, function walkTo(target) { const time = now(), position = here(time), walk = self.walk; let route; + if (position.done && !self.arrived) { self.arrived = true; emit("arrive", { x: position.x, y: position.y }); } if (position.done) { + if (target.x === position.x && target.y === position.y) return false; const path = findPath(map, { x: Math.round(position.x), y: Math.round(position.y) }, target, blocked()); if (!path) return false; route = { path, startedAt: time }; @@ -74,7 +77,7 @@ export function createEngine({ canvas, map, theme, start, reducedMotion = false, held = dir; step(dir); }; - const onKeyUp = (event) => { if (!event.ctrlKey && !event.metaKey && !event.altKey && KEYS[event.key] === held) held = null; }; + const onKeyUp = (event) => { if (KEYS[event.key] === held) held = null; }; const onBlur = () => { held = null; }; const inputs = [["pointerup", onPointer], ["keydown", onKeyDown], ["keyup", onKeyUp], ["blur", onBlur]]; for (const [type, listener] of inputs) canvas.addEventListener(type, listener); @@ -82,13 +85,13 @@ export function createEngine({ canvas, map, theme, start, reducedMotion = false, const engine = { walkTo, position() { const position = here(); return { x: Math.round(position.x), y: Math.round(position.y) }; }, - on(type, listener) { listeners[type].push(listener); return () => { listeners[type] = listeners[type].filter((item) => item !== listener); }; }, - setSelf({ name }) { self.name = name; schedule(); }, + on(type, listener) { if (!Object.hasOwn(listeners, type)) throw new Error("Unknown engine event: " + type); listeners[type].push(listener); return () => { listeners[type] = listeners[type].filter((item) => item !== listener); }; }, + setSelf({ id, name }) { if (id) self.id = id; self.name = name; schedule(); }, setTheme(next) { current = next; renderer.setTheme(next); schedule(); }, setBounds(rect) { bounds = rect || null; schedule(); }, setLabels(list) { labels = list; schedule(); }, setDecor(list) { decor = list; schedule(); }, - setOthers(list) { others = list; schedule(); }, + setOthers(list) { others = list.filter(sound); schedule(); }, resize() { viewport = { width: canvas.clientWidth || 1, height: canvas.clientHeight || 1 }; renderer.resize(viewport.width, viewport.height, ratio()); schedule(); }, destroy() { destroyed = true; for (const [type, listener] of inputs) canvas.removeEventListener(type, listener); if (timer !== null) cancel(timer); timer = null; } }; diff --git a/world/renderer-canvas.js b/world/renderer-canvas.js index a828ed5..9a370dc 100644 --- a/world/renderer-canvas.js +++ b/world/renderer-canvas.js @@ -90,6 +90,8 @@ function drawEffects(ctx, theme, width, height, time) { } } +export const fontStack = (name) => { const family = name || "IBM Plex Sans"; return (/\s/.test(family) ? "\"" + family + "\"" : family) + ", sans-serif"; }; +const FILLS = new Set(["flat", "tiles", "path", "grass", "water", "wall", "neon"]); export function createCanvasRenderer(canvas, { map, theme }) { const ctx = canvas.getContext("2d"), s = map.tileSize; let current = theme, viewport = { width: 1, height: 1, ratio: 1 }; @@ -101,8 +103,8 @@ export function createCanvasRenderer(canvas, { map, theme }) { if (glow) ctx.shadowBlur = 0; } function decorItem(item) { + if (!Object.hasOwn(DECOR_DRAW, item.kind) || !Object.hasOwn(current.decor, item.kind)) return; const entry = current.decor[item.kind]; - if (!entry) return; ctx.save(); ctx.translate((item.x + 0.5) * s, (item.y + 0.5) * s); ctx.rotate(((item.rotation || 0) * Math.PI) / 180); ctx.translate(-s / 2, -s / 2); DECOR_DRAW[item.kind](ctx, 0, 0, s, entry.variants?.[item.variant || 0] || entry.colors); ctx.restore(); @@ -111,7 +113,7 @@ export function createCanvasRenderer(canvas, { map, theme }) { resize(width, height, ratio = 1) { viewport = { width, height, ratio }; canvas.width = Math.round(width * ratio); canvas.height = Math.round(height * ratio); }, setTheme(next) { current = next; }, draw({ camera, time = 0, avatars = [], decor = [], labels = [], bounds = null, motion = true }) { - const { width, height, ratio } = viewport, font = current.ui?.font || "IBM Plex Sans", t = motion ? time : 0; + const { width, height, ratio } = viewport, font = fontStack(current.ui?.font), t = motion ? time : 0; ctx.setTransform(ratio, 0, 0, ratio, 0, 0); box(ctx, current.ui?.["--paper"] || "#111111", 0, 0, width, height); ctx.save(); ctx.scale(camera.zoom, camera.zoom); ctx.translate(-camera.x, -camera.y); @@ -120,7 +122,7 @@ export function createCanvasRenderer(canvas, { map, theme }) { const x1 = Math.min(area.x + area.width - 1, Math.floor((camera.x + width / camera.zoom) / s)), y1 = Math.min(area.y + area.height - 1, Math.floor((camera.y + height / camera.zoom) / s)); for (let y = y0; y <= y1; y += 1) for (let x = x0; x <= x1; x += 1) { const index = y * map.width + x, role = map.roles[index], ground = map.ground[index]; - if (ground && ground !== role) tile(ground, x, y, t); + if (ground && ground !== role && !(role && FILLS.has(current.roles[role].pattern))) tile(ground, x, y, t); if (role) tile(role, x, y, t); } for (const item of map.interactables) decorItem(item); From 41c5c319c79b58e833948519439dcbc211fd7e79 Mon Sep 17 00:00:00 2001 From: Brent G Date: Wed, 7 Oct 2026 20:13:16 +0000 Subject: [PATCH 29/71] Record follow-ups for Plan C MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🤖 Built with SMT --- docs/superpowers/plans/2026-10-07-world-engine.md | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/docs/superpowers/plans/2026-10-07-world-engine.md b/docs/superpowers/plans/2026-10-07-world-engine.md index 06e8cdd..6f2561a 100644 --- a/docs/superpowers/plans/2026-10-07-world-engine.md +++ b/docs/superpowers/plans/2026-10-07-world-engine.md @@ -1438,3 +1438,18 @@ git commit -m "Open the walkable plaza page" -m "🤖 Built with SMT Date: Thu, 8 Oct 2026 08:39:32 +0000 Subject: [PATCH 30/71] Add the Stoa live plan and cap the plaza at 200 in the spec MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🤖 Built with SMT --- .../superpowers/plans/2026-10-08-stoa-live.md | 2055 +++++++++++++++++ .../specs/2026-10-07-stoa-spaces-design.md | 8 +- 2 files changed, 2059 insertions(+), 4 deletions(-) create mode 100644 docs/superpowers/plans/2026-10-08-stoa-live.md diff --git a/docs/superpowers/plans/2026-10-08-stoa-live.md b/docs/superpowers/plans/2026-10-08-stoa-live.md new file mode 100644 index 0000000..e77b241 --- /dev/null +++ b/docs/superpowers/plans/2026-10-08-stoa-live.md @@ -0,0 +1,2055 @@ +# Stoa Live: Presence And Rooms (Plan C1 of 5) Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Make the Stoa shared and enterable. People see each other walk in real time over Agora Signaling, talk in public and room messages with speech bubbles, step into lot rooms (the first person in becomes host and sets the topic), and visit their own spaces at `/stoa/s/`, with unlisted and private payloads encrypted end to end. + +**Architecture:** The browser keeps one Signaling login and joins one space channel at a time. +- `world/signaling.js` wraps the SDK. It encrypts keyed channels and normalizes presence events. +- `world/presence.js` turns other people's presence and messages into engine walks, applying the spec's receiver rules. +- `stoa/place.js` connects one channel to the engine. `stoa/lease.js` keeps the server entry lease alive. +- `stoa/stage.js` moves the person between the plaza, lot rooms, and user spaces. + +The server (Plan A) already issues leases, channel names, keys, and tokens. This plan adds only the SDK file and navigation on the server side. + +**Tech Stack:** Browser ES modules, `agora-rtm-sdk` 2.3.0 (new dependency; its bundle needs `window.AgoraRTC` loaded first), WebCrypto AES-GCM, and the Node 20 test runner with fakes for the SDK and DOM-free modules. + +**Spec:** `docs/superpowers/specs/2026-10-07-stoa-spaces-design.md`, sections 1–2 and 4 (presence, movement, entering and leaving rooms, messages, Signaling protection, accessibility layer). + +**Builds on:** +- **Plan A:** `/api/spaces/*`, `/api/signaling/token` (returns `{ appId, userId, token, expiresAt, channels }`), enter, heartbeat, and leave responses `{ space?, firstIn?, channel, key, blocked, hostId }`. +- **Plan B:** the engine (`walkTo`, `on`, `setOthers`, `setBounds`, `setSelf`, `setTheme`, `setLabels`, `setDecor`, `position`, `resize`, `destroy`), `positionAt`, `validPath`, `walkable`, and the Stoa page. +- **Notes:** "Notes for Plan C" in `docs/superpowers/plans/2026-10-07-world-engine.md`. + +**Plan C2 (later):** calls inside rooms, decorate mode, host tools (hand over, remove), creating spaces and inviting people, and the `/meet` redirect. + +## Global Constraints + +- **Messages:** never store message content on the server. Public and room messages exist only for people subscribed when they are sent. +- **Rate limits:** clients publish movement at most 4 times per second. Keyboard movement publishes `walk` on press and `stop` on release only. Clients send chat at most 1 per second with a burst of 3, and each message is at most 500 characters. +- **Receiver rules:** clients drop messages and presence state from `g-` users, from publishers without presence state on the channel, from blocked people, and anything that fails validation or decryption. +- **Encryption:** unlisted and private channels carry `e1.` + base64(iv ‖ AES-GCM ciphertext) for every message and for presence state (`{ s: sealed JSON }`). Listed channels are plaintext. +- **Guests:** guests watch the plaza only. They subscribe with `beQuiet: true` and never publish. +- **Accessibility:** the canvas is never the only way to act. The panel offers rooms with Go to, people, a message log (`role="log"`), a message form, a topic form for hosts, and a leave button. All DOM text uses `textContent`. +- **No Signaling:** if Signaling is not configured (503), the plaza and rooms still work locally: leases, entering rooms, topics. The page says live movement is not connected. +- **Style:** two-space indent, double quotes, semicolons, compact one-line style. Browser modules are plain ESM with no Node imports. The only new dependency is `agora-rtm-sdk@2.3.0`. +- **Commits:** every commit message ends with the line `🤖 Built with SMT `. No co-author trailers. Run `npm test` before every commit; it must pass completely. + +## File Structure + +| File | Responsibility | +| --- | --- | +| `package.json`, `package-lock.json` | `agora-rtm-sdk@2.3.0` | +| `scripts/serve.mjs`, `scripts/build.mjs` | Serve and package `/assets/agora-rtm.js`; allowlist new modules | +| `world/sdk.js` (create) | Load the RTC and Signaling SDK scripts in order | +| `world/protocol.js` (create) | Message and state formats, validation, receiver rules, straight walks, rate limiter | +| `world/crypto.js` (create) | AES-GCM seal and open for keyed channels | +| `world/engine.js`, `world/renderer-canvas.js` (modify) | Keyboard `walk`/`stop`/`face`, `teleport`, `setInteractive`, `facing`, speech bubbles, bounds-gated clicks, decor re-route | +| `world/signaling.js` (create) | Signaling client wrapper | +| `world/presence.js` (create) | Other people's state and walks for one channel | +| `stoa/place.js`, `stoa/lease.js` (create) | One channel's live wiring; the entry lease | +| `stoa/panel.js`, `stoa/stage.js` (create); `stoa.js`, `stoa.html`, `styles.css`, `script.js` (modify) | The page | +| `*.html` (modify) | Nav link "Rooms" becomes "Stoa" | +| `tests/stoa-protocol.test.mjs`, `tests/stoa-signaling.test.mjs`, `tests/stoa-place.test.mjs`, `tests/world-engine.test.mjs`, `tests/world-renderer.test.mjs`, `tests/spaces-api.test.mjs`, `tests/server.test.mjs` | Tests | + +--- + +### Task 1: The Signaling SDK, its loader, and the Stoa nav link + +**Files:** +- Modify: `package.json`, `package-lock.json` (by `npm install`), `scripts/serve.mjs:24,349` and `publicFiles`, `scripts/build.mjs:11`, `index.html`, `explore.html`, `services.html`, `radar.html`, `meetings.html`, `account.html`, `stoa.html` (nav), `tests/server.test.mjs` (production files) +- Create: `world/sdk.js` +- Test: `tests/spaces-api.test.mjs` (append) + +**Interfaces:** +- Produces: + - `GET /assets/agora-rtm.js` serves the `agora-rtm-sdk` browser bundle, from `node_modules` in development and from `dist/assets` in production. + - `world/sdk.js` exports `loadAgora() -> Promise<{ AgoraRTC, AgoraRTM }>`. It loads `/assets/agora-rtc.js` then `/assets/agora-rtm.js` once each, because the Signaling bundle reads `window.AgoraRTC` when it loads. + - Every page's nav shows `Stoa` instead of the Rooms link. `stoa.html` marks it `class="is-active" aria-current="page"`. + +- [ ] **Step 1: Install the dependency** + +Run: `npm install agora-rtm-sdk@2.3.0 --save-exact` +Expected: `package.json` dependencies gain `"agora-rtm-sdk": "2.3.0"`, and `package-lock.json` updates. The SDK has a peer dependency on `agora-rtc-sdk-ng ^4.24.3`, which the installed 4.24.8 satisfies. + +- [ ] **Step 2: Write the failing test** + +Append to `tests/spaces-api.test.mjs`: + +```js +test("the Signaling SDK and its loader are served, and every page links to the Stoa", async () => { + const { instance, url } = await start(); + try { + const sdk = await request(url, "/assets/agora-rtm.js"); + assert.equal(sdk.status, 200); + assert.match(sdk.headers.get("content-type"), /text\/javascript/); + assert.match(await sdk.text(), /AgoraRTM/); + const loader = await request(url, "/world/sdk.js"); + assert.equal(loader.status, 200); + for (const page of ["/", "/explore.html", "/services.html", "/radar.html", "/meetings.html", "/account.html", "/stoa/"]) { + const html = await (await request(url, page)).text(); + assert.match(html, /]*>Stoa<\/a>/, page); + assert.doesNotMatch(html, />Rooms<\/a>/, page); + } + assert.match(await (await request(url, "/stoa/")).text(), /Stoa<\/a>/); + } finally { await close(instance); } +}); +``` + +In `tests/server.test.mjs`, in the production output test: +1. Add `"world/sdk.js"` to the `files` array. +2. Inside its `try` block, add: + +```js + assert.equal((await request("/assets/agora-rtm.js", { base: built.url })).status, 200); +``` + +- [ ] **Step 3: Run tests to verify they fail** + +Run: `node --test tests/spaces-api.test.mjs` +Expected: FAIL. `/assets/agora-rtm.js` returns 404. + +- [ ] **Step 4: Implement** + +In `scripts/serve.mjs`, replace line 24 (`const sdkFile = createRequire(import.meta.url).resolve("agora-rtc-sdk-ng");`) with: + +```js +// Vendored browser SDKs are served from node_modules in development and from dist/assets after a build. +const vendor = { "assets/agora-rtc.js": createRequire(import.meta.url).resolve("agora-rtc-sdk-ng"), "assets/agora-rtm.js": createRequire(import.meta.url).resolve("agora-rtm-sdk") }; +``` + +Replace the read in the static handler (`const content = await readFile(filename === "assets/agora-rtc.js" && !existsSync(file) ? sdkFile : file);`) with: + +```js + const content = await readFile(Object.hasOwn(vendor, filename) && !existsSync(file) ? vendor[filename] : file); +``` + +Replace any other use of `sdkFile` in the file with `vendor["assets/agora-rtc.js"]`. Add `"assets/agora-rtm.js"` and `"world/sdk.js"` to `publicFiles`. + +In `scripts/build.mjs`, after the line that copies `agora-rtc-sdk-ng` to `assets/agora-rtc.js`, add: + +```js +await copyFile(createRequire(import.meta.url).resolve("agora-rtm-sdk"), new URL("assets/agora-rtm.js", output)); +``` + +Create `world/sdk.js`: + +```js +// Loads the Agora RTC and Signaling SDKs once each, in order: the Signaling bundle reads window.AgoraRTC as it loads. +const loading = new Map(); +function script(src) { + if (!loading.has(src)) loading.set(src, new Promise((resolve, reject) => { + const element = document.createElement("script"); + element.src = src; element.async = true; + element.onload = () => resolve(); + element.onerror = () => { loading.delete(src); element.remove(); reject(new Error("Could not load " + src + ".")); }; + document.head.append(element); + })); + return loading.get(src); +} +export async function loadAgora() { + if (!window.AgoraRTC) await script("/assets/agora-rtc.js"); + if (!window.AgoraRTM) await script("/assets/agora-rtm.js"); + if (!window.AgoraRTC || !window.AgoraRTM) throw new Error("The Agora SDKs did not load."); + return { AgoraRTC: window.AgoraRTC, AgoraRTM: window.AgoraRTM }; +} +``` + +Nav: in each of the seven HTML files, replace the Rooms link with the Stoa link. The current markup is `Rooms` in six files and `Rooms` in `meetings.html`: + +```bash +sed -i -E 's#]*>Rooms#Stoa#' index.html explore.html services.html radar.html meetings.html account.html stoa.html +sed -i 's#Stoa#Stoa#' stoa.html +``` + +`meetings.html` keeps working at its URL until Plan C2 retires it. + +- [ ] **Step 5: Run tests to verify they pass** + +Run: `npm test` +Expected: PASS, including the server test that fetches every page's internal links. + +- [ ] **Step 6: Commit** + +```bash +git add package.json package-lock.json scripts/serve.mjs scripts/build.mjs world/sdk.js index.html explore.html services.html radar.html meetings.html account.html stoa.html tests/spaces-api.test.mjs tests/server.test.mjs +git commit -m "Serve the Signaling SDK and link the Stoa from every page" -m "🤖 Built with SMT " +``` + +--- + +### Task 2: The live protocol + +**Files:** +- Create: `world/protocol.js` +- Test: `tests/stoa-protocol.test.mjs` + +**Interfaces:** +- Consumes: `validPath`, `walkable` (`world/map.js`), `STEP` (`world/motion.js`). +- Produces: + - **Constants:** `MESSAGE_LIMIT = 8192`, `TEXT_LIMIT = 500`, `SKEW_LIMIT = 2000`, `STRAIGHT_LIMIT = 40`. + - **`actorFromUser(userId)`:** maps `"a-"` to `"account:"` and `"m-"` to `"member:"`; anything else (including `g-`) gives `null`. + - **`readMessage(text, map)`:** returns a validated message or `null`: + - `{ t: "move", path, startedAt }` (path checked with `validPath`) + - `{ t: "walk", from, dir, startedAt }` + - `{ t: "stop", at }` + - `{ t: "face", dir }` + - `{ t: "say", text }` (trimmed, 1–500 characters) + - `{ t: "refresh" }` + - **`writeMessage(message)`:** returns a JSON string. + - **`writeState({ x, y, dir, name })`:** returns a state of string values, with the name cut to 60 characters. + - **`readState(states, map)`:** returns `{ x, y, dir, name }` or `null` when the tile is not walkable or the values are missing. + - **`trusted(publisher, { self, present: Set, blocked: Set })`:** returns a boolean. It is false for yourself, for `g-` users, for publishers absent from `present`, and for anyone whose actor id is in `blocked`. + - **`straightPath(map, from, dir, extra = new Set(), limit = STRAIGHT_LIMIT)`:** returns the tiles from `from` stepping in `dir` while walkable, `from` included. + - **`stopAt(walk, at)`:** returns the walk truncated at `at`, or `null` when `at` is not on its path. + - **`shared(startedAt, now)`:** returns `startedAt`, or `now` when the difference is more than `SKEW_LIMIT`. + - **`createLimiter({ perSecond, burst = perSecond, now, later })`:** returns `{ send(action, { latest = false } = {}) -> boolean }`. + - It runs `action` when a token is free. + - Otherwise, with `latest`, it keeps only the newest pending action and runs it when a token frees up. Without `latest`, it drops the action and returns `false`. + +- [ ] **Step 1: Write the failing test** + +Create `tests/stoa-protocol.test.mjs`: + +```js +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; +import { parseMap } from "../world/map.js"; +import { MESSAGE_LIMIT, SKEW_LIMIT, STRAIGHT_LIMIT, TEXT_LIMIT, actorFromUser, createLimiter, readMessage, readState, shared, stopAt, straightPath, trusted, writeMessage, writeState } from "../world/protocol.js"; + +const plaza = parseMap(JSON.parse(await readFile(new URL("../worlds/plaza/map.json", import.meta.url), "utf8"))); +const uuid = "72a639ba-3a45-4afe-936b-111111111111"; + +test("limits are fixed", () => { + assert.deepEqual([MESSAGE_LIMIT, TEXT_LIMIT, SKEW_LIMIT, STRAIGHT_LIMIT], [8192, 500, 2000, 40]); +}); +test("Signaling user IDs map back to portal actors", () => { + assert.equal(actorFromUser("a-" + uuid), "account:" + uuid); + assert.equal(actorFromUser("m-" + uuid), "member:" + uuid); + for (const other of ["g-0123456789abcdef", "a-nope", "x-" + uuid, "", null]) assert.equal(actorFromUser(other), null, String(other)); +}); +test("only well-formed messages are accepted", () => { + const move = { t: "move", path: [{ x: 21, y: 17 }, { x: 21, y: 16 }], startedAt: 1000 }; + assert.deepEqual(readMessage(writeMessage({ ...move, extra: "dropped" }), plaza), move); + assert.deepEqual(readMessage(writeMessage({ t: "walk", from: { x: 21, y: 17 }, dir: "up", startedAt: 5 }), plaza), { t: "walk", from: { x: 21, y: 17 }, dir: "up", startedAt: 5 }); + assert.deepEqual(readMessage(writeMessage({ t: "stop", at: { x: 21, y: 15 } }), plaza), { t: "stop", at: { x: 21, y: 15 } }); + assert.deepEqual(readMessage(writeMessage({ t: "face", dir: "left" }), plaza), { t: "face", dir: "left" }); + assert.deepEqual(readMessage(writeMessage({ t: "say", text: " hello " }), plaza), { t: "say", text: "hello" }); + assert.deepEqual(readMessage(writeMessage({ t: "refresh" }), plaza), { t: "refresh" }); + for (const bad of [ + "not json", "null", "[]", writeMessage({ t: "dance" }), + writeMessage({ ...move, path: [{ x: 21, y: 17 }, { x: 21, y: 15 }] }), + writeMessage({ ...move, path: [{ x: 0, y: 0 }] }), + writeMessage({ ...move, startedAt: "soon" }), + writeMessage({ t: "walk", from: { x: 0, y: 0 }, dir: "up", startedAt: 1 }), + writeMessage({ t: "walk", from: { x: 21, y: 17 }, dir: "north", startedAt: 1 }), + writeMessage({ t: "stop", at: { x: 1.5, y: 2 } }), + writeMessage({ t: "say", text: " " }), + writeMessage({ t: "say", text: "x".repeat(501) }), + "x".repeat(8193) + ]) assert.equal(readMessage(bad, plaza), null, bad.slice(0, 60)); +}); +test("presence state is strings out and validated values in", () => { + assert.deepEqual(writeState({ x: 21, y: 17, dir: "up", name: "Ada" + "x".repeat(80) }), { x: "21", y: "17", dir: "up", name: "Ada" + "x".repeat(57) }); + assert.deepEqual(readState({ x: "21", y: "17", dir: "up", name: " Ada " }, plaza), { x: 21, y: 17, dir: "up", name: "Ada" }); + assert.deepEqual(readState({ x: "21", y: "17", dir: "sideways" }, plaza), { x: 21, y: 17, dir: "down", name: "Builder" }); + for (const bad of [null, {}, { x: "0", y: "0" }, { x: "a", y: "1" }, { x: "21.5", y: "17" }]) assert.equal(readState(bad, plaza), null, JSON.stringify(bad)); +}); +test("receivers trust only present, signed-in, unblocked publishers", () => { + const present = new Set(["a-" + uuid, "m-" + uuid, "g-0123456789abcdef"]); + const context = { self: "a-self", present, blocked: new Set() }; + assert.equal(trusted("a-" + uuid, context), true); + assert.equal(trusted("a-self", { ...context, present: new Set(["a-self"]) }), false); + assert.equal(trusted("g-0123456789abcdef", context), false); + assert.equal(trusted("a-absent", context), false); + assert.equal(trusted("m-" + uuid, { ...context, blocked: new Set(["member:" + uuid]) }), false); +}); +test("keyboard walks go straight until blocked and stop on a tile of the path", () => { + const up = straightPath(plaza, { x: 21, y: 17 }, "up"); + assert.deepEqual(up.map((tile) => tile.y), [17, 16, 15, 14, 13, 12]); + assert.deepEqual(straightPath(plaza, { x: 21, y: 18 }, "down"), [{ x: 21, y: 18 }]); + assert.equal(straightPath(plaza, { x: 1, y: 18 }, "right").length, STRAIGHT_LIMIT + 1, "a long open row is capped"); + assert.deepEqual(straightPath(plaza, { x: 21, y: 17 }, "up", new Set(["21,15"])).map((tile) => tile.y), [17, 16]); + const walk = { path: up, startedAt: 10, dir: "up" }; + assert.deepEqual(stopAt(walk, { x: 21, y: 15 }), { path: up.slice(0, 3), startedAt: 10, dir: "up" }); + assert.equal(stopAt(walk, { x: 5, y: 5 }), null); +}); +test("start times from skewed clocks fall back to the receiver's clock", () => { + assert.equal(shared(10000, 11000), 10000); + assert.equal(shared(10000, 12001), 12001); + assert.equal(shared(15000, 12000), 12000); +}); +test("the limiter allows a burst, drops extra chat, and keeps only the latest pending move", () => { + let time = 0; const timers = []; const sent = []; + const limiter = createLimiter({ perSecond: 4, burst: 2, now: () => time, later: (callback, delay) => { timers.push({ callback, delay }); return timers.length; } }); + assert.equal(limiter.send(() => sent.push("a")), true); + assert.equal(limiter.send(() => sent.push("b")), true); + assert.equal(limiter.send(() => sent.push("dropped")), false); + assert.equal(limiter.send(() => sent.push("old"), { latest: true }), true); + assert.equal(limiter.send(() => sent.push("new"), { latest: true }), true); + assert.deepEqual(sent, ["a", "b"]); + assert.equal(timers.length, 1); + assert.equal(timers[0].delay, 250); + time = 250; timers[0].callback(); + assert.deepEqual(sent, ["a", "b", "new"]); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `node --test tests/stoa-protocol.test.mjs` +Expected: FAIL with `Cannot find module` for `world/protocol.js`. + +- [ ] **Step 3: Implement** + +Create `world/protocol.js`: + +```js +import { validPath, walkable } from "./map.js"; +import { STEP } from "./motion.js"; + +// The live protocol on a space channel: what clients publish, what receivers accept, and how keyboard walks are shared. +export const MESSAGE_LIMIT = 8192; +export const TEXT_LIMIT = 500; +export const SKEW_LIMIT = 2000; +export const STRAIGHT_LIMIT = 40; +const DIRS = Object.keys(STEP); +const isTile = (value) => Boolean(value) && Number.isInteger(value.x) && Number.isInteger(value.y); +const copy = ({ x, y }) => ({ x, y }); + +export const actorFromUser = (userId) => /^a-[a-f0-9-]{36}$/.test(userId || "") ? "account:" + userId.slice(2) : /^m-[a-f0-9-]{36}$/.test(userId || "") ? "member:" + userId.slice(2) : null; + +export function readMessage(text, map) { + if (typeof text !== "string" || text.length > MESSAGE_LIMIT) return null; + let data; + try { data = JSON.parse(text); } catch { return null; } + if (!data || typeof data !== "object" || Array.isArray(data)) return null; + switch (data.t) { + case "move": return Array.isArray(data.path) && data.path.every(isTile) && validPath(map, data.path) && Number.isFinite(data.startedAt) ? { t: "move", path: data.path.map(copy), startedAt: data.startedAt } : null; + case "walk": return isTile(data.from) && walkable(map, data.from.x, data.from.y) && DIRS.includes(data.dir) && Number.isFinite(data.startedAt) ? { t: "walk", from: copy(data.from), dir: data.dir, startedAt: data.startedAt } : null; + case "stop": return isTile(data.at) && walkable(map, data.at.x, data.at.y) ? { t: "stop", at: copy(data.at) } : null; + case "face": return DIRS.includes(data.dir) ? { t: "face", dir: data.dir } : null; + case "say": { const say = typeof data.text === "string" ? data.text.trim() : ""; return say && data.text.length <= TEXT_LIMIT ? { t: "say", text: say } : null; } + case "refresh": return { t: "refresh" }; + default: return null; + } +} +export const writeMessage = (message) => JSON.stringify(message); + +export const writeState = ({ x, y, dir, name }) => ({ x: String(x), y: String(y), dir: String(dir), name: String(name).slice(0, 60) }); +export function readState(states, map) { + if (!states || typeof states !== "object") return null; + const x = Number(states.x), y = Number(states.y); + if (states.x === undefined || states.y === undefined || !Number.isInteger(x) || !Number.isInteger(y) || !walkable(map, x, y)) return null; + const name = typeof states.name === "string" ? states.name.trim().slice(0, 60) : ""; + return { x, y, dir: DIRS.includes(states.dir) ? states.dir : "down", name: name || "Builder" }; +} + +export const trusted = (publisher, { self, present, blocked }) => typeof publisher === "string" && publisher !== self && !publisher.startsWith("g-") && present.has(publisher) && !blocked.has(actorFromUser(publisher)); + +export function straightPath(map, from, dir, extra = new Set(), limit = STRAIGHT_LIMIT) { + const [dx, dy] = STEP[dir], path = [copy(from)]; + while (path.length <= limit) { + const last = path[path.length - 1], next = { x: last.x + dx, y: last.y + dy }; + if (!walkable(map, next.x, next.y, extra)) break; + path.push(next); + } + return path; +} +export function stopAt(walk, at) { + const index = walk.path.findIndex((step) => step.x === at.x && step.y === at.y); + return index < 0 ? null : { ...walk, path: walk.path.slice(0, index + 1) }; +} +export const shared = (startedAt, now) => Math.abs(startedAt - now) > SKEW_LIMIT ? now : startedAt; + +export function createLimiter({ perSecond, burst = perSecond, now = () => Date.now(), later = (callback, delay) => setTimeout(callback, delay) }) { + let tokens = burst, last = now(), waiting = null, timer = null; + const refill = () => { const time = now(); tokens = Math.min(burst, tokens + ((time - last) / 1000) * perSecond); last = time; }; + const wait = () => Math.ceil(((1 - tokens) / perSecond) * 1000); + function flush() { + timer = null; refill(); + if (waiting && tokens >= 1) { tokens -= 1; const action = waiting; waiting = null; action(); } + else if (waiting) timer = later(flush, wait()); + } + return { + send(action, { latest = false } = {}) { + refill(); + if (!waiting && tokens >= 1) { tokens -= 1; action(); return true; } + if (!latest) return false; + waiting = action; + if (timer === null) timer = later(flush, wait()); + return true; + } + }; +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `node --test tests/stoa-protocol.test.mjs` +Expected: PASS (9 tests). + +- [ ] **Step 5: Commit** + +```bash +git add world/protocol.js tests/stoa-protocol.test.mjs +git commit -m "Add the live Stoa protocol" -m "🤖 Built with SMT " +``` + +--- + +### Task 3: Payload encryption + +**Files:** +- Create: `world/crypto.js` +- Test: `tests/stoa-protocol.test.mjs` (append) + +**Interfaces:** +- Produces: + - `importKey(base64) -> Promise` (AES-GCM, 32 raw bytes) + - `seal(key, text) -> Promise`, which returns `"e1." + base64(12-byte iv ‖ ciphertext)` with a fresh random iv each time + - `open(key, value) -> Promise`, which returns `null` for anything that isn't a valid `e1.` value for that key (wrong key, tampered, plaintext, bad base64) +- It uses `globalThis.crypto` (WebCrypto), `atob`, `btoa`, `TextEncoder`, and `TextDecoder`, which are available in browsers and Node 20. + +- [ ] **Step 1: Write the failing test** + +Append to `tests/stoa-protocol.test.mjs`: + +```js +import { randomBytes } from "node:crypto"; +import { importKey, open, seal } from "../world/crypto.js"; + +test("sealed payloads open only with the same space key", async () => { + const keyText = randomBytes(32).toString("base64"), otherText = randomBytes(32).toString("base64"); + const key = await importKey(keyText), other = await importKey(otherText); + const first = await seal(key, "hello plaza"), second = await seal(key, "hello plaza"); + assert.match(first, /^e1\.[A-Za-z0-9+/=]+$/); + assert.notEqual(first, second, "every seal uses a fresh iv"); + assert.equal(await open(key, first), "hello plaza"); + assert.equal(await open(other, first), null); + assert.equal(await open(key, first.slice(0, -4) + "AAAA"), null); + for (const bad of ["hello plaza", "e1.", "e1.!!!", null, 42]) assert.equal(await open(key, bad), null, String(bad)); + assert.equal(await open(key, await seal(key, "名字 ✓")), "名字 ✓"); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `node --test tests/stoa-protocol.test.mjs` +Expected: FAIL with `Cannot find module` for `world/crypto.js`. + +- [ ] **Step 3: Implement** + +Create `world/crypto.js`: + +```js +// End-to-end payload encryption for unlisted and private spaces. The server derives one key per space and channel epoch. +const toBytes = (base64) => Uint8Array.from(atob(base64), (char) => char.charCodeAt(0)); +const toBase64 = (bytes) => { let text = ""; for (const byte of bytes) text += String.fromCharCode(byte); return btoa(text); }; + +export const importKey = (base64) => globalThis.crypto.subtle.importKey("raw", toBytes(base64), "AES-GCM", false, ["encrypt", "decrypt"]); + +export async function seal(key, text) { + const iv = globalThis.crypto.getRandomValues(new Uint8Array(12)); + const sealed = new Uint8Array(await globalThis.crypto.subtle.encrypt({ name: "AES-GCM", iv }, key, new TextEncoder().encode(text))); + const out = new Uint8Array(12 + sealed.length); + out.set(iv); out.set(sealed, 12); + return "e1." + toBase64(out); +} + +export async function open(key, value) { + if (typeof value !== "string" || !value.startsWith("e1.")) return null; + try { + const data = toBytes(value.slice(3)); + if (data.length < 29) return null; + return new TextDecoder().decode(await globalThis.crypto.subtle.decrypt({ name: "AES-GCM", iv: data.slice(0, 12) }, key, data.slice(12))); + } catch { return null; } +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `node --test tests/stoa-protocol.test.mjs` +Expected: PASS (10 tests). + +- [ ] **Step 5: Commit** + +```bash +git add world/crypto.js tests/stoa-protocol.test.mjs +git commit -m "Encrypt payloads for unlisted and private spaces" -m "🤖 Built with SMT " +``` + +--- +### Task 4: Engine — keyboard walks, teleport, input control, and speech bubbles + +**Files:** +- Modify: `world/engine.js` (replace the whole file), `world/renderer-canvas.js` (add speech bubbles) +- Test: `tests/world-engine.test.mjs` (adjust setup, replace two tests, append), `tests/world-renderer.test.mjs` (append) + +**Interfaces:** +- Consumes: `straightPath` (`world/protocol.js`, Task 2). +- Produces engine changes. Everything else from Plan B is unchanged. + - **Events:** + - `on("walk" | "stop" | "face", listener)` joins `move` and `arrive`. + - A held key emits one `walk { from, dir, startedAt }` for a straight path until blocked. + - Releasing the key (or blur, or turning input off) emits `stop { at }` at the next tile boundary. + - A key toward a wall emits `face { dir }`. + - Pointer walks still emit `move { path, startedAt }`. + - **New methods:** + - `teleport(tile, dir = "down")` places you without walking and emits nothing. + - `facing() -> dir` + - `setInteractive(boolean)` ignores pointer and keys when false; turning input off stops a keyboard walk. + - `say(text, ms = 8000)` shows your own bubble. + - **`setOthers`:** entries may carry `bubble: { text, until }`. The renderer receives `avatar.bubble` (text or null). + - **Pointer clicks:** clicks on tiles outside `setBounds` are ignored. + - **`setDecor`:** when blocking decor lands on your remaining route, a pointer walk re-routes (emits `move`) and a keyboard walk stops before it (emits `stop`). +- Renderer: `draw` draws a speech bubble above any avatar with `bubble` text. Lines wrap at about 160 px, with at most 3 lines and an ellipsis. + +- [ ] **Step 1: Update the tests** + +In `tests/world-engine.test.mjs`: +1. Change the `setup` signature to `function setup({ reducedMotion = false, theme = agora } = {})` and pass `theme` (instead of `agora`) to `createEngine`. +2. Change the events object to `{ move: [], arrive: [], walk: [], stop: [], face: [] }`. +3. Replace the two `engine.on("move" ...)` and `engine.on("arrive" ...)` lines with: + +```js + for (const type of Object.keys(events)) engine.on(type, (value) => events[type].push(value)); +``` + +Delete the tests titled "holding a direction key keeps walking until it is released" and "a blocked step only turns the walker", then append: + +```js +test("a held key publishes one walk and its release publishes one stop", () => { + const { engine, handlers, events, flush, advance } = setup(); + handlers.keydown(key("ArrowUp")); + assert.equal(events.move.length, 0); + assert.deepEqual(events.walk, [{ from: { x: 21, y: 17 }, dir: "up", startedAt: 1000 }]); + handlers.keydown(key("ArrowUp")); + assert.equal(events.walk.length, 1, "key repeat does not restart the walk"); + advance(300); flush(); + handlers.keyup(key("ArrowUp")); + assert.deepEqual(events.stop, [{ at: { x: 21, y: 15 } }]); + advance(1000); flush(); + assert.deepEqual(engine.position(), { x: 21, y: 15 }); + assert.deepEqual(events.arrive.at(-1), { x: 21, y: 15 }); +}); +test("a key toward a wall only turns you and says so", () => { + const { engine, handlers, events, flush, advance, drawn } = setup(); + engine.walkTo({ x: 1, y: 18 }); advance(10000); flush(); + handlers.keydown(key("s")); flush(); + assert.deepEqual(events.face, [{ dir: "down" }]); + assert.equal(events.walk.length, 0); + assert.equal(drawn.at(-1).avatars[0].dir, "down"); +}); +test("teleporting, turning input off, and clicks outside the bounds", () => { + const { engine, handlers, events, flush, drawn } = setup(); + engine.teleport({ x: 17, y: 31 }, "up"); + engine.setBounds({ x: 15, y: 21, width: 6, height: 12 }); + flush(); + assert.deepEqual([engine.position(), engine.facing(), events.move.length], [{ x: 17, y: 31 }, "up", 0]); + const { camera } = drawn.at(-1); + const click = (tile) => handlers.pointerup({ button: 0, clientX: (tile.x * 32 + 16 - camera.x) * camera.zoom, clientY: (tile.y * 32 + 16 - camera.y) * camera.zoom }); + click({ x: 22, y: 25 }); + assert.equal(events.move.length, 0, "a tile outside the bounds is ignored"); + click({ x: 16, y: 29 }); + assert.equal(events.move.length, 1); + engine.setInteractive(false); + click({ x: 16, y: 28 }); handlers.keydown(key("ArrowUp")); + assert.deepEqual([events.move.length, events.walk.length], [1, 0]); +}); +test("turning input off ends a keyboard walk", () => { + const { engine, handlers, events } = setup(); + handlers.keydown(key("ArrowUp")); + engine.setInteractive(false); + assert.deepEqual(events.stop, [{ at: { x: 21, y: 17 } }]); +}); +test("speech bubbles show for a while, then the engine redraws without them", () => { + const { engine, drawn, flush, advance, timers } = setup({ theme: minimal }); + assert.equal(timers.length, 0); + engine.say("Hello plaza"); + engine.setOthers([{ id: "bo", name: "Bo", walk: { path: [{ x: 22, y: 17 }], startedAt: 0 }, bubble: { text: "Hi!", until: 4000 } }]); + flush(); + assert.deepEqual(drawn.at(-1).avatars.map((avatar) => avatar.bubble), ["Hello plaza", "Hi!"]); + assert.equal(timers.length, 1, "a timer wakes the engine when the first bubble ends"); + advance(3001); timers[0](); flush(); + assert.deepEqual(drawn.at(-1).avatars.map((avatar) => avatar.bubble), ["Hello plaza", null]); + advance(5000); timers[1](); flush(); + assert.deepEqual(drawn.at(-1).avatars.map((avatar) => avatar.bubble), [null, null]); +}); +test("a decoration placed on your route sends you around it", () => { + const { engine, events, flush, advance } = setup(); + engine.walkTo({ x: 21, y: 12 }); advance(100); flush(); + engine.setDecor([{ kind: "statue", x: 21, y: 14 }]); + assert.equal(events.move.length, 2); + const route = events.move.at(-1).path; + assert.equal(route.some((step) => step.x === 21 && step.y === 14), false); + assert.deepEqual(route.at(-1), { x: 21, y: 12 }); +}); +test("a decoration in front of a keyboard walk stops it before the decoration", () => { + const { engine, handlers, events } = setup(); + handlers.keydown(key("ArrowUp")); + engine.setDecor([{ kind: "statue", x: 21, y: 14 }]); + assert.deepEqual(events.stop, [{ at: { x: 21, y: 15 } }]); +}); +``` + +Append to `tests/world-renderer.test.mjs`: + +```js +test("speech bubbles are drawn above avatars that are talking", () => { + const { calls } = render(agora, { avatars: [{ id: "a", name: "Ada", x: 5, y: 5, dir: "down", bubble: "Hello there" }, { id: "b", name: "Bo", x: 7, y: 5, dir: "down" }], motion: false }); + const texts = calls.filter(([name]) => name === "fillText").map(([, text]) => text); + assert.equal(texts.filter((text) => text === "Hello there").length, 1); +}); +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `node --test tests/world-engine.test.mjs tests/world-renderer.test.mjs` +Expected: FAIL. There are no `walk`/`face` events, and `teleport`, `setInteractive` and `say` are not functions. No bubble text is drawn. + +- [ ] **Step 3: Implement** + +Replace `world/engine.js` with: + +```js +import { findPath, validPath } from "./map.js"; +import { BLOCKING_DECOR } from "./kinds.js"; +import { follow, screenToTile, zoomFor } from "./camera.js"; +import { KEYS, WALK_SPEED, positionAt } from "./motion.js"; +import { straightPath } from "./protocol.js"; +import { createCanvasRenderer } from "./renderer-canvas.js"; + +// Times are wall-clock (Date.now) because walk start times are published, so peers share the clock. +// One person's movement, input, camera, and drawing. Other people arrive through setOthers. +// Events: "move" { path, startedAt } for pointer walks; "walk" { from, dir, startedAt } and "stop" { at } for keyboard +// walks; "face" { dir } when a key only turns you; "arrive" { x, y } when a walk ends. +export function createEngine({ canvas, map, theme, start, reducedMotion = false, renderer = createCanvasRenderer(canvas, { map, theme }), now = () => Date.now(), raf = (callback) => requestAnimationFrame(callback), later = (callback, delay) => setTimeout(callback, delay), cancel = (handle) => clearTimeout(handle), ratio = () => globalThis.devicePixelRatio || 1 }) { + const listeners = { arrive: [], move: [], walk: [], stop: [], face: [] }; + const self = { id: "self", name: "You", walk: { path: [start], startedAt: now(), dir: "down" }, arrived: true, keyboard: false, bubble: null }; + let current = theme, others = [], decor = [], labels = [], held = null, pending = false, timer = null, destroyed = false, interactive = true; + let bounds = null, camera = { x: 0, y: 0, zoom: 1 }, viewport = { width: 1, height: 1 }; + const emit = (type, value) => { for (const listener of listeners[type]) listener(value); }; + const here = (time = now()) => positionAt(self.walk, time); + const blocked = () => new Set(decor.filter((item) => BLOCKING_DECOR.has(item.kind)).map((item) => item.x + "," + item.y)); + const ambient = () => !reducedMotion && (current.effects.some((effect) => effect !== "neon-glow") || (map.roles.includes("water") && current.roles.water?.pattern === "water")); + const sound = (other) => Boolean(other) && Boolean(other.walk) && Number.isFinite(other.walk.startedAt) && Array.isArray(other.walk.path) && other.walk.path.every((step) => step && Number.isInteger(step.x) && Number.isInteger(step.y)) && validPath(map, other.walk.path); + const speech = (bubble, time) => bubble && bubble.until > time ? bubble.text : null; + const inBounds = (tile) => !bounds || (tile.x >= bounds.x && tile.y >= bounds.y && tile.x < bounds.x + bounds.width && tile.y < bounds.y + bounds.height); + const progress = (walk, time) => { const travelled = Math.max(0, ((time - walk.startedAt) / 1000) * WALK_SPEED); return { travelled, index: Math.floor(travelled), part: travelled - Math.floor(travelled) }; }; + const schedule = () => { if (!destroyed && !pending) { pending = true; raf(frame); } }; + + function frame() { + pending = false; + if (destroyed) return; + const time = now(), s = map.tileSize; + const area = bounds || { x: 0, y: 0, width: map.width, height: map.height }; + const reached = here(time); + if (reached.done && !self.arrived) { self.arrived = true; self.keyboard = false; emit("arrive", { x: reached.x, y: reached.y }); if (held) keyWalk(held); } + const shown = here(time); + camera = follow({ x: (shown.x + 0.5) * s, y: (shown.y + 0.5) * s }, viewport, { x: area.x * s, y: area.y * s, width: area.width * s, height: area.height * s }, zoomFor(viewport, s)); + const crowd = others.map((other) => ({ id: other.id, name: other.name, bubble: speech(other.bubble, time), ...positionAt(other.walk, time) })); + renderer.draw({ camera, time, avatars: [{ id: self.id, name: self.name, self: true, bubble: speech(self.bubble, time), ...shown }, ...crowd], decor, labels, bounds, motion: !reducedMotion }); + const talking = [self.bubble, ...others.map((other) => other.bubble)].filter((bubble) => bubble && bubble.until > time); + if (!shown.done || crowd.some((other) => !other.done)) schedule(); + else if (timer === null && (ambient() || talking.length)) timer = later(() => { timer = null; schedule(); }, ambient() ? 66 : Math.max(50, Math.min(...talking.map((bubble) => bubble.until - time)) + 10)); + } + + function walkTo(target) { + const time = now(), position = here(time), walk = self.walk, avoid = blocked(); + let route; + if (position.done && !self.arrived) { self.arrived = true; emit("arrive", { x: position.x, y: position.y }); } + if (position.done) { + if (target.x === position.x && target.y === position.y) return false; + const path = findPath(map, { x: Math.round(position.x), y: Math.round(position.y) }, target, avoid); + if (!path) return false; + route = { path, startedAt: time }; + } else { + // Mid-walk: keep the segment in progress so the avatar does not snap to a tile, unless that tile is now blocked. + const { index, part } = progress(walk, time), next = walk.path[index + 1]; + if (avoid.has(next.x + "," + next.y)) { + const path = findPath(map, walk.path[index], target, avoid); + if (!path) return false; + route = { path, startedAt: time }; + } else { + const ahead = findPath(map, next, target, avoid); + if (!ahead) return false; + route = { path: [walk.path[index], ...ahead], startedAt: time - (part * 1000) / WALK_SPEED }; + } + } + self.walk = { ...route, dir: position.dir }; + self.arrived = false; self.keyboard = false; + emit("move", route); + schedule(); + return true; + } + + // Keyboard walks are shared as one "walk" (straight until blocked) and one "stop", so a held key sends two messages. + function keyWalk(dir) { + const time = now(), position = here(time); + if (!position.done) return; + const from = { x: position.x, y: position.y }, path = straightPath(map, from, dir, blocked()); + if (path.length < 2) { self.walk = { path: [from], startedAt: time, dir }; emit("face", { dir }); schedule(); return; } + self.walk = { path, startedAt: time, dir }; + self.arrived = false; self.keyboard = true; + emit("walk", { from, dir, startedAt: time }); + schedule(); + } + function keyStop() { + if (!self.keyboard) return; + self.keyboard = false; + const walk = self.walk, index = Math.min(walk.path.length - 1, Math.ceil(progress(walk, now()).travelled)), at = walk.path[index]; + self.walk = { ...walk, path: walk.path.slice(0, index + 1) }; + emit("stop", { at: { x: at.x, y: at.y } }); + schedule(); + } + + const onPointer = (event) => { + if (!interactive || event.button) return; + const box = canvas.getBoundingClientRect(), tile = screenToTile(camera, event.clientX - box.left, event.clientY - box.top, map.tileSize); + if (inBounds(tile)) walkTo(tile); + }; + const onKeyDown = (event) => { + const dir = KEYS[event.key]; + if (!interactive || !dir || event.ctrlKey || event.metaKey || event.altKey) return; + event.preventDefault(); + if (held === dir) return; + keyStop(); + held = dir; + keyWalk(dir); + }; + const onKeyUp = (event) => { if (KEYS[event.key] === held) { held = null; keyStop(); } }; + const onBlur = () => { held = null; keyStop(); }; + const inputs = [["pointerup", onPointer], ["keydown", onKeyDown], ["keyup", onKeyUp], ["blur", onBlur]]; + for (const [type, listener] of inputs) canvas.addEventListener(type, listener); + + const engine = { + walkTo, + position() { const position = here(); return { x: Math.round(position.x), y: Math.round(position.y) }; }, + facing() { return here().dir; }, + teleport(tile, dir = "down") { held = null; self.keyboard = false; self.walk = { path: [{ x: tile.x, y: tile.y }], startedAt: now(), dir }; self.arrived = true; schedule(); }, + on(type, listener) { if (!Object.hasOwn(listeners, type)) throw new Error("Unknown engine event: " + type); listeners[type].push(listener); return () => { listeners[type] = listeners[type].filter((item) => item !== listener); }; }, + say(text, ms = 8000) { self.bubble = { text: String(text), until: now() + ms }; schedule(); }, + setInteractive(value) { interactive = Boolean(value); if (!interactive) { held = null; keyStop(); } }, + setSelf({ id, name }) { if (id) self.id = id; self.name = name; schedule(); }, + setTheme(next) { current = next; renderer.setTheme(next); schedule(); }, + setBounds(rect) { bounds = rect || null; schedule(); }, + setLabels(list) { labels = list; schedule(); }, + setDecor(list) { + decor = list; + const time = now(), position = here(time), walk = self.walk; + if (!position.done) { + const avoid = blocked(), { index } = progress(walk, time); + const hit = walk.path.findIndex((step, at) => at > index && avoid.has(step.x + "," + step.y)); + if (hit > 0 && self.keyboard) { self.keyboard = false; self.walk = { ...walk, path: walk.path.slice(0, hit) }; const at = walk.path[hit - 1]; emit("stop", { at: { x: at.x, y: at.y } }); } + else if (hit > 0 && !walkTo(walk.path[walk.path.length - 1])) { const at = { x: walk.path[index].x, y: walk.path[index].y }; self.walk = { path: [at], startedAt: time, dir: position.dir }; emit("move", { path: [at], startedAt: time }); } + } + schedule(); + }, + setOthers(list) { others = list.filter(sound); schedule(); }, + resize() { viewport = { width: canvas.clientWidth || 1, height: canvas.clientHeight || 1 }; renderer.resize(viewport.width, viewport.height, ratio()); schedule(); }, + destroy() { destroyed = true; for (const [type, listener] of inputs) canvas.removeEventListener(type, listener); if (timer !== null) cancel(timer); timer = null; } + }; + engine.resize(); + return engine; +} +``` + +In `world/renderer-canvas.js`: +1. Add this function after `label(...)`: + +```js +// A speech bubble above an avatar: up to three wrapped lines, the last ending with an ellipsis when cut. +function bubble(ctx, text, cx, bottom, font, theme) { + ctx.font = "500 11px " + font; + const lines = []; + for (const word of String(text).split(/\s+/).filter(Boolean)) { + const last = lines.length ? lines[lines.length - 1] + " " + word : null; + if (last !== null && ctx.measureText(last).width <= 160) lines[lines.length - 1] = last; else lines.push(word); + } + const shown = lines.slice(0, 3); + if (lines.length > 3) shown[2] = shown[2] + "…"; + const width = Math.min(176, Math.max(...shown.map((line) => ctx.measureText(line).width)) + 12), height = shown.length * 14 + 8, left = cx - width / 2, top = bottom - height - 6; + const surface = theme.ui?.["--surface"] || "#ffffff"; + box(ctx, surface, left, top, width, height); + ctx.strokeStyle = theme.ui?.["--line"] || theme.avatar.outline; ctx.lineWidth = 1; ctx.strokeRect(left + 0.5, top + 0.5, width - 1, height - 1); + ctx.fillStyle = surface; ctx.beginPath(); ctx.moveTo(cx - 5, top + height); ctx.lineTo(cx + 5, top + height); ctx.lineTo(cx, top + height + 6); ctx.fill(); + ctx.fillStyle = theme.ui?.["--ink"] || "#272d28"; ctx.textAlign = "center"; ctx.textBaseline = "middle"; + shown.forEach((line, index) => ctx.fillText(line, cx, top + 11 + index * 14)); +} +``` + +2. At the end of `drawAvatar`, after its `label(...)` call, add: + +```js + if (avatar.bubble) bubble(ctx, avatar.bubble, cx, cy - s * 0.5, font, theme); +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `node --test tests/world-engine.test.mjs tests/world-renderer.test.mjs` +Expected: PASS. + +Run: `npm test` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add world/engine.js world/renderer-canvas.js tests/world-engine.test.mjs tests/world-renderer.test.mjs +git commit -m "Share keyboard walks as walk and stop, and add teleport, input control, and speech bubbles" -m "🤖 Built with SMT " +``` + +--- + +### Task 5: The Signaling client + +**Files:** +- Create: `world/signaling.js` +- Test: `tests/stoa-signaling.test.mjs` + +**Interfaces:** +- Consumes: `importKey`, `open`, `seal` (Task 3). It also needs an `AgoraRTM` object with `RTM` (`new AgoraRTM.RTM(appId, userId)`). The client provides: + - `addEventListener(type, handler)` for `message`, `presence`, `tokenPrivilegeWillExpire` and `linkState` + - `login({ token })`, `subscribe(name, { withMessage, withPresence, beQuiet })`, `unsubscribe(name)`, `publish(name, text)`, `renewToken(token)`, `logout()` + - `presence.setState(name, "MESSAGE", states)` and `presence.whoNow(name, "MESSAGE", { includedUserId, includedState, page })`, which return `{ occupants: [{ userId, states }], nextPage }` +- Produces `createLive({ AgoraRTM, fetchToken, onStatus = () => {} })`. `fetchToken() -> { appId, userId, token, channels }` is the server's `/api/signaling/token` response. It returns: + - **Properties:** `userId`, `guest` (whether the user ID starts with `g-`) + - **`connect() -> access`:** logs in once. + - **`renew() -> access`:** fetches a fresh token and calls `renewToken`. + - **`join(name, { spaceId, key = null }, { message(publisher, text), presence(change) })`:** subscribes with presence, and with `beQuiet` for guests. + - Messages from yourself, on unknown channels, or that fail to decrypt never reach the handlers. + - `change` is one of `{ type: "snapshot", people: [{ userId, states }] }`, `{ type: "join", userId }`, `{ type: "leave", userId }` or `{ type: "state", userId, states }`. `states` is decrypted on keyed channels; changes from yourself or with undecryptable states are dropped. + - **`leave(name)`** + - **`publish(name, text)`:** sealed on keyed channels; throws when not joined. + - **`setState(name, state)`:** sends `{ s: sealed JSON }` on keyed channels. + - **`who(name) -> [{ userId, states }]`:** follows `nextPage` (at most 50 pages) and skips yourself and undecryptable states. + - **`close()`** + - `tokenPrivilegeWillExpire` triggers `renew()`. + - Incoming events are handled one at a time in arrival order, so decryption never reorders them. + +- [ ] **Step 1: Write the failing test** + +Create `tests/stoa-signaling.test.mjs`: + +```js +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { randomBytes } from "node:crypto"; +import { createLive } from "../world/signaling.js"; +import { importKey, open, seal } from "../world/crypto.js"; + +const settle = () => new Promise((resolve) => setTimeout(resolve, 20)); +const me = "a-72a639ba-3a45-4afe-936b-111111111111", ada = "a-72a639ba-3a45-4afe-936b-222222222222"; + +function fakeAgora() { + const clients = []; + class RTM { + constructor(appId, userId) { + Object.assign(this, { appId, userId, calls: [], handlers: {}, pages: [] }); + const self = this; + this.presence = { + async setState(...args) { self.calls.push(["setState", ...args]); }, + async whoNow(...args) { self.calls.push(["whoNow", ...args]); return self.pages.shift() || { occupants: [], nextPage: "" }; } + }; + clients.push(this); + } + addEventListener(type, handler) { this.handlers[type] = handler; } + async login(options) { this.calls.push(["login", options]); } + async subscribe(...args) { this.calls.push(["subscribe", ...args]); } + async unsubscribe(...args) { this.calls.push(["unsubscribe", ...args]); } + async publish(...args) { this.calls.push(["publish", ...args]); } + async renewToken(token) { this.calls.push(["renewToken", token]); } + async logout() { this.calls.push(["logout"]); } + } + return { AgoraRTM: { RTM }, clients }; +} +async function connected(userId = me) { + const { AgoraRTM, clients } = fakeAgora(); + let issued = 0; + const live = createLive({ AgoraRTM, fetchToken: async () => ({ appId: "a".repeat(32), userId, token: "token-" + (++issued), channels: [] }) }); + await live.connect(); + return { live, client: clients[0] }; +} +const recorder = () => { const seen = { messages: [], changes: [] }; return { seen, handlers: { message: (publisher, text) => seen.messages.push([publisher, text]), presence: (change) => seen.changes.push(change) } }; }; + +test("connecting logs in once as the user from the token", async () => { + const { live, client } = await connected(); + assert.deepEqual([client.appId, client.userId, live.userId, live.guest], ["a".repeat(32), me, me, false]); + assert.deepEqual(client.calls, [["login", { token: "token-1" }]]); +}); +test("guests subscribe quietly; members do not", async () => { + const guest = await connected("g-0123456789abcdef"); + await guest.live.join("ab-stoa-plaza", { spaceId: "plaza" }, recorder().handlers); + assert.equal(guest.live.guest, true); + assert.deepEqual(guest.client.calls.at(-1), ["subscribe", "ab-stoa-plaza", { withMessage: true, withPresence: true, beQuiet: true }]); + const member = await connected(); + await member.live.join("ab-stoa-plaza", { spaceId: "plaza" }, recorder().handlers); + assert.equal(member.client.calls.at(-1)[2].beQuiet, false); +}); +test("plain channels carry text as is; keyed channels are sealed both ways", async () => { + const keyText = randomBytes(32).toString("base64"), key = await importKey(keyText); + const { live, client } = await connected(); + const plaza = recorder(), hidden = recorder(); + await live.join("ab-stoa-plaza", { spaceId: "plaza" }, plaza.handlers); + await live.join("ab-stoa-secret", { spaceId: "s1", key: keyText }, hidden.handlers); + await live.publish("ab-stoa-plaza", "hello"); + await live.publish("ab-stoa-secret", "psst"); + const published = client.calls.filter(([name]) => name === "publish"); + assert.deepEqual(published[0], ["publish", "ab-stoa-plaza", "hello"]); + assert.match(published[1][2], /^e1\./); + assert.equal(await open(key, published[1][2]), "psst"); + client.handlers.message({ channelName: "ab-stoa-plaza", publisher: ada, message: "hi" }); + client.handlers.message({ channelName: "ab-stoa-secret", publisher: ada, message: await seal(key, "secret hi") }); + client.handlers.message({ channelName: "ab-stoa-secret", publisher: ada, message: "not sealed" }); + client.handlers.message({ channelName: "ab-stoa-plaza", publisher: me, message: "my echo" }); + client.handlers.message({ channelName: "ab-stoa-other", publisher: ada, message: "stray" }); + await settle(); + assert.deepEqual(plaza.seen.messages, [[ada, "hi"]]); + assert.deepEqual(hidden.seen.messages, [[ada, "secret hi"]]); + await assert.rejects(live.publish("ab-stoa-missing", "x"), /Not in channel/); +}); +test("presence events become snapshot, join, leave, and state changes, decrypted where keyed", async () => { + const keyText = randomBytes(32).toString("base64"), key = await importKey(keyText); + const { live, client } = await connected(); + const hidden = recorder(); + await live.join("ab-stoa-secret", { spaceId: "s1", key: keyText }, hidden.handlers); + const sealed = async (state) => ({ s: await seal(key, JSON.stringify(state)) }); + client.handlers.presence({ channelName: "ab-stoa-secret", eventType: "SNAPSHOT", snapshot: [{ userId: ada, states: await sealed({ x: "1", y: "2" }) }, { userId: me, states: await sealed({ x: "3", y: "4" }) }, { userId: "a-bad", states: { s: "garbage" } }] }); + client.handlers.presence({ channelName: "ab-stoa-secret", eventType: "REMOTE_JOIN", publisher: ada }); + client.handlers.presence({ channelName: "ab-stoa-secret", eventType: "REMOTE_STATE_CHANGED", publisher: ada, stateChanged: await sealed({ x: "5", y: "6" }) }); + client.handlers.presence({ channelName: "ab-stoa-secret", eventType: "REMOTE_TIMEOUT", publisher: ada }); + client.handlers.presence({ channelName: "ab-stoa-secret", eventType: "INTERVAL", interval: { join: { users: [] }, leave: { users: ["a-left"] }, timeout: { users: [] }, userStateList: [{ userId: ada, states: await sealed({ x: "7", y: "8" }) }] } }); + await settle(); + assert.deepEqual(hidden.seen.changes, [ + { type: "snapshot", people: [{ userId: ada, states: { x: "1", y: "2" } }] }, + { type: "join", userId: ada }, + { type: "state", userId: ada, states: { x: "5", y: "6" } }, + { type: "leave", userId: ada }, + { type: "leave", userId: "a-left" }, + { type: "state", userId: ada, states: { x: "7", y: "8" } } + ]); +}); +test("state is sealed on keyed channels and who() follows every page", async () => { + const keyText = randomBytes(32).toString("base64"), key = await importKey(keyText); + const { live, client } = await connected(); + await live.join("ab-stoa-plaza", { spaceId: "plaza" }, recorder().handlers); + await live.join("ab-stoa-secret", { spaceId: "s1", key: keyText }, recorder().handlers); + await live.setState("ab-stoa-plaza", { x: "1", y: "1" }); + await live.setState("ab-stoa-secret", { x: "2", y: "2" }); + const states = client.calls.filter(([name]) => name === "setState"); + assert.deepEqual(states[0], ["setState", "ab-stoa-plaza", "MESSAGE", { x: "1", y: "1" }]); + assert.deepEqual(JSON.parse(await open(key, states[1][3].s)), { x: "2", y: "2" }); + client.pages = [{ occupants: [{ userId: ada, states: { x: "1" } }, { userId: me, states: {} }], nextPage: "p2" }, { occupants: [{ userId: "a-two", states: { x: "2" } }], nextPage: "" }]; + assert.deepEqual(await live.who("ab-stoa-plaza"), [{ userId: ada, states: { x: "1" } }, { userId: "a-two", states: { x: "2" } }]); + const asks = client.calls.filter(([name]) => name === "whoNow"); + assert.deepEqual([asks[0][3], asks[1][3]], [{ includedUserId: true, includedState: true }, { includedUserId: true, includedState: true, page: "p2" }]); +}); +test("expiring tokens are renewed; leaving and closing stop delivery", async () => { + const { live, client } = await connected(); + const plaza = recorder(); + await live.join("ab-stoa-plaza", { spaceId: "plaza" }, plaza.handlers); + client.handlers.tokenPrivilegeWillExpire("ab-stoa-plaza"); + await settle(); + assert.deepEqual(client.calls.filter(([name]) => name === "renewToken"), [["renewToken", "token-2"]]); + await live.leave("ab-stoa-plaza"); + client.handlers.message({ channelName: "ab-stoa-plaza", publisher: ada, message: "after" }); + await settle(); + assert.deepEqual(plaza.seen.messages, []); + assert.deepEqual(client.calls.filter(([name]) => name === "unsubscribe"), [["unsubscribe", "ab-stoa-plaza"]]); + await live.close(); + assert.deepEqual(client.calls.at(-1), ["logout"]); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `node --test tests/stoa-signaling.test.mjs` +Expected: FAIL with `Cannot find module` for `world/signaling.js`. + +- [ ] **Step 3: Implement** + +Create `world/signaling.js`: + +```js +import { importKey, open, seal } from "./crypto.js"; + +// One Agora Signaling login with space channels joined as needed. Keyed (unlisted and private) channels are encrypted +// end to end; presence events are normalized into snapshot, join, leave, and state changes per channel. +export function createLive({ AgoraRTM, fetchToken, onStatus = () => {} }) { + let client = null, userId = null, queue = Promise.resolve(); + const channels = new Map(); + // Events are handled one at a time so decryption never reorders presence or messages. + const enqueue = (kind, task) => { queue = queue.then(task).catch((error) => onStatus(kind, error)); }; + async function statesOf(channel, states) { + if (!channel.key) return states || {}; + const text = await open(channel.key, states?.s); + if (text === null) return null; + try { const value = JSON.parse(text); return value && typeof value === "object" ? value : null; } catch { return null; } + } + async function people(channel, list) { + const entries = await Promise.all((list || []).map(async (entry) => ({ userId: entry.userId, states: await statesOf(channel, entry.states) }))); + return entries.filter((entry) => entry.userId !== userId && entry.states); + } + async function onPresence(event) { + const channel = channels.get(event.channelName); + if (!channel) return; + const emit = (change) => channel.handlers.presence(change); + if (event.eventType === "SNAPSHOT") emit({ type: "snapshot", people: await people(channel, event.snapshot) }); + else if (event.eventType === "REMOTE_JOIN") { if (event.publisher !== userId) emit({ type: "join", userId: event.publisher }); } + else if (event.eventType === "REMOTE_LEAVE" || event.eventType === "REMOTE_TIMEOUT") { if (event.publisher !== userId) emit({ type: "leave", userId: event.publisher }); } + else if (event.eventType === "REMOTE_STATE_CHANGED") { + if (event.publisher === userId) return; + const states = await statesOf(channel, event.stateChanged); + if (states) emit({ type: "state", userId: event.publisher, states }); + } else if (event.eventType === "INTERVAL") { + const interval = event.interval || {}; + for (const id of [...(interval.leave?.users || []), ...(interval.timeout?.users || [])]) if (id !== userId) emit({ type: "leave", userId: id }); + for (const entry of await people(channel, interval.userStateList)) emit({ type: "state", userId: entry.userId, states: entry.states }); + } + } + async function onMessage(event) { + const channel = channels.get(event.channelName); + if (!channel || typeof event.message !== "string" || event.publisher === userId) return; + const text = channel.key ? await open(channel.key, event.message) : event.message; + if (text !== null && channels.get(event.channelName) === channel) channel.handlers.message(event.publisher, text); + } + async function renew() { const access = await fetchToken(); await client.renewToken(access.token); return access; } + return { + get userId() { return userId; }, + get guest() { return String(userId).startsWith("g-"); }, + async connect() { + const access = await fetchToken(); + userId = access.userId; + client = new AgoraRTM.RTM(access.appId, userId); + client.addEventListener("message", (event) => enqueue("message", () => onMessage(event))); + client.addEventListener("presence", (event) => enqueue("presence", () => onPresence(event))); + client.addEventListener("tokenPrivilegeWillExpire", () => { renew().catch((error) => onStatus("token", error)); }); + client.addEventListener("linkState", (event) => onStatus("link", event)); + await client.login({ token: access.token }); + return access; + }, + renew, + async join(name, { spaceId, key = null }, handlers) { + channels.set(name, { spaceId, key: key ? await importKey(key) : null, handlers }); + await client.subscribe(name, { withMessage: true, withPresence: true, beQuiet: String(userId).startsWith("g-") }); + }, + async leave(name) { if (!channels.delete(name)) return; await client.unsubscribe(name).catch(() => {}); }, + async publish(name, text) { + const channel = channels.get(name); + if (!channel) throw new Error("Not in channel " + name + "."); + await client.publish(name, channel.key ? await seal(channel.key, text) : text); + }, + async setState(name, state) { + const channel = channels.get(name); + if (!channel) return; + await client.presence.setState(name, "MESSAGE", channel.key ? { s: await seal(channel.key, JSON.stringify(state)) } : state); + }, + async who(name) { + const channel = channels.get(name); + if (!channel) return []; + const list = []; + let page = ""; + for (let count = 0; count < 50; count += 1) { + const result = await client.presence.whoNow(name, "MESSAGE", { includedUserId: true, includedState: true, ...(page ? { page } : {}) }); + list.push(...await people(channel, result.occupants)); + page = result.nextPage; + if (!page) break; + } + return list; + }, + async close() { channels.clear(); await client?.logout().catch(() => {}); client = null; } + }; +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `node --test tests/stoa-signaling.test.mjs` +Expected: PASS (6 tests). + +- [ ] **Step 5: Commit** + +```bash +git add world/signaling.js tests/stoa-signaling.test.mjs +git commit -m "Add the Signaling client with encrypted channels and normalized presence" -m "🤖 Built with SMT " +``` + +--- + +### Task 6: Other people on a channel + +**Files:** +- Create: `world/presence.js` +- Test: `tests/stoa-protocol.test.mjs` (append) + +**Interfaces:** +- Consumes: `positionAt` (motion), and `actorFromUser`, `readMessage`, `readState`, `shared`, `stopAt`, `straightPath`, `trusted` (Task 2). +- Produces `BUBBLE_MS = 8000` and `createPresence({ map, self, now = Date.now })`, where `self` is your Signaling user ID. It returns: + - **`snapshot(list)`:** replaces everyone. + - **`state(userId, states)`:** a valid state places a resting person. While they walk, only the name updates. Guests, yourself, unknown ID formats, blocked people and invalid states are ignored. + - **`leave(userId)`** + - **`message(publisher, text)`:** returns `{ ...message, from: { id, name } }` or `null`. + - It applies receiver rules and validation. + - `move` sets the path. + - `walk` sets a straight path. + - `stop` truncates the walk, or rests the person at `at` when it isn't on their path. + - `face` turns the person in place. + - `say` sets a bubble for `BUBBLE_MS`. + - Start times beyond `SKEW_LIMIT` use the local clock. + - **`setBlocked(actorIds)`:** removes blocked people and ignores them afterwards. + - **`list() -> [{ id, name, walk, bubble }]`**, where `bubble` is `{ text, until }` or `null`. This is ready for `engine.setOthers`. + +- [ ] **Step 1: Write the failing test** + +Append to `tests/stoa-protocol.test.mjs`: + +```js +import { BUBBLE_MS, createPresence } from "../world/presence.js"; + +const ada = "a-72a639ba-3a45-4afe-936b-222222222222", bo = "m-72a639ba-3a45-4afe-936b-333333333333", self = "a-72a639ba-3a45-4afe-936b-111111111111"; +const at = (x, y, extra = {}) => ({ x: String(x), y: String(y), dir: "down", name: "Ada", ...extra }); +const people = (time = 0) => { const clock = { time }; return { clock, room: createPresence({ map: plaza, self, now: () => clock.time }) }; }; + +test("presence lists signed-in people with valid states only", () => { + const { room } = people(1000); + room.snapshot([{ userId: ada, states: at(21, 17) }, { userId: bo, states: at(22, 17, { name: "Bo" }) }, { userId: self, states: at(20, 17) }, { userId: "g-0123456789abcdef", states: at(19, 17) }, { userId: "a-bad", states: at(18, 17) }, { userId: "a-72a639ba-3a45-4afe-936b-444444444444", states: { x: "0", y: "0" } }]); + assert.equal(BUBBLE_MS, 8000); + assert.deepEqual(room.list(), [ + { id: "account:72a639ba-3a45-4afe-936b-222222222222", name: "Ada", walk: { path: [{ x: 21, y: 17 }], startedAt: 1000, dir: "down" }, bubble: null }, + { id: "member:72a639ba-3a45-4afe-936b-333333333333", name: "Bo", walk: { path: [{ x: 22, y: 17 }], startedAt: 1000, dir: "down" }, bubble: null } + ]); + room.leave(bo); + assert.equal(room.list().length, 1); +}); +test("moves, keyboard walks, stops, turns, and speech follow trusted messages", () => { + const { clock, room } = people(1000); + room.snapshot([{ userId: ada, states: at(21, 17) }]); + assert.equal(room.message(bo, writeMessage({ t: "say", text: "hi" })), null, "people without presence are ignored"); + assert.equal(room.message(ada, "nonsense"), null); + const moved = room.message(ada, writeMessage({ t: "move", path: [{ x: 21, y: 17 }, { x: 21, y: 16 }], startedAt: 1500 })); + assert.deepEqual(moved.from, { id: "account:72a639ba-3a45-4afe-936b-222222222222", name: "Ada" }); + assert.deepEqual(room.list()[0].walk, { path: [{ x: 21, y: 17 }, { x: 21, y: 16 }], startedAt: 1500 }); + clock.time = 5000; + room.message(ada, writeMessage({ t: "walk", from: { x: 21, y: 16 }, dir: "up", startedAt: 5000 })); + assert.deepEqual(room.list()[0].walk.path.map((tile) => tile.y), [16, 15, 14, 13, 12]); + room.message(ada, writeMessage({ t: "stop", at: { x: 21, y: 14 } })); + assert.deepEqual(room.list()[0].walk.path.map((tile) => tile.y), [16, 15, 14]); + clock.time = 9000; + room.message(ada, writeMessage({ t: "stop", at: { x: 6, y: 8 } })); + assert.deepEqual(room.list()[0].walk.path, [{ x: 6, y: 8 }]); + room.message(ada, writeMessage({ t: "face", dir: "left" })); + assert.deepEqual(room.list()[0].walk, { path: [{ x: 6, y: 8 }], startedAt: 9000, dir: "left" }); + room.message(ada, writeMessage({ t: "say", text: "Hello!" })); + assert.deepEqual(room.list()[0].bubble, { text: "Hello!", until: 9000 + BUBBLE_MS }); + room.message(ada, writeMessage({ t: "move", path: [{ x: 6, y: 8 }, { x: 7, y: 8 }], startedAt: 1 })); + assert.equal(room.list()[0].walk.startedAt, 9000, "a far-off clock falls back to the local one"); +}); +test("state updates rest people, never interrupt a walk, and blocked people disappear", () => { + const { clock, room } = people(1000); + room.snapshot([{ userId: ada, states: at(21, 17) }]); + room.state(ada, at(22, 17)); + assert.deepEqual(room.list()[0].walk.path, [{ x: 22, y: 17 }]); + room.message(ada, writeMessage({ t: "move", path: [{ x: 22, y: 17 }, { x: 22, y: 16 }], startedAt: 1000 })); + room.state(ada, at(22, 16, { name: "Ada L." })); + assert.deepEqual([room.list()[0].walk.path.length, room.list()[0].name], [2, "Ada L."]); + clock.time = 2000; + room.setBlocked(["account:72a639ba-3a45-4afe-936b-222222222222"]); + assert.deepEqual(room.list(), []); + room.state(ada, at(21, 17)); + assert.deepEqual(room.list(), [], "blocked people stay hidden"); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `node --test tests/stoa-protocol.test.mjs` +Expected: FAIL with `Cannot find module` for `world/presence.js`. + +- [ ] **Step 3: Implement** + +Create `world/presence.js`: + +```js +import { positionAt } from "./motion.js"; +import { actorFromUser, readMessage, readState, shared, stopAt, straightPath, trusted } from "./protocol.js"; + +// Other people on one channel: resting places from presence state, live walks from messages, and short speech bubbles. +export const BUBBLE_MS = 8000; +export function createPresence({ map, self, now = () => Date.now() }) { + const people = new Map(); + let blocked = new Set(); + const tileOf = (person, time) => { const position = positionAt(person.walk, time); return { x: Math.round(position.x), y: Math.round(position.y), dir: position.dir }; }; + function state(userId, states) { + const id = actorFromUser(userId); + if (!id || userId === self || blocked.has(id)) return; + const value = readState(states, map); + if (!value) return; + const time = now(), person = people.get(userId); + if (person && !positionAt(person.walk, time).done) { person.name = value.name; return; } + people.set(userId, { id, userId, name: value.name, walk: { path: [{ x: value.x, y: value.y }], startedAt: time, dir: value.dir }, bubble: person?.bubble || null }); + } + return { + snapshot(list) { people.clear(); for (const entry of list) state(entry.userId, entry.states); }, + state, + leave(userId) { people.delete(userId); }, + message(publisher, text) { + if (!trusted(publisher, { self, present: new Set(people.keys()), blocked })) return null; + const message = readMessage(text, map); + if (!message) return null; + const person = people.get(publisher), time = now(); + if (message.t === "move") person.walk = { path: message.path, startedAt: shared(message.startedAt, time) }; + else if (message.t === "walk") person.walk = { path: straightPath(map, message.from, message.dir), startedAt: shared(message.startedAt, time), dir: message.dir }; + else if (message.t === "stop") person.walk = stopAt(person.walk, message.at) || { path: [message.at], startedAt: time, dir: tileOf(person, time).dir }; + else if (message.t === "face") { const tile = tileOf(person, time); person.walk = { path: [{ x: tile.x, y: tile.y }], startedAt: time, dir: message.dir }; } + else if (message.t === "say") person.bubble = { text: message.text, until: time + BUBBLE_MS }; + return { ...message, from: { id: person.id, name: person.name } }; + }, + setBlocked(list) { blocked = new Set(list); for (const [userId, person] of people) if (blocked.has(person.id)) people.delete(userId); }, + list() { return [...people.values()].map(({ id, name, walk, bubble }) => ({ id, name, walk, bubble })); } + }; +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `node --test tests/stoa-protocol.test.mjs` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add world/presence.js tests/stoa-protocol.test.mjs +git commit -m "Track other people on a channel with the receiver rules" -m "🤖 Built with SMT " + +--- + +### Task 7: One channel's live wiring and the entry lease + +**Files:** +- Create: `stoa/place.js`, `stoa/lease.js` +- Test: `tests/stoa-place.test.mjs` + +**Interfaces:** +- Consumes: `createPresence` (Task 6); `createLimiter`, `writeMessage`, `writeState` (Task 2); a `live` from Task 5 (`userId`, `guest`, `join`, `leave`, `publish`, `setState`, `who`); an engine from Task 4 (`on`, `position`, `facing`, `say`). +- Produces: + - **`openPlace({ live, map, engine, self: { name }, channel: { name, key, spaceId }, onPeople, onSay, onRefresh, now, later }) -> Promise`:** + - It joins the channel, takes a `who` snapshot, and calls `onPeople(list)` on every change. The caller merges the people of all places for `engine.setOthers` and the panel, which keeps it cell-ready. + - For members it shares your resting state on open and on each `arrive`. It publishes engine `move`/`walk`/`stop`/`face` events through a 4 per second limiter that keeps the latest pending message. + - Guests publish nothing. + - The returned place has: + - `channel`, the channel name + - `people()` + - `say(text) -> boolean`, rate-limited to 1 per second with a burst of 3. It shows your own bubble and calls `onSay({ name, text, self: true })`. + - `refresh()`, which publishes `{ t: "refresh" }` + - `setBlocked(actorIds)` + - `close()`, which stops publishing, ignores late events and leaves the channel + - **`createLease({ api, spaceId, onAccess, onLost, every = 20000, repeat = setInterval, stopRepeat = clearInterval, beacon }) -> { stop(), leave(), leaveOnUnload() }`:** + - A heartbeat runs every `every` ms. A success calls `onAccess(access)`. A 403, 404 or 410 stops the heartbeat and calls `onLost(error)`; other errors keep it running. + - `leave()` stops and POSTs `/leave`. `leaveOnUnload()` stops and sends a beacon to `/leave`. + +- [ ] **Step 1: Write the failing test** + +Create `tests/stoa-place.test.mjs`: + +```js +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; +import { parseMap } from "../world/map.js"; +import { openPlace } from "../stoa/place.js"; +import { createLease } from "../stoa/lease.js"; + +const plaza = parseMap(JSON.parse(await readFile(new URL("../worlds/plaza/map.json", import.meta.url), "utf8"))); +const me = "a-72a639ba-3a45-4afe-936b-111111111111", ada = "a-72a639ba-3a45-4afe-936b-222222222222", bo = "m-72a639ba-3a45-4afe-936b-333333333333"; +const tick = () => new Promise((resolve) => setImmediate(resolve)); +const state = (x, y, name) => ({ x: String(x), y: String(y), dir: "down", name }); + +function fakeLive({ guest = false, who = [] } = {}) { + return { + userId: guest ? "g-0123456789abcdef" : me, guest, joined: [], left: [], published: [], states: [], handlers: null, + async join(name, options, handlers) { this.joined.push([name, options]); this.handlers = handlers; }, + async leave(name) { this.left.push(name); }, + async publish(name, text) { this.published.push(JSON.parse(text)); }, + async setState(name, value) { this.states.push([name, value]); }, + async who() { return who; } + }; +} +function fakeEngine() { + const listeners = {}; + return { + said: [], + on(type, listener) { (listeners[type] ||= []).push(listener); return () => { listeners[type] = listeners[type].filter((item) => item !== listener); }; }, + emit(type, value) { for (const listener of listeners[type] || []) listener(value); }, + position() { return { x: 21, y: 17 }; }, facing() { return "up"; }, + say(text) { this.said.push(text); } + }; +} +async function opened({ guest = false, who = [], now = () => 1000, later } = {}) { + const live = fakeLive({ guest, who }), engine = fakeEngine(), seen = { people: [], said: [], refreshed: 0 }; + const place = await openPlace({ live, map: plaza, engine, self: { name: "Me" }, channel: { name: "ab-stoa-plaza", key: null, spaceId: "plaza" }, onPeople: (list) => { seen.people = list; }, onSay: (entry) => seen.said.push(entry), onRefresh: () => { seen.refreshed += 1; }, now, later }); + return { live, engine, place, seen }; +} + +test("opening a place joins its channel, shows who is there, and shares where you stand", async () => { + const { live, place, seen } = await opened({ who: [{ userId: ada, states: state(22, 17, "Ada") }] }); + assert.deepEqual(live.joined, [["ab-stoa-plaza", { spaceId: "plaza", key: null }]]); + assert.deepEqual(seen.people.map((person) => person.name), ["Ada"]); + assert.deepEqual(live.states, [["ab-stoa-plaza", { x: "21", y: "17", dir: "up", name: "Me" }]]); + assert.equal(place.channel, "ab-stoa-plaza"); + assert.deepEqual(place.people().map((person) => person.name), ["Ada"]); +}); +test("movement is published within the rate limit, keeping the latest pending message", async () => { + let time = 0; const timers = []; + const { live, engine } = await opened({ now: () => time, later: (callback) => { timers.push(callback); return timers.length; } }); + for (let index = 0; index < 6; index += 1) engine.emit("move", { path: [{ x: 21, y: 17 }, { x: 21, y: 16 }], startedAt: index }); + await tick(); + assert.deepEqual(live.published.map((message) => message.startedAt), [0, 1, 2, 3]); + time = 250; timers[0](); await tick(); + assert.deepEqual(live.published.map((message) => message.startedAt), [0, 1, 2, 3, 5]); + time = 10000; + engine.emit("walk", { from: { x: 21, y: 17 }, dir: "up", startedAt: 10000 }); + engine.emit("stop", { at: { x: 21, y: 15 } }); + engine.emit("face", { dir: "left" }); + engine.emit("arrive", { x: 21, y: 15 }); + await tick(); + assert.deepEqual(live.published.slice(-3), [{ t: "walk", from: { x: 21, y: 17 }, dir: "up", startedAt: 10000 }, { t: "stop", at: { x: 21, y: 15 } }, { t: "face", dir: "left" }]); + assert.equal(live.states.length, 2, "arriving shares the new resting place"); +}); +test("messages and presence from others reach the people list and the message log", async () => { + const { live, seen } = await opened({ who: [{ userId: ada, states: state(22, 17, "Ada") }] }); + live.handlers.message(ada, JSON.stringify({ t: "say", text: "Hi all" })); + assert.deepEqual(seen.said, [{ name: "Ada", text: "Hi all", self: false }]); + assert.equal(seen.people[0].bubble.text, "Hi all"); + live.handlers.message(ada, JSON.stringify({ t: "refresh" })); + assert.equal(seen.refreshed, 1); + live.handlers.presence({ type: "state", userId: bo, states: state(20, 17, "Bo") }); + assert.deepEqual(seen.people.map((person) => person.name), ["Ada", "Bo"]); + live.handlers.presence({ type: "leave", userId: ada }); + assert.deepEqual(seen.people.map((person) => person.name), ["Bo"]); + live.handlers.presence({ type: "snapshot", people: [{ userId: ada, states: state(22, 17, "Ada") }] }); + assert.deepEqual(seen.people.map((person) => person.name), ["Ada"]); +}); +test("saying something publishes it, shows your bubble, and respects the chat limit", async () => { + const { live, engine, place, seen } = await opened(); + assert.equal(place.say(" hello "), true); + await tick(); + assert.deepEqual(live.published.at(-1), { t: "say", text: "hello" }); + assert.deepEqual(engine.said, ["hello"]); + assert.deepEqual(seen.said, [{ name: "Me", text: "hello", self: true }]); + assert.equal(place.say("two"), true); + assert.equal(place.say("three"), true); + assert.equal(place.say("four"), false, "a fourth message in the same second is refused"); + assert.equal(place.say(" "), false); + place.refresh(); await tick(); + assert.deepEqual(live.published.at(-1), { t: "refresh" }); +}); +test("guests only watch", async () => { + const { live, engine, place } = await opened({ guest: true }); + engine.emit("move", { path: [{ x: 21, y: 17 }, { x: 21, y: 16 }], startedAt: 1 }); + await tick(); + assert.deepEqual([live.published, live.states, place.say("hi")], [[], [], false]); +}); +test("closing leaves the channel and stops publishing; blocked people are dropped", async () => { + const { live, engine, place, seen } = await opened({ who: [{ userId: ada, states: state(22, 17, "Ada") }] }); + place.setBlocked(["account:72a639ba-3a45-4afe-936b-222222222222"]); + assert.deepEqual(seen.people, []); + await place.close(); + assert.deepEqual(live.left, ["ab-stoa-plaza"]); + engine.emit("move", { path: [{ x: 21, y: 17 }, { x: 21, y: 16 }], startedAt: 1 }); + live.handlers.presence({ type: "state", userId: bo, states: state(20, 17, "Bo") }); + await tick(); + assert.deepEqual([live.published, seen.people], [[], []]); +}); +test("the lease heartbeats, reports access, survives passing errors, and ends cleanly", async () => { + const calls = [], beacons = [], accesses = [], losses = []; + let beat, stopped = 0; + const replies = [{ channel: "c2", key: null, blocked: [], hostId: null }]; + const api = async (path, options) => { + calls.push([path, options?.method]); + if (!path.endsWith("/heartbeat")) return { left: true }; + const reply = replies.shift(); + if (reply instanceof Error) throw reply; + return reply; + }; + const lease = createLease({ api, spaceId: "lot-rtc-lab", onAccess: (access) => accesses.push(access), onLost: (error) => losses.push(error.status), repeat: (callback, every) => { beat = callback; assert.equal(every, 20000); return 7; }, stopRepeat: () => { stopped += 1; }, beacon: (url) => beacons.push(url) }); + await beat(); + assert.deepEqual(accesses, [{ channel: "c2", key: null, blocked: [], hostId: null }]); + replies.push(Object.assign(new Error("Busy"), { status: 500 })); + await beat(); + assert.deepEqual(losses, []); + replies.push(Object.assign(new Error("Gone"), { status: 410 })); + await beat(); + assert.deepEqual([losses, stopped], [[410], 1]); + await lease.leave(); + assert.deepEqual(calls.at(-1), ["/api/spaces/lot-rtc-lab/leave", "POST"]); + lease.leaveOnUnload(); + assert.deepEqual(beacons, ["/api/spaces/lot-rtc-lab/leave"]); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `node --test tests/stoa-place.test.mjs` +Expected: FAIL with `Cannot find module` for `stoa/place.js`. + +- [ ] **Step 3: Implement** + +Create `stoa/place.js`: + +```js +import { createPresence } from "../world/presence.js"; +import { createLimiter, writeMessage, writeState } from "../world/protocol.js"; + +// One channel's live layer: who is there, what they say, and your own movement published within the budget. +// The caller merges people from every open place, so a space can later span several area-of-interest cells. +export async function openPlace({ live, map, engine, self, channel, onPeople = () => {}, onSay = () => {}, onRefresh = () => {}, now = () => Date.now(), later }) { + const guest = live.guest; + const presence = createPresence({ map, self: live.userId, now }); + const moves = createLimiter({ perSecond: 4, burst: 4, now, later }), chats = createLimiter({ perSecond: 1, burst: 3, now, later }); + let open = true; + const show = () => onPeople(presence.list()); + await live.join(channel.name, { spaceId: channel.spaceId, key: channel.key }, { + message(publisher, text) { + if (!open) return; + const message = presence.message(publisher, text); + if (!message) return; + if (message.t === "say") onSay({ name: message.from.name, text: message.text, self: false }); + if (message.t === "refresh") onRefresh(); + show(); + }, + presence(change) { + if (!open) return; + if (change.type === "snapshot") presence.snapshot(change.people); + else if (change.type === "leave") presence.leave(change.userId); + else if (change.type === "state") presence.state(change.userId, change.states); + show(); + } + }); + presence.snapshot(await live.who(channel.name)); + show(); + const publish = (message) => { if (open) live.publish(channel.name, writeMessage(message)).catch(() => {}); }; + const rest = () => { const at = engine.position(); live.setState(channel.name, writeState({ x: at.x, y: at.y, dir: engine.facing(), name: self.name })).catch(() => {}); }; + const stops = guest ? [] : [ + engine.on("move", (route) => moves.send(() => publish({ t: "move", path: route.path, startedAt: route.startedAt }), { latest: true })), + engine.on("walk", (walk) => moves.send(() => publish({ t: "walk", from: walk.from, dir: walk.dir, startedAt: walk.startedAt }), { latest: true })), + engine.on("stop", (stop) => moves.send(() => publish({ t: "stop", at: stop.at }), { latest: true })), + engine.on("face", (face) => moves.send(() => publish({ t: "face", dir: face.dir }), { latest: true })), + engine.on("arrive", rest) + ]; + if (!guest) rest(); + return { + channel: channel.name, + people: () => presence.list(), + say(text) { + const clean = String(text).trim().slice(0, 500); + if (guest || !open || !clean) return false; + if (!chats.send(() => publish({ t: "say", text: clean }))) return false; + engine.say(clean); + onSay({ name: self.name, text: clean, self: true }); + return true; + }, + refresh() { if (!guest) publish({ t: "refresh" }); }, + setBlocked(list) { presence.setBlocked(list); show(); }, + async close() { open = false; for (const stop of stops) stop(); await live.leave(channel.name); } + }; +} +``` + +Create `stoa/lease.js`: + +```js +// Keeps an entry lease alive and reports when it ends. Leaving on page close uses a beacon so it still reaches the server. +export function createLease({ api, spaceId, onAccess = () => {}, onLost = () => {}, every = 20000, repeat = (callback, delay) => setInterval(callback, delay), stopRepeat = (handle) => clearInterval(handle), beacon = (url) => navigator.sendBeacon?.(url) }) { + const base = "/api/spaces/" + spaceId; + let timer = null; + const stop = () => { if (timer !== null) { stopRepeat(timer); timer = null; } }; + timer = repeat(async () => { + try { onAccess(await api(base + "/heartbeat", { method: "POST" })); } + catch (error) { if ([403, 404, 410].includes(error.status)) { stop(); onLost(error); } } + }, every); + return { + stop, + async leave() { stop(); try { await api(base + "/leave", { method: "POST" }); } catch { /* the lease expires on its own */ } }, + leaveOnUnload() { stop(); beacon(base + "/leave"); } + }; +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `node --test tests/stoa-place.test.mjs` +Expected: PASS (7 tests). + +- [ ] **Step 5: Commit** + +```bash +git add stoa/place.js stoa/lease.js tests/stoa-place.test.mjs +git commit -m "Wire one channel's presence and movement, and keep entry leases alive" -m "🤖 Built with SMT " +``` + +--- + +### Task 8: The live Stoa page — plaza, lot rooms, and user spaces + +**Files:** +- Create: `stoa/panel.js`, `stoa/stage.js` +- Modify: `stoa.js` (replace the whole file), `stoa.html` (the ` diff --git a/stoa.js b/stoa.js index 7359e01..5f42b0f 100644 --- a/stoa.js +++ b/stoa.js @@ -76,37 +76,64 @@ await sessionReady; const self = { id: state.account?.id || state.profile?.id || null, name: state.profile?.name || state.account?.name || "You", signedIn: Boolean(state.account || state.profile) }; panel.setPeople([], self.name); panel.canTalk(self.signedIn); -let live = null, plazaChannel = null; -if (engine) { - engine.setSelf({ id: self.id || undefined, name: self.name }); +let live = null, stage = null; +const unavailable = "The Stoa view isn't available, so there's nothing to walk or talk in."; +// Controls work even without the canvas: Go to explains where the door is, Sign in and Start a space still do their jobs. +panel.on({ + go: (target) => { + if (!stage) { panel.say(unavailable + " " + target.title + " is at the door tile (" + target.door.x + ", " + target.door.y + ")."); return; } + const notice = stage.watchNotice(target); + if (notice) panel.say(notice); + else if (engine.walkTo(target.door)) panel.say("Walking to " + target.title + "."); + else { const at = engine.position(); panel.say(at.x === target.door.x && at.y === target.door.y ? "You're already at the door of " + target.title + "." : target.title + " can't be reached right now."); } + canvas.focus(); + }, + say: (text) => stage ? stage.say(text) : (panel.say(unavailable), false), + topic: (topic, tags) => stage?.setTopic(topic, tags), + leave: () => stage?.leaveRoom(), + start: async () => { try { const created = await api("/api/spaces", { method: "POST", body: JSON.stringify({ title: self.name + "'s space" }) }); location.assign("/stoa/s/" + created.space.id); } catch (error) { panel.say(error.message); } }, + signin: () => openSignIn() +}); +// Signaling loads and connects in the background: the page is already usable, and the stage picks the connection up when it is ready. +async function connectLive() { + let candidate = null, timer = null, abandoned = false, connecting = null; try { - const { AgoraRTM } = await loadAgora(); - live = createLive({ AgoraRTM, fetchToken: () => api("/api/signaling/token", { method: "POST", body: "{}" }), onStatus: (kind, detail) => { if (kind === "link" && detail?.currentState === "FAILED") panel.say("The live connection was lost. Reload the page to reconnect."); } }); - plazaChannel = (await live.connect()).channels?.[0]?.name || null; + connecting = (async () => { + const { AgoraRTM } = await loadAgora(); + candidate = createLive({ + AgoraRTM, + fetchToken: () => api("/api/signaling/token", { method: "POST", body: "{}" }), + onStatus: (kind, detail) => { + if (kind === "duplicate") panel.say("You opened the Stoa in another tab; live movement continues there."); + else if (kind === "link" && detail?.currentState === "FAILED") panel.say("The live connection was lost. Reload the page to reconnect."); + } + }); + return (await candidate.connect()).channels?.[0]?.name || null; + })(); + const timeout = new Promise((resolve, reject) => { timer = setTimeout(() => { abandoned = true; reject(Object.assign(new Error("Live movement is taking too long to connect."), { timedOut: true })); }, 10000); }); + const channel = await Promise.race([connecting, timeout]); + clearTimeout(timer); + live = candidate; + await stage.attachLive(live, channel); } catch (error) { + clearTimeout(timer); + // A connection that finishes after the timeout is closed again rather than left running unattached. + if (abandoned) connecting?.then(() => candidate?.close(), () => {}).catch(() => {}); + else candidate?.close().catch(() => {}); live = null; - problems.push(error.status === 503 ? "Live movement isn't connected yet, so you won't see other people. You can still walk and visit rooms." : "Live movement couldn't start (" + (error.message || "unknown error") + ")."); + panel.say(error.status === 503 ? "Live movement isn't connected yet, so you won't see other people. You can still walk and visit rooms." : error.timedOut ? error.message : "Live movement couldn't start (" + (error.message || "unknown error") + ")."); } - const stage = createStage({ api, map, engine, panel, live, self, plaza: spaceId ? null : { bounds: plazaBounds, channel: plazaChannel } }); - panel.on({ - go: (target) => { - const notice = stage.watchNotice(target); - if (notice) panel.say(notice); - else if (engine.walkTo(target.door)) panel.say("Walking to " + target.title + "."); - else { const at = engine.position(); panel.say(at.x === target.door.x && at.y === target.door.y ? "You're already at the door of " + target.title + "." : target.title + " can't be reached right now."); } - canvas.focus(); - }, - say: (text) => stage.say(text), - topic: (topic, tags) => stage.setTopic(topic, tags), - leave: () => stage.leaveRoom(), - start: async () => { try { const created = await api("/api/spaces", { method: "POST", body: JSON.stringify({ title: self.name + "'s space" }) }); location.assign("/stoa/s/" + created.space.id); } catch (error) { panel.say(error.message); } }, - signin: () => openSignIn() - }); +} +if (engine) { + engine.setSelf({ id: self.id || undefined, name: self.name }); + stage = createStage({ api, map, engine, panel, live: null, self, plaza: spaceId ? null : { bounds: plazaBounds, channel: null } }); engine.on("arrive", (tile) => stage.onArrive(tile)); - addEventListener("pagehide", () => stage.unload()); - if (spaceId) await stage.openSpace(spaceId, invite); - else { - await stage.toPlaza(null, minimal ? "Minimal view is on." : preview ? "Previewing the " + preview.name + " theme. Only you see it." : undefined); + // Leaving the page releases the entry lease and, as far as the browser allows, the Signaling login. + addEventListener("pagehide", () => { stage.unload(); live?.close().catch(() => {}); }); + const entering = spaceId ? stage.openSpace(spaceId, invite) : stage.toPlaza(null, minimal ? "Minimal view is on." : preview ? "Previewing the " + preview.name + " theme. Only you see it." : undefined); + connectLive(); + await entering; + if (!spaceId) { if (lot) panel.say("You're at the door of " + lot.title + ". Step in when you're ready."); setInterval(async () => { try { const listing = await stage.poll(); summaries = new Map(listing.rooms.map((room) => [room.slug, room])); panel.setRooms(map.lots, summaries); engine.setLabels(labels()); } diff --git a/stoa/lease.js b/stoa/lease.js index 05530b4..7f37664 100644 --- a/stoa/lease.js +++ b/stoa/lease.js @@ -5,7 +5,7 @@ export function createLease({ api, spaceId, onAccess = () => {}, onLost = () => const stop = () => { stopped = true; if (timer !== null) { stopRepeat(timer); timer = null; } }; timer = repeat(async () => { try { const access = await api(base + "/heartbeat", { method: "POST" }); if (!stopped) onAccess(access); } - catch (error) { if (!stopped && [403, 404, 410].includes(error.status)) { stop(); onLost(error); } } + catch (error) { if (!stopped && [401, 403, 404, 410].includes(error.status)) { stop(); onLost(error); } } }, every); return { stop, diff --git a/stoa/panel.js b/stoa/panel.js index 1f4f9f4..89b7e55 100644 --- a/stoa/panel.js +++ b/stoa/panel.js @@ -1,30 +1,51 @@ -const $ = (selector) => document.querySelector(selector); -const node = (tag, text, className) => { const element = document.createElement(tag); if (text) element.textContent = text; if (className) element.className = className; return element; }; +// People are rebuilt only when who is here (or a name) changes, so a busy plaza doesn't churn the page. +export const peopleKey = (list, selfName) => selfName + "\n" + list.map((person) => person.id + "\u0000" + person.name).sort().join("\n"); // The Stoa side panel: everything the canvas offers, as plain controls for keyboard and screen reader users. -export function createPanel() { - const status = $("#stoa-status"), here = $("#stoa-here"), topicForm = $("#stoa-topic-form"), offer = $("#stoa-offer"), roomsSection = $("#stoa-rooms-section"), rooms = $("#stoa-rooms"), people = $("#stoa-people"), messages = $("#stoa-messages"), sayForm = $("#stoa-say"), sayInput = $("#stoa-say-text"), signin = $("#stoa-signin"); +export function createPanel(doc = document) { + const $ = (selector) => doc.querySelector(selector); + const node = (tag, text, className) => { const element = doc.createElement(tag); if (text) element.textContent = text; if (className) element.className = className; return element; }; + const status = $("#stoa-status"), here = $("#stoa-here"), topicForm = $("#stoa-topic-form"), offer = $("#stoa-offer"), roomsSection = $("#stoa-rooms-section"), rooms = $("#stoa-rooms"), people = $("#stoa-people"), messages = $("#stoa-messages"), sayForm = $("#stoa-say"), sayInput = $("#stoa-say-text"), signin = $("#stoa-signin"), signinText = $("#stoa-signin-text"), signinButton = $("#stoa-signin-button"); + let shownPeople = null; + const roomRows = new Map(); const handlers = { go() {}, say() { return false; }, topic() {}, leave() {}, start() {}, signin() {} }; sayForm.addEventListener("submit", (event) => { event.preventDefault(); if (handlers.say(sayInput.value)) sayInput.value = ""; }); topicForm.addEventListener("submit", (event) => { event.preventDefault(); handlers.topic(topicForm.elements.topic.value, topicForm.elements.tags.value); }); $("#stoa-leave").addEventListener("click", () => handlers.leave()); $("#stoa-start").addEventListener("click", () => handlers.start()); - $("#stoa-signin-button").addEventListener("click", () => handlers.signin()); + signinButton.addEventListener("click", () => handlers.signin()); return { on(next) { Object.assign(handlers, next); }, say(message) { status.textContent = message; }, + // Rows are created once per room and updated in place, so a focused Go to button keeps focus across polls. setRooms(lots, summaries) { - rooms.replaceChildren(...lots.map((lot) => { + const wanted = []; + for (const lot of lots) { const room = summaries.get(lot.slug) || { topic: null, occupancy: 0, capacity: lot.capacity }; - const entry = node("li", "", "stoa-room"), go = node("button", "Go to", "button button-secondary button-small"); - go.type = "button"; - go.setAttribute("aria-label", "Walk to " + lot.title); - go.addEventListener("click", () => handlers.go(lot)); - entry.append(node("strong", lot.title), node("span", (room.topic || "Open") + " · " + room.occupancy + " of " + room.capacity + " here"), go); - return entry; - })); + let row = roomRows.get(lot.slug); + if (!row) { + const entry = node("li", "", "stoa-room"), status = node("span"), go = node("button", "Go to", "button button-secondary button-small"); + go.type = "button"; + go.setAttribute("aria-label", "Walk to " + lot.title); + go.addEventListener("click", () => handlers.go(lot)); + entry.append(node("strong", lot.title), status, go); + row = { entry, status }; + roomRows.set(lot.slug, row); + } + const text = (room.topic || "Open") + " \u00b7 " + room.occupancy + " of " + room.capacity + " here"; + if (row.status.textContent !== text) row.status.textContent = text; + wanted.push(row.entry); + } + for (const slug of [...roomRows.keys()]) if (!lots.some((lot) => lot.slug === slug)) roomRows.delete(slug); + const now = [...rooms.children]; + if (now.length !== wanted.length || wanted.some((entry, index) => entry !== now[index])) rooms.replaceChildren(...wanted); + }, + setPeople(list, selfName) { + const key = peopleKey(list, selfName); + if (key === shownPeople) return; + shownPeople = key; + people.replaceChildren(node("li", selfName + " (you)"), ...list.map((person) => node("li", person.name))); }, - setPeople(list, selfName) { people.replaceChildren(node("li", selfName + " (you)"), ...list.map((person) => node("li", person.name))); }, addMessage({ name, text, self }) { const entry = node("li", "", self ? "is-self" : ""); entry.append(node("strong", name), document.createTextNode(" " + text)); @@ -32,7 +53,8 @@ export function createPanel() { while (messages.children.length > 50) messages.firstElementChild.remove(); }, clearMessages() { messages.replaceChildren(); }, - canTalk(allowed) { sayForm.hidden = !allowed; signin.hidden = allowed; }, + // When talking isn't possible, a reason (for someone already signed in) replaces the sign-in prompt. + canTalk(allowed, reason = "") { sayForm.hidden = !allowed; signin.hidden = allowed; signinText.textContent = reason || "Sign in to walk and talk."; signinButton.hidden = Boolean(reason); }, showRoom({ kicker, title, topic, tags = [], host, leaveLabel }) { here.hidden = false; roomsSection.hidden = true; $("#stoa-here-kicker").textContent = kicker; diff --git a/stoa/place.js b/stoa/place.js index 815ede3..7afde1b 100644 --- a/stoa/place.js +++ b/stoa/place.js @@ -9,13 +9,13 @@ export async function openPlace({ live, map, engine, self, channel, onPeople = ( const moves = createLimiter({ perSecond: 4, burst: 4, now, later }), chats = createLimiter({ perSecond: 1, burst: 3, now, later }); let open = true; const show = () => onPeople(presence.list()); - await live.join(channel.name, { spaceId: channel.spaceId, key: channel.key }, { + await live.join(channel.name, { spaceId: channel.spaceId, key: channel.key, quiet }, { message(publisher, text) { if (!open) return; const message = presence.message(publisher, text); if (!message) return; if (message.t === "say") onSay({ name: message.from.name, text: message.text, self: false }); - if (message.t === "refresh") onRefresh(); + if (message.t === "refresh") onRefresh(message.from.id); show(); }, presence(change) { @@ -29,13 +29,13 @@ export async function openPlace({ live, map, engine, self, channel, onPeople = ( try { presence.snapshot(await live.who(channel.name)); } catch (error) { open = false; await live.leave(channel.name).catch(() => {}); throw error; } show(); - const publish = (message) => { if (open) live.publish(channel.name, writeMessage(message)).catch(() => {}); }; + const publish = (message) => open ? live.publish(channel.name, writeMessage(message)).catch(() => false).then((result) => result !== false) : Promise.resolve(false); const rest = () => { const at = engine.position(); live.setState(channel.name, writeState({ x: at.x, y: at.y, dir: engine.facing(), name: self.name })).catch(() => {}); }; const stops = guest ? [] : [ engine.on("move", (route) => moves.send(() => publish({ t: "move", path: route.path, startedAt: route.startedAt }), { latest: true })), engine.on("walk", (walk) => moves.send(() => publish({ t: "walk", from: walk.from, dir: walk.dir, startedAt: walk.startedAt }), { latest: true })), engine.on("stop", (stop) => moves.send(() => publish({ t: "stop", at: stop.at }), { latest: true })), - engine.on("face", (face) => moves.send(() => publish({ t: "face", dir: face.dir }), { latest: true })), + engine.on("face", (face) => moves.send(() => publish({ t: "face", dir: face.dir, at: face.at }), { latest: true })), engine.on("arrive", (at) => { moves.send(() => publish({ t: "stop", at: { x: at.x, y: at.y } }), { latest: true }); rest(); }) ]; if (!guest) rest(); @@ -45,7 +45,7 @@ export async function openPlace({ live, map, engine, self, channel, onPeople = ( say(text) { const clean = String(text).trim().slice(0, 500); if (guest || !open || !clean) return false; - if (!chats.send(() => publish({ t: "say", text: clean }))) return false; + if (!chats.send(() => { publish({ t: "say", text: clean }).then((sent) => { if (!sent && open) onSay({ name: self.name, text: clean, self: true, failed: true }); }); })) return false; engine.say(clean); onSay({ name: self.name, text: clean, self: true }); return true; diff --git a/stoa/stage.js b/stoa/stage.js index 41482d5..dba1ffe 100644 --- a/stoa/stage.js +++ b/stoa/stage.js @@ -7,19 +7,27 @@ const inside = (rect, tile) => tile.x >= rect.x && tile.y >= rect.y && tile.x < export const outsideDoor = (map, lot) => [[0, 1], [0, -1], [1, 0], [-1, 0]].map(([dx, dy]) => ({ x: lot.door.x + dx, y: lot.door.y + dy })).find((tile) => walkable(map, tile.x, tile.y) && !inside(lot.interior, tile)) || lot.door; // Moves the person between the plaza, lot rooms, and user spaces: entry leases on the server, channels on Signaling. -export function createStage({ api, map, engine, panel, live, self, plaza, createLease = realLease }) { - let places = [], lease = null, here = null, chain = Promise.resolve(); +const REFRESH_GAP = 5000; +// `live` may be null at first and attached later with attachLive, so the page never waits on the SDK. +export function createStage({ api, map, engine, panel, live: firstLive, self, plaza, createLease = realLease, schedule = (callback) => typeof requestAnimationFrame === "function" ? requestAnimationFrame(callback) : setTimeout(callback, 16), now = () => Date.now(), later = (callback, delay) => setTimeout(callback, delay) }) { + let live = firstLive, places = [], lease = null, here = null, chain = Promise.resolve(), drawing = false; // State changes run strictly one at a time, each re-checking its preconditions when it starts. const transition = (task) => (chain = chain.then(task, task).catch((error) => panel.say(error.message || "Something went wrong."))); const doors = new Map(map.lots.map((lot) => [lot.door.x + "," + lot.door.y, lot])); const exits = new Map(map.lots.map((lot) => [lot.entry.x + "," + (lot.entry.y + 1), lot])); // Cell-ready: a space may later span several channels; people from all of them are merged by id. + // Presence events can arrive in bursts, so the lists are rebuilt at most once per frame. const showPeople = () => { - const seen = new Map(); - for (const place of places) for (const person of place.people()) seen.set(person.id, person); - const list = [...seen.values()]; - engine.setOthers(list); - panel.setPeople(list, self.name); + if (drawing) return; + drawing = true; + schedule(() => { + drawing = false; + const seen = new Map(); + for (const place of places) for (const person of place.people()) seen.set(person.id, person); + const list = [...seen.values()]; + engine.setOthers(list); + panel.setPeople(list, self.name); + }); }; const showRoom = (space, hostId) => panel.showRoom({ kicker: here?.kind === "lot" ? "LOT ROOM" : space.visibility === "private" ? "PRIVATE SPACE" : "UNLISTED SPACE", title: space.title, topic: space.topic, tags: space.tags || [], host: Boolean(self.id) && hostId === self.id, leaveLabel: here?.kind === "lot" ? "Back to the plaza" : "Leave this space" }); @@ -35,7 +43,7 @@ export function createStage({ api, map, engine, panel, live, self, plaza, create showPeople(); if (live && next.access?.channel) { try { - const place = await openPlace({ live, map, engine, self, quiet: Boolean(next.watch), channel: { name: next.access.channel, key: next.access.key || null, spaceId: next.space.id }, onPeople: showPeople, onSay: panel.addMessage, onRefresh: () => { refreshRoom(); } }); + const place = await openPlace({ live, map, engine, self, quiet: Boolean(next.watch), channel: { name: next.access.channel, key: next.access.key || null, spaceId: next.space.id }, onPeople: showPeople, onSay: (entry) => { if (entry.failed) panel.say("That message didn't send."); else panel.addMessage(entry); }, onRefresh: (from) => { if (here === next && from && from === next.access?.hostId) refreshRoom(); } }); if (here !== next) { await place.close().catch(() => {}); return; } places = [place]; place.setBlocked(next.access.blocked || []); @@ -44,10 +52,11 @@ export function createStage({ api, map, engine, panel, live, self, plaza, create } if (here !== next) return; if (next.leased) { - const made = createLease({ api, spaceId: next.space.id, onAccess: (access) => { if (lease === made) onAccess(access, next); }, onLost: () => { transition(() => lost(made)); } }); + const made = createLease({ api, spaceId: next.space.id, onAccess: (access) => { if (lease === made) onAccess(access, next); }, onLost: (error) => { transition(() => lost(made, error)); } }); lease = made; } - panel.canTalk(next.leased ? self.signedIn : false); + const talk = Boolean(next.leased) && self.signedIn; + panel.canTalk(talk, talk || !self.signedIn ? undefined : next.full ? "The plaza is full \u2014 you're watching." : next.kind === "left" ? "You're no longer in this space." : "You're watching."); } function onAccess(access, at) { @@ -64,14 +73,42 @@ export function createStage({ api, map, engine, panel, live, self, plaza, create if (here.kind !== "plaza" && access.hostId !== here.access.hostId) { here.access = { ...here.access, ...access }; refreshRoom(); } } - async function lost(from) { + // A lease the server dropped (410) gets one re-entry into the same place; removed or hidden (403, 404) does not. + async function reenter(at) { + if (at.kind === "lot") { + const access = await api("/api/spaces/lot-" + at.lot.slug + "/enter", { method: "POST", body: "{}" }); + await live?.renew().catch(() => {}); + await settle({ kind: "lot", space: access.space, lot: at.lot, access, bounds: at.lot.interior, start: null, leased: true }); + showRoom(access.space, access.hostId); + } else { + const access = await api("/api/spaces/" + encodeURIComponent(at.space.id) + "/enter", { method: "POST", body: JSON.stringify(at.invite ? { invite: at.invite } : {}) }); + await live?.renew().catch(() => {}); + await settle({ kind: "space", space: access.space, access, invite: at.invite, bounds: null, start: null, leased: true }); + showRoom(access.space, access.hostId); + } + } + + async function lost(from, error) { if (!here || lease !== from) return; + const at = here; + if (error?.status === 410 && (at.kind === "lot" || at.kind === "space")) { + try { await reenter(at); return; } catch { if (here !== at) return; } + } if (here.kind === "lot") { const lot = here.lot; await toPlazaTask(outsideDoor(map, lot)); panel.say("You're no longer in " + lot.title + "."); } else if (here.kind === "space") { await settle({ kind: "left", space: here.space, bounds: here.bounds }); engine.setInteractive(false); panel.say("You're no longer in this space."); } else await toPlazaTask(null); } - async function refreshRoom() { + // Room refreshes are limited to one request at a time and one every few seconds; a refresh in between becomes one trailing request. + let inflight = false, trailing = false, waiting = null, lastRefresh = -Infinity; + function refreshRoom() { + if (inflight) { trailing = true; return; } + const wait = lastRefresh + REFRESH_GAP - now(); + if (wait > 0) { if (waiting === null) waiting = later(() => { waiting = null; refreshRoom(); }, wait); return; } + inflight = true; lastRefresh = now(); + fetchRoom().finally(() => { inflight = false; if (trailing) { trailing = false; refreshRoom(); } }); + } + async function fetchRoom() { const at = here; if (!at || at.kind === "plaza" || !at.space) return; try { @@ -184,6 +221,15 @@ export function createStage({ api, map, engine, panel, live, self, plaza, create if (here?.kind === "plaza") for (const place of places) place.setBlocked(listing.plaza.blocked || []); return listing; }, + // Called when Signaling finishes connecting after the page started: reopens the current place's channel. + attachLive: (next, plazaChannel) => transition(async () => { + live = next; + if (plaza && plazaChannel) plaza.channel = plazaChannel; + const at = here; + if (!at || at.kind === "left") return; + const access = at.kind === "plaza" && !at.leased ? (plaza?.channel ? { channel: plaza.channel, key: null, blocked: [] } : null) : at.access; + if (access?.channel) await settle({ ...at, access, start: null }); + }), unload() { lease?.leaveOnUnload(); } }; } diff --git a/tests/stoa-panel.test.mjs b/tests/stoa-panel.test.mjs new file mode 100644 index 0000000..bd207b0 --- /dev/null +++ b/tests/stoa-panel.test.mjs @@ -0,0 +1,52 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { createPanel, peopleKey } from "../stoa/panel.js"; + +// A minimal document: just enough of the element API for the panel to build lists and wire forms. +class Element { + constructor(tag) { Object.assign(this, { tagName: tag, children: [], listeners: {}, attrs: {}, hidden: false, textContent: "", className: "", replaced: 0, elements: { topic: { value: "" }, tags: { value: "" } } }); } + append(...items) { this.children.push(...items); } + replaceChildren(...items) { this.children = items; this.replaced += 1; } + addEventListener(type, listener) { this.listeners[type] = listener; } + setAttribute(name, value) { this.attrs[name] = value; } + get firstElementChild() { return this.children[0]; } + remove() {} +} +function fakeDocument() { + const registry = new Map(); + return { registry, querySelector: (selector) => { if (!registry.has(selector)) registry.set(selector, new Element(selector)); return registry.get(selector); }, createElement: (tag) => new Element(tag), createTextNode: (text) => ({ text }) }; +} +const lots = [{ slug: "agents", title: "AI agents", capacity: 8 }, { slug: "voice", title: "Voice", capacity: 6 }]; + +test("room rows are created once and updated in place, so focus survives polling", () => { + const doc = fakeDocument(), panel = createPanel(doc), rooms = doc.registry.get("#stoa-rooms"); + panel.setRooms(lots, new Map()); + const [first, second] = rooms.children, button = first.children[2]; + assert.equal(first.children[1].textContent, "Open · 0 of 8 here"); + panel.setRooms(lots, new Map([["agents", { topic: "Evals", occupancy: 3, capacity: 8 }]])); + assert.deepEqual(rooms.children, [first, second]); + assert.equal(rooms.children[0].children[2], button, "the same Go to button stays in the page"); + assert.equal(first.children[1].textContent, "Evals · 3 of 8 here"); + assert.equal(rooms.replaced, 1, "the list was built once"); +}); +test("people are rebuilt only when who is here or a name changes", () => { + const doc = fakeDocument(), panel = createPanel(doc), list = doc.registry.get("#stoa-people"); + const ada = { id: "account:1", name: "Ada", walk: {} }; + panel.setPeople([ada], "Me"); + panel.setPeople([{ ...ada, walk: { moved: true } }], "Me"); + assert.equal(list.replaced, 1, "walking changes nothing visible in the list"); + panel.setPeople([{ ...ada, name: "Ada L." }], "Me"); + assert.equal(list.replaced, 2); + panel.setPeople([{ ...ada, name: "Ada L." }, { id: "account:2", name: "Bo" }], "Me"); + assert.equal(list.replaced, 3); + assert.equal(peopleKey([{ id: "b", name: "B" }, { id: "a", name: "A" }], "Me"), peopleKey([{ id: "a", name: "A" }, { id: "b", name: "B" }], "Me")); +}); +test("a reason replaces the sign-in prompt for people who are signed in", () => { + const doc = fakeDocument(), panel = createPanel(doc), signin = doc.registry.get("#stoa-signin"), button = doc.registry.get("#stoa-signin-button"), text = doc.registry.get("#stoa-signin-text"); + panel.canTalk(false); + assert.deepEqual([signin.hidden, button.hidden, text.textContent], [false, false, "Sign in to walk and talk."]); + panel.canTalk(false, "The plaza is full — you're watching."); + assert.deepEqual([signin.hidden, button.hidden, text.textContent], [false, true, "The plaza is full — you're watching."]); + panel.canTalk(true); + assert.equal(signin.hidden, true); +}); diff --git a/tests/stoa-place.test.mjs b/tests/stoa-place.test.mjs index 5b9f512..e0a0ad9 100644 --- a/tests/stoa-place.test.mjs +++ b/tests/stoa-place.test.mjs @@ -32,15 +32,15 @@ function fakeEngine() { say(text) { this.said.push(text); } }; } -async function opened({ guest = false, who = [], now = () => 1000, later } = {}) { +async function opened({ quiet = false, guest = false, who = [], now = () => 1000, later } = {}) { const live = fakeLive({ guest, who }), engine = fakeEngine(), seen = { people: [], said: [], refreshed: 0 }; - const place = await openPlace({ live, map: plaza, engine, self: { name: "Me" }, channel: { name: "ab-stoa-plaza", key: null, spaceId: "plaza" }, onPeople: (list) => { seen.people = list; }, onSay: (entry) => seen.said.push(entry), onRefresh: () => { seen.refreshed += 1; }, now, later }); + const place = await openPlace({ live, map: plaza, engine, self: { name: "Me" }, channel: { name: "ab-stoa-plaza", key: null, spaceId: "plaza" }, onPeople: (list) => { seen.people = list; }, onSay: (entry) => seen.said.push(entry), onRefresh: (from) => { seen.refreshed += 1; seen.refreshedBy = from; }, now, later, quiet }); return { live, engine, place, seen }; } test("opening a place joins its channel, shows who is there, and shares where you stand", async () => { const { live, place, seen } = await opened({ who: [{ userId: ada, states: state(22, 17, "Ada") }] }); - assert.deepEqual(live.joined, [["ab-stoa-plaza", { spaceId: "plaza", key: null }]]); + assert.deepEqual(live.joined, [["ab-stoa-plaza", { spaceId: "plaza", key: null, quiet: false }]]); assert.deepEqual(seen.people.map((person) => person.name), ["Ada"]); assert.deepEqual(live.states, [["ab-stoa-plaza", { x: "21", y: "17", dir: "up", name: "Me" }]]); assert.equal(place.channel, "ab-stoa-plaza"); @@ -57,10 +57,10 @@ test("movement is published within the rate limit, keeping the latest pending me time = 10000; engine.emit("walk", { from: { x: 21, y: 17 }, dir: "up", startedAt: 10000 }); engine.emit("stop", { at: { x: 21, y: 15 } }); - engine.emit("face", { dir: "left" }); + engine.emit("face", { dir: "left", at: { x: 21, y: 15 } }); engine.emit("arrive", { x: 21, y: 15 }); await tick(); - assert.deepEqual(live.published.slice(-4), [{ t: "walk", from: { x: 21, y: 17 }, dir: "up", startedAt: 10000 }, { t: "stop", at: { x: 21, y: 15 } }, { t: "face", dir: "left" }, { t: "stop", at: { x: 21, y: 15 } }]); + assert.deepEqual(live.published.slice(-4), [{ t: "walk", from: { x: 21, y: 17 }, dir: "up", startedAt: 10000 }, { t: "stop", at: { x: 21, y: 15 } }, { t: "face", dir: "left", at: { x: 21, y: 15 } }, { t: "stop", at: { x: 21, y: 15 } }]); assert.equal(live.states.length, 2, "arriving shares the new resting place"); }); test("messages and presence from others reach the people list and the message log", async () => { @@ -70,6 +70,7 @@ test("messages and presence from others reach the people list and the message lo assert.equal(seen.people[0].bubble.text, "Hi all"); live.handlers.message(ada, JSON.stringify({ t: "refresh" })); assert.equal(seen.refreshed, 1); + assert.equal(seen.refreshedBy, "account:72a639ba-3a45-4afe-936b-222222222222", "the publisher is passed on so the caller can check they host the room"); live.handlers.presence({ type: "state", userId: bo, states: state(20, 17, "Bo") }); assert.deepEqual(seen.people.map((person) => person.name), ["Ada", "Bo"]); live.handlers.presence({ type: "leave", userId: ada }); @@ -173,3 +174,24 @@ test("each lot has a walkable plaza tile just outside its door", () => { assert.ok(tile.y < 20, lot.slug + " is on the plaza, not inside"); } }); +test("watchers join quietly even when signed in", async () => { + const { live } = await opened({ quiet: true }); + assert.equal(live.joined[0][1].quiet, true); + assert.deepEqual(live.states, [], "and share nothing"); +}); +test("a message that fails to publish is reported as failed", async () => { + const { live, engine, place, seen } = await opened(); + live.publish = async () => { throw new Error("offline"); }; + assert.equal(place.say("hello"), true); + await tick(); + assert.deepEqual(seen.said.map((entry) => [entry.text, entry.self, entry.failed === true]), [["hello", true, false], ["hello", true, true]]); +}); +test("a 401 ends the lease, but a 429 or a network error keeps it going", async () => { + for (const [status, ends] of [[401, true], [429, false], [undefined, false]]) { + const losses = []; let beat, stopped = 0; + const api = async () => { throw Object.assign(new Error("no"), { status }); }; + createLease({ api, spaceId: "s", onLost: (e) => losses.push(e.status), repeat: (callback) => { beat = callback; return 1; }, stopRepeat: () => { stopped += 1; }, beacon: () => {} }); + await beat(); + assert.deepEqual([losses.length, stopped], ends ? [1, 1] : [0, 0], String(status)); + } +}); diff --git a/tests/stoa-protocol.test.mjs b/tests/stoa-protocol.test.mjs index 86e400d..c9ca66f 100644 --- a/tests/stoa-protocol.test.mjs +++ b/tests/stoa-protocol.test.mjs @@ -22,7 +22,7 @@ test("only well-formed messages are accepted", () => { assert.deepEqual(readMessage(writeMessage({ ...move, extra: "dropped" }), plaza), move); assert.deepEqual(readMessage(writeMessage({ t: "walk", from: { x: 21, y: 17 }, dir: "up", startedAt: 5 }), plaza), { t: "walk", from: { x: 21, y: 17 }, dir: "up", startedAt: 5 }); assert.deepEqual(readMessage(writeMessage({ t: "stop", at: { x: 21, y: 15 } }), plaza), { t: "stop", at: { x: 21, y: 15 } }); - assert.deepEqual(readMessage(writeMessage({ t: "face", dir: "left" }), plaza), { t: "face", dir: "left" }); + assert.deepEqual(readMessage(writeMessage({ t: "face", dir: "left", at: { x: 21, y: 17 } }), plaza), { t: "face", dir: "left", at: { x: 21, y: 17 } }); assert.deepEqual(readMessage(writeMessage({ t: "say", text: " hello " }), plaza), { t: "say", text: "hello" }); assert.deepEqual(readMessage(writeMessage({ t: "refresh" }), plaza), { t: "refresh" }); for (const bad of [ @@ -33,6 +33,8 @@ test("only well-formed messages are accepted", () => { writeMessage({ t: "walk", from: { x: 0, y: 0 }, dir: "up", startedAt: 1 }), writeMessage({ t: "walk", from: { x: 21, y: 17 }, dir: "north", startedAt: 1 }), writeMessage({ t: "stop", at: { x: 1.5, y: 2 } }), + writeMessage({ t: "face", dir: "left" }), + writeMessage({ t: "face", dir: "left", at: { x: 0, y: 0 } }), writeMessage({ t: "say", text: " " }), writeMessage({ t: "say", text: "x".repeat(501) }), "x".repeat(8193) @@ -129,7 +131,7 @@ test("moves, keyboard walks, stops, turns, and speech follow trusted messages", clock.time = 9000; room.message(ada, writeMessage({ t: "stop", at: { x: 6, y: 8 } })); assert.deepEqual(room.list()[0].walk.path, [{ x: 6, y: 8 }]); - room.message(ada, writeMessage({ t: "face", dir: "left" })); + room.message(ada, writeMessage({ t: "face", dir: "left", at: { x: 6, y: 8 } })); assert.deepEqual(room.list()[0].walk, { path: [{ x: 6, y: 8 }], startedAt: 9000, dir: "left" }); room.message(ada, writeMessage({ t: "say", text: "Hello!" })); assert.deepEqual(room.list()[0].bubble, { text: "Hello!", until: 9000 + BUBBLE_MS }); @@ -150,3 +152,10 @@ test("state updates rest people, never interrupt a walk, and blocked people disa room.state(ada, at(21, 17)); assert.deepEqual(room.list(), [], "blocked people stay hidden"); }); +test("a move that starts far from where the person is is dropped", () => { + const { room } = people(1000); + room.snapshot([{ userId: ada, states: at(21, 17) }]); + assert.equal(room.message(ada, writeMessage({ t: "move", path: [{ x: 21, y: 13 }, { x: 21, y: 12 }], startedAt: 1000 })), null); + assert.deepEqual(room.list()[0].walk.path, [{ x: 21, y: 17 }]); + assert.ok(room.message(ada, writeMessage({ t: "move", path: [{ x: 21, y: 15 }, { x: 21, y: 14 }], startedAt: 1000 })), "two tiles away still counts as the same walk"); +}); diff --git a/tests/stoa-signaling.test.mjs b/tests/stoa-signaling.test.mjs index 9665ced..4e380b7 100644 --- a/tests/stoa-signaling.test.mjs +++ b/tests/stoa-signaling.test.mjs @@ -10,8 +10,8 @@ const me = "a-72a639ba-3a45-4afe-936b-111111111111", ada = "a-72a639ba-3a45-4afe function fakeAgora() { const clients = []; class RTM { - constructor(appId, userId) { - Object.assign(this, { appId, userId, calls: [], handlers: {}, pages: [] }); + constructor(appId, userId, config) { + Object.assign(this, { appId, userId, config, calls: [], handlers: {}, pages: [] }); const self = this; this.presence = { async setState(...args) { self.calls.push(["setState", ...args]); }, @@ -21,7 +21,7 @@ function fakeAgora() { } addEventListener(type, handler) { this.handlers[type] = handler; } async login(options) { this.calls.push(["login", options]); } - async subscribe(...args) { this.calls.push(["subscribe", ...args]); } + async subscribe(...args) { if (this.failSubscribe) throw new Error("subscribe failed"); this.calls.push(["subscribe", ...args]); } async unsubscribe(...args) { this.calls.push(["unsubscribe", ...args]); } async publish(...args) { this.calls.push(["publish", ...args]); } async renewToken(token) { this.calls.push(["renewToken", token]); } @@ -29,10 +29,10 @@ function fakeAgora() { } return { AgoraRTM: { RTM }, clients }; } -async function connected(userId = me) { +async function connected(userId = me, onStatus) { const { AgoraRTM, clients } = fakeAgora(); let issued = 0; - const live = createLive({ AgoraRTM, fetchToken: async () => ({ appId: "a".repeat(32), userId, token: "token-" + (++issued), channels: [] }) }); + const live = createLive({ AgoraRTM, onStatus, fetchToken: async () => ({ appId: "a".repeat(32), userId, token: "token-" + (++issued), channels: [] }) }); await live.connect(); return { live, client: clients[0] }; } @@ -125,3 +125,45 @@ test("expiring tokens are renewed; leaving and closing stop delivery", async () await live.close(); assert.deepEqual(client.calls.at(-1), ["logout"]); }); +test("the client times people out after 30 seconds so ghosts clear quickly", async () => { + const { client } = await connected(); + assert.deepEqual(client.config, { presenceTimeout: 30 }); +}); +test("guests cannot publish or set state, and quiet joins subscribe quietly", async () => { + const guest = await connected("g-0123456789abcdef"); + await guest.live.join("ab-stoa-plaza", { spaceId: "plaza" }, recorder().handlers); + await assert.rejects(guest.live.publish("ab-stoa-plaza", "x"), /Guests cannot publish/); + await assert.rejects(guest.live.setState("ab-stoa-plaza", { x: "1" }), /Guests cannot publish/); + assert.equal(guest.client.calls.filter(([name]) => name === "publish" || name === "setState").length, 0); + const member = await connected(); + await member.live.join("ab-stoa-plaza", { spaceId: "plaza", quiet: true }, recorder().handlers); + assert.equal(member.client.calls.at(-1)[2].beQuiet, true); +}); +test("a failed subscribe forgets the channel and rethrows", async () => { + const { live, client } = await connected(); + client.failSubscribe = true; + const plaza = recorder(); + await assert.rejects(live.join("ab-stoa-plaza", { spaceId: "plaza" }, plaza.handlers), /subscribe failed/); + client.handlers.message({ channelName: "ab-stoa-plaza", publisher: ada, message: "late" }); + await settle(); + assert.deepEqual(plaza.seen.messages, []); + await assert.rejects(live.publish("ab-stoa-plaza", "x"), /Not in channel/); +}); +test("presence still being decrypted when a channel is left is not delivered", async () => { + const keyText = randomBytes(32).toString("base64"), key = await importKey(keyText); + const { live, client } = await connected(); + const hidden = recorder(); + await live.join("ab-stoa-secret", { spaceId: "s1", key: keyText }, hidden.handlers); + client.handlers.presence({ channelName: "ab-stoa-secret", eventType: "REMOTE_STATE_CHANGED", publisher: ada, stateChanged: { s: await seal(key, JSON.stringify({ x: "1", y: "2" })) } }); + for (let index = 0; index < 3; index += 1) await Promise.resolve(); + await live.leave("ab-stoa-secret"); + await settle(); + assert.deepEqual(hidden.seen.changes, []); +}); +test("a second login with the same user is reported as its own status", async () => { + const seen = []; + const { client } = await connected(me, (kind, detail) => seen.push([kind, detail?.reasonCode || detail?.currentState])); + client.handlers.linkState({ currentState: "FAILED", reasonCode: "SAME_UID_LOGIN" }); + client.handlers.linkState({ currentState: "FAILED", reasonCode: "UNKNOWN" }); + assert.deepEqual(seen, [["duplicate", "SAME_UID_LOGIN"], ["link", "UNKNOWN"]]); +}); diff --git a/tests/stoa-stage.test.mjs b/tests/stoa-stage.test.mjs index d7209f5..12023ad 100644 --- a/tests/stoa-stage.test.mjs +++ b/tests/stoa-stage.test.mjs @@ -10,26 +10,28 @@ const tick = () => new Promise((resolve) => setImmediate(resolve)); const deferred = () => { const d = {}; d.promise = new Promise((resolve, reject) => { d.resolve = resolve; d.reject = reject; }); return d; }; const space = (id, title = id) => ({ id, title, topic: null, tags: [], visibility: "public" }); -function setup({ api, signedIn = true } = {}) { - const calls = { teleports: [], bounds: [], interactive: null, published: [], states: [], canTalk: [], showRoom: [], offer: 0, joined: [], left: [] }; - const live = { +function setup({ api, signedIn = true, withLive = true, clock = { time: 0, timers: [] } } = {}) { + const calls = { teleports: [], bounds: [], interactive: null, published: [], states: [], canTalk: [], reasons: [], showRoom: [], offer: 0, joined: [], left: [], others: 0, people: 0, handlers: {}, quiet: {} }; + const frames = []; + const live = !withLive ? null : { userId: "a-me", guest: false, renew: async () => {}, - join: async (name) => { calls.joined.push(name); }, leave: async (name) => { calls.left.push(name); }, + join: async (name, options, handlers) => { calls.joined.push(name); calls.handlers[name] = handlers; calls.quiet[name] = options.quiet; }, leave: async (name) => { calls.left.push(name); }, publish: async (name, text) => { calls.published.push(text); }, setState: async (name, text) => { calls.states.push(text); }, who: async () => [] }; const handlers = {}, pos = { x: lot.door.x, y: lot.door.y }; const engine = { - teleport: (tile) => { calls.teleports.push(tile); }, setBounds: (bounds) => { calls.bounds.push(bounds); }, setOthers() {}, position: () => pos, setInteractive: (value) => { calls.interactive = value; }, facing: () => "down", say() {}, walkTo: () => true, + teleport: (tile) => { calls.teleports.push(tile); }, setBounds: (bounds) => { calls.bounds.push(bounds); }, setOthers() { calls.others += 1; }, position: () => pos, setInteractive: (value) => { calls.interactive = value; }, facing: () => "down", say() {}, walkTo: () => true, on(name, handler) { handlers[name] = handler; return () => {}; } }; const panel = { - said: [], say(message) { this.said.push(message); }, setPeople() {}, addMessage() {}, clearMessages() {}, showPlaza() {}, hideOffer() {}, - canTalk: (value) => calls.canTalk.push(value), showRoom: (room) => calls.showRoom.push(room), showOffer: () => { calls.offer += 1; } + said: [], say(message) { this.said.push(message); }, setPeople() { calls.people += 1; }, addMessage(entry) { this.added = (this.added || 0) + 1; }, clearMessages() {}, showPlaza() {}, hideOffer() {}, + canTalk: (value, reason) => { calls.canTalk.push(value); calls.reasons.push(reason); }, showRoom: (room) => calls.showRoom.push(room), showOffer: () => { calls.offer += 1; } }; const leases = []; const createLease = (options) => { const entry = { options, active: true, stop() { entry.active = false; }, async leave() { entry.active = false; }, leaveOnUnload() { entry.active = false; } }; leases.push(entry); return entry; }; - const stage = createStage({ api, map: plaza, engine, panel, live, self: { id: "a-me", name: "Me", signedIn }, plaza: { bounds: null, channel: "plaza-1" }, createLease }); - return { stage, calls, panel, leases, handlers }; + const stage = createStage({ api, map: plaza, engine, panel, live, self: { id: "a-me", name: "Me", signedIn }, plaza: { bounds: null, channel: "plaza-1" }, createLease, schedule: (callback) => frames.push(callback), now: () => clock.time, later: (callback, delay) => { clock.timers.push({ callback, delay }); return clock.timers.length; } }); + const flushFrames = () => { for (const callback of frames.splice(0)) callback(); }; + return { stage, calls, panel, leases, handlers, live, flushFrames, clock }; } const access = (id, channel) => ({ space: space(id), channel, key: null, blocked: [], hostId: "a-me" }); @@ -181,3 +183,149 @@ test("a guest arriving on a door is asked to sign in without calling the API", a assert.equal(panel.said.at(-1), "Sign in to step into " + lot.title + "."); assert.deepEqual(paths, []); }); + +const hostUser = "a-72a639ba-3a45-4afe-936b-222222222222", hostActor = "account:72a639ba-3a45-4afe-936b-222222222222", guestUser = "a-72a639ba-3a45-4afe-936b-333333333333"; +const present = (userId, name) => ({ userId, states: { x: String(lot.entry.x), y: String(lot.entry.y), dir: "down", name } }); + +test("many presence events in one frame update the engine and panel once", async () => { + const api = async (path) => path.endsWith("/enter") ? access("plaza", "plaza-1") : {}; + const { stage, calls, flushFrames } = setup({ api }); + await stage.toPlaza(null); + flushFrames(); + const [othersBefore, peopleBefore] = [calls.others, calls.people]; + const handlers = calls.handlers["plaza-1"]; + for (let index = 0; index < 50; index += 1) handlers.presence({ type: "state", userId: "a-72a639ba-3a45-4afe-936b-" + String(index).padStart(12, "0"), states: { x: String(lot.door.x), y: String(lot.door.y + 1), dir: "down", name: "P" + index } }); + assert.equal(calls.others, othersBefore, "nothing is drawn until the frame"); + flushFrames(); + assert.deepEqual([calls.others - othersBefore, calls.people - peopleBefore], [1, 1]); +}); + +async function inLot({ hostBy = hostActor } = {}) { + const gets = [], refreshes = []; + const api = async (path) => { + if (path === "/api/spaces/plaza/enter") return access("plaza", "plaza-1"); + if (path.endsWith("/enter")) return { ...access("lot-" + lot.slug, "lot-ch"), hostId: hostBy }; + if (path.startsWith("/api/spaces/lot-") && !path.includes("/leave")) { gets.push(path); const wait = deferred(); refreshes.push(wait); return wait.promise; } + return {}; + }; + const env = setup({ api }); + await env.stage.toPlaza(null); + await env.stage.enterLot(lot); + const handlers = env.calls.handlers["lot-ch"]; + handlers.presence({ type: "snapshot", people: [present(hostUser, "Host"), present(guestUser, "Visitor")] }); + const say = (user) => handlers.message(user, JSON.stringify({ t: "refresh" })); + return { ...env, gets, refreshes, say }; +} + +test("refresh requests are coalesced to one in flight and one every five seconds", async () => { + const { gets, refreshes, say, clock } = await inLot(); + for (let index = 0; index < 10; index += 1) say(hostUser); + await tick(); + assert.equal(gets.length, 1, "one request while the first is in flight"); + refreshes[0].resolve({ space: space("lot-" + lot.slug) }); + await tick(); await tick(); + assert.equal(gets.length, 1, "the trailing request waits for the gap"); + assert.equal(clock.timers.length, 1); + assert.ok(clock.timers[0].delay > 0 && clock.timers[0].delay <= 5000); + clock.time += 5000; clock.timers[0].callback(); + await tick(); + assert.equal(gets.length, 2); +}); + +test("refresh messages from anyone but the host are ignored", async () => { + const { gets, say } = await inLot(); + say(guestUser); + await tick(); + assert.equal(gets.length, 0); + say(hostUser); + await tick(); + assert.equal(gets.length, 1); +}); + +test("a lease lost with 410 in a lot or space re-enters once; 403 still ends it", async () => { + for (const [status, expected] of [[410, "reentered"], [403, "removed"]]) { + const paths = []; + const api = async (path) => { + paths.push(path); + if (path === "/api/spaces/plaza/enter") return access("plaza", "plaza-1"); + if (path.endsWith("/enter")) return access("lot-" + lot.slug, "lot-ch"); + return {}; + }; + const { stage, leases, calls } = setup({ api }); + await stage.toPlaza(null); + await stage.enterLot(lot); + const lotEnters = () => paths.filter((path) => path === "/api/spaces/lot-" + lot.slug + "/enter").length; + leases.at(-1).options.onLost({ status }); + for (let i = 0; i < 6; i += 1) await tick(); + if (expected === "reentered") { + assert.equal(lotEnters(), 2); + assert.deepEqual(calls.bounds.at(-1), lot.interior); + } else { + assert.equal(lotEnters(), 1); + assert.notDeepEqual(calls.bounds.at(-1), lot.interior); + } + assert.equal(leases.filter((entry) => entry.active).length, 1, String(status)); + } +}); + +test("a lost space lease re-enters with the same invite, then reports it gone if that fails", async () => { + const bodies = []; let fail = false; + const api = async (path, options) => { + if (path.endsWith("/enter")) { bodies.push(options?.body); if (fail) { const error = new Error("hidden"); error.status = 404; throw error; } return access("sp-1", "sp-ch"); } + return {}; + }; + const { stage, leases, panel, calls } = setup({ api }); + await stage.openSpace("sp-1", "inv"); + leases.at(-1).options.onLost({ status: 410 }); + for (let i = 0; i < 6; i += 1) await tick(); + assert.deepEqual(bodies, ['{"invite":"inv"}', '{"invite":"inv"}']); + fail = true; + leases.at(-1).options.onLost({ status: 410 }); + for (let i = 0; i < 6; i += 1) await tick(); + assert.equal(panel.said.at(-1), "You're no longer in this space."); + assert.equal(calls.canTalk.at(-1), false); + assert.equal(calls.reasons.at(-1), "You're no longer in this space."); +}); + +test("signed-in watchers of a full plaza are told why, not asked to sign in", async () => { + const api = async () => { const error = new Error("full"); error.status = 409; error.data = { full: true, offer: { spaces: [], canCreate: false } }; throw error; }; + const { stage, calls } = setup({ api }); + await stage.toPlaza(null); + assert.equal(calls.canTalk.at(-1), false); + assert.match(calls.reasons.at(-1), /plaza is full/); + assert.equal(calls.quiet["plaza-1"], true, "watchers subscribe quietly"); + const guest = setup({ api, signedIn: false }); + await guest.stage.toPlaza(null); + assert.equal(guest.calls.reasons.at(-1), undefined); +}); + +test("a message that fails to send is reported in the status line", async () => { + const api = async (path) => path.endsWith("/enter") ? access("plaza", "plaza-1") : {}; + const { stage, panel, live } = setup({ api }); + await stage.toPlaza(null); + live.publish = async () => { throw new Error("offline"); }; + assert.equal(stage.say("hello"), true); + await tick(); await tick(); + assert.equal(panel.said.at(-1), "That message didn't send."); +}); + +test("connecting live after the stage started opens the current place's channel", async () => { + const api = async (path) => path.endsWith("/enter") ? access("plaza", "plaza-1") : {}; + const { stage, calls, live } = setup({ api, withLive: false }); + await stage.toPlaza(null); + assert.deepEqual(calls.joined, []); + const later = setup({ api }).live; + const joined = []; + later.join = async (name, options, handlers) => { joined.push([name, options.quiet]); calls.handlers[name] = handlers; }; + await stage.attachLive(later, "plaza-1"); + assert.deepEqual(joined, [["plaza-1", false]]); +}); + +test("connecting live while watching the plaza joins it quietly", async () => { + const api = async () => { const error = new Error("full"); error.status = 409; error.data = { full: true, offer: { spaces: [], canCreate: false } }; throw error; }; + const { stage } = setup({ api, withLive: false }); + await stage.toPlaza(null); + const joined = [], later = { userId: "a-me", guest: false, renew: async () => {}, join: async (name, options) => { joined.push([name, options.quiet]); }, leave: async () => {}, publish: async () => {}, setState: async () => {}, who: async () => [] }; + await stage.attachLive(later, "plaza-1"); + assert.deepEqual(joined, [["plaza-1", true]]); +}); diff --git a/tests/world-engine.test.mjs b/tests/world-engine.test.mjs index 15326e0..6bb4b1d 100644 --- a/tests/world-engine.test.mjs +++ b/tests/world-engine.test.mjs @@ -10,10 +10,10 @@ const plaza = parseMap(JSON.parse(await readFile(new URL("../worlds/plaza/map.js const [agora, minimal] = loadThemes(root); const key = (name) => ({ key: name, preventDefault() {} }); -function setup({ reducedMotion = false, theme = agora } = {}) { +function setup({ reducedMotion = false, theme = agora, size = [640, 480] } = {}) { let time = 1000; const frames = [], timers = [], drawn = [], themes = [], handlers = {}, events = { move: [], arrive: [], walk: [], stop: [], face: [] }; - const canvas = { clientWidth: 640, clientHeight: 480, addEventListener: (type, listener) => { handlers[type] = listener; }, removeEventListener: (type) => { delete handlers[type]; }, getBoundingClientRect: () => ({ left: 0, top: 0 }) }; + const canvas = { clientWidth: size[0], clientHeight: size[1], addEventListener: (type, listener) => { handlers[type] = listener; }, removeEventListener: (type) => { delete handlers[type]; }, getBoundingClientRect: () => ({ left: 0, top: 0 }) }; const renderer = { resize() {}, setTheme: (theme) => themes.push(theme), draw: (frame) => drawn.push(frame) }; const engine = createEngine({ canvas, map: plaza, theme, start: plaza.spawns[0], reducedMotion, renderer, now: () => time, raf: (callback) => frames.push(callback), later: (callback) => { timers.push(callback); return timers.length; }, cancel() {}, ratio: () => 1 }); for (const type of Object.keys(events)) engine.on(type, (value) => events[type].push(value)); @@ -198,7 +198,7 @@ test("a key toward a wall only turns you and says so", () => { const { engine, handlers, events, flush, advance, drawn } = setup(); engine.walkTo({ x: 1, y: 18 }); advance(10000); flush(); handlers.keydown(key("s")); flush(); - assert.deepEqual(events.face, [{ dir: "down" }]); + assert.deepEqual(events.face, [{ dir: "down", at: { x: 1, y: 18 } }]); assert.equal(events.walk.length, 0); assert.equal(drawn.at(-1).avatars[0].dir, "down"); }); @@ -252,3 +252,12 @@ test("a decoration in front of a keyboard walk stops it before the decoration", engine.setDecor([{ kind: "statue", x: 21, y: 14 }]); assert.deepEqual(events.stop, [{ at: { x: 21, y: 15 } }]); }); +test("only people within about two screens of the camera are drawn, but everyone is kept", () => { + const { engine, drawn, flush, advance } = setup({ size: [320, 240] }); + const others = [{ id: "bo", name: "Bo", walk: { path: [{ x: 22, y: 17 }], startedAt: 0 } }, { id: "far", name: "Far", walk: { path: [{ x: 42, y: 1 }], startedAt: 0 } }]; + engine.setOthers(others); flush(); + assert.deepEqual(drawn.at(-1).avatars.map((avatar) => avatar.id), ["self", "bo"]); + assert.equal(engine.walkTo({ x: 42, y: 1 }), true); + advance(10000); engine.setOthers(others); flush(); + assert.equal(drawn.at(-1).avatars.length, 3, "the far person is drawn again once the camera is near"); +}); diff --git a/tests/world-renderer.test.mjs b/tests/world-renderer.test.mjs index 7bb3781..acba333 100644 --- a/tests/world-renderer.test.mjs +++ b/tests/world-renderer.test.mjs @@ -13,10 +13,10 @@ const [agora, minimal, cyberpunk] = loadThemes(root); const whole = { x: 0, y: 0, zoom: 1 }; // A recording 2D context: every method call and property assignment is logged in order. -function fakeCanvas() { +function fakeCanvas(measure = () => 20) { const calls = [], values = {}; const ctx = new Proxy(values, { - get: (target, name) => name in target ? target[name] : name === "measureText" ? () => ({ width: 20 }) : (...args) => { calls.push([name, ...args]); }, + get: (target, name) => name in target ? target[name] : name === "measureText" ? (text) => ({ width: measure(text) }) : (...args) => { calls.push([name, ...args]); }, set: (target, name, value) => { target[name] = value; calls.push(["set:" + String(name), value]); return true; } }); return { canvas: { width: 0, height: 0, getContext: () => ctx }, calls }; @@ -118,3 +118,11 @@ test("speech bubbles are drawn above avatars that are talking", () => { const texts = calls.filter(([name]) => name === "fillText").map(([, text]) => text); assert.equal(texts.filter((text) => text === "Hello there").length, 1); }); +test("a single very long word in a bubble is cut to fit with an ellipsis", () => { + const { canvas, calls } = fakeCanvas((text) => String(text).length * 7); + const renderer = createCanvasRenderer(canvas, { map: plaza, theme: agora }); + renderer.resize(640, 480, 1); + renderer.draw({ camera: whole, avatars: [{ id: "a", name: "Ada", x: 5, y: 5, dir: "down", bubble: "x".repeat(80) + " ok" }], motion: false }); + const lines = calls.filter(([name]) => name === "fillText").map(([, text]) => text).filter((text) => text.includes("x")); + assert.ok(lines.length > 0 && lines.every((text) => text.length * 7 <= 160 && text.endsWith("…")), lines.join("|")); +}); diff --git a/world/engine.js b/world/engine.js index 8117c5e..9c25ae5 100644 --- a/world/engine.js +++ b/world/engine.js @@ -8,7 +8,7 @@ import { createCanvasRenderer } from "./renderer-canvas.js"; // Times are wall-clock (Date.now) because walk start times are published, so peers share the clock. // One person's movement, input, camera, and drawing. Other people arrive through setOthers. // Events: "move" { path, startedAt } for pointer walks; "walk" { from, dir, startedAt } and "stop" { at } for keyboard -// walks; "face" { dir } when a key only turns you; "arrive" { x, y } when a walk ends. +// walks; "face" { dir, at } when a key only turns you; "arrive" { x, y } when a walk ends. export function createEngine({ canvas, map, theme, start, reducedMotion = false, renderer = createCanvasRenderer(canvas, { map, theme }), now = () => Date.now(), raf = (callback) => requestAnimationFrame(callback), later = (callback, delay) => setTimeout(callback, delay), cancel = (handle) => clearTimeout(handle), ratio = () => globalThis.devicePixelRatio || 1 }) { const listeners = { arrive: [], move: [], walk: [], stop: [], face: [] }; const self = { id: "self", name: "You", walk: { path: [start], startedAt: now(), dir: "down" }, arrived: true, keyboard: false, bubble: null }; @@ -33,7 +33,9 @@ export function createEngine({ canvas, map, theme, start, reducedMotion = false, if (reached.done && !self.arrived) { self.arrived = true; self.keyboard = false; emit("arrive", { x: reached.x, y: reached.y }); if (held) keyWalk(held); } const shown = here(time); camera = follow({ x: (shown.x + 0.5) * s, y: (shown.y + 0.5) * s }, viewport, { x: area.x * s, y: area.y * s, width: area.width * s, height: area.height * s }, zoomFor(viewport, s)); - const crowd = others.map((other) => ({ id: other.id, name: other.name, bubble: speech(other.bubble, time), ...positionAt(other.walk, time) })); + // Only people within a screen of the camera centre in each direction are drawn; everyone else is kept for when they come near. + const reach = { x: viewport.width / camera.zoom / s, y: viewport.height / camera.zoom / s }, middle = { x: (camera.x + viewport.width / camera.zoom / 2) / s, y: (camera.y + viewport.height / camera.zoom / 2) / s }; + const crowd = others.map((other) => ({ id: other.id, name: other.name, bubble: speech(other.bubble, time), ...positionAt(other.walk, time) })).filter((other) => Math.abs(other.x + 0.5 - middle.x) <= reach.x && Math.abs(other.y + 0.5 - middle.y) <= reach.y); renderer.draw({ camera, time, avatars: [{ id: self.id, name: self.name, self: true, bubble: speech(self.bubble, time), ...shown }, ...crowd], decor, labels, bounds, motion: !reducedMotion }); const talking = [self.bubble, ...others.map((other) => other.bubble)].filter((bubble) => bubble && bubble.until > time); if (!shown.done || crowd.some((other) => !other.done)) schedule(); @@ -74,7 +76,7 @@ export function createEngine({ canvas, map, theme, start, reducedMotion = false, const time = now(), position = here(time); if (!position.done) return; const from = { x: position.x, y: position.y }, path = straightPath(map, from, dir, blocked()); - if (path.length < 2) { self.walk = { path: [from], startedAt: time, dir }; emit("face", { dir }); schedule(); return; } + if (path.length < 2) { self.walk = { path: [from], startedAt: time, dir }; emit("face", { dir, at: { x: from.x, y: from.y } }); schedule(); return; } self.walk = { path, startedAt: time, dir }; self.arrived = false; self.keyboard = true; emit("walk", { from, dir, startedAt: time }); diff --git a/world/presence.js b/world/presence.js index 818b02c..9a8c4da 100644 --- a/world/presence.js +++ b/world/presence.js @@ -25,10 +25,13 @@ export function createPresence({ map, self, now = () => Date.now() }) { const message = readMessage(text, map); if (!message) return null; const person = people.get(publisher), time = now(); + // A path that starts far from where the person is can't be a real walk. + const start = message.t === "move" ? message.path[0] : message.t === "walk" ? message.from : null; + if (start) { const tile = tileOf(person, time); if (Math.max(Math.abs(start.x - tile.x), Math.abs(start.y - tile.y)) > 2) return null; } if (message.t === "move") person.walk = { path: message.path, startedAt: shared(message.startedAt, time) }; else if (message.t === "walk") person.walk = { path: straightPath(map, message.from, message.dir), startedAt: shared(message.startedAt, time), dir: message.dir }; else if (message.t === "stop") person.walk = stopAt(person.walk, message.at) || { path: [message.at], startedAt: time, dir: tileOf(person, time).dir }; - else if (message.t === "face") { const tile = tileOf(person, time); person.walk = { path: [{ x: tile.x, y: tile.y }], startedAt: time, dir: message.dir }; } + else if (message.t === "face") person.walk = { path: [message.at], startedAt: time, dir: message.dir }; else if (message.t === "say") person.bubble = { text: message.text, until: time + BUBBLE_MS }; return { ...message, from: { id: person.id, name: person.name } }; }, diff --git a/world/protocol.js b/world/protocol.js index 71c74c0..84f29c9 100644 --- a/world/protocol.js +++ b/world/protocol.js @@ -21,7 +21,7 @@ export function readMessage(text, map) { case "move": return Array.isArray(data.path) && data.path.every(isTile) && validPath(map, data.path) && Number.isFinite(data.startedAt) ? { t: "move", path: data.path.map(copy), startedAt: data.startedAt } : null; case "walk": return isTile(data.from) && walkable(map, data.from.x, data.from.y) && DIRS.includes(data.dir) && Number.isFinite(data.startedAt) ? { t: "walk", from: copy(data.from), dir: data.dir, startedAt: data.startedAt } : null; case "stop": return isTile(data.at) && walkable(map, data.at.x, data.at.y) ? { t: "stop", at: copy(data.at) } : null; - case "face": return DIRS.includes(data.dir) ? { t: "face", dir: data.dir } : null; + case "face": return DIRS.includes(data.dir) && isTile(data.at) && walkable(map, data.at.x, data.at.y) ? { t: "face", dir: data.dir, at: copy(data.at) } : null; case "say": { const say = typeof data.text === "string" ? data.text.trim() : ""; return say && data.text.length <= TEXT_LIMIT ? { t: "say", text: say } : null; } case "refresh": return { t: "refresh" }; default: return null; diff --git a/world/renderer-canvas.js b/world/renderer-canvas.js index 4eaafa8..8b65efc 100644 --- a/world/renderer-canvas.js +++ b/world/renderer-canvas.js @@ -62,13 +62,15 @@ function label(ctx, text, cx, top, font, theme) { // A speech bubble above an avatar: up to three wrapped lines, the last ending with an ellipsis when cut. function bubble(ctx, text, cx, bottom, font, theme) { ctx.font = "500 11px " + font; + const fit = (line) => { let cut = line; while (cut.length > 1 && ctx.measureText(cut + "…").width > 160) cut = cut.slice(0, -1); return cut + "…"; }; const lines = []; - for (const word of String(text).split(/\s+/).filter(Boolean)) { + for (const piece of String(text).split(/\s+/).filter(Boolean)) { + const word = ctx.measureText(piece).width > 160 ? fit(piece) : piece; const last = lines.length ? lines[lines.length - 1] + " " + word : null; if (last !== null && ctx.measureText(last).width <= 160) lines[lines.length - 1] = last; else lines.push(word); } const shown = lines.slice(0, 3); - if (lines.length > 3) shown[2] = shown[2] + "…"; + if (lines.length > 3) shown[2] = ctx.measureText(shown[2] + "…").width > 160 ? fit(shown[2]) : shown[2] + "…"; const width = Math.min(176, Math.max(...shown.map((line) => ctx.measureText(line).width)) + 12), height = shown.length * 14 + 8, left = cx - width / 2, top = bottom - height - 6; const surface = theme.ui?.["--surface"] || "#ffffff"; box(ctx, surface, left, top, width, height); diff --git a/world/signaling.js b/world/signaling.js index fb0ccc0..28ee55a 100644 --- a/world/signaling.js +++ b/world/signaling.js @@ -5,6 +5,7 @@ import { importKey, open, seal } from "./crypto.js"; export function createLive({ AgoraRTM, fetchToken, onStatus = () => {} }) { let client = null, userId = null, queue = Promise.resolve(); const channels = new Map(); + const guest = () => String(userId).startsWith("g-"); // Events are handled one at a time so decryption never reorders presence or messages. const enqueue = (kind, task) => { queue = queue.then(task).catch((error) => onStatus(kind, error)); }; async function statesOf(channel, states) { @@ -20,7 +21,7 @@ export function createLive({ AgoraRTM, fetchToken, onStatus = () => {} }) { async function onPresence(event) { const channel = channels.get(event.channelName); if (!channel) return; - const emit = (change) => channel.handlers.presence(change); + const emit = (change) => { if (channels.get(event.channelName) === channel) channel.handlers.presence(change); }; if (event.eventType === "SNAPSHOT") emit({ type: "snapshot", people: await people(channel, event.snapshot) }); else if (event.eventType === "REMOTE_JOIN") { if (event.publisher !== userId) emit({ type: "join", userId: event.publisher }); } else if (event.eventType === "REMOTE_LEAVE" || event.eventType === "REMOTE_TIMEOUT") { if (event.publisher !== userId) emit({ type: "leave", userId: event.publisher }); } @@ -43,30 +44,33 @@ export function createLive({ AgoraRTM, fetchToken, onStatus = () => {} }) { async function renew() { const access = await fetchToken(); await client.renewToken(access.token); return access; } return { get userId() { return userId; }, - get guest() { return String(userId).startsWith("g-"); }, + get guest() { return guest(); }, async connect() { const access = await fetchToken(); userId = access.userId; - client = new AgoraRTM.RTM(access.appId, userId); + client = new AgoraRTM.RTM(access.appId, userId, { presenceTimeout: 30 }); client.addEventListener("message", (event) => enqueue("message", () => onMessage(event))); client.addEventListener("presence", (event) => enqueue("presence", () => onPresence(event))); client.addEventListener("tokenPrivilegeWillExpire", () => { renew().catch((error) => onStatus("token", error)); }); - client.addEventListener("linkState", (event) => onStatus("link", event)); + client.addEventListener("linkState", (event) => onStatus(event?.reasonCode === "SAME_UID_LOGIN" ? "duplicate" : "link", event)); await client.login({ token: access.token }); return access; }, renew, - async join(name, { spaceId, key = null }, handlers) { + async join(name, { spaceId, key = null, quiet = false }, handlers) { channels.set(name, { spaceId, key: key ? await importKey(key) : null, handlers }); - await client.subscribe(name, { withMessage: true, withPresence: true, beQuiet: String(userId).startsWith("g-") }); + try { await client.subscribe(name, { withMessage: true, withPresence: true, beQuiet: guest() || Boolean(quiet) }); } + catch (error) { channels.delete(name); throw error; } }, async leave(name) { if (!channels.delete(name)) return; await client.unsubscribe(name).catch(() => {}); }, async publish(name, text) { + if (guest()) throw new Error("Guests cannot publish."); const channel = channels.get(name); if (!channel) throw new Error("Not in channel " + name + "."); await client.publish(name, channel.key ? await seal(channel.key, text) : text); }, async setState(name, state) { + if (guest()) throw new Error("Guests cannot publish."); const channel = channels.get(name); if (!channel) return; await client.presence.setState(name, "MESSAGE", channel.key ? { s: await seal(channel.key, JSON.stringify(state)) } : state); From 1bce889788f9ce3dc5d45de2f70f4605961a7c16 Mon Sep 17 00:00:00 2001 From: Brent G Date: Thu, 8 Oct 2026 09:29:27 +0000 Subject: [PATCH 47/71] Record follow-ups for the Stoa live plan MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🤖 Built with SMT --- docs/superpowers/plans/2026-10-08-stoa-live.md | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/docs/superpowers/plans/2026-10-08-stoa-live.md b/docs/superpowers/plans/2026-10-08-stoa-live.md index 7ca50f9..47949c4 100644 --- a/docs/superpowers/plans/2026-10-08-stoa-live.md +++ b/docs/superpowers/plans/2026-10-08-stoa-live.md @@ -2054,3 +2054,14 @@ git commit -m "Cap the plaza at 200 people" -m "🤖 Built with SMT Date: Thu, 8 Oct 2026 18:24:15 +0000 Subject: [PATCH 48/71] Keep drawing people who walk in from off-screen MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Culling hid walkers from the redraw decision, so with reduced motion or the Minimal theme a person crossing into view was only drawn once they stopped. Positions are now computed for everyone, a slow check keeps running while someone walks off-screen, and only nearby people are drawn. 🤖 Built with SMT --- docs/superpowers/plans/2026-10-08-stoa-live.md | 2 +- tests/world-engine.test.mjs | 13 +++++++++++++ world/engine.js | 7 +++++-- 3 files changed, 19 insertions(+), 3 deletions(-) diff --git a/docs/superpowers/plans/2026-10-08-stoa-live.md b/docs/superpowers/plans/2026-10-08-stoa-live.md index 47949c4..1d763ed 100644 --- a/docs/superpowers/plans/2026-10-08-stoa-live.md +++ b/docs/superpowers/plans/2026-10-08-stoa-live.md @@ -2057,7 +2057,7 @@ Deferred to Plan C2: calls inside rooms, decorate mode UI, hand over hosting and ## Follow-ups (from the final review) -- **Open, important:** the engine's off-screen culling also decides whether to keep animating. Someone walking in from beyond the cull margin isn't redrawn while crossing the screen when nothing else animates (reduced motion or the Minimal theme). Fix: compute every other person's position to decide on scheduling, and cull only the avatars that are drawn. +- **Fixed 2026-10-08:** the engine's off-screen culling also decided whether to keep animating. Someone walking in from beyond the cull margin isn't redrawn while crossing the screen when nothing else animates (reduced motion or the Minimal theme). Fix: compute every other person's position to decide on scheduling, and cull only the avatars that are drawn. - **410 re-entry:** re-entry after a 410 has no cap across separate losses. A server that keeps answering 410 causes an enter every heartbeat. - **Far-start drop:** after the latest-only limiter replaces a `stop`, a peer's next `walk` can start more than 2 tiles from the receiver's model and is dropped until the next `stop` or state. Consider a looser tolerance, or resyncing to the walk's `from` when the person's model is mid-walk. - **Bubble truncation:** it measures text one character at a time. Use a binary search or cache per bubble text. diff --git a/tests/world-engine.test.mjs b/tests/world-engine.test.mjs index 6bb4b1d..2954346 100644 --- a/tests/world-engine.test.mjs +++ b/tests/world-engine.test.mjs @@ -261,3 +261,16 @@ test("only people within about two screens of the camera are drawn, but everyone advance(10000); engine.setOthers(others); flush(); assert.equal(drawn.at(-1).avatars.length, 3, "the far person is drawn again once the camera is near"); }); +test("someone walking in from off-screen is drawn as they arrive, even with nothing else animating", () => { + const { engine, drawn, frames, timers, advance } = setup({ reducedMotion: true, theme: minimal, size: [200, 200] }); + const path = []; + for (let x = 1; x <= 12; x += 1) path.push({ x, y: 9 }); + engine.setOthers([{ id: "far", name: "Far", walk: { path, startedAt: 1000 } }]); + const run = () => { for (const callback of [...frames.splice(0), ...timers.splice(0)]) callback(); }; + run(); + assert.equal(drawn.at(-1).avatars.some((avatar) => avatar.id === "far"), false, "far away people are not drawn"); + for (let step = 0; step < 20; step += 1) { advance(100); run(); } + const far = drawn.at(-1).avatars.find((avatar) => avatar.id === "far"); + assert.ok(far, "the walker is drawn once they come into view"); + assert.equal(far.x, 11, "and at their real position while still walking"); +}); diff --git a/world/engine.js b/world/engine.js index 9c25ae5..2fe6e3b 100644 --- a/world/engine.js +++ b/world/engine.js @@ -35,11 +35,14 @@ export function createEngine({ canvas, map, theme, start, reducedMotion = false, camera = follow({ x: (shown.x + 0.5) * s, y: (shown.y + 0.5) * s }, viewport, { x: area.x * s, y: area.y * s, width: area.width * s, height: area.height * s }, zoomFor(viewport, s)); // Only people within a screen of the camera centre in each direction are drawn; everyone else is kept for when they come near. const reach = { x: viewport.width / camera.zoom / s, y: viewport.height / camera.zoom / s }, middle = { x: (camera.x + viewport.width / camera.zoom / 2) / s, y: (camera.y + viewport.height / camera.zoom / 2) / s }; - const crowd = others.map((other) => ({ id: other.id, name: other.name, bubble: speech(other.bubble, time), ...positionAt(other.walk, time) })).filter((other) => Math.abs(other.x + 0.5 - middle.x) <= reach.x && Math.abs(other.y + 0.5 - middle.y) <= reach.y); + const everyone = others.map((other) => ({ id: other.id, name: other.name, bubble: speech(other.bubble, time), ...positionAt(other.walk, time) })); + const crowd = everyone.filter((other) => Math.abs(other.x + 0.5 - middle.x) <= reach.x && Math.abs(other.y + 0.5 - middle.y) <= reach.y); renderer.draw({ camera, time, avatars: [{ id: self.id, name: self.name, self: true, bubble: speech(self.bubble, time), ...shown }, ...crowd], decor, labels, bounds, motion: !reducedMotion }); const talking = [self.bubble, ...others.map((other) => other.bubble)].filter((bubble) => bubble && bubble.until > time); + // People walking off-screen are not drawn, but a slow check keeps running so they appear as soon as they come into view. + const distant = everyone.some((other) => !other.done); if (!shown.done || crowd.some((other) => !other.done)) schedule(); - else if (timer === null && (ambient() || talking.length)) timer = later(() => { timer = null; schedule(); }, ambient() ? 66 : Math.max(50, Math.min(...talking.map((bubble) => bubble.until - time)) + 10)); + else if (timer === null && (ambient() || distant || talking.length)) timer = later(() => { timer = null; schedule(); }, ambient() ? 66 : distant ? 100 : Math.max(50, Math.min(...talking.map((bubble) => bubble.until - time)) + 10)); } function walkTo(target) { From 1bcc6a30657c0394c618d43ee6fc53906aa37a85 Mon Sep 17 00:00:00 2001 From: Brent G Date: Thu, 8 Oct 2026 18:50:03 +0000 Subject: [PATCH 49/71] Record the live Signaling check MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🤖 Built with SMT --- docs/superpowers/plans/2026-10-08-stoa-live.md | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/docs/superpowers/plans/2026-10-08-stoa-live.md b/docs/superpowers/plans/2026-10-08-stoa-live.md index 1d763ed..941d9f8 100644 --- a/docs/superpowers/plans/2026-10-08-stoa-live.md +++ b/docs/superpowers/plans/2026-10-08-stoa-live.md @@ -2065,3 +2065,20 @@ Deferred to Plan C2: calls inside rooms, decorate mode UI, hand over hosting and - **Back/forward cache:** returning to the page doesn't reconnect, because `pagehide` closed both live and the lease. - **Display names on the live layer are self-asserted:** resolve them from the server in Plan E's profile cards. - **Real Agora check:** run a live check with real Agora Signaling (two signed-in users and a guest; plaza, lot, private space with invite; tab close; duplicate tab). + +## Live check (2026-10-08, real Agora Signaling) + +Two signed-in browsers and a guest, run against a project with Signaling enabled. These passed: +- presence (about 1.6 s) +- plaza chat (about 0.3 s) and keyboard and Go to movement +- a lot room: host, topic visible on the plaza within about 2.4 s, room chat kept from the plaza +- leaving a room +- a private space with an invite, with no plaintext in 82 captured websocket frames +- a graceful tab close (gone in 0.3 s) +- the duplicate-tab notice + +Notes from the run: +- People entering a lot both appear on its entry tile and overlap until someone moves. +- After an abrupt exit, a lot room counts the old person for up to about 75 s (60 s lease plus a 15 s sweep). +- An abrupt browser death (as opposed to a graceful close) was not measured; Signaling's presence timeout is 30 s. +- Signing in from two places with the same account keeps only the newest Signaling session; this is by design and the page explains it. The SDK also logs it as a console error. From 7c52c62ac346c6678aad5fd33795b0c45e99f4fc Mon Sep 17 00:00:00 2001 From: Brent G Date: Thu, 8 Oct 2026 19:06:57 +0000 Subject: [PATCH 50/71] Plan Stoa rooms: calls, decorating, host tools, your spaces MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🤖 Built with SMT --- .../plans/2026-10-08-stoa-rooms.md | 2120 +++++++++++++++++ 1 file changed, 2120 insertions(+) create mode 100644 docs/superpowers/plans/2026-10-08-stoa-rooms.md diff --git a/docs/superpowers/plans/2026-10-08-stoa-rooms.md b/docs/superpowers/plans/2026-10-08-stoa-rooms.md new file mode 100644 index 0000000..1923d25 --- /dev/null +++ b/docs/superpowers/plans/2026-10-08-stoa-rooms.md @@ -0,0 +1,2120 @@ +# Stoa Rooms (Plan C2) Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Make rooms useful once you're inside them. Add Agora RTC calls with theme-chosen video layouts, decorate mode for hosts, host tools (hand over, remove), creating and managing your own spaces (settings, invites, members), and retire the old meetings page in favour of the Stoa. + +**Architecture:** The server already enforces everything (Plan A): `rtc-token`, `decor`, `host`, `remove`, `invitations`, `members`, `PUT`/`DELETE` on spaces. This plan adds two read paths on the server (`GET /api/spaces/mine` and member names for owners) and builds the browser side. `world/rtc-client.js` is UI-free Agora RTC, extracted from the legacy `call.js`. `stoa/call.js`, `stoa/decor.js` and `stoa/spaces.js` are page layers. The stage (`stoa/stage.js`) stays the single owner of "where am I". It tells the page when the room changes (`onRoom`), applies decorations, and runs host actions. Two new channel notices carry changes to peers: `{t:"decor", version}` and `{t:"rekey"}`. + +**Tech Stack:** Node 20 built-ins, browser ES modules, `agora-rtc-sdk-ng` (served at `/assets/agora-rtc.js`), `agora-rtm-sdk` 2.x, Node test runner. + +**Spec:** `docs/superpowers/specs/2026-10-07-stoa-spaces-design.md` (sections 1 Hosts, Decorating, User-created spaces; 2 removal; 3 RTC; API table). Earlier plans: `2026-10-07-spaces-core.md` (A), `2026-10-07-world-engine.md` (B), `2026-10-08-stoa-live.md` (C1). + +## Global Constraints + +- Node.js 20+, built-in modules only on the server. No new npm dependencies. +- JavaScript: two-space indentation, double quotes, semicolons. Match the dense one-line style of the surrounding files. Comments explain *why*, sparingly. +- Every commit message ends with the line `🤖 Built with SMT `. No co-author trailers. +- Credentials never appear in source, tests, docs, or memory. Tests use the fake values already in the test files (`"a".repeat(32)` and so on). +- Native buttons, accessible names, `aria-pressed` for toggles. The panel offers everything the canvas offers. Respect `prefers-reduced-motion`. +- Keep the paper, terracotta and charcoal palette and the shared CSS variables. Call controls look the same in every theme (spec section 3). +- Devices stay off until the person chooses to join a call. Joining only to listen must work (spec section 3). +- The plaza has no call (spec section 3). Calls exist only in lot rooms and user spaces, and only while the person holds the room's lease. +- `npm test` must pass after every task. +- Never stop or restart the user's server on port 3002. + +## File Structure + +| File | Responsibility | +| --- | --- | +| `world/protocol.js` (modify) | Reads `decor` and `rekey` notices | +| `world/engine.js` (modify) | `draw` event (camera + drawn avatars), `setPicking` for decor placement | +| `stoa/lease.js` (modify) | `beatNow()` heartbeat on demand | +| `stoa/place.js` (modify) | `onDecor`, `onRekey`, `decorChanged(version)`, `rekey()` | +| `world/rtc-client.js` (create) | Agora RTC without UI: join, devices, screen share, renewal, snapshots | +| `scripts/spaces/service.mjs`, `routes.mjs` (modify) | `GET /api/spaces/mine`; `roster` / `blockedRoster` names for owners | +| `stoa/call.js` (create) | Call controls and video tiles; layouts `over-avatar`, `strip`, `grid` | +| `stoa/decor.js` (create) | Decor working copy (pure) and decor-mode editor (DOM) | +| `stoa/spaces.js` (create) | Your spaces list, create dialog, manage dialog (settings, invites, members, delete) | +| `stoa/stage.js` (modify) | `onRoom`, `onPeople`, `people()`, decor apply/save, host tools, rekey | +| `stoa/panel.js` (modify) | Host tools in the people list; Decorate and Manage buttons | +| `stoa.html`, `stoa.js`, `styles.css` (modify) | Markup, wiring, styles | +| `scripts/serve.mjs`, `scripts/build.mjs`, `scripts/auth.mjs`, `script.js`, HTML pages, README (modify) | Retire meetings | +| `meetings.html`, `meetings.js`, `call.js` (delete) | Replaced by the Stoa | + +Each new browser file must be added to `publicFiles` in `scripts/serve.mjs` in the task that creates it. `scripts/build.mjs` already copies `world/` and `stoa/` whole. + +--- + +### Task 1: Channel notices, heartbeat on demand, and engine hooks + +**Files:** +- Modify: `world/protocol.js` (the `readMessage` switch) +- Modify: `stoa/place.js`, `stoa/lease.js`, `world/engine.js` +- Test: `tests/stoa-protocol.test.mjs`, `tests/stoa-place.test.mjs`, `tests/world-engine.test.mjs` + +**Interfaces:** +- Produces: `readMessage` accepts `{ t: "decor", version }` (integer ≥ 1) and `{ t: "rekey" }`. +- Produces: `openPlace({ ..., onDecor(fromActorId, version), onRekey(fromActorId) })`. The returned place gains `decorChanged(version)` (publishes `{t:"decor",version}`) and `rekey()` (publishes `{t:"rekey"}` and returns `Promise` for whether it was sent). Guests and quiet places publish neither. +- Produces: `createLease(...)` returns `{ stop, beatNow, leave, leaveOnUnload }`. `beatNow()` runs one heartbeat now with the same success and loss handling, and resolves when it finishes. After `stop()` it does nothing. +- Produces: engine event `"draw"` emitted after every drawn frame with `{ camera: { x, y, zoom }, tileSize, avatars: [{ id, x, y, self }] }`. `avatars` holds only the people actually drawn, self first. `self` is a boolean. +- Produces: `engine.setPicking(handler | null)`. While a handler is set, a primary click or tap inside the bounds calls `handler({ x, y })` instead of walking. This works even when `setInteractive(false)`. + +- [ ] **Step 1: Write the failing tests** + +Append to `tests/stoa-protocol.test.mjs` (it already has `plaza` and `readMessage`): + +```js +test("decor and rekey notices are read strictly", () => { + assert.deepEqual(readMessage(JSON.stringify({ t: "decor", version: 3 }), plaza), { t: "decor", version: 3 }); + for (const version of [0, -1, 1.5, "3", undefined, null]) assert.equal(readMessage(JSON.stringify({ t: "decor", version }), plaza), null, String(version)); + assert.deepEqual(readMessage(JSON.stringify({ t: "rekey", extra: 1 }), plaza), { t: "rekey" }); +}); +``` + +Append to `tests/stoa-place.test.mjs`. The existing `opened()` helper does not pass the new callbacks, so this test calls `openPlace` directly: + +```js +test("decor and rekey notices reach the caller with who sent them, and can be published", async () => { + const live = fakeLive({ who: [{ userId: ada, states: state(22, 17, "Ada") }] }), engine = fakeEngine(), seen = []; + const place = await openPlace({ live, map: plaza, engine, self: { name: "Me" }, channel: { name: "room-1", key: null, spaceId: "lot-x" }, onDecor: (from, version) => seen.push(["decor", from, version]), onRekey: (from) => seen.push(["rekey", from]) }); + live.handlers.message(ada, JSON.stringify({ t: "decor", version: 2 })); + live.handlers.message(ada, JSON.stringify({ t: "rekey" })); + live.handlers.message(bo, JSON.stringify({ t: "rekey" })); + const adaId = "account:72a639ba-3a45-4afe-936b-222222222222"; + assert.deepEqual(seen, [["decor", adaId, 2], ["rekey", adaId]], "people without presence are ignored"); + place.decorChanged(5); + assert.equal(await place.rekey(), true); + assert.deepEqual(live.published.slice(-2), [{ t: "decor", version: 5 }, { t: "rekey" }]); +}); +test("watchers never publish decor or rekey notices", async () => { + const { live, place } = await opened({ quiet: true }); + place.decorChanged(1); + assert.equal(await place.rekey(), false); + assert.deepEqual(live.published, []); +}); +test("a heartbeat can be run at once, and does nothing after the lease stops", async () => { + const accesses = [], losses = []; + let replies = [{ channel: "c3", key: null, blocked: [], hostId: null }, Object.assign(new Error("Gone"), { status: 410 })]; + const api = async () => { const reply = replies.shift(); if (reply instanceof Error) throw reply; return reply; }; + const lease = createLease({ api, spaceId: "lot-x", onAccess: (access) => accesses.push(access.channel), onLost: (error) => losses.push(error.status), repeat: () => 1, stopRepeat() {} }); + await lease.beatNow(); + await lease.beatNow(); + assert.deepEqual([accesses, losses], [["c3"], [410]]); + replies = [{ channel: "c4" }]; + await lease.beatNow(); + assert.deepEqual(accesses, ["c3"], "a stopped lease does not beat"); +}); +``` + +Append to `tests/world-engine.test.mjs`: + +```js +test("each drawn frame is reported with the camera and where the drawn people stand", () => { + const { engine, flush } = setup(); + const frames = []; + engine.on("draw", (frame) => frames.push(frame)); + engine.setOthers([{ id: "bo", name: "Bo", walk: { path: [{ x: 22, y: 17 }], startedAt: 0 } }]); flush(); + const last = frames.at(-1); + assert.equal(last.tileSize, 32); + assert.ok(last.camera.zoom > 0); + assert.deepEqual(last.avatars.map(({ id, x, y, self }) => [id, x, y, self]), [["self", 21, 17, true], ["bo", 22, 17, false]]); +}); +test("picking hands clicked tiles to the picker instead of walking, even with input off", () => { + const { engine, handlers, events, drawn } = setup(); + const picked = []; + engine.setInteractive(false); + engine.setPicking((tile) => picked.push(tile)); + const { camera } = drawn.at(-1); + const click = () => handlers.pointerup({ button: 0, clientX: (23 * 32 + 16 - camera.x) * camera.zoom, clientY: (17 * 32 + 16 - camera.y) * camera.zoom }); + click(); + assert.deepEqual(picked, [{ x: 23, y: 17 }]); + assert.equal(events.move.length, 0); + engine.setPicking(null); + click(); + assert.equal(events.move.length, 0, "with picking off, input off still means no walking"); + engine.setInteractive(true); + click(); + assert.equal(events.move.length, 1); +}); +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `node --test tests/stoa-protocol.test.mjs tests/stoa-place.test.mjs tests/world-engine.test.mjs` +Expected: the five new tests FAIL (`null` instead of a notice, `place.decorChanged is not a function`, `lease.beatNow is not a function`, `Unknown engine event: draw`, `engine.setPicking is not a function`). + +- [ ] **Step 3: Implement** + +`world/protocol.js`: in `readMessage`, add two cases before `default`: + +```js + case "decor": return Number.isInteger(data.version) && data.version > 0 ? { t: "decor", version: data.version } : null; + case "rekey": return { t: "rekey" }; +``` + +`stoa/place.js`: +- Signature: add `onDecor = () => {}, onRekey = () => {}` after `onRefresh = () => {}`. +- In the `message` handler, after the `refresh` line: + +```js + if (message.t === "decor") onDecor(message.from.id, message.version); + if (message.t === "rekey") onRekey(message.from.id); +``` + +- In the returned object, after `refresh()`: + +```js + decorChanged(version) { if (!guest) publish({ t: "decor", version }); }, + rekey() { return guest ? Promise.resolve(false) : publish({ t: "rekey" }); }, +``` + +`stoa/lease.js`: replace the body so the heartbeat is a named function: + +```js +// Keeps an entry lease alive and reports when it ends. Leaving on page close uses a beacon so it still reaches the server. +export function createLease({ api, spaceId, onAccess = () => {}, onLost = () => {}, every = 20000, repeat = (callback, delay) => setInterval(callback, delay), stopRepeat = (handle) => clearInterval(handle), beacon = (url) => navigator.sendBeacon?.(url) }) { + const base = "/api/spaces/" + spaceId; + let timer = null, stopped = false; + const stop = () => { stopped = true; if (timer !== null) { stopRepeat(timer); timer = null; } }; + const beat = async () => { + try { const access = await api(base + "/heartbeat", { method: "POST" }); if (!stopped) onAccess(access); } + catch (error) { if (!stopped && [401, 403, 404, 410].includes(error.status)) { stop(); onLost(error); } } + }; + timer = repeat(beat, every); + return { + stop, + // An extra heartbeat right away, for when the host says access has changed. + beatNow: () => stopped ? Promise.resolve() : beat(), + async leave() { stop(); try { await api(base + "/leave", { method: "POST" }); } catch { /* the lease expires on its own */ } }, + leaveOnUnload() { stop(); beacon(base + "/leave"); } + }; +} +``` + +`world/engine.js`: +- Header comment: add `"draw" { camera, tileSize, avatars } after each frame` to the events list. +- `const listeners = { arrive: [], move: [], walk: [], stop: [], face: [], draw: [] };` +- Add `picking = null` to the `let current = theme, ...` declarations. +- In `frame()`, replace the `renderer.draw(...)` line with: + +```js + const avatars = [{ id: self.id, name: self.name, self: true, bubble: speech(self.bubble, time), ...shown }, ...crowd]; + renderer.draw({ camera, time, avatars, decor, labels, bounds, motion: !reducedMotion }); + if (listeners.draw.length) emit("draw", { camera, tileSize: s, avatars: avatars.map((avatar) => ({ id: avatar.id, x: avatar.x, y: avatar.y, self: Boolean(avatar.self) })) }); +``` + +- Replace `onPointer`: + +```js + const onPointer = (event) => { + if (event.button || (!interactive && !picking)) return; + const box = canvas.getBoundingClientRect(), tile = screenToTile(camera, event.clientX - box.left, event.clientY - box.top, map.tileSize); + if (!inBounds(tile)) return; + if (picking) picking(tile); else walkTo(tile); + }; +``` + +- Add after `setInteractive`: `setPicking(handler) { picking = typeof handler === "function" ? handler : null; },` + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `npm test` +Expected: PASS, with 6 more tests than before. The protocol test file already covers `refresh`; nothing else changes. + +- [ ] **Step 5: Commit** + +```bash +git add world/protocol.js world/engine.js stoa/place.js stoa/lease.js tests/stoa-protocol.test.mjs tests/stoa-place.test.mjs tests/world-engine.test.mjs +git commit -m "Add decor and rekey notices, on-demand heartbeats, and engine draw and pick hooks + +🤖 Built with SMT " +``` + +--- + +### Task 2: A UI-free RTC client + +**Files:** +- Create: `world/rtc-client.js` +- Modify: `scripts/serve.mjs` (`publicFiles`: add `"world/rtc-client.js"`) +- Test: `tests/rtc-client.test.mjs` + +**Interfaces:** +- Consumes: the AgoraRTC Web SDK 4.x API, the same calls the legacy `call.js` uses. Credentials come from `POST /api/spaces//rtc-token` with the shape `{ appId, channel, uid, screenUid, token, screenToken, expiresAt }` (`scripts/calls.mjs` `credentials()`). +- Produces: `mediaError(error) -> string`, `isScreen(uid) -> boolean`. +- Produces: `createRtcClient({ AgoraRTC, fetchCredentials(uid?), onChange(snapshot), onError(message), onEnded(message) })`, returning: + - `join({ audio, video })`, `toggle("audio" | "video")`, `share()`. Each resolves to `true` when it ran and `false` when another action was running, the client was closed, or the action failed (failures are reported through `onError`). + - `leave() -> Promise` + - `play(uid, element) -> boolean` + - `resumeAudio()` + - `snapshot()`, which returns `{ joined, busy, closed, uid, screenUid, audio, video, screen, audioBlocked, peers: [{ uid, audio, video, screen }] }`. `audio`/`video` mean your own tracks are on. `peers` excludes your own screen-share uid. +- `fetchCredentials()` with no argument asks for a new identity. With a uid it renews that identity. + +- [ ] **Step 1: Write the failing tests** + +Create `tests/rtc-client.test.mjs`: + +```js +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { createRtcClient, isScreen, mediaError } from "../world/rtc-client.js"; + +const tick = () => new Promise((resolve) => setImmediate(resolve)); +const me = "u1_aaaaaaaaaaaa", peer = "u2_bbbbbbbbbbbb"; +const credentials = (n = 1) => ({ appId: "app", channel: "agora-build-space-x", uid: me, screenUid: me + "_screen", token: "t" + n, screenToken: "s" + n }); +function fakeTrack(kind) { + const track = { kind, enabled: true, closed: false, stopped: false, played: [], listeners: {}, async setEnabled(value) { track.enabled = value; }, close() { track.closed = true; }, stop() { track.stopped = true; }, play(element, options) { track.played.push([element, options]); }, on(type, listener) { track.listeners[type] = listener; } }; + return track; +} +function fakeAgora({ deny = null } = {}) { + const sdk = { + clients: [], tracks: [], checkSystemRequirements: () => true, + createClient() { + const client = { + listeners: {}, remoteUsers: [], joined: null, published: [], left: false, renewed: [], connectionState: "DISCONNECTED", + on(type, listener) { client.listeners[type] = listener; }, emit(type, ...args) { client.listeners[type]?.(...args); }, + async join(appId, channel, token, uid) { client.joined = { appId, channel, token, uid }; client.connectionState = "CONNECTED"; }, + async leave() { client.left = true; client.connectionState = "DISCONNECTED"; }, + async publish(tracks) { client.published.push(...[].concat(tracks)); }, + async subscribe(user, media) { user[media + "Track"] = fakeTrack(media); }, + async renewToken(token) { client.renewed.push(token); }, + removeAllListeners() { client.listeners = {}; } + }; + sdk.clients.push(client); + return client; + }, + async createMicrophoneAudioTrack() { if (deny === "audio") throw Object.assign(new Error("denied"), { code: "PERMISSION_DENIED" }); const track = fakeTrack("audio"); sdk.tracks.push(track); return track; }, + async createCameraVideoTrack() { const track = fakeTrack("video"); sdk.tracks.push(track); return track; }, + async createScreenVideoTrack() { const track = fakeTrack("screen"); sdk.tracks.push(track); return track; } + }; + return sdk; +} +function make({ sdk = fakeAgora(), fetch } = {}) { + const fetches = [], errors = [], ended = []; + const rtc = createRtcClient({ AgoraRTC: sdk, fetchCredentials: fetch || (async (uid) => { fetches.push(uid); return credentials(fetches.length); }), onError: (message) => errors.push(message), onEnded: (message) => ended.push(message) }); + return { rtc, sdk, fetches, errors, ended }; +} +const own = (snapshot) => ({ joined: snapshot.joined, audio: snapshot.audio, video: snapshot.video, screen: snapshot.screen }); + +test("screen-share uids and device errors are recognised", () => { + assert.equal(isScreen(me + "_screen"), true); + assert.equal(isScreen(me), false); + assert.match(mediaError({ code: "PERMISSION_DENIED" }), /declined/); + assert.match(mediaError({ name: "NotFoundError" }), /could not be found/); + assert.equal(mediaError({ status: 403, message: "Step in first." }), "Step in first."); +}); +test("joining publishes the chosen devices, toggles work, and leaving releases everything", async () => { + const { rtc, sdk } = make(); + assert.equal(await rtc.join({ audio: true }), true); + const [client] = sdk.clients; + assert.deepEqual(client.joined, { appId: "app", channel: "agora-build-space-x", token: "t1", uid: me }); + assert.deepEqual(client.published.map((track) => track.kind), ["audio"]); + assert.deepEqual(own(rtc.snapshot()), { joined: true, audio: true, video: false, screen: false }); + await rtc.toggle("audio"); + assert.equal(rtc.snapshot().audio, false); + await rtc.toggle("video"); + assert.equal(rtc.snapshot().video, true); + await rtc.leave(); + assert.ok(sdk.tracks.every((track) => track.closed), "every device is released"); + assert.equal(client.left, true); + assert.equal(await rtc.toggle("audio"), false, "nothing runs after leaving"); +}); +test("people appear as they publish and disappear as they leave; your own screen is not a peer", async () => { + const { rtc, sdk } = make(); + await rtc.join({}); + const [client] = sdk.clients, user = { uid: peer }; + client.remoteUsers.push(user); + client.emit("user-joined", user); + client.emit("user-published", user, "video"); + await tick(); + assert.deepEqual(rtc.snapshot().peers, [{ uid: peer, audio: false, video: true, screen: false }]); + const element = {}; + assert.equal(rtc.play(peer, element), true); + assert.deepEqual(user.videoTrack.played, [[element, { fit: "cover" }]]); + client.emit("user-unpublished", user, "video"); + assert.equal(rtc.snapshot().peers[0].video, false); + assert.equal(rtc.play(peer, element), false); + client.emit("user-joined", { uid: me + "_screen" }); + assert.equal(rtc.snapshot().peers.length, 1); + client.remoteUsers = []; + client.emit("user-left", user); + assert.deepEqual(rtc.snapshot().peers, []); +}); +test("tokens renew with the same identity, and a refused renewal ends the call", async () => { + let refuse = false; + const fetches = []; + const { rtc, sdk, ended } = make({ fetch: async (uid) => { fetches.push(uid); if (refuse) throw Object.assign(new Error("Step into this space before joining its call."), { status: 403 }); return credentials(fetches.length); } }); + await rtc.join({ audio: true }); + const [client] = sdk.clients; + client.emit("token-privilege-will-expire"); + await tick(); await tick(); + assert.deepEqual([fetches, client.renewed], [[undefined, me], ["t2"]]); + refuse = true; + client.emit("token-privilege-did-expire"); + for (let index = 0; index < 5; index += 1) await tick(); + assert.deepEqual(ended, ["Step into this space before joining its call."]); + assert.equal(client.left, true); + assert.ok(sdk.tracks.every((track) => track.closed)); +}); +test("a declined microphone still joins to listen, with a clear reason", async () => { + const { rtc, errors } = make({ sdk: fakeAgora({ deny: "audio" }) }); + assert.equal(await rtc.join({ audio: true }), true); + assert.deepEqual(own(rtc.snapshot()), { joined: true, audio: false, video: false, screen: false }); + assert.match(errors[0], /declined/); +}); +test("leaving while still joining releases everything and never joins", async () => { + let release; + const sdk = fakeAgora(); + const { rtc } = make({ sdk, fetch: () => new Promise((resolve) => { release = resolve; }) }); + const joining = rtc.join({ audio: true }); + await tick(); + const leaving = rtc.leave(); + release(credentials()); + await leaving; await joining; + assert.equal(sdk.clients.length, 0); + assert.equal(sdk.tracks.length, 0); +}); +test("screen sharing uses a second client and stops when the browser ends it", async () => { + const { rtc, sdk } = make(); + await rtc.join({}); + assert.equal(await rtc.share(), true); + const screenClient = sdk.clients[1]; + assert.deepEqual([screenClient.joined.uid, screenClient.joined.token], [me + "_screen", "s2"]); + assert.equal(rtc.snapshot().screen, true); + const element = {}; + assert.equal(rtc.play(me + "_screen", element), true); + const [screenTrack] = screenClient.published; + assert.deepEqual(screenTrack.played, [[element, { fit: "contain" }]]); + screenTrack.listeners["track-ended"](); + await tick(); await tick(); + assert.equal(rtc.snapshot().screen, false); + assert.equal(screenClient.left, true); + assert.equal(screenTrack.closed, true); +}); +test("an unexpected disconnect ends the call", async () => { + const { rtc, sdk, ended } = make(); + await rtc.join({}); + sdk.clients[0].emit("connection-state-change", "DISCONNECTED", "CONNECTED"); + for (let index = 0; index < 5; index += 1) await tick(); + assert.deepEqual(ended, ["The call disconnected. Join again when you're ready."]); + assert.equal(rtc.snapshot().joined, false); +}); +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `node --test tests/rtc-client.test.mjs` +Expected: FAIL with `Cannot find module '../world/rtc-client.js'`. + +- [ ] **Step 3: Implement `world/rtc-client.js`** + +```js +// Agora RTC with no UI: joining, devices, publishing, screen sharing, and token renewal. The page draws from snapshots. +export const isScreen = (uid) => String(uid).endsWith("_screen"); +export function mediaError(error) { + if (error?.status) return error.message; + const code = String(error?.code || error?.name || ""); + if (/PERMISSION|NOT_ALLOWED|NotAllowed/i.test(code)) return "Device access was declined. Check your browser permissions and try again."; + if (/NOT_FOUND|NotFound/i.test(code)) return "That microphone or camera could not be found. You can still listen to the call."; + return "The call could not complete that action. Check your connection and try again."; +} + +export function createRtcClient({ AgoraRTC, fetchCredentials, onChange = () => {}, onError = () => {}, onEnded = () => {} }) { + let client = null, credentials = null, audio = null, video = null, screen = null, joined = false, busy = false, closed = false, renewing = null, audioBlocked = false, work = Promise.resolve(); + const remote = new Map(); + const snapshot = () => ({ joined, busy, closed, uid: credentials?.uid || null, screenUid: credentials?.screenUid || null, audio: Boolean(audio?.enabled), video: Boolean(video?.enabled), screen: Boolean(screen), audioBlocked, peers: [...remote.values()].map((peer) => ({ uid: peer.uid, audio: peer.audio, video: peer.video, screen: isScreen(peer.uid) })) }); + const changed = () => { if (!closed) onChange(snapshot()); }; + // One action at a time; a click while another action runs is ignored. + function operate(action) { + if (busy || closed) return Promise.resolve(false); + busy = true; changed(); + work = (async () => { + try { await action(); return true; } + catch (error) { if (!closed) onError(mediaError(error)); return false; } + finally { busy = false; changed(); } + })(); + return work; + } + async function renew() { + if (closed) return; + if (renewing) return renewing; + renewing = (async () => { + const next = await fetchCredentials(credentials.uid); + if (closed) return; + credentials = next; + await client.renewToken(next.token); + if (screen?.client.connectionState === "CONNECTED") await screen.client.renewToken(next.screenToken); + })(); + try { await renewing; } finally { renewing = null; } + } + const end = (message) => { if (!closed) leave().then(() => onEnded(message)); }; + function watchToken(target) { + const refresh = () => renew().catch((error) => end(error?.status ? error.message : "Call access could not be renewed. Join again.")); + target.on("token-privilege-will-expire", refresh); + target.on("token-privilege-did-expire", refresh); + } + function watch() { + const own = (user) => user.uid === credentials.screenUid; + const entry = (user) => { if (!remote.has(user.uid)) remote.set(user.uid, { uid: user.uid, user, audio: false, video: false }); return remote.get(user.uid); }; + client.on("user-joined", (user) => { if (closed || own(user)) return; entry(user); changed(); }); + client.on("user-left", (user) => { remote.delete(user.uid); changed(); }); + client.on("user-published", (user, media) => { + if (closed || own(user)) return; + client.subscribe(user, media).then(() => { + if (closed || !client.remoteUsers.some((peer) => peer.uid === user.uid)) return; + const peer = entry(user); + peer.user = user; peer[media] = true; + if (media === "audio") user.audioTrack?.play(); + changed(); + }).catch(() => { if (!closed) onError("One person's audio or video could not load. It will retry when they share it again."); }); + }); + client.on("user-unpublished", (user, media) => { + if (closed || own(user)) return; + if (media === "video") user.videoTrack?.stop(); + if (media === "audio") user.audioTrack?.stop(); + const peer = remote.get(user.uid); + if (peer) { peer[media] = false; changed(); } + }); + client.on("connection-state-change", (next, previous) => { if (next === "DISCONNECTED" && previous !== "DISCONNECTING" && joined) end("The call disconnected. Join again when you're ready."); }); + watchToken(client); + } + async function enable(kind) { + const track = kind === "audio" ? await AgoraRTC.createMicrophoneAudioTrack() : await AgoraRTC.createCameraVideoTrack({ encoderConfig: "480p_1" }); + if (closed) { track.close(); return; } + if (kind === "audio") audio = track; else video = track; + try { await client.publish(track); } + catch (error) { track.close(); if (kind === "audio") audio = null; else video = null; throw error; } + } + async function stopScreen() { + const current = screen; + if (!current) return; + screen = null; + current.client.removeAllListeners(); + for (const track of current.tracks) { track.stop(); track.close(); } + await current.client.leave().catch(() => {}); + } + async function leave() { + if (closed) return; + closed = true; + // Devices are released at once, even while a join or publish is still pending. + for (const track of [audio, video, ...(screen?.tracks || [])]) track?.close(); + await work.catch(() => {}); + await Promise.allSettled([stopScreen(), (async () => { client?.removeAllListeners(); await client?.leave(); })()]); + for (const track of [audio, video]) track?.close(); + audio = null; video = null; joined = false; remote.clear(); + } + return { + snapshot, + join: ({ audio: withAudio = false, video: withVideo = false } = {}) => operate(async () => { + if (!AgoraRTC.checkSystemRequirements()) throw Object.assign(new Error("This browser can't join calls. Try a current Chrome, Edge, Firefox, or Safari."), { status: 400 }); + AgoraRTC.onAutoplayFailed = () => { audioBlocked = true; changed(); }; + credentials = await fetchCredentials(); + if (closed) return; + client = AgoraRTC.createClient({ mode: "rtc", codec: "vp8" }); + watch(); + try { await client.join(credentials.appId, credentials.channel, credentials.token, credentials.uid); } + catch (error) { client.removeAllListeners(); await client.leave().catch(() => {}); client = null; throw error; } + if (closed) return; + joined = true; + for (const [kind, wanted] of [["audio", withAudio], ["video", withVideo]]) { + if (wanted && !closed) { try { await enable(kind); } catch (error) { onError(mediaError(error)); } } + } + }), + toggle: (kind) => operate(async () => { + if (!joined) return; + const track = kind === "audio" ? audio : video; + if (!track) { await enable(kind); return; } + await track.setEnabled(!track.enabled); + if (kind === "video" && !track.enabled) track.stop(); + }), + share: () => operate(async () => { + if (!joined) return; + if (screen) { await stopScreen(); return; } + // Capture during the click, before any network work, as browsers require. + const captured = await AgoraRTC.createScreenVideoTrack({ encoderConfig: "1080p_1" }, "auto"); + const tracks = Array.isArray(captured) ? captured : [captured]; + if (closed) { for (const track of tracks) track.close(); return; } + const second = AgoraRTC.createClient({ mode: "rtc", codec: "vp8" }); + screen = { client: second, tracks }; + tracks[0].on("track-ended", () => { if (!closed && screen?.client === second) stopScreen().then(changed); }); + try { + await renew(); + if (closed || screen?.client !== second) return; + watchToken(second); + await second.join(credentials.appId, credentials.channel, credentials.screenToken, credentials.screenUid); + if (closed || screen?.client !== second) return; + await second.publish(tracks); + } catch (error) { await stopScreen(); throw error; } + }), + // Plays someone's video into an element; false when there is nothing to show. + play(uid, element) { + if (closed) return false; + const peer = remote.get(uid); + const track = uid === credentials?.uid ? (video?.enabled ? video : null) : uid === credentials?.screenUid ? screen?.tracks[0] : peer?.video ? peer.user.videoTrack : null; + if (!track) return false; + track.play(element, { fit: isScreen(uid) ? "contain" : "cover" }); + return true; + }, + resumeAudio() { for (const user of client?.remoteUsers || []) user.audioTrack?.play(); audioBlocked = false; changed(); }, + leave + }; +} +``` + +Add `"world/rtc-client.js"` to `publicFiles` in `scripts/serve.mjs`, next to `"world/sdk.js"`. + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `node --test tests/rtc-client.test.mjs && npm test` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add world/rtc-client.js scripts/serve.mjs tests/rtc-client.test.mjs +git commit -m "Add a UI-free Agora RTC client for room calls + +🤖 Built with SMT " +``` + +--- + +### Task 3: Your spaces, and member names for owners + +**Files:** +- Modify: `scripts/spaces/service.mjs` (the `view` helper and a new `mine` method) +- Modify: `scripts/spaces/routes.mjs` (one route) +- Test: `tests/spaces-service.test.mjs`, `tests/spaces-api.test.mjs` + +**Interfaces:** +- Produces: `spaces.mine(token)` and `GET /api/spaces/mine`, both returning `{ spaces: [{ id, title, purpose, visibility, access, capacity, themeId, owner, occupancy, path }], owned, limit, canCreate }`. The list holds every non-system space where you are a member (owners are members), newest first. Signed out: 401. +- Produces: when the viewer can `edit` the space (the owner or an admin), `GET /api/spaces/` (and every other `view`) also includes `roster: [{ id, name }]` for `members` and `blockedRoster: [{ id, name }]` for `blocked`. Names come from profiles (`state.people`), then accounts (`state.accounts`), then `"Builder"`. + +- [ ] **Step 1: Write the failing tests** + +Append to `tests/spaces-service.test.mjs`: + +```js +test("your spaces lists spaces you own or belong to, newest first, with your allowance", async (t) => { + const { spaces, clock, person } = await setup(t); + const owner = await person("Owner"), friend = await person("Friend"), stranger = await person("Stranger"); + const first = await spaces.create(owner.token, { title: "First space" }); + clock.now += 1000; + const second = await spaces.create(owner.token, { title: "Second space", visibility: "private" }); + await spaces.addMember(owner.token, first.id, friend.id); + const mine = await spaces.mine(owner.token); + assert.deepEqual(mine.spaces.map((space) => [space.title, space.owner, space.path]), [["Second space", true, "/stoa/s/" + second.id], ["First space", true, "/stoa/s/" + first.id]]); + assert.deepEqual([mine.owned, mine.canCreate], [2, mine.limit > 2]); + assert.deepEqual((await spaces.mine(friend.token)).spaces.map((space) => [space.title, space.owner, space.occupancy]), [["First space", false, 0]]); + assert.deepEqual((await spaces.mine(stranger.token)).spaces, []); + await assert.rejects(spaces.mine(null), { status: 401 }); +}); +test("owners see member and blocked names; others see neither", async (t) => { + const { spaces, person } = await setup(t); + const owner = await person("Owner"), friend = await person("Friend"); + const space = await spaces.create(owner.token, { title: "Named space" }); + await spaces.addMember(owner.token, space.id, friend.id); + const seen = await spaces.get(owner.token, space.id); + assert.deepEqual(seen.roster, [{ id: owner.id, name: "Owner" }, { id: friend.id, name: "Friend" }]); + assert.deepEqual(seen.blockedRoster, []); + const asFriend = await spaces.get(friend.token, space.id); + assert.equal(asFriend.roster, undefined); + assert.equal(asFriend.blockedRoster, undefined); +}); +``` + +Append to `tests/spaces-api.test.mjs`: + +```js +test("your spaces needs sign-in and lists what you own", async () => { + const { instance, url } = await start(); + try { + assert.equal((await request(url, "/api/spaces/mine")).status, 401); + const ada = await join(url, "Ada"); + const created = await (await post(url, "/api/spaces", ada, { title: "Ada's lab" })).json(); + const mine = await (await request(url, "/api/spaces/mine", { cookie: ada })).json(); + assert.deepEqual(mine.spaces.map((space) => [space.id, space.owner]), [[created.space.id, true]]); + } finally { await close(instance); } +}); +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `node --test tests/spaces-service.test.mjs tests/spaces-api.test.mjs` +Expected: FAIL (`spaces.mine is not a function`; `roster` is undefined; `/api/spaces/mine` returns 404). + +- [ ] **Step 3: Implement** + +`scripts/spaces/service.mjs`: replace the `view` line with: + +```js + const nameOf = (state, id) => state.people.find((person) => person.id === id)?.name || (state.accounts || []).find((account) => account.id === id)?.name || "Builder"; + const roster = (state, ids) => ids.map((id) => ({ id, name: nameOf(state, id) })); + // Owners manage members by name, so their view carries names for the member and blocked lists. + const view = (state, space, actor) => { + const manage = can(actor, "edit", space), result = publicSpace(space, { occupants: occupants(state, space), manage }); + return manage ? { ...result, roster: roster(state, space.members), blockedRoster: roster(state, space.blocked) } : result; + }; +``` + +Add a method after `get`: + +```js + async mine(token) { + const actor = signedIn(await actorFor(token)); + await ready(); + const state = await store.snapshot(); + const list = state.spaces.filter((space) => !system(space) && space.members.some((id) => actor.ids.includes(id))).sort((a, b) => b.createdAt.localeCompare(a.createdAt)); + const owns = owned(state, actor).length, limit = entitlements(actor.plan).spaces; + return { spaces: list.map((space) => ({ id: space.id, title: space.title, purpose: space.purpose, visibility: space.visibility, access: space.access, capacity: space.capacity, themeId: space.themeId, owner: actor.ids.includes(space.ownerId), occupancy: leasesFor(state, space.id, now()).length, path: "/stoa/s/" + space.id })), owned: owns, limit, canCreate: owns < limit }; + }, +``` + +The test advances `clock.now` between the two spaces, so ISO `createdAt` ordering is enough. + +Every `view` an owner receives now carries `roster` and `blockedRoster` (create, get, update, enter). If an existing test compares a whole owner view with `deepEqual`, add the two fields to its expected value rather than weakening the assertion. + +`scripts/spaces/routes.mjs`: after the `POST /api/spaces` line add: + +```js + if (path === "/api/spaces/mine" && method === "GET") return { status: 200, body: await spaces.mine(token) }; +``` + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `npm test` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add scripts/spaces/service.mjs scripts/spaces/routes.mjs tests/spaces-service.test.mjs tests/spaces-api.test.mjs +git commit -m "List your spaces and give owners member names + +🤖 Built with SMT " +``` + +--- + +### Task 4: Calls inside rooms + +**Files:** +- Create: `stoa/call.js` +- Modify: `stoa/stage.js` (`onRoom`, `onPeople`, `people()`) +- Modify: `stoa.html`, `stoa.js`, `styles.css`, `scripts/serve.mjs` (`publicFiles`: add `"stoa/call.js"`) +- Test: `tests/stoa-call.test.mjs` (create), `tests/stoa-stage.test.mjs`, `tests/spaces-api.test.mjs` (markup ids) + +**Interfaces:** +- Consumes: `createRtcClient`, `isScreen` (Task 2); the engine `"draw"` event (Task 1); `loadAgora()` from `world/sdk.js`; theme `video` (`"over-avatar" | "strip" | "grid"`). +- Produces (pure, tested): + - `actorUuid(uid)` + - `nameFor(uid, { people, self, snapshot })` + - `tileList(snapshot, { people, self }) -> [{ uid, name, video, screen, mine }]` + - `overAvatar(tile, frame) -> { left, top } | null` +- Produces (DOM): `createCallView({ doc, engine, api, self, people, say, loadSdk, secure })` returning `{ enter(spaceId), exit(), setLayout(layout), refresh() }`. +- Produces (stage): `createStage({ ..., onRoom(space | null), onPeople(list) })`. + - `onRoom` fires only when the leased room changes: entering a lot or space gives its `space`; the plaza, watching, or "left" gives `null`. Re-opening the same room on a new channel does not fire it. + - `onPeople(list)` fires each time the merged people list is rebuilt. + - New method `stage.people()` returns the latest merged list. + +How people are matched: RTC uids are `_<12 hex>` (and `..._screen`). Presence ids are `account:` or `member:`. Your own engine avatar has `self: true`. + +- [ ] **Step 1: Write the failing tests** + +Create `tests/stoa-call.test.mjs`: + +```js +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { actorUuid, nameFor, overAvatar, tileList } from "../stoa/call.js"; + +const ada = "72a639ba-3a45-4afe-936b-222222222222", me = "72a639ba-3a45-4afe-936b-111111111111"; +const people = [{ id: "account:" + ada, name: "Ada" }]; +const self = { id: "account:" + me, name: "Me" }; +const snapshot = { joined: true, uid: me + "_aaaaaaaaaaaa", screenUid: me + "_aaaaaaaaaaaa_screen", audio: true, video: false, screen: true, peers: [{ uid: ada + "_bbbbbbbbbbbb", audio: true, video: true, screen: false }, { uid: ada + "_bbbbbbbbbbbb_screen", audio: false, video: true, screen: true }, { uid: "99999999-0000-4000-8000-000000000000_cccccccccccc", audio: true, video: false, screen: false }] }; + +test("call identities map back to people by their UUID", () => { + assert.equal(actorUuid(ada + "_bbbbbbbbbbbb_screen"), ada); + const names = tileList(snapshot, { people, self }).map((tile) => [tile.name, tile.video, tile.screen, tile.mine]); + assert.deepEqual(names, [["Me (you)", false, false, true], ["Your screen", true, true, true], ["Ada", true, false, false], ["Ada's screen", true, true, false], ["Builder", false, false, false]]); + assert.deepEqual(tileList({ ...snapshot, joined: false }, { people, self }), []); + assert.equal(nameFor(snapshot.uid, { people, self, snapshot }), "Me (you)"); +}); +test("over-avatar tiles sit above the matching avatar; screens and people out of view do not float", () => { + const frame = { tileSize: 32, camera: { x: 100, y: 50, zoom: 2 }, avatars: [{ id: self.id, x: 5, y: 4, self: true }, { id: "account:" + ada, x: 7, y: 3, self: false }] }; + const [mine, screen, adaTile, , stranger] = tileList(snapshot, { people, self }); + assert.deepEqual(overAvatar(mine, frame), { left: ((5 + 0.5) * 32 - 100) * 2, top: (4 * 32 - 50) * 2 }); + assert.deepEqual(overAvatar(adaTile, frame), { left: ((7 + 0.5) * 32 - 100) * 2, top: (3 * 32 - 50) * 2 }); + assert.equal(overAvatar(screen, frame), null); + assert.equal(overAvatar(stranger, frame), null); + assert.equal(overAvatar(mine, null), null); +}); +``` + +In `tests/stoa-stage.test.mjs`: +- Change `function setup({ api, signedIn = true, withLive = true, clock = { time: 0, timers: [] } } = {})` to also take `...options`, and spread `...options` into the `createStage({...})` call, after the existing fields. +- Append: + +```js +test("the call follows the room: one callback per room entered or left, not per channel change", async () => { + const rooms = []; + const api = async (path) => path === "/api/spaces/plaza/enter" ? access("plaza", "plaza-1") : path.endsWith("/enter") ? access("lot-" + lot.slug, "lot-ch") : {}; + const { stage, leases } = setup({ api, onRoom: (room) => rooms.push(room ? room.id : null) }); + await stage.toPlaza(null); + await stage.enterLot(lot); + leases.at(-1).options.onAccess({ channel: "lot-ch-2", key: null, blocked: [], hostId: "a-me" }); + await tick(); await tick(); + await stage.leaveRoom(); + assert.deepEqual(rooms, ["lot-" + lot.slug, null]); +}); +test("the merged people list is kept and reported each time it is rebuilt", async () => { + const lists = []; + const api = async (path) => path === "/api/spaces/plaza/enter" ? access("plaza", "plaza-1") : {}; + const { stage, calls, flushFrames } = setup({ api, onPeople: (list) => lists.push(list.map((person) => person.name)) }); + await stage.toPlaza(null); + calls.handlers["plaza-1"].presence({ type: "snapshot", people: [{ userId: "a-72a639ba-3a45-4afe-936b-222222222222", states: { x: String(lot.door.x), y: String(lot.door.y + 1), dir: "down", name: "Ada" } }] }); + flushFrames(); + assert.deepEqual(lists.at(-1), ["Ada"]); + assert.deepEqual(stage.people().map((person) => person.name), ["Ada"]); +}); +``` + +In `tests/spaces-api.test.mjs`, in the test that checks Stoa markup ids (the `for (const id of ["stoa-here", ...` line), add `"stoa-call", "stoa-call-join", "stoa-video"` to the id list and `"/stoa/call.js", "/world/rtc-client.js"` to the module list. + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `node --test tests/stoa-call.test.mjs tests/stoa-stage.test.mjs tests/spaces-api.test.mjs` +Expected: FAIL (missing module, `stage.people is not a function`, no `onRoom` calls, missing ids). + +- [ ] **Step 3: Implement the stage changes** + +In `stoa/stage.js`: +- Add `onRoom = () => {}, onPeople = () => {}` to the `createStage` options, after `self, plaza,`. +- Add `everyone = [], roomId = null` to the `let live = firstLive, ...` declarations. +- In `showPeople`'s scheduled callback, after `const list = [...seen.values()];`, add `everyone = list;`. After `panel.setPeople(...)`, add `onPeople(list);`. +- In `settle(next)`, right after `here = next;`: + +```js + // The call and the decor editor belong to a room, so the page hears about room changes, not channel changes. + const room = next.leased && (next.kind === "lot" || next.kind === "space") ? next.space : null; + if ((room?.id || null) !== roomId) { roomId = room?.id || null; onRoom(room); } +``` + +- In the returned object, add `people: () => everyone,` after `setTopic,`. + +- [ ] **Step 4: Implement `stoa/call.js`** + +```js +import { createRtcClient, isScreen } from "../world/rtc-client.js"; +import { loadAgora } from "../world/sdk.js"; + +// Calls inside rooms: native controls in the panel, video tiles over the map in the layout the theme chooses. +// RTC uids start with the person's actor UUID, so tiles are named and placed from presence. +export const actorUuid = (uid) => String(uid).split("_")[0]; +export function nameFor(uid, { people, self, snapshot }) { + if (uid === snapshot.uid) return self.name + " (you)"; + if (uid === snapshot.screenUid) return "Your screen"; + const person = people.find((entry) => String(entry.id).split(":")[1] === actorUuid(uid)); + return (person?.name || "Builder") + (isScreen(uid) ? "'s screen" : ""); +} +export function tileList(snapshot, { people, self }) { + if (!snapshot?.joined) return []; + const own = [{ uid: snapshot.uid, video: snapshot.video, screen: false, mine: true }, ...(snapshot.screen ? [{ uid: snapshot.screenUid, video: true, screen: true, mine: true }] : [])]; + return [...own, ...snapshot.peers.map((peer) => ({ uid: peer.uid, video: peer.video, screen: peer.screen, mine: false }))].map((tile) => ({ ...tile, name: nameFor(tile.uid, { people, self, snapshot }) })); +} +// Where a tile floats in over-avatar layout: just above the person's head, in canvas pixels. Null when they aren't drawn. +export function overAvatar(tile, frame) { + if (tile.screen || !frame) return null; + const avatar = frame.avatars.find((entry) => tile.mine ? entry.self : String(entry.id).split(":")[1] === actorUuid(tile.uid)); + if (!avatar) return null; + return { left: ((avatar.x + 0.5) * frame.tileSize - frame.camera.x) * frame.camera.zoom, top: (avatar.y * frame.tileSize - frame.camera.y) * frame.camera.zoom }; +} + +export function createCallView({ doc = document, engine, api, self, people = () => [], say = () => {}, loadSdk = loadAgora, secure = () => globalThis.isSecureContext !== false }) { + const $ = (selector) => doc.querySelector(selector); + const section = $("#stoa-call"), status = $("#stoa-call-status"), preflight = $("#stoa-call-preflight"), layer = $("#stoa-video"); + const buttons = { join: $("#stoa-call-join"), audio: $("#stoa-call-audio"), video: $("#stoa-call-video"), share: $("#stoa-call-share"), resume: $("#stoa-call-resume"), leave: $("#stoa-call-leave") }; + const tiles = new Map(); + let spaceId = null, rtc = null, layout = "strip", frame = null; + + function place() { + for (const view of tiles.values()) { + const spot = layout === "over-avatar" ? overAvatar(view.tile, frame) : null; + view.card.classList.toggle("is-floating", Boolean(spot)); + view.card.style.transform = spot ? "translate(" + Math.round(spot.left) + "px, " + Math.round(spot.top) + "px) translate(-50%, -100%)" : ""; + // Over avatars, a person without video is already shown by their avatar. + view.card.hidden = layout === "over-avatar" && !view.tile.screen && !view.playing; + } + } + function render() { + const now = rtc ? rtc.snapshot() : null, joined = Boolean(now?.joined); + buttons.join.hidden = joined; preflight.hidden = joined; + buttons.join.disabled = !secure() || Boolean(now?.busy); + for (const key of ["audio", "video", "share", "leave"]) { buttons[key].hidden = !joined; buttons[key].disabled = Boolean(now?.busy); } + if (!globalThis.navigator?.mediaDevices?.getDisplayMedia) buttons.share.disabled = true; + const toggle = (button, on, onText, offText) => { button.textContent = on ? onText : offText; button.setAttribute("aria-pressed", String(on)); }; + toggle(buttons.audio, Boolean(now?.audio), "Mute mic", "Turn mic on"); + toggle(buttons.video, Boolean(now?.video), "Turn camera off", "Turn camera on"); + toggle(buttons.share, Boolean(now?.screen), "Stop sharing", "Share screen"); + buttons.resume.hidden = !now?.audioBlocked; + layer.hidden = !joined; + layer.dataset.layout = layout; + const wanted = joined ? tileList(now, { people: people(), self }) : []; + for (const [uid, view] of tiles) if (!wanted.some((tile) => tile.uid === uid)) { view.card.remove(); tiles.delete(uid); } + for (const tile of wanted) { + let view = tiles.get(tile.uid); + if (!view) { + const card = doc.createElement("article"), player = doc.createElement("div"), label = doc.createElement("span"); + card.className = "stoa-tile" + (tile.screen ? " is-screen" : ""); player.className = "stoa-tile-player"; label.className = "stoa-tile-name"; + card.append(player, label); + layer.append(card); + view = { card, player, label, playing: false, tile }; + tiles.set(tile.uid, view); + } + view.tile = tile; + if (view.label.textContent !== tile.name) { view.label.textContent = tile.name; view.card.setAttribute("aria-label", tile.name); } + if (tile.video && !view.playing) view.playing = rtc.play(tile.uid, view.player); + if (!tile.video) view.playing = false; + view.card.classList.toggle("has-video", view.playing); + } + place(); + } + function reset(message) { + for (const view of tiles.values()) view.card.remove(); + tiles.clear(); + render(); + status.textContent = message; + } + async function join() { + if (rtc || !spaceId) return; + const id = spaceId; + status.textContent = "Connecting to the call..."; + let AgoraRTC; + try { ({ AgoraRTC } = await loadSdk()); } catch (error) { if (spaceId === id) status.textContent = "The call couldn't load (" + (error.message || "unknown error") + ")."; return; } + if (spaceId !== id || rtc) return; + AgoraRTC.setLogLevel?.(3); + const client = createRtcClient({ + AgoraRTC, + fetchCredentials: (uid) => api("/api/spaces/" + encodeURIComponent(id) + "/rtc-token", { method: "POST", body: JSON.stringify(uid ? { uid } : {}) }), + onChange: () => { if (rtc === client) render(); }, + onError: (message) => { if (rtc === client) say(message); }, + onEnded: (message) => { if (rtc === client) { rtc = null; reset(message); } } + }); + rtc = client; + render(); + await client.join({ audio: $("#stoa-call-mic").checked, video: $("#stoa-call-camera").checked }); + if (rtc !== client) return; + if (!client.snapshot().joined) { rtc = null; await client.leave(); reset("You're not in the call. Try joining again."); return; } + status.textContent = "You're in the call."; + } + async function leave(message) { const client = rtc; rtc = null; await client?.leave(); reset(message); } + + buttons.join.addEventListener("click", join); + buttons.audio.addEventListener("click", () => rtc?.toggle("audio")); + buttons.video.addEventListener("click", () => rtc?.toggle("video")); + buttons.share.addEventListener("click", () => rtc?.share()); + buttons.resume.addEventListener("click", () => rtc?.resumeAudio()); + buttons.leave.addEventListener("click", () => leave("You left the call.")); + engine.on("draw", (next) => { frame = next; if (layout === "over-avatar" && tiles.size) place(); }); + return { + enter(id) { + if (spaceId === id) return; + if (rtc) leave(""); + spaceId = id; + section.hidden = false; + reset(secure() ? "Join to talk with everyone in this room. Your mic and camera stay off until you choose." : "Open this page over HTTPS to use your microphone and camera."); + }, + exit() { spaceId = null; section.hidden = true; if (rtc) leave(""); else reset(""); }, + setLayout(next) { layout = next || "strip"; render(); }, + refresh() { if (rtc) render(); } + }; +} +``` + +Add `"stoa/call.js"` to `publicFiles` in `scripts/serve.mjs`. + +- [ ] **Step 5: Markup, styles, and wiring** + +`stoa.html`: +- Wrap the canvas so tiles can sit over it. Replace `

...

` with: + +```html +

Click or tap to walk. Select the map to use the arrow keys or WASD.

+``` + +- Inside `
`, before the `#stoa-leave` button: + +```html + +``` + +`styles.css`: append after the `.stoa-signin` rule: + +```css +.stoa-view { position: relative; } +.stoa-video { position: absolute; inset: 0; display: flex; flex-wrap: wrap; align-content: flex-start; gap: 8px; padding: 8px; pointer-events: none; overflow: hidden; } +.stoa-video[data-layout="grid"] { inset: 0 0 0 auto; width: min(46%, 520px); display: grid; grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); } +.stoa-tile { position: relative; width: 168px; aspect-ratio: 16 / 10; overflow: hidden; border: 1px solid var(--line); border-radius: 4px; background: #202824; pointer-events: auto; } +.stoa-video[data-layout="grid"] .stoa-tile { width: auto; } +.stoa-tile.is-screen { width: min(100%, 360px); aspect-ratio: 16 / 9; } +.stoa-tile.is-floating { position: absolute; left: 0; top: 0; width: 96px; } +.stoa-tile-player { position: absolute; inset: 0; } +.stoa-tile-name { position: absolute; left: 6px; bottom: 6px; max-width: calc(100% - 12px); padding: 2px 6px; border-radius: 3px; background: rgba(32, 40, 36, 0.78); color: #f3efe6; font: 11px var(--mono); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.stoa-call { margin: 4px 0 14px; } +.stoa-call-status { margin: 0 0 8px; font-size: 12px; color: var(--muted); } +.stoa-call-preflight { display: flex; flex-wrap: wrap; gap: 6px 14px; margin-bottom: 8px; font-size: 12px; } +.stoa-call-preflight label { display: flex; align-items: center; gap: 6px; } +.stoa-call-controls { display: flex; flex-wrap: wrap; gap: 6px; } +.stoa-call-controls [aria-pressed="true"] { background: #e6ebdc; border-color: #98a98a; } +.stoa-call-leave { color: var(--accent); } +@media (prefers-reduced-motion: no-preference) { .stoa-tile.is-floating { transition: opacity 0.2s; } } +``` + +`stoa.js`: +- `import { createCallView } from "./stoa/call.js";` +- In the theme block, `active` is a `const` arrow function inside `try`. Hoist it so `stoa.js` can reach it later: declare `let active = () => null;` next to `let map = null, engine = null, ...`, and assign `active = () => minimal ? byId.get("minimal") : chosen;` inside the block. +- In the minimal toggle handler, after `engine.setTheme(active());`, add `call?.setLayout(active().video);`. Declare `let call = null;` next to `let live = null, stage = null;`. That declaration comes after the theme block in the file, but the click handler only runs after the page has loaded, so `call` exists by then. Keep it at module scope. +- Add `ids: [state.account?.id, state.profile?.id].filter(Boolean)` to `self`. +- In the `if (engine) {` block, before `stage = createStage(...)`: + +```js + call = createCallView({ engine, api, self, people: () => stage?.people() || [], say: (text) => panel.say(text) }); + call.setLayout(active().video); +``` + +- Add `onRoom: (room) => room ? call.enter(room.id) : call.exit(), onPeople: () => call.refresh()` to the `createStage({...})` options. +- In the `pagehide` listener, add `call.exit();` before `stage.unload();`. + +- [ ] **Step 6: Run the tests** + +Run: `npm test` +Expected: PASS. + +- [ ] **Step 7: Check it in a browser** + +Start a throwaway server on a free port (never 3002): `PORT=4390 node scripts/serve.mjs --port 4390`. No Agora credentials are configured there, so expect the call UI to say calls aren't connected. +- In a lot room, the Call section shows "Join call" with the mic and camera checkboxes. +- Pressing Join reports "Agora calls are not connected yet." through the status line or panel, and the controls reset. +- On the plaza, the Call section is hidden. + +Check desktop (1280×800) and mobile (390×844) widths with headless Chrome, as in Plan C1. Stop the throwaway server afterwards. The live call check with real credentials happens at the end of the plan (Final verification). + +- [ ] **Step 8: Commit** + +```bash +git add stoa/call.js stoa/stage.js stoa.html stoa.js styles.css scripts/serve.mjs tests/stoa-call.test.mjs tests/stoa-stage.test.mjs tests/spaces-api.test.mjs +git commit -m "Add calls inside Stoa rooms with theme-chosen video layouts + +🤖 Built with SMT " +``` + +--- + +### Task 5: Decorate mode + +**Files:** +- Create: `stoa/decor.js` +- Modify: `stoa/stage.js`, `stoa/panel.js`, `stoa.html`, `stoa.js`, `styles.css`, `scripts/serve.mjs` (`publicFiles`: add `"stoa/decor.js"`) +- Test: `tests/stoa-decor.test.mjs` (create), `tests/stoa-stage.test.mjs`, `tests/stoa-panel.test.mjs` + +**Interfaces:** +- Consumes: `engine.setPicking`, `engine.setDecor`, `engine.setInteractive`, `engine.position()`, `engine.facing()`. From Task 1: `place.decorChanged`, `onDecor`. From Task 4: `onRoom`. `DECOR_KINDS` from `world/kinds.js`, `KEYS`/`STEP` from `world/motion.js`, `walkable` from `world/map.js`. +- Produces (pure): `createArrangement({ map, area, items, makeId })` returning: + - `items()`, `selected()`, `select(id)`, `deselect()` + - `pick(tile, kind)` → `"selected" | "moved" | "placed" | "blocked" | "full" | "none" | "unknown"` + - `place(kind, tile)`, `move(dir)`, `rotate()`, `remove()` + - Limit: 60 items. Free tile means inside `area`, walkable on the map, and not already used. +- Produces (DOM): `createDecorEditor({ doc, canvas, engine, map, onSave(items) -> Promise, onClose(), say })` returning `{ open({ items, area }), close(), abort(), isOpen() }`. `close()` restores the original arrangement in the engine; `abort()` just ends editing without restoring. +- Produces (stage): + - `settle` applies `next.space?.decor || []` to the engine unless editing. + - `fetchRoom` re-applies the fetched `space.decor`. + - A `decor` notice from the host or the space owner triggers `refreshRoom()`. + - New methods: `canManage() -> boolean`, `decorArea() -> rect | null`, `decor() -> items`, `setEditing(boolean)`, `saveDecor(items) -> Promise`. +- Produces (panel): `showRoom({ ..., host })` also shows `#stoa-decor-open` when `host`. The panel gets a new handler, `decorate`. + +"Host" in the panel means "can do host things": you are the host or the space's owner. The stage computes it with a new helper, `mine(id)`, which checks `self.ids` (falling back to `[self.id]`). + +- [ ] **Step 1: Write the failing tests** + +Create `tests/stoa-decor.test.mjs`: + +```js +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; +import { parseMap, walkable } from "../world/map.js"; +import { createArrangement } from "../stoa/decor.js"; + +const room = parseMap(JSON.parse(await readFile(new URL("../worlds/room/map.json", import.meta.url), "utf8"))); +const area = { x: 0, y: 0, width: room.width, height: room.height }; +// The first three open tiles in a row on the room map. +const open = (() => { for (let y = 0; y < room.height; y += 1) for (let x = 0; x < room.width - 2; x += 1) if ([0, 1, 2].every((dx) => walkable(room, x + dx, y))) return { x, y }; throw new Error("no open row"); })(); +let next = 0; +const make = (items = []) => createArrangement({ map: room, area, items, makeId: () => "d" + (next += 1) }); + +test("placing, selecting, moving, rotating, and removing decorations", () => { + const decor = make(); + assert.equal(decor.pick(open, "plant"), "placed"); + assert.deepEqual(decor.selected(), { id: "d1", kind: "plant", x: open.x, y: open.y, rotation: 0, variant: 0 }); + assert.equal(decor.move("right"), "moved"); + assert.deepEqual([decor.selected().x, decor.selected().y], [open.x + 1, open.y]); + assert.equal(decor.rotate(), "rotated"); + assert.equal(decor.selected().rotation, 90); + decor.deselect(); + assert.equal(decor.pick(open, "sofa"), "placed"); + assert.equal(decor.pick({ x: open.x + 1, y: open.y }), "selected", "a click on a placed item selects it"); + assert.equal(decor.selected().kind, "plant"); + assert.equal(decor.move("left"), "blocked", "the sofa is in the way"); + assert.equal(decor.pick({ x: open.x + 2, y: open.y }), "moved", "a click elsewhere moves the selected item"); + assert.equal(decor.remove(), "removed"); + assert.deepEqual(decor.items().map((item) => item.kind), ["sofa"]); + assert.equal(decor.remove(), "none"); +}); +test("decorations stay on open floor inside the area, one per tile, at most 60", () => { + const decor = createArrangement({ map: room, area: { x: open.x, y: open.y, width: 1, height: 1 }, items: [] , makeId: () => "x" + (next += 1) }); + assert.equal(decor.place("plant", { x: open.x + 1, y: open.y }), "blocked", "outside the area"); + assert.equal(decor.place("dragon", open), "unknown"); + assert.equal(decor.place("plant", open), "placed"); + assert.equal(decor.place("lamp", open), "blocked", "one per tile"); + const full = make(Array.from({ length: 60 }, (_, index) => ({ id: "f" + index, kind: "plant", x: 0, y: index, rotation: 0, variant: 0 }))); + assert.equal(full.place("plant", open), "full"); + assert.equal(full.items().length, 60); +}); +test("the working copy never changes the items it was given", () => { + const items = [{ id: "a", kind: "lamp", x: open.x, y: open.y, rotation: 0, variant: 0 }]; + const decor = make(items); + decor.select("a"); decor.rotate(); + assert.equal(items[0].rotation, 0); +}); +``` + +In `tests/stoa-stage.test.mjs`: +- Add `decor: []` to the `calls` object, and add `setDecor: (list) => { calls.decor.push(list); }` to the fake `engine`. +- In the fake `createLease`, add `beatNow() { entry.beats = (entry.beats || 0) + 1; return Promise.resolve(); }` to the entry. Task 6 uses it; adding it now keeps the fake complete. +- Append: + +```js +test("a room's saved decorations are shown on entry and cleared on the plaza", async () => { + const items = [{ id: "d1", kind: "plant", x: lot.entry.x + 1, y: lot.entry.y, rotation: 0, variant: 0 }]; + const api = async (path) => path === "/api/spaces/plaza/enter" ? access("plaza", "plaza-1") : path.endsWith("/enter") ? { ...access("lot-" + lot.slug, "lot-ch"), space: { ...space("lot-" + lot.slug), decor: items } } : {}; + const { stage, calls } = setup({ api }); + await stage.toPlaza(null); + await stage.enterLot(lot); + assert.deepEqual(calls.decor.at(-1), items); + assert.deepEqual(stage.decor(), items); + await stage.leaveRoom(); + assert.deepEqual(calls.decor.at(-1), []); +}); +test("saving decorations stores them, shows them, and tells the room the new version", async () => { + const saved = [{ id: "d2", kind: "lamp", x: lot.entry.x, y: lot.entry.y - 1, rotation: 0, variant: 0 }], puts = []; + const api = async (path, options) => { + if (path === "/api/spaces/plaza/enter") return access("plaza", "plaza-1"); + if (path.endsWith("/enter")) return access("lot-" + lot.slug, "lot-ch"); + if (path.endsWith("/decor")) { puts.push(JSON.parse(options.body)); return { decor: saved, version: 4 }; } + return {}; + }; + const { stage, calls } = setup({ api }); + await stage.toPlaza(null); + await stage.enterLot(lot); + assert.equal(stage.canManage(), true, "the first one in hosts the lot"); + assert.deepEqual(stage.decorArea(), lot.interior); + assert.equal(await stage.saveDecor(saved), true); + assert.deepEqual(puts, [{ items: saved }]); + assert.deepEqual(calls.decor.at(-1), saved); + assert.ok(calls.published.includes(JSON.stringify({ t: "decor", version: 4 }))); +}); +test("a decor notice from the host refetches the room; from anyone else it is ignored", async () => { + const { gets, calls } = await inLot(); + const handlers = calls.handlers["lot-ch"]; + handlers.message(guestUser, JSON.stringify({ t: "decor", version: 2 })); + await tick(); + assert.equal(gets.length, 0); + handlers.message(hostUser, JSON.stringify({ t: "decor", version: 2 })); + await tick(); + assert.equal(gets.length, 1); +}); +``` + +`inLot`, `hostUser`, `guestUser`, `present` and `hostActor` already exist in this file. + +In `tests/stoa-panel.test.mjs`, append: + +```js +test("the Decorate button shows only for the host and calls the decorate handler", () => { + const doc = fakeDocument(), panel = createPanel(doc); + let opened = 0; + panel.on({ decorate: () => { opened += 1; } }); + panel.showRoom({ kicker: "LOT ROOM", title: "AI agents", topic: null, tags: [], host: false, leaveLabel: "Back" }); + assert.equal(doc.querySelector("#stoa-decor-open").hidden, true); + panel.showRoom({ kicker: "LOT ROOM", title: "AI agents", topic: null, tags: [], host: true, leaveLabel: "Back" }); + assert.equal(doc.querySelector("#stoa-decor-open").hidden, false); + doc.querySelector("#stoa-decor-open").listeners.click(); + assert.equal(opened, 1); +}); +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `node --test tests/stoa-decor.test.mjs tests/stoa-stage.test.mjs tests/stoa-panel.test.mjs` +Expected: FAIL. + +- [ ] **Step 3: Implement `stoa/decor.js`** + +```js +import { walkable } from "../world/map.js"; +import { DECOR_KINDS } from "../world/kinds.js"; +import { KEYS, STEP } from "../world/motion.js"; + +export const DECOR_LABELS = { plant: "Plant", lamp: "Lamp", rug: "Rug", sofa: "Sofa", chair: "Chair", table: "Table", whiteboard: "Whiteboard", bookshelf: "Bookshelf", screen: "Screen", banner: "Banner", poster: "Poster", statue: "Statue", fountain: "Fountain" }; +const LIMIT = 60; + +// The host's working copy of a room's decorations. The server checks the whole arrangement on save; this keeps edits sensible. +export function createArrangement({ map, area, items = [], makeId = () => "d" + Math.random().toString(36).slice(2, 10) }) { + let list = items.map((item) => ({ ...item })), selected = null; + const inside = (tile) => tile.x >= area.x && tile.y >= area.y && tile.x < area.x + area.width && tile.y < area.y + area.height; + const free = (tile, except = null) => inside(tile) && walkable(map, tile.x, tile.y) && !list.some((item) => item !== except && item.x === tile.x && item.y === tile.y); + const current = () => list.find((item) => item.id === selected) || null; + const arrangement = { + items: () => list.map((item) => ({ ...item })), + selected: () => current() ? { ...current() } : null, + select(id) { selected = list.some((item) => item.id === id) ? id : null; return Boolean(selected); }, + deselect() { selected = null; }, + // A click on a placed item selects it; elsewhere it moves the selected item there, or places the chosen kind. + pick(tile, kind) { + const hit = list.find((item) => item.x === tile.x && item.y === tile.y); + if (hit) { selected = hit.id; return "selected"; } + const item = current(); + if (item) { if (!free(tile, item)) return "blocked"; item.x = tile.x; item.y = tile.y; return "moved"; } + return kind ? arrangement.place(kind, tile) : "none"; + }, + place(kind, tile) { + if (!DECOR_KINDS.includes(kind)) return "unknown"; + if (list.length >= LIMIT) return "full"; + if (!free(tile)) return "blocked"; + const item = { id: makeId(), kind, x: tile.x, y: tile.y, rotation: 0, variant: 0 }; + list.push(item); selected = item.id; + return "placed"; + }, + move(dir) { + const item = current(); + if (!item) return "none"; + const [dx, dy] = STEP[dir], next = { x: item.x + dx, y: item.y + dy }; + if (!free(next, item)) return "blocked"; + item.x = next.x; item.y = next.y; + return "moved"; + }, + rotate() { const item = current(); if (!item) return "none"; item.rotation = (item.rotation + 90) % 360; return "rotated"; }, + remove() { const item = current(); if (!item) return "none"; list = list.filter((entry) => entry !== item); selected = null; return "removed"; } + }; + return arrangement; +} + +const OUTCOMES = { blocked: "That tile is taken, blocked, or outside the room.", full: "A room holds up to 60 decorations.", none: "Choose a decoration first, or select one on the map." }; +// Decor mode: the map picks tiles instead of walking, and the panel lists every item for keyboard and screen reader users. +export function createDecorEditor({ doc = document, canvas, engine, map, onSave = async () => false, onClose = () => {}, say = () => {} }) { + const $ = (selector) => doc.querySelector(selector); + const section = $("#stoa-decor"), palette = $("#stoa-palette"), list = $("#stoa-decor-list"), save = $("#stoa-decor-save"); + let arrangement = null, original = [], kind = null; + const describe = (item) => DECOR_LABELS[item.kind] + " at " + item.x + ", " + item.y + (item.rotation ? ", turned " + item.rotation + "°" : ""); + const kindButtons = DECOR_KINDS.map((entry) => { + const button = doc.createElement("button"); + button.type = "button"; button.className = "button button-secondary button-small"; button.textContent = DECOR_LABELS[entry]; + button.setAttribute("aria-pressed", "false"); + button.addEventListener("click", () => { kind = kind === entry ? null : entry; arrangement?.deselect(); render(); }); + palette.append(button); + return [entry, button]; + }); + function render() { + if (!arrangement) return; + engine.setDecor(arrangement.items()); + for (const [entry, button] of kindButtons) button.setAttribute("aria-pressed", String(kind === entry)); + const chosen = arrangement.selected(); + list.replaceChildren(...arrangement.items().map((item) => { + const row = doc.createElement("li"), button = doc.createElement("button"); + button.type = "button"; button.textContent = describe(item); + if (chosen?.id === item.id) button.setAttribute("aria-current", "true"); + button.addEventListener("click", () => { arrangement.select(item.id); render(); canvas.focus(); }); + row.append(button); + return row; + })); + $("#stoa-decor-selected").textContent = chosen ? "Selected: " + describe(chosen) + ". Arrow keys move it, R turns it, Delete removes it." : kind ? "Click the map to place a " + DECOR_LABELS[kind].toLowerCase() + "." : "Choose a decoration, or select one to move it."; + } + const report = (outcome) => { if (OUTCOMES[outcome]) say(OUTCOMES[outcome]); render(); }; + const onKey = (event) => { + if (!arrangement || event.ctrlKey || event.metaKey || event.altKey) return; + const dir = KEYS[event.key]; + if (dir) { event.preventDefault(); report(arrangement.move(dir)); } + else if (event.key === "r" || event.key === "R") { event.preventDefault(); report(arrangement.rotate()); } + else if (event.key === "Delete" || event.key === "Backspace") { event.preventDefault(); report(arrangement.remove()); } + else if (event.key === "Escape") { arrangement.deselect(); kind = null; render(); } + }; + function finish(restore) { + if (!arrangement) return; + canvas.removeEventListener("keydown", onKey); + engine.setPicking(null); + engine.setInteractive(true); + if (restore) engine.setDecor(original); + arrangement = null; kind = null; + section.hidden = true; + onClose(); + } + $("#stoa-decor-here").addEventListener("click", () => { + if (!arrangement) return; + const at = engine.position(), [dx, dy] = STEP[engine.facing()] || [0, 1]; + report(kind ? arrangement.place(kind, { x: at.x + dx, y: at.y + dy }) : "none"); + }); + save.addEventListener("click", async () => { + if (!arrangement) return; + save.disabled = true; + try { if (await onSave(arrangement.items())) finish(false); } finally { save.disabled = false; } + }); + $("#stoa-decor-cancel").addEventListener("click", () => finish(true)); + return { + open({ items = [], area }) { + if (arrangement) return; + original = items.map((item) => ({ ...item })); + arrangement = createArrangement({ map, area: area || { x: 0, y: 0, width: map.width, height: map.height }, items: original }); + engine.setInteractive(false); + engine.setPicking((tile) => report(arrangement.pick(tile, kind))); + canvas.addEventListener("keydown", onKey); + section.hidden = false; + render(); + }, + close: () => finish(true), + abort: () => finish(false), + isOpen: () => Boolean(arrangement) + }; +} +``` + +`DECOR_LABELS` must cover all 13 entries of `DECOR_KINDS` (they match the spec list). + +Add `"stoa/decor.js"` to `publicFiles`. + +- [ ] **Step 4: Implement the stage changes** + +In `stoa/stage.js`: +- Add `currentDecor = [], editing = false` to the `let` declarations. +- Add helpers after `showRoom`. Also change `showRoom` so `host` uses `mine`: + +```js + const mine = (id) => Boolean(id) && (self.ids || [self.id]).includes(id); + const canManage = () => Boolean(here && (here.kind === "lot" || here.kind === "space") && here.leased && self.signedIn && (mine(here.access?.hostId) || mine(here.space?.ownerId))); + const showDecor = (items) => { currentDecor = items || []; if (!editing) engine.setDecor(currentDecor); }; +``` + + In `showRoom`, replace `host: Boolean(self.id) && hostId === self.id` with `host: Boolean(self.id) && (mine(hostId) || mine(space.ownerId))`. +- In `settle(next)`, after the `onRoom` block from Task 4, add `editing = false; showDecor(next.space?.decor);`. A transition always ends editing; `stoa.js` aborts the editor through `onRoom`. +- In the `openPlace({...})` call inside `settle`, add: + +```js +onDecor: (from) => { if (here === next && from && (from === next.access?.hostId || from === next.space?.ownerId)) refreshRoom(); } +``` + +- In `fetchRoom`, after `if (here === at) showRoom(space, space.hostId);`, turn the `if` into a block that also calls `showDecor(space.decor);`. +- Add to the returned object: + +```js + canManage, + decor: () => currentDecor, + decorArea: () => here?.kind === "lot" ? here.lot.interior : here?.kind === "space" ? { x: 0, y: 0, width: map.width, height: map.height } : null, + setEditing(value) { editing = Boolean(value); if (!editing) engine.setDecor(currentDecor); }, + async saveDecor(items) { + const at = here; + if (!canManage()) { panel.say("Only the host can decorate."); return false; } + try { + const result = await api("/api/spaces/" + encodeURIComponent(at.space.id) + "/decor", { method: "PUT", body: JSON.stringify({ items }) }); + if (here !== at) return false; + editing = false; + showDecor(result.decor); + for (const place of places) place.decorChanged(result.version); + panel.say("Decorations saved."); + return true; + } catch (error) { if (here === at) panel.say(error.message); return false; } + }, +``` + +- [ ] **Step 5: Panel, markup, styles, wiring** + +`stoa/panel.js`: +- Add `decorate() {}` to `handlers`. +- Wire it: `$("#stoa-decor-open").addEventListener("click", () => handlers.decorate());` +- In `showRoom`, add `$("#stoa-decor-open").hidden = !host;`. +- In `showPlaza`, add `$("#stoa-decor-open").hidden = true;`. + +`stoa.html`: inside `#stoa-here`, after the topic form, add: + +```html + + +``` + +`styles.css`, appended: + +```css +.stoa-decor { margin: 10px 0 14px; padding: 12px; border: 1px solid var(--line); border-radius: 4px; } +.stoa-decor-selected { margin: 0 0 8px; font-size: 12px; color: var(--muted); } +.stoa-palette { display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 8px; } +.stoa-palette [aria-pressed="true"] { background: #e6ebdc; border-color: #98a98a; } +.stoa-decor-list { list-style: none; margin: 8px 0; padding: 0; max-height: 160px; overflow-y: auto; font-size: 12px; } +.stoa-decor-list button { background: none; border: 0; padding: 3px 0; font: inherit; color: inherit; cursor: pointer; text-align: left; } +.stoa-decor-list [aria-current="true"] { color: var(--accent); font-weight: 600; } +.stoa-decor-actions { display: flex; gap: 6px; } +``` + +`stoa.js`: +- `import { createDecorEditor } from "./stoa/decor.js";` +- In the `if (engine) {` block, before `createStage`: + +```js + decor = createDecorEditor({ canvas, engine, map, say: (text) => panel.say(text), onSave: (items) => stage.saveDecor(items), onClose: () => stage.setEditing(false) }); +``` + +- Change `onRoom` to `(room) => { decor?.abort(); if (room) call.enter(room.id); else call.exit(); }`. +- Register the panel handler (the existing `panel.on({...})` call; add an entry): + +```js + decorate: () => { + if (!stage?.canManage()) { panel.say("Only the host can decorate."); return; } + stage.setEditing(true); + decor.open({ items: stage.decor(), area: stage.decorArea() }); + panel.say("Decor mode is on. Choose a decoration, then click the map or use Place in front of me."); + canvas.focus(); + }, +``` + + Declare `let decor = null;` at module scope next to `call` (the handler runs only after the block assigns it), and write the handler's open call as `decor?.open(...)`. +- `onClose` runs whenever editing ends (save, cancel, or abort), so the stage's `editing` flag always clears. + +- [ ] **Step 6: Run the tests** + +Run: `npm test` +Expected: PASS. + +- [ ] **Step 7: Check it in a browser** + +On a throwaway server (not 3002), sign in as a test profile, walk into a lot room as the first person, and press Decorate. +- Choose Plant and click a floor tile. It appears and the list shows "Plant at x, y". +- Select it in the list, then press the arrow keys, R, and Delete. Each one works. +- Save. "Decorations saved." appears and the plant stays. +- Cancel after a change. The saved arrangement returns. + +Check at desktop and mobile widths. + +- [ ] **Step 8: Commit** + +```bash +git add stoa/decor.js stoa/stage.js stoa/panel.js stoa.html stoa.js styles.css scripts/serve.mjs tests/stoa-decor.test.mjs tests/stoa-stage.test.mjs tests/stoa-panel.test.mjs +git commit -m "Add decorate mode for room hosts + +🤖 Built with SMT " +``` + +--- + +### Task 6: Host tools — hand over and remove + +**Files:** +- Modify: `stoa/panel.js` (`setPeople`, `peopleKey`, handlers), `stoa/stage.js`, `stoa.js`, `styles.css` +- Test: `tests/stoa-panel.test.mjs`, `tests/stoa-stage.test.mjs` + +**Interfaces:** +- Consumes: `POST /api/spaces//host { actorId }` → `{ hostId }`. `POST /api/spaces//remove { actorId }` → `{ removed, channel, key, blocked, hostId }`. From Task 1: `place.rekey()`, `onRekey`, `lease.beatNow()`. From Task 5: `canManage()`. +- Produces (panel): + - `setPeople(list, selfName, { manage = false, hostId = null, selfId = null } = {})`. A host badge marks whoever is host, including you. When `manage` is true, each other person gets "Make host" (unless they already are) and "Remove" buttons. + - `peopleKey(list, selfName, options)` includes `manage` and `hostId`, so the list rebuilds when either changes. + - New handlers: `makeHost(person)`, `remove(person)`. +- Produces (stage): `makeHost(person)`, `removePerson(person)`. A `rekey` notice from the host or owner makes the lease heartbeat at once (`lease.beatNow()`). + +- [ ] **Step 1: Write the failing tests** + +`tests/stoa-panel.test.mjs`. The fake `Element` needs `classList` and `type`; extend its constructor with `classList: { toggle() {}, add() {} }`. Then append: + +```js +test("hosts get Make host and Remove for everyone else; the host is marked", () => { + const doc = fakeDocument(), panel = createPanel(doc), calls = []; + panel.on({ makeHost: (person) => calls.push(["host", person.id]), remove: (person) => calls.push(["remove", person.id]) }); + const list = [{ id: "account:a", name: "Ada" }, { id: "account:b", name: "Bo" }]; + panel.setPeople(list, "Me", { manage: false, hostId: "account:a", selfId: "account:me" }); + const people = doc.querySelector("#stoa-people"); + const text = (item) => [item.textContent, ...item.children.map((child) => child.textContent)].join("|"); + assert.match(text(people.children[1]), /host/, "Ada is marked as host"); + assert.equal(people.children[1].children.some((child) => child.tagName === "button"), false, "no tools without manage"); + panel.setPeople(list, "Me", { manage: true, hostId: "account:me", selfId: "account:me" }); + assert.match(text(people.children[0]), /host/, "you are marked as host"); + const buttons = people.children[1].children.filter((child) => child.tagName === "button"); + assert.deepEqual(buttons.map((button) => button.textContent), ["Make host", "Remove"]); + buttons[0].listeners.click(); buttons[1].listeners.click(); + assert.deepEqual(calls, [["host", "account:a"], ["remove", "account:a"]]); + assert.match(buttons[1].attrs["aria-label"], /Remove Ada/); +}); +test("the people key changes with manage and host", () => { + const list = [{ id: "account:a", name: "Ada" }]; + assert.notEqual(peopleKey(list, "Me", { manage: true }), peopleKey(list, "Me", { manage: false })); + assert.notEqual(peopleKey(list, "Me", { hostId: "account:a" }), peopleKey(list, "Me", { hostId: null })); +}); +``` + +`tests/stoa-stage.test.mjs`, appended: + +```js +test("the host can hand over hosting; the room stops offering host tools", async () => { + const posts = []; + const api = async (path, options) => { + if (path === "/api/spaces/plaza/enter") return access("plaza", "plaza-1"); + if (path.endsWith("/enter")) return access("lot-" + lot.slug, "lot-ch"); + if (path.endsWith("/host")) { posts.push(JSON.parse(options.body)); return { hostId: "account:72a639ba-3a45-4afe-936b-222222222222" }; } + return {}; + }; + const { stage, calls } = setup({ api }); + await stage.toPlaza(null); + await stage.enterLot(lot); + await stage.makeHost({ id: "account:72a639ba-3a45-4afe-936b-222222222222", name: "Ada" }); + assert.deepEqual(posts, [{ actorId: "account:72a639ba-3a45-4afe-936b-222222222222" }]); + assert.equal(calls.showRoom.at(-1).host, false); + assert.equal(stage.canManage(), false); + assert.ok(calls.published.includes(JSON.stringify({ t: "refresh" })), "the room is told to refresh"); +}); +test("removing someone tells the room to check access, then reopens on the new channel", async () => { + const api = async (path) => { + if (path === "/api/spaces/plaza/enter") return access("plaza", "plaza-1"); + if (path.endsWith("/enter")) return access("space-1", "space-ch"); + if (path.endsWith("/remove")) return { removed: true, channel: "space-ch-2", key: null, blocked: ["account:bad"], hostId: "a-me" }; + return {}; + }; + const { stage, calls } = setup({ api }); + await stage.openSpace("space-1"); + await stage.removePerson({ id: "account:bad", name: "Bad" }); + assert.ok(calls.published.includes(JSON.stringify({ t: "rekey" }))); + assert.deepEqual(calls.joined.slice(-1), ["space-ch-2"]); + assert.ok(calls.left.includes("space-ch")); +}); +test("a rekey from the host makes the lease check in now; from anyone else it is ignored", async () => { + const { calls, leases } = await inLot(); + const handlers = calls.handlers["lot-ch"], lease = leases.at(-1); + handlers.message(guestUser, JSON.stringify({ t: "rekey" })); + assert.equal(lease.beats || 0, 0); + handlers.message(hostUser, JSON.stringify({ t: "rekey" })); + assert.equal(lease.beats, 1); +}); +``` + +If `openSpace` in the test setup needs the live connection to have joined (`withLive` defaults to true) and `access()` returns `hostId: "a-me"` with `self.id = "a-me"`, then `canManage()` is true for the space. Keep these values. + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `node --test tests/stoa-panel.test.mjs tests/stoa-stage.test.mjs` +Expected: FAIL. + +- [ ] **Step 3: Implement the panel** + +`stoa/panel.js`: + +```js +// People are rebuilt only when who is here, a name, the host, or your host tools change, so a busy plaza doesn't churn the page. +export const peopleKey = (list, selfName, { manage = false, hostId = null } = {}) => [selfName, manage ? "manage" : "", hostId || ""].join("\u0001") + "\n" + list.map((person) => person.id + "\u0000" + person.name).sort().join("\n"); +``` + +- Add `makeHost() {}, remove() {}` to `handlers`. +- Replace `setPeople`: + +```js + setPeople(list, selfName, options = {}) { + const { manage = false, hostId = null, selfId = null } = options; + const key = peopleKey(list, selfName, options); + if (key === shownPeople) return; + shownPeople = key; + const row = (name, isHost) => { const item = node("li", "", "stoa-person"); item.append(node("span", name)); if (isHost) item.append(node("span", "host", "stoa-host-badge")); return item; }; + const tool = (text, label, action) => { const button = node("button", text, "button button-secondary button-small"); button.type = "button"; button.setAttribute("aria-label", label); button.addEventListener("click", action); return button; }; + people.replaceChildren(row(selfName + " (you)", Boolean(selfId) && selfId === hostId), ...list.map((person) => { + const item = row(person.name, person.id === hostId); + if (manage) { + if (person.id !== hostId) item.append(tool("Make host", "Make " + person.name + " the host", () => handlers.makeHost(person))); + item.append(tool("Remove", "Remove " + person.name + " from this room", () => handlers.remove(person))); + } + return item; + })); + }, +``` + +`styles.css`: + +```css +.stoa-person { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; } +.stoa-person > span:first-child { margin-right: auto; } +.stoa-host-badge { font: 10px var(--mono); letter-spacing: 1px; text-transform: uppercase; color: var(--accent); border: 1px solid currentColor; border-radius: 3px; padding: 1px 5px; } +``` + +- [ ] **Step 4: Implement the stage** + +In `stoa/stage.js`: +- In `showPeople`'s callback, change `panel.setPeople(list, self.name)` to: + +```js + panel.setPeople(list, self.name, { manage: canManage(), hostId: here?.kind === "lot" || here?.kind === "space" ? here.access?.hostId || null : null, selfId: self.id }); +``` + + `canManage` is a `const` arrow declared after `showPeople`. The scheduled callback runs later, so that is fine. +- `showRoom` → also call `showPeople()` at its end, so host changes refresh the tools. +- In the `openPlace` options in `settle`, add: + +```js +onRekey: (from) => { if (here === next && from && (from === next.access?.hostId || from === next.space?.ownerId)) lease?.beatNow(); } +``` + +- Add two functions after `setTopic`: + +```js + async function makeHost(person) { + const at = here; + if (!canManage()) return; + try { + const { hostId } = await api("/api/spaces/" + encodeURIComponent(at.space.id) + "/host", { method: "POST", body: JSON.stringify({ actorId: person.id }) }); + if (here !== at) return; + at.access = { ...at.access, hostId }; + showRoom(at.space, hostId); + for (const place of places) place.refresh(); + panel.say(person.name + " is the host now."); + } catch (error) { if (here === at) panel.say(error.message); } + } + // Removal blocks the person and, in private and unlisted spaces, moves everyone else to a new channel. + async function removePerson(person) { + const at = here; + if (!canManage()) return; + try { + const access = await api("/api/spaces/" + encodeURIComponent(at.space.id) + "/remove", { method: "POST", body: JSON.stringify({ actorId: person.id }) }); + if (here !== at) return; + await Promise.all(places.map((place) => place.rekey())); + panel.say(person.name + " was removed from this room."); + onAccess(access, at); + await chain; + } catch (error) { if (here === at) panel.say(error.message); } + } +``` + + `onAccess` already reopens on a new channel through `transition` (renew, then `settle`) and applies `blocked` otherwise. `await chain` lets the test observe the reopened channel. +- Add `makeHost, removePerson,` to the returned object. + +`stoa.js`, in `panel.on({...})`: + +```js + makeHost: (person) => stage?.makeHost(person), + remove: (person) => { if (confirm("Remove " + person.name + " from this room? They can't come back until the room empties or the owner allows them back.")) stage?.removePerson(person); }, +``` + +- [ ] **Step 5: Run the tests** + +Run: `npm test` +Expected: PASS. + +- [ ] **Step 6: Commit** + +```bash +git add stoa/panel.js stoa/stage.js stoa.js styles.css tests/stoa-panel.test.mjs tests/stoa-stage.test.mjs +git commit -m "Let hosts hand over hosting and remove people + +🤖 Built with SMT " +``` + +--- + +### Task 7: Create and manage your spaces + +**Files:** +- Create: `stoa/spaces.js` +- Modify: `stoa.html`, `stoa.js`, `stoa/panel.js` (Manage button), `stoa/stage.js` (owner flag), `styles.css`, `scripts/serve.mjs` (`publicFiles`: add `"stoa/spaces.js"`) +- Test: `tests/stoa-spaces.test.mjs` (create), `tests/stoa-panel.test.mjs`, `tests/spaces-api.test.mjs` (markup ids) + +**Interfaces:** +- Consumes: `GET /api/spaces/mine` and `roster`/`blockedRoster` from Task 3. Also `POST /api/spaces`, `PUT/DELETE /api/spaces/`, `POST /api/spaces//invitations {}` → `{ path, usesLeft, expiresAt }`, `POST /api/spaces//members { memberId }`, `DELETE /api/spaces//members/`. +- Produces (pure): + - `spacePayload(values) -> { title, purpose, visibility, access, capacity, themeId }`. Private forces access to `members`; unknown visibility becomes `unlisted`; capacity is parsed as an integer. + - `describeSpace(space) -> string` + - `inviteUrl(origin, path) -> string` +- Produces (DOM): `createSpacesUi({ doc, api, say, navigate, confirm, copy, origin })` returning `{ showMine(listing), openCreate(prefill), openManage(spaceId) }`. +- Produces (panel): `showRoom({ ..., owner })` shows `#stoa-manage` when `owner`. New handler `manage`. `showPlaza()` shows `#stoa-mine-section` only when `setSignedIn(true)` was called. New panel method `setSignedIn(value)`. +- Produces (stage): `showRoom` passes `owner: here?.kind === "space" && mine(space.ownerId)`. + +- [ ] **Step 1: Write the failing tests** + +Create `tests/stoa-spaces.test.mjs`: + +```js +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { describeSpace, inviteUrl, spacePayload } from "../stoa/spaces.js"; + +test("form values become a valid space request", () => { + assert.deepEqual(spacePayload({ title: " Lab ", purpose: " Pairing ", visibility: "private", access: "open", capacity: "8", themeId: "cyberpunk" }), { title: "Lab", purpose: "Pairing", visibility: "private", access: "members", capacity: 8, themeId: "cyberpunk" }); + assert.deepEqual(spacePayload({ title: "Lab", visibility: "listed", access: "house", capacity: "12" }), { title: "Lab", purpose: "", visibility: "unlisted", access: "house", capacity: 12, themeId: "agora" }); + assert.equal(spacePayload({ title: "Lab", access: "everyone", capacity: "x" }).access, "members"); + assert.ok(Number.isNaN(spacePayload({ title: "Lab", capacity: "x" }).capacity), "the server reports a bad capacity"); +}); +test("spaces are described in plain words, and invite links are absolute", () => { + assert.equal(describeSpace({ visibility: "private", access: "members", occupancy: 2 }), "Private · members only · 2 here"); + assert.equal(describeSpace({ visibility: "unlisted", access: "open", occupancy: 0 }), "Unlisted · anyone with the link · 0 here"); + assert.equal(inviteUrl("https://agora.build", "/stoa/s/abc?invite=xyz"), "https://agora.build/stoa/s/abc?invite=xyz"); +}); +``` + +`tests/stoa-panel.test.mjs`, appended: + +```js +test("Manage shows only for the owner; your spaces only for signed-in people on the plaza", () => { + const doc = fakeDocument(), panel = createPanel(doc); + let managed = 0; + panel.on({ manage: () => { managed += 1; } }); + panel.showRoom({ kicker: "UNLISTED SPACE", title: "Lab", topic: null, tags: [], host: true, owner: false, leaveLabel: "Leave" }); + assert.equal(doc.querySelector("#stoa-manage").hidden, true); + panel.showRoom({ kicker: "UNLISTED SPACE", title: "Lab", topic: null, tags: [], host: true, owner: true, leaveLabel: "Leave" }); + assert.equal(doc.querySelector("#stoa-manage").hidden, false); + doc.querySelector("#stoa-manage").listeners.click(); + assert.equal(managed, 1); + panel.showPlaza(); + assert.equal(doc.querySelector("#stoa-mine-section").hidden, true); + panel.setSignedIn(true); + panel.showPlaza(); + assert.equal(doc.querySelector("#stoa-mine-section").hidden, false); +}); +``` + +`tests/spaces-api.test.mjs`: add `"stoa-mine-section", "stoa-new", "space-dialog", "manage-dialog", "stoa-manage"` to the Stoa markup id list, and `"/stoa/spaces.js"` to the module list. + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `node --test tests/stoa-spaces.test.mjs tests/stoa-panel.test.mjs tests/spaces-api.test.mjs` +Expected: FAIL. + +- [ ] **Step 3: Implement `stoa/spaces.js`** + +```js +// Your own spaces: the list on the plaza, the create dialog, and the owner's manage dialog (settings, invites, members). +export const THEME_CHOICES = [["agora", "Agora"], ["minimal", "Minimal"], ["cyberpunk", "Cyberpunk"]]; +const ACCESS_WORDS = { open: "anyone with the link", house: "house members", members: "members only" }; +export function spacePayload(values) { + const visibility = values.visibility === "private" ? "private" : "unlisted"; + const access = visibility === "private" ? "members" : Object.hasOwn(ACCESS_WORDS, values.access) ? values.access : "members"; + return { title: String(values.title || "").trim(), purpose: String(values.purpose || "").trim(), visibility, access, capacity: Number.parseInt(values.capacity, 10), themeId: THEME_CHOICES.some(([id]) => id === values.themeId) ? values.themeId : "agora" }; +} +export const describeSpace = (space) => [space.visibility === "private" ? "Private" : "Unlisted", ACCESS_WORDS[space.access] || "members only", space.occupancy + " here"].join(" · "); +export const inviteUrl = (origin, path) => new URL(path, origin).href; + +export function createSpacesUi({ doc = document, api, say = () => {}, navigate = (path) => location.assign(path), confirm = (text) => window.confirm(text), copy = (text) => navigator.clipboard.writeText(text), origin = location.origin }) { + const $ = (selector) => doc.querySelector(selector); + const node = (tag, text, className) => { const element = doc.createElement(tag); if (text) element.textContent = text; if (className) element.className = className; return element; }; + const createDialog = $("#space-dialog"), createForm = $("#space-form"), manageDialog = $("#manage-dialog"), manageForm = $("#manage-form"); + let managing = null; + // Private spaces admit members and invited people only, so the access choice is locked to members. + const lockAccess = (form) => { const priv = form.elements.visibility.value === "private"; if (priv) form.elements.access.value = "members"; form.elements.access.disabled = priv; }; + for (const form of [createForm, manageForm]) form.elements.visibility.addEventListener("change", () => lockAccess(form)); + const fill = (form, space) => { for (const field of ["title", "purpose", "visibility", "access", "capacity", "themeId"]) form.elements[field].value = space[field] ?? ""; lockAccess(form); }; + const values = (form) => spacePayload(Object.fromEntries(["title", "purpose", "visibility", "access", "capacity", "themeId"].map((field) => [field, form.elements[field].value]))); + + createForm.addEventListener("submit", async (event) => { + event.preventDefault(); + const button = createForm.querySelector("[type=submit]"), error = $("#space-error"); + button.disabled = true; error.textContent = ""; + try { const { space } = await api("/api/spaces", { method: "POST", body: JSON.stringify(values(createForm)) }); navigate("/stoa/s/" + space.id); } + catch (failure) { error.textContent = failure.message; } + finally { button.disabled = false; } + }); + + // `keep` is a person who gets no button: the server keeps the owner a member. + function people(list, selector, label, action, keep = null) { + $(selector).replaceChildren(...list.map((person) => { + const item = node("li", "", "manage-person"); + item.append(node("span", person.name)); + if (action && person.id !== keep) { const button = node("button", label, "button button-secondary button-small"); button.type = "button"; button.setAttribute("aria-label", label + ": " + person.name); button.addEventListener("click", () => action(person)); item.append(button); } + return item; + })); + if (!list.length) $(selector).replaceChildren(node("li", "Nobody yet.", "muted")); + } + async function load(id) { + const { space } = await api("/api/spaces/" + encodeURIComponent(id)); + managing = space; + fill(manageForm, space); + $("#manage-title").textContent = "Manage " + space.title; + people(space.roster || [], "#manage-members", "Remove", async (person) => { + if (!confirm("Remove " + person.name + " from the members of " + space.title + "?")) return; + try { await api("/api/spaces/" + encodeURIComponent(space.id) + "/members/" + encodeURIComponent(person.id), { method: "DELETE" }); await load(space.id); say(person.name + " is no longer a member."); } + catch (error) { $("#manage-error").textContent = error.message; } + }, space.ownerId); + people(space.blockedRoster || [], "#manage-blocked", "Allow back", async (person) => { + try { await api("/api/spaces/" + encodeURIComponent(space.id) + "/members", { method: "POST", body: JSON.stringify({ memberId: person.id }) }); await load(space.id); say(person.name + " can come back."); } + catch (error) { $("#manage-error").textContent = error.message; } + }); + } + manageForm.addEventListener("submit", async (event) => { + event.preventDefault(); + const error = $("#manage-error"); + error.textContent = ""; + try { const { space } = await api("/api/spaces/" + encodeURIComponent(managing.id), { method: "PUT", body: JSON.stringify(values(manageForm)) }); managing = { ...managing, ...space }; say("Space settings saved."); } + catch (failure) { error.textContent = failure.message; } + }); + $("#manage-invite").addEventListener("click", async () => { + try { + const invite = await api("/api/spaces/" + encodeURIComponent(managing.id) + "/invitations", { method: "POST", body: "{}" }); + const field = $("#manage-invite-url"); + field.value = inviteUrl(origin, invite.path); + field.hidden = false; $("#manage-copy").hidden = false; + $("#manage-invite-note").textContent = "Works " + invite.usesLeft + " times until " + new Date(invite.expiresAt).toLocaleString() + ". Anyone with it can join as a member."; + } catch (error) { $("#manage-error").textContent = error.message; } + }); + $("#manage-copy").addEventListener("click", async () => { + try { await copy($("#manage-invite-url").value); say("Invite link copied."); } + catch { $("#manage-invite-url").select?.(); say("Copy the selected link."); } + }); + $("#manage-delete").addEventListener("click", async () => { + if (!managing || !confirm("Delete " + managing.title + " for everyone? This can't be undone.")) return; + try { await api("/api/spaces/" + encodeURIComponent(managing.id), { method: "DELETE" }); navigate("/stoa/"); } + catch (error) { $("#manage-error").textContent = error.message; } + }); + + return { + showMine({ spaces = [], canCreate = false, owned = 0, limit = 0 } = {}) { + $("#stoa-mine").replaceChildren(...(spaces.length ? spaces.map((space) => { + const item = node("li", "", "stoa-room"), link = node("a", space.title); + link.href = space.path; + item.append(link, node("span", describeSpace(space) + (space.owner ? "" : " · member"))); + return item; + }) : [node("li", "You don't have any spaces yet.", "muted")])); + $("#stoa-new").disabled = !canCreate; + $("#stoa-mine-note").textContent = canCreate ? "" : "You own " + owned + " of " + limit + " spaces your plan allows."; + }, + openCreate(prefill = {}) { + createForm.reset(); + fill(createForm, { title: "", purpose: "", visibility: "unlisted", access: "members", capacity: 12, themeId: "agora", ...prefill }); + $("#space-error").textContent = ""; + createDialog.showModal(); + }, + async openManage(id) { + $("#manage-error").textContent = ""; + $("#manage-invite-url").hidden = true; $("#manage-copy").hidden = true; $("#manage-invite-note").textContent = ""; + try { await load(id); manageDialog.showModal(); } + catch (error) { say(error.message); } + } + }; +} +``` + +Add `"stoa/spaces.js"` to `publicFiles`. + +- [ ] **Step 4: Markup, panel, stage, styles, wiring** + +`stoa.html`: +- In the panel, after the `#stoa-rooms-section` section: + +```html + +``` + +- In `#stoa-here`, after `#stoa-decor-open`: `` +- Replace the legacy `#room-dialog` dialog in `stoa.html` with the two new dialogs. `script.js` still needs `#room-form` on the page until Task 8. To keep it from throwing, Task 7 keeps the `#room-dialog` markup as is; Task 8 removes it everywhere. Add the new dialogs before `
`: + +```html + +
A PLACE OF YOUR OWN
+

Start a space.

+

Your space is unlisted or private: people find it through you. You host it whenever you're there.

+
+ + +
+ + +
+
+ + +
+ + +
+
+ +
YOUR SPACE
+

Manage space

+
+ + +
+ + +
+
+ + +
+ +
+

Invite

+
+

+

Members

+
    +

    Removed

    +
      + +
      +
      +``` + +`stoa/panel.js`: +- Add `manage() {}` to `handlers`. Wire `$("#stoa-manage")` click → `handlers.manage()` and `$("#stoa-new")` click → `handlers.start()`. +- Add `let signedIn = false;`. +- `showRoom` also sets `$("#stoa-manage").hidden = !owner;` (destructure `owner = false`) and `$("#stoa-mine-section").hidden = true;`. +- `showPlaza` also sets `$("#stoa-manage").hidden = true; $("#stoa-mine-section").hidden = !signedIn;`. +- New method `setSignedIn(value) { signedIn = Boolean(value); }`. + +`stoa/stage.js`, `showRoom`: add `owner: here?.kind === "space" && mine(space.ownerId)` to the object passed to `panel.showRoom`. + +`styles.css`: + +```css +.stoa-mine-note { font-size: 12px; color: var(--muted); } +.manage-invite { display: flex; flex-wrap: wrap; gap: 6px; align-items: center; } +.manage-invite input { flex: 1 1 220px; font: 12px var(--mono); padding: 7px 8px; border: 1px solid var(--line); border-radius: 4px; } +.manage-people { list-style: none; margin: 0 0 8px; padding: 0; } +.manage-person { display: flex; justify-content: space-between; align-items: center; gap: 8px; padding: 6px 0; border-bottom: 1px solid var(--line); font-size: 14px; } +#stoa-mine a { color: inherit; font-weight: 500; } +``` + +`stoa.js`: +- `import { createSpacesUi } from "./stoa/spaces.js";` +- After `await sessionReady;` and `self`: `panel.setSignedIn(self.signedIn);` and `const spacesUi = createSpacesUi({ api, say: (text) => panel.say(text) });` +- Replace the `start` handler with: + +```js + start: () => self.signedIn ? spacesUi.openCreate() : (panel.say("Sign in to start a space."), openSignIn()), + manage: () => spaceId && spacesUi.openManage(spaceId), +``` + +- On the plaza (`!spaceId`) when signed in, load your spaces once after entering: `api("/api/spaces/mine").then((mine) => spacesUi.showMine(mine)).catch(() => {});`. Then honour `?start=`: if `new URLSearchParams(location.search).has("start")`, call the `start` behaviour with `{ title: params.get("start") }` as the prefill (signed out: say and open sign-in). Remove `start` from the URL with `history.replaceState` so a reload doesn't reopen the dialog. +- The full-plaza offer's `#stoa-start` keeps calling `handlers.start()`, which now opens the dialog. + +- [ ] **Step 5: Run the tests** + +Run: `npm test` +Expected: PASS. + +- [ ] **Step 6: Check it in a browser** + +On a throwaway server (not 3002), signed in: +- Start a space (private). You land in it. Manage → change the capacity → Save. +- Create an invite link. Copy it and open it in a second browser profile. That profile joins as a member and appears under Members. +- Remove the member. Then delete the space; you land on `/stoa/`. +- The plaza's Your spaces list shows the space before deletion. + +Check at desktop and mobile widths, and keyboard-only through both dialogs. + +- [ ] **Step 7: Commit** + +```bash +git add stoa/spaces.js stoa/panel.js stoa/stage.js stoa.html stoa.js styles.css scripts/serve.mjs tests/stoa-spaces.test.mjs tests/stoa-panel.test.mjs tests/spaces-api.test.mjs +git commit -m "Create, invite to, and manage your own Stoa spaces + +🤖 Built with SMT " +``` + +--- + +### Task 8: Retire the meetings page + +**Files:** +- Delete: `meetings.html`, `meetings.js`, `call.js` +- Modify: `scripts/serve.mjs` (`publicFiles`, the `/api/rooms` routes, `/meet/`, `/meetings.html`) +- Modify: `scripts/build.mjs` (file list), `scripts/auth.mjs` (`returnPath`) +- Modify: `script.js` (`newRoom`, the room form handler, room drafts) +- Modify: `index.html`, `explore.html`, `services.html`, `radar.html`, `account.html`, `stoa.html` (remove `#room-dialog`), `index.html` (the Common Room note) +- Modify: `styles.css` (drop `.call-*` and meetings-only rules that nothing uses any more) +- Modify: `README.md` +- Modify: `scripts/calls.mjs` (drop `issue(room, ...)` if nothing else uses it) +- Test: `tests/server.test.mjs`, `tests/auth.test.mjs`, `tests/spaces-api.test.mjs`, `tests/calls.test.mjs` + +**Interfaces:** +- Produces: + - `GET /meet/` → 308 to `/stoa/s/`, keeping the query string. + - `GET /meetings.html` → 308 to `/stoa/s/` when `?room=`, otherwise to `/stoa/`. + - Every `/api/rooms` path (`/api/rooms`, `/api/rooms//join`, `/api/rooms//call`), any method → 410 `{ error: "Meeting rooms are now Stoa spaces. Start one from the Stoa." }`, using the server's normal error JSON (`AppError(410, ...)`). + - `returnPath` accepts `/stoa/`, `/stoa/room/` and `/stoa/s/`, and keeps accepting `/meet/`. It drops `/meetings.html`. + - `newRoom(person, title)` in `script.js` navigates to `/stoa/?start=`, where the title is `"Connect with " + person.name` when a person is given. +- The store keeps its legacy room data and functions. Plan A migrates rooms into spaces at startup, and freezing creation here (410) makes that migration complete, as the Plan A notes required. + +- [ ] **Step 1: Write the failing tests** + +Read `tests/server.test.mjs` lines 30–40, 130–215 and 270–290 first. In it: +- Remove `"/meetings.html"` from the page list near line 36. +- Delete the tests (or the parts of tests) that create, join or call rooms through `/api/rooms`, and the assertions about `meetings.js`. Keep any test that only exercises store-level legacy data (around line 213) as it is. +- Remove `meetings.html`, `meetings.js` and `call.js` from the production-files list near line 275, and the `/meetings.html` 200 check near line 280. +- Add: + +```js +test("old meeting links and the rooms API point people to the Stoa", async () => { + const id = "11111111-1111-1111-1111-111111111111"; + const meet = await fetch(base + "/meet/" + id + "?invite=abc", { redirect: "manual" }); + assert.deepEqual([meet.status, meet.headers.get("location")], [308, "/stoa/s/" + id + "?invite=abc"]); + const page = await fetch(base + "/meetings.html?room=" + id, { redirect: "manual" }); + assert.deepEqual([page.status, page.headers.get("location")], [308, "/stoa/s/" + id]); + const bare = await fetch(base + "/meetings.html", { redirect: "manual" }); + assert.deepEqual([bare.status, bare.headers.get("location")], [308, "/stoa/"]); + for (const [path, method] of [["/api/rooms", "GET"], ["/api/rooms", "POST"], ["/api/rooms/" + id + "/join", "POST"], ["/api/rooms/" + id + "/call", "POST"]]) { + const response = await fetch(base + path, { method, headers: { "Content-Type": "application/json" }, ...(method === "POST" ? { body: "{}" } : {}) }); + assert.equal(response.status, 410, method + " " + path); + assert.match((await response.json()).error, /Stoa/); + } + for (const file of ["/meetings.js", "/call.js"]) assert.equal((await fetch(base + file)).status, 404, file); +}); +``` + +Use whatever this file calls its shared server URL (`base` above). Read the file's top to find the name. + +`tests/auth.test.mjs`: +- Lines 197–202 use `returnTo=/meetings.html`. Change them to `/stoa/` and expect `location` to be `/stoa/?signin=success`. +- Add assertions to the `returnPath` test (near line 67): + +```js + assert.equal(returnPath("/stoa/"), "/stoa/"); + assert.equal(returnPath("/stoa/room/ai-agents?x=1"), "/stoa/room/ai-agents?x=1"); + assert.equal(returnPath("/stoa/s/11111111-1111-1111-1111-111111111111?invite=abc&signin=failed"), "/stoa/s/11111111-1111-1111-1111-111111111111?invite=abc"); + assert.equal(returnPath("/meetings.html"), "/"); + assert.equal(returnPath("/stoa/s/not-a-uuid"), "/"); +``` + +`tests/spaces-api.test.mjs`, line ~174: remove `"/meetings.html"` from the page list. + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `npm test` +Expected: the new redirect, 410 and `returnPath` assertions FAIL. + +- [ ] **Step 3: Implement the server side** + +`scripts/serve.mjs`: +- Remove `"meetings.html"`, `"meetings.js"` and `"call.js"` from `publicFiles`. +- Replace the whole `/api/rooms` branch chain (the `else if (path === "/api/rooms" ...` arms, around lines 323–335) with one arm: + +```js + } else if (path === "/api/rooms" || path.startsWith("/api/rooms/")) throw new AppError(410, "Meeting rooms are now Stoa spaces. Start one from the Stoa."); +``` + + Keep the surrounding `if`/`else if` chain intact; read the lines before and after first. +- Replace the `/meet/` lookup (the `const meeting = ...` line and the 404 line after it) with: + +```js + const meeting = /^\/meet\/([a-f0-9-]{36})$/.exec(path); + if (meeting) { response.writeHead(308, { Location: "/stoa/s/" + meeting[1] + requestUrl.search }).end(); return; } + if (path === "/meetings.html") { const room = requestUrl.searchParams.get("room"); response.writeHead(308, { Location: /^[a-f0-9-]{36}$/.test(room || "") ? "/stoa/s/" + room : "/stoa/" }).end(); return; } +``` + +- Change `const filename = stoa ? "stoa.html" : meeting ? "meetings.html" : ...` to `const filename = stoa ? "stoa.html" : path === "/" ? "index.html" : path.slice(1);`. +- If `store.rooms()` or `calls.issue` are no longer referenced in `serve.mjs`, leave the store alone. In `scripts/calls.mjs`, remove `issue(room, person, input)` only if `grep -rn "calls.issue(\|\.issue(room" scripts` finds no other callers, and remove its test in `tests/calls.test.mjs`. Keep `issueSpace`. + +`scripts/build.mjs`: remove `"meetings.html"`, `"meetings.js"` and `"call.js"` from the copied file list. + +`scripts/auth.mjs`, in `returnPath`, replace the allowlist line with: + +```js + const stoa = /^\/stoa\/(?:room\/[a-z0-9-]{2,40}|s\/[a-f0-9-]{36})?$/.test(url.pathname); + if (!["/", "/index.html", "/explore.html", "/radar.html", "/services.html", "/account.html", "/oauth/authorize"].includes(url.pathname) && !stoa && !/^\/meet\/[a-f0-9-]{36}$/.test(url.pathname)) return "/"; +``` + +- [ ] **Step 4: Implement the page side** + +`script.js`: +- Replace `newRoom` with: + +```js +// Rooms are Stoa spaces now: starting one opens the Stoa's create dialog. +export function newRoom(person, title = "") { + location.assign("/stoa/?start=" + encodeURIComponent(person ? "Connect with " + person.name : title)); +} +``` + +- Delete the `document.querySelector("#room-form").addEventListener("submit", ...)` block. +- In the `sessionReady.then(...)` block, remove the `foundry-room-draft` logic. Keep: if signed in with an account but no profile (and not on account or services), `openJoin()`; otherwise `notify("You are signed in. Your profile and rooms are ready.")`. Change that wording to `"You are signed in. Your profile and spaces are ready."`. +- In `openProfile`, rename the "Start a room" button text to "Start a space". +- `grep -n "foundry-room-draft\|room-dialog\|room-form\|room-error" script.js` must return nothing afterwards. + +HTML pages: +- Remove the entire `<dialog id="room-dialog" ...>...</dialog>` block from `index.html`, `explore.html`, `services.html`, `radar.html`, `account.html` and `stoa.html`. Each block starts at `<dialog id="room-dialog"` and ends at the next `</dialog>`; each spans 4 lines. Use a small Node script or careful edits, then `grep -c room-dialog *.html` must print 0 for each file. +- `index.html` line ~54: the note `<a class="tool-note note-room" href="/meetings.html">` changes to `href="/stoa/"`. Read the line and update its copy so it describes walking into the Stoa's rooms rather than a meetings page. Keep the note's markup and classes. +- The `data-new-room` buttons on the homepage keep working through `newRoom`. + +`styles.css`: remove rules used only by `meetings.html` and `call.js`. Before deleting each selector, check that `grep -rn "<class-name>" --include='*.html' --include='*.js' .` (excluding `node_modules`, `dist` and `docs`) finds no remaining use. Candidates: `.call-panel`, `.call-heading`, `.call-status`, `.call-stage`, `.call-tile`, `.call-player`, `.call-placeholder`, `.call-tile-name`, `.call-preflight`, `.call-controls`, `.meeting-intro`, and the matching media-query lines. + +Delete the files: `git rm meetings.html meetings.js call.js`. + +`README.md`: +- Pages and data: replace the `meetings.html` line with: `` - `stoa.html` / `stoa.js` / `stoa/` / `world/`: the Stoa — a walkable plaza with first-come rooms and your own spaces, live presence and messages over Agora Signaling, and calls over Agora RTC. `` +- "Agora group calls": rewrite it for the Stoa. Calls happen inside lot rooms and your spaces, through `POST /api/spaces/<id>/rtc-token`, and only for people currently in the room. Theme video layouts are over avatars (Agora), a strip (Cyberpunk), or a grid (Minimal). Keep the existing device, screen-share, HTTPS and token-TTL facts that still hold. Old `/meet/<id>` links redirect to `/stoa/s/<id>`; `/api/rooms` answers 410. +- "Stoa spaces": add hosts (hand over, remove, decorate), your spaces (create, invite links, members, settings, delete), and plan limits. +- Remove "creating a room, and joining a room roster" from the activity paragraph if it no longer happens. `record(...)` calls in `store.mjs` for rooms no longer run because the routes are gone; leave `store.mjs` unchanged. + +- [ ] **Step 5: Run the tests** + +Run: `npm test && npm run build` +Expected: PASS, and `dist/` has no `meetings.html`, `meetings.js` or `call.js`. + +- [ ] **Step 6: Check it in a browser** + +On a throwaway server: +- `/meet/<existing legacy room id>` lands in that space's Stoa page (the migrated room). +- The homepage's Common Room note links to `/stoa/`. A Demo days "plan a session" button opens the Stoa's Start a space dialog with the title filled in. +- A builder profile's "Start a space" does the same with "Connect with <name>". + +- [ ] **Step 7: Commit** + +```bash +git add -A +git commit -m "Retire the meetings page in favour of Stoa spaces + +🤖 Built with SMT <smt@agora.build>" +``` + +Before committing, check `git status` shows only the intended files (no `.data/`, `dist/` or scratch files). + +--- + +## Final verification (controller, after the final review) + +1. `npm test` and `npm run build` pass. +2. Live check with real Agora. The user approved using the atem project's credentials for live tests. Put them only in the environment of a throwaway server on a free port; never print them or write them to a file. + - Two headless Chrome profiles use fake media devices (`--use-fake-device-for-media-stream --use-fake-ui-for-media-stream`) on `http://localhost:<port>`. + - Both enter the same lot room and join the call: each sees the other's video tile in the over-avatar layout. With the Minimal view on, the grid layout shows. + - Toggle mic and camera; share a screen. + - The host decorates; the other client sees the change. + - The host hands over hosting, then the new host removes the other person. The removed person leaves the call and the room. + - In a private space, the owner creates an invite link and the second profile joins with it. + - Record the results in this plan under "Live check", stop the server, and delete its temporary data. +3. Screenshots at desktop and mobile widths for the PR. + +## Notes for later plans + +- Plan D (Agora Chat): team messages and DMs, with the call panel's "chat" control (spec section 3) linking to the room's Chat group. +- Selection highlight in decor mode needs a renderer hook (an outline at a tile). v1 names the selection in the panel only. +- Proximity audio (spec section 2) can use `overAvatar`'s matching to decide which streams to subscribe to. From 1edd2fd217e69b94a1a1e0f02b3ee178ba45263b Mon Sep 17 00:00:00 2001 From: Brent G <deliberatekids@gmail.com> Date: Thu, 8 Oct 2026 19:08:04 +0000 Subject: [PATCH 51/71] Add decor and rekey notices, on-demand heartbeats, and engine draw and pick hooks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🤖 Built with SMT <smt@agora.build> --- stoa/lease.js | 7 +++++-- stoa/place.js | 6 +++++- tests/stoa-place.test.mjs | 31 +++++++++++++++++++++++++++++++ tests/stoa-protocol.test.mjs | 6 ++++++ tests/world-engine.test.mjs | 28 ++++++++++++++++++++++++++++ world/engine.js | 17 +++++++++++------ world/protocol.js | 2 ++ 7 files changed, 88 insertions(+), 9 deletions(-) diff --git a/stoa/lease.js b/stoa/lease.js index 7f37664..5f3304f 100644 --- a/stoa/lease.js +++ b/stoa/lease.js @@ -3,12 +3,15 @@ export function createLease({ api, spaceId, onAccess = () => {}, onLost = () => const base = "/api/spaces/" + spaceId; let timer = null, stopped = false; const stop = () => { stopped = true; if (timer !== null) { stopRepeat(timer); timer = null; } }; - timer = repeat(async () => { + const beat = async () => { try { const access = await api(base + "/heartbeat", { method: "POST" }); if (!stopped) onAccess(access); } catch (error) { if (!stopped && [401, 403, 404, 410].includes(error.status)) { stop(); onLost(error); } } - }, every); + }; + timer = repeat(beat, every); return { stop, + // An extra heartbeat right away, for when the host says access has changed. + beatNow: () => stopped ? Promise.resolve() : beat(), async leave() { stop(); try { await api(base + "/leave", { method: "POST" }); } catch { /* the lease expires on its own */ } }, leaveOnUnload() { stop(); beacon(base + "/leave"); } }; diff --git a/stoa/place.js b/stoa/place.js index 7afde1b..ad7b694 100644 --- a/stoa/place.js +++ b/stoa/place.js @@ -3,7 +3,7 @@ import { createLimiter, writeMessage, writeState } from "../world/protocol.js"; // One channel's live layer: who is there, what they say, and your own movement published within the budget. // The caller merges people from every open place, so a space can later span several area-of-interest cells. -export async function openPlace({ live, map, engine, self, channel, onPeople = () => {}, onSay = () => {}, onRefresh = () => {}, quiet = false, now = () => Date.now(), later }) { +export async function openPlace({ live, map, engine, self, channel, onPeople = () => {}, onSay = () => {}, onRefresh = () => {}, onDecor = () => {}, onRekey = () => {}, quiet = false, now = () => Date.now(), later }) { const guest = live.guest || quiet; const presence = createPresence({ map, self: live.userId, now }); const moves = createLimiter({ perSecond: 4, burst: 4, now, later }), chats = createLimiter({ perSecond: 1, burst: 3, now, later }); @@ -16,6 +16,8 @@ export async function openPlace({ live, map, engine, self, channel, onPeople = ( if (!message) return; if (message.t === "say") onSay({ name: message.from.name, text: message.text, self: false }); if (message.t === "refresh") onRefresh(message.from.id); + if (message.t === "decor") onDecor(message.from.id, message.version); + if (message.t === "rekey") onRekey(message.from.id); show(); }, presence(change) { @@ -51,6 +53,8 @@ export async function openPlace({ live, map, engine, self, channel, onPeople = ( return true; }, refresh() { if (!guest) publish({ t: "refresh" }); }, + decorChanged(version) { if (!guest) publish({ t: "decor", version }); }, + rekey() { return guest ? Promise.resolve(false) : publish({ t: "rekey" }); }, setBlocked(list) { presence.setBlocked(list); show(); }, async close() { open = false; for (const stop of stops) stop(); await live.leave(channel.name); } }; diff --git a/tests/stoa-place.test.mjs b/tests/stoa-place.test.mjs index e0a0ad9..bd5cd8b 100644 --- a/tests/stoa-place.test.mjs +++ b/tests/stoa-place.test.mjs @@ -195,3 +195,34 @@ test("a 401 ends the lease, but a 429 or a network error keeps it going", async assert.deepEqual([losses.length, stopped], ends ? [1, 1] : [0, 0], String(status)); } }); + +test("decor and rekey notices reach the caller with who sent them, and can be published", async () => { + const live = fakeLive({ who: [{ userId: ada, states: state(22, 17, "Ada") }] }), engine = fakeEngine(), seen = []; + const place = await openPlace({ live, map: plaza, engine, self: { name: "Me" }, channel: { name: "room-1", key: null, spaceId: "lot-x" }, onDecor: (from, version) => seen.push(["decor", from, version]), onRekey: (from) => seen.push(["rekey", from]) }); + live.handlers.message(ada, JSON.stringify({ t: "decor", version: 2 })); + live.handlers.message(ada, JSON.stringify({ t: "rekey" })); + live.handlers.message(bo, JSON.stringify({ t: "rekey" })); + const adaId = "account:72a639ba-3a45-4afe-936b-222222222222"; + assert.deepEqual(seen, [["decor", adaId, 2], ["rekey", adaId]], "people without presence are ignored"); + place.decorChanged(5); + assert.equal(await place.rekey(), true); + assert.deepEqual(live.published.slice(-2), [{ t: "decor", version: 5 }, { t: "rekey" }]); +}); +test("watchers never publish decor or rekey notices", async () => { + const { live, place } = await opened({ quiet: true }); + place.decorChanged(1); + assert.equal(await place.rekey(), false); + assert.deepEqual(live.published, []); +}); +test("a heartbeat can be run at once, and does nothing after the lease stops", async () => { + const accesses = [], losses = []; + let replies = [{ channel: "c3", key: null, blocked: [], hostId: null }, Object.assign(new Error("Gone"), { status: 410 })]; + const api = async () => { const reply = replies.shift(); if (reply instanceof Error) throw reply; return reply; }; + const lease = createLease({ api, spaceId: "lot-x", onAccess: (access) => accesses.push(access.channel), onLost: (error) => losses.push(error.status), repeat: () => 1, stopRepeat() {} }); + await lease.beatNow(); + await lease.beatNow(); + assert.deepEqual([accesses, losses], [["c3"], [410]]); + replies = [{ channel: "c4" }]; + await lease.beatNow(); + assert.deepEqual(accesses, ["c3"], "a stopped lease does not beat"); +}); diff --git a/tests/stoa-protocol.test.mjs b/tests/stoa-protocol.test.mjs index c9ca66f..e527309 100644 --- a/tests/stoa-protocol.test.mjs +++ b/tests/stoa-protocol.test.mjs @@ -159,3 +159,9 @@ test("a move that starts far from where the person is is dropped", () => { assert.deepEqual(room.list()[0].walk.path, [{ x: 21, y: 17 }]); assert.ok(room.message(ada, writeMessage({ t: "move", path: [{ x: 21, y: 15 }, { x: 21, y: 14 }], startedAt: 1000 })), "two tiles away still counts as the same walk"); }); + +test("decor and rekey notices are read strictly", () => { + assert.deepEqual(readMessage(JSON.stringify({ t: "decor", version: 3 }), plaza), { t: "decor", version: 3 }); + for (const version of [0, -1, 1.5, "3", undefined, null]) assert.equal(readMessage(JSON.stringify({ t: "decor", version }), plaza), null, String(version)); + assert.deepEqual(readMessage(JSON.stringify({ t: "rekey", extra: 1 }), plaza), { t: "rekey" }); +}); diff --git a/tests/world-engine.test.mjs b/tests/world-engine.test.mjs index 2954346..834c31f 100644 --- a/tests/world-engine.test.mjs +++ b/tests/world-engine.test.mjs @@ -274,3 +274,31 @@ test("someone walking in from off-screen is drawn as they arrive, even with noth assert.ok(far, "the walker is drawn once they come into view"); assert.equal(far.x, 11, "and at their real position while still walking"); }); + +test("each drawn frame is reported with the camera and where the drawn people stand", () => { + const { engine, flush } = setup(); + const frames = []; + engine.on("draw", (frame) => frames.push(frame)); + engine.setOthers([{ id: "bo", name: "Bo", walk: { path: [{ x: 22, y: 17 }], startedAt: 0 } }]); flush(); + const last = frames.at(-1); + assert.equal(last.tileSize, 32); + assert.ok(last.camera.zoom > 0); + assert.deepEqual(last.avatars.map(({ id, x, y, self }) => [id, x, y, self]), [["self", 21, 17, true], ["bo", 22, 17, false]]); +}); +test("picking hands clicked tiles to the picker instead of walking, even with input off", () => { + const { engine, handlers, events, drawn } = setup(); + const picked = []; + engine.setInteractive(false); + engine.setPicking((tile) => picked.push(tile)); + const { camera } = drawn.at(-1); + const click = () => handlers.pointerup({ button: 0, clientX: (23 * 32 + 16 - camera.x) * camera.zoom, clientY: (17 * 32 + 16 - camera.y) * camera.zoom }); + click(); + assert.deepEqual(picked, [{ x: 23, y: 17 }]); + assert.equal(events.move.length, 0); + engine.setPicking(null); + click(); + assert.equal(events.move.length, 0, "with picking off, input off still means no walking"); + engine.setInteractive(true); + click(); + assert.equal(events.move.length, 1); +}); diff --git a/world/engine.js b/world/engine.js index 2fe6e3b..e8ffe89 100644 --- a/world/engine.js +++ b/world/engine.js @@ -8,11 +8,12 @@ import { createCanvasRenderer } from "./renderer-canvas.js"; // Times are wall-clock (Date.now) because walk start times are published, so peers share the clock. // One person's movement, input, camera, and drawing. Other people arrive through setOthers. // Events: "move" { path, startedAt } for pointer walks; "walk" { from, dir, startedAt } and "stop" { at } for keyboard -// walks; "face" { dir, at } when a key only turns you; "arrive" { x, y } when a walk ends. +// walks; "face" { dir, at } when a key only turns you; "arrive" { x, y } when a walk ends; +// "draw" { camera, tileSize, avatars } after each frame. export function createEngine({ canvas, map, theme, start, reducedMotion = false, renderer = createCanvasRenderer(canvas, { map, theme }), now = () => Date.now(), raf = (callback) => requestAnimationFrame(callback), later = (callback, delay) => setTimeout(callback, delay), cancel = (handle) => clearTimeout(handle), ratio = () => globalThis.devicePixelRatio || 1 }) { - const listeners = { arrive: [], move: [], walk: [], stop: [], face: [] }; + const listeners = { arrive: [], move: [], walk: [], stop: [], face: [], draw: [] }; const self = { id: "self", name: "You", walk: { path: [start], startedAt: now(), dir: "down" }, arrived: true, keyboard: false, bubble: null }; - let current = theme, others = [], decor = [], labels = [], held = null, pending = false, timer = null, destroyed = false, interactive = true; + let current = theme, others = [], decor = [], labels = [], held = null, pending = false, timer = null, destroyed = false, interactive = true, picking = null; let bounds = null, camera = { x: 0, y: 0, zoom: 1 }, viewport = { width: 1, height: 1 }; const emit = (type, value) => { for (const listener of listeners[type]) listener(value); }; const here = (time = now()) => positionAt(self.walk, time); @@ -37,7 +38,9 @@ export function createEngine({ canvas, map, theme, start, reducedMotion = false, const reach = { x: viewport.width / camera.zoom / s, y: viewport.height / camera.zoom / s }, middle = { x: (camera.x + viewport.width / camera.zoom / 2) / s, y: (camera.y + viewport.height / camera.zoom / 2) / s }; const everyone = others.map((other) => ({ id: other.id, name: other.name, bubble: speech(other.bubble, time), ...positionAt(other.walk, time) })); const crowd = everyone.filter((other) => Math.abs(other.x + 0.5 - middle.x) <= reach.x && Math.abs(other.y + 0.5 - middle.y) <= reach.y); - renderer.draw({ camera, time, avatars: [{ id: self.id, name: self.name, self: true, bubble: speech(self.bubble, time), ...shown }, ...crowd], decor, labels, bounds, motion: !reducedMotion }); + const avatars = [{ id: self.id, name: self.name, self: true, bubble: speech(self.bubble, time), ...shown }, ...crowd]; + renderer.draw({ camera, time, avatars, decor, labels, bounds, motion: !reducedMotion }); + if (listeners.draw.length) emit("draw", { camera, tileSize: s, avatars: avatars.map((avatar) => ({ id: avatar.id, x: avatar.x, y: avatar.y, self: Boolean(avatar.self) })) }); const talking = [self.bubble, ...others.map((other) => other.bubble)].filter((bubble) => bubble && bubble.until > time); // People walking off-screen are not drawn, but a slow check keeps running so they appear as soon as they come into view. const distant = everyone.some((other) => !other.done); @@ -95,9 +98,10 @@ export function createEngine({ canvas, map, theme, start, reducedMotion = false, } const onPointer = (event) => { - if (!interactive || event.button) return; + if (event.button || (!interactive && !picking)) return; const box = canvas.getBoundingClientRect(), tile = screenToTile(camera, event.clientX - box.left, event.clientY - box.top, map.tileSize); - if (inBounds(tile)) walkTo(tile); + if (!inBounds(tile)) return; + if (picking) picking(tile); else walkTo(tile); }; const onKeyDown = (event) => { const dir = KEYS[event.key]; @@ -120,6 +124,7 @@ export function createEngine({ canvas, map, theme, start, reducedMotion = false, teleport(tile, dir = "down") { held = null; self.keyboard = false; self.walk = { path: [{ x: tile.x, y: tile.y }], startedAt: now(), dir }; self.arrived = true; schedule(); }, on(type, listener) { if (!Object.hasOwn(listeners, type)) throw new Error("Unknown engine event: " + type); listeners[type].push(listener); return () => { listeners[type] = listeners[type].filter((item) => item !== listener); }; }, say(text, ms = 8000) { self.bubble = { text: String(text), until: now() + ms }; schedule(); }, + setPicking(handler) { picking = typeof handler === "function" ? handler : null; }, setInteractive(value) { interactive = Boolean(value); if (!interactive) { held = null; keyStop(); } }, setSelf({ id, name }) { if (id) self.id = id; self.name = name; schedule(); }, setTheme(next) { current = next; renderer.setTheme(next); schedule(); }, diff --git a/world/protocol.js b/world/protocol.js index 84f29c9..da11ffc 100644 --- a/world/protocol.js +++ b/world/protocol.js @@ -24,6 +24,8 @@ export function readMessage(text, map) { case "face": return DIRS.includes(data.dir) && isTile(data.at) && walkable(map, data.at.x, data.at.y) ? { t: "face", dir: data.dir, at: copy(data.at) } : null; case "say": { const say = typeof data.text === "string" ? data.text.trim() : ""; return say && data.text.length <= TEXT_LIMIT ? { t: "say", text: say } : null; } case "refresh": return { t: "refresh" }; + case "decor": return Number.isInteger(data.version) && data.version > 0 ? { t: "decor", version: data.version } : null; + case "rekey": return { t: "rekey" }; default: return null; } } From 38eedd41fa632b16eb08f2161ffa5a70bc8b2d35 Mon Sep 17 00:00:00 2001 From: Brent G <deliberatekids@gmail.com> Date: Thu, 8 Oct 2026 19:09:18 +0000 Subject: [PATCH 52/71] Add a UI-free Agora RTC client for room calls MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🤖 Built with SMT <smt@agora.build> --- scripts/serve.mjs | 2 +- tests/rtc-client.test.mjs | 144 ++++++++++++++++++++++++++++++++++++ world/rtc-client.js | 150 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 295 insertions(+), 1 deletion(-) create mode 100644 tests/rtc-client.test.mjs create mode 100644 world/rtc-client.js diff --git a/scripts/serve.mjs b/scripts/serve.mjs index 28d2568..d30dac1 100644 --- a/scripts/serve.mjs +++ b/scripts/serve.mjs @@ -23,7 +23,7 @@ import { loadThemes } from "./spaces/themes.mjs"; const root = fileURLToPath(new URL("../", import.meta.url)); // Vendored browser SDKs are served from node_modules in development and from dist/assets after a build. const vendor = { "assets/agora-rtc.js": createRequire(import.meta.url).resolve("agora-rtc-sdk-ng"), "assets/agora-rtm.js": createRequire(import.meta.url).resolve("agora-rtm-sdk") }; -const publicFiles = new Set(["index.html", "explore.html", "services.html", "radar.html", "meetings.html", "account.html", "account.js", "stoa.html", "stoa.js", "world/map.js", "world/kinds.js", "world/themes.js", "world/camera.js", "world/motion.js", "world/renderer-canvas.js", "world/engine.js", "world/sdk.js", "stoa/panel.js", "stoa/stage.js", "stoa/place.js", "stoa/lease.js", "world/protocol.js", "world/crypto.js", "world/signaling.js", "world/presence.js", "styles.css", "script.js", "people.js", "activity.js", "explore.js", "services.js", "radar.js", "meetings.js", "call.js", "assets/agora-rtc.js", "assets/agora-rtm.js", "assets/favicon.svg", "assets/guohai.jpg"]); +const publicFiles = new Set(["index.html", "explore.html", "services.html", "radar.html", "meetings.html", "account.html", "account.js", "stoa.html", "stoa.js", "world/map.js", "world/kinds.js", "world/themes.js", "world/camera.js", "world/motion.js", "world/renderer-canvas.js", "world/engine.js", "world/sdk.js", "world/rtc-client.js", "stoa/panel.js", "stoa/stage.js", "stoa/place.js", "stoa/lease.js", "world/protocol.js", "world/crypto.js", "world/signaling.js", "world/presence.js", "styles.css", "script.js", "people.js", "activity.js", "explore.js", "services.js", "radar.js", "meetings.js", "call.js", "assets/agora-rtc.js", "assets/agora-rtm.js", "assets/favicon.svg", "assets/guohai.jpg"]); const types = { ".html": "text/html; charset=utf-8", ".css": "text/css; charset=utf-8", ".js": "text/javascript; charset=utf-8", ".svg": "image/svg+xml", ".jpg": "image/jpeg" }; function json(response, status, body, headers = {}) { diff --git a/tests/rtc-client.test.mjs b/tests/rtc-client.test.mjs new file mode 100644 index 0000000..b62f7dc --- /dev/null +++ b/tests/rtc-client.test.mjs @@ -0,0 +1,144 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { createRtcClient, isScreen, mediaError } from "../world/rtc-client.js"; + +const tick = () => new Promise((resolve) => setImmediate(resolve)); +const me = "u1_aaaaaaaaaaaa", peer = "u2_bbbbbbbbbbbb"; +const credentials = (n = 1) => ({ appId: "app", channel: "agora-build-space-x", uid: me, screenUid: me + "_screen", token: "t" + n, screenToken: "s" + n }); +function fakeTrack(kind) { + const track = { kind, enabled: true, closed: false, stopped: false, played: [], listeners: {}, async setEnabled(value) { track.enabled = value; }, close() { track.closed = true; }, stop() { track.stopped = true; }, play(element, options) { track.played.push([element, options]); }, on(type, listener) { track.listeners[type] = listener; } }; + return track; +} +function fakeAgora({ deny = null } = {}) { + const sdk = { + clients: [], tracks: [], checkSystemRequirements: () => true, + createClient() { + const client = { + listeners: {}, remoteUsers: [], joined: null, published: [], left: false, renewed: [], connectionState: "DISCONNECTED", + on(type, listener) { client.listeners[type] = listener; }, emit(type, ...args) { client.listeners[type]?.(...args); }, + async join(appId, channel, token, uid) { client.joined = { appId, channel, token, uid }; client.connectionState = "CONNECTED"; }, + async leave() { client.left = true; client.connectionState = "DISCONNECTED"; }, + async publish(tracks) { client.published.push(...[].concat(tracks)); }, + async subscribe(user, media) { user[media + "Track"] = fakeTrack(media); }, + async renewToken(token) { client.renewed.push(token); }, + removeAllListeners() { client.listeners = {}; } + }; + sdk.clients.push(client); + return client; + }, + async createMicrophoneAudioTrack() { if (deny === "audio") throw Object.assign(new Error("denied"), { code: "PERMISSION_DENIED" }); const track = fakeTrack("audio"); sdk.tracks.push(track); return track; }, + async createCameraVideoTrack() { const track = fakeTrack("video"); sdk.tracks.push(track); return track; }, + async createScreenVideoTrack() { const track = fakeTrack("screen"); sdk.tracks.push(track); return track; } + }; + return sdk; +} +function make({ sdk = fakeAgora(), fetch } = {}) { + const fetches = [], errors = [], ended = []; + const rtc = createRtcClient({ AgoraRTC: sdk, fetchCredentials: fetch || (async (uid) => { fetches.push(uid); return credentials(fetches.length); }), onError: (message) => errors.push(message), onEnded: (message) => ended.push(message) }); + return { rtc, sdk, fetches, errors, ended }; +} +const own = (snapshot) => ({ joined: snapshot.joined, audio: snapshot.audio, video: snapshot.video, screen: snapshot.screen }); + +test("screen-share uids and device errors are recognised", () => { + assert.equal(isScreen(me + "_screen"), true); + assert.equal(isScreen(me), false); + assert.match(mediaError({ code: "PERMISSION_DENIED" }), /declined/); + assert.match(mediaError({ name: "NotFoundError" }), /could not be found/); + assert.equal(mediaError({ status: 403, message: "Step in first." }), "Step in first."); +}); +test("joining publishes the chosen devices, toggles work, and leaving releases everything", async () => { + const { rtc, sdk } = make(); + assert.equal(await rtc.join({ audio: true }), true); + const [client] = sdk.clients; + assert.deepEqual(client.joined, { appId: "app", channel: "agora-build-space-x", token: "t1", uid: me }); + assert.deepEqual(client.published.map((track) => track.kind), ["audio"]); + assert.deepEqual(own(rtc.snapshot()), { joined: true, audio: true, video: false, screen: false }); + await rtc.toggle("audio"); + assert.equal(rtc.snapshot().audio, false); + await rtc.toggle("video"); + assert.equal(rtc.snapshot().video, true); + await rtc.leave(); + assert.ok(sdk.tracks.every((track) => track.closed), "every device is released"); + assert.equal(client.left, true); + assert.equal(await rtc.toggle("audio"), false, "nothing runs after leaving"); +}); +test("people appear as they publish and disappear as they leave; your own screen is not a peer", async () => { + const { rtc, sdk } = make(); + await rtc.join({}); + const [client] = sdk.clients, user = { uid: peer }; + client.remoteUsers.push(user); + client.emit("user-joined", user); + client.emit("user-published", user, "video"); + await tick(); + assert.deepEqual(rtc.snapshot().peers, [{ uid: peer, audio: false, video: true, screen: false }]); + const element = {}; + assert.equal(rtc.play(peer, element), true); + assert.deepEqual(user.videoTrack.played, [[element, { fit: "cover" }]]); + client.emit("user-unpublished", user, "video"); + assert.equal(rtc.snapshot().peers[0].video, false); + assert.equal(rtc.play(peer, element), false); + client.emit("user-joined", { uid: me + "_screen" }); + assert.equal(rtc.snapshot().peers.length, 1); + client.remoteUsers = []; + client.emit("user-left", user); + assert.deepEqual(rtc.snapshot().peers, []); +}); +test("tokens renew with the same identity, and a refused renewal ends the call", async () => { + let refuse = false; + const fetches = []; + const { rtc, sdk, ended } = make({ fetch: async (uid) => { fetches.push(uid); if (refuse) throw Object.assign(new Error("Step into this space before joining its call."), { status: 403 }); return credentials(fetches.length); } }); + await rtc.join({ audio: true }); + const [client] = sdk.clients; + client.emit("token-privilege-will-expire"); + await tick(); await tick(); + assert.deepEqual([fetches, client.renewed], [[undefined, me], ["t2"]]); + refuse = true; + client.emit("token-privilege-did-expire"); + for (let index = 0; index < 5; index += 1) await tick(); + assert.deepEqual(ended, ["Step into this space before joining its call."]); + assert.equal(client.left, true); + assert.ok(sdk.tracks.every((track) => track.closed)); +}); +test("a declined microphone still joins to listen, with a clear reason", async () => { + const { rtc, errors } = make({ sdk: fakeAgora({ deny: "audio" }) }); + assert.equal(await rtc.join({ audio: true }), true); + assert.deepEqual(own(rtc.snapshot()), { joined: true, audio: false, video: false, screen: false }); + assert.match(errors[0], /declined/); +}); +test("leaving while still joining releases everything and never joins", async () => { + let release; + const sdk = fakeAgora(); + const { rtc } = make({ sdk, fetch: () => new Promise((resolve) => { release = resolve; }) }); + const joining = rtc.join({ audio: true }); + await tick(); + const leaving = rtc.leave(); + release(credentials()); + await leaving; await joining; + assert.equal(sdk.clients.length, 0); + assert.equal(sdk.tracks.length, 0); +}); +test("screen sharing uses a second client and stops when the browser ends it", async () => { + const { rtc, sdk } = make(); + await rtc.join({}); + assert.equal(await rtc.share(), true); + const screenClient = sdk.clients[1]; + assert.deepEqual([screenClient.joined.uid, screenClient.joined.token], [me + "_screen", "s2"]); + assert.equal(rtc.snapshot().screen, true); + const element = {}; + assert.equal(rtc.play(me + "_screen", element), true); + const [screenTrack] = screenClient.published; + assert.deepEqual(screenTrack.played, [[element, { fit: "contain" }]]); + screenTrack.listeners["track-ended"](); + await tick(); await tick(); + assert.equal(rtc.snapshot().screen, false); + assert.equal(screenClient.left, true); + assert.equal(screenTrack.closed, true); +}); +test("an unexpected disconnect ends the call", async () => { + const { rtc, sdk, ended } = make(); + await rtc.join({}); + sdk.clients[0].emit("connection-state-change", "DISCONNECTED", "CONNECTED"); + for (let index = 0; index < 5; index += 1) await tick(); + assert.deepEqual(ended, ["The call disconnected. Join again when you're ready."]); + assert.equal(rtc.snapshot().joined, false); +}); diff --git a/world/rtc-client.js b/world/rtc-client.js new file mode 100644 index 0000000..79c7bf0 --- /dev/null +++ b/world/rtc-client.js @@ -0,0 +1,150 @@ +// Agora RTC with no UI: joining, devices, publishing, screen sharing, and token renewal. The page draws from snapshots. +export const isScreen = (uid) => String(uid).endsWith("_screen"); +export function mediaError(error) { + if (error?.status) return error.message; + const code = String(error?.code || error?.name || ""); + if (/PERMISSION|NOT_ALLOWED|NotAllowed/i.test(code)) return "Device access was declined. Check your browser permissions and try again."; + if (/NOT_FOUND|NotFound/i.test(code)) return "That microphone or camera could not be found. You can still listen to the call."; + return "The call could not complete that action. Check your connection and try again."; +} + +export function createRtcClient({ AgoraRTC, fetchCredentials, onChange = () => {}, onError = () => {}, onEnded = () => {} }) { + let client = null, credentials = null, audio = null, video = null, screen = null, joined = false, busy = false, closed = false, renewing = null, audioBlocked = false, work = Promise.resolve(); + const remote = new Map(); + const snapshot = () => ({ joined, busy, closed, uid: credentials?.uid || null, screenUid: credentials?.screenUid || null, audio: Boolean(audio?.enabled), video: Boolean(video?.enabled), screen: Boolean(screen), audioBlocked, peers: [...remote.values()].map((peer) => ({ uid: peer.uid, audio: peer.audio, video: peer.video, screen: isScreen(peer.uid) })) }); + const changed = () => { if (!closed) onChange(snapshot()); }; + // One action at a time; a click while another action runs is ignored. + function operate(action) { + if (busy || closed) return Promise.resolve(false); + busy = true; changed(); + work = (async () => { + try { await action(); return true; } + catch (error) { if (!closed) onError(mediaError(error)); return false; } + finally { busy = false; changed(); } + })(); + return work; + } + async function renew() { + if (closed) return; + if (renewing) return renewing; + renewing = (async () => { + const next = await fetchCredentials(credentials.uid); + if (closed) return; + credentials = next; + await client.renewToken(next.token); + if (screen?.client.connectionState === "CONNECTED") await screen.client.renewToken(next.screenToken); + })(); + try { await renewing; } finally { renewing = null; } + } + const end = (message) => { if (!closed) leave().then(() => onEnded(message)); }; + function watchToken(target) { + const refresh = () => renew().catch((error) => end(error?.status ? error.message : "Call access could not be renewed. Join again.")); + target.on("token-privilege-will-expire", refresh); + target.on("token-privilege-did-expire", refresh); + } + function watch() { + const own = (user) => user.uid === credentials.screenUid; + const entry = (user) => { if (!remote.has(user.uid)) remote.set(user.uid, { uid: user.uid, user, audio: false, video: false }); return remote.get(user.uid); }; + client.on("user-joined", (user) => { if (closed || own(user)) return; entry(user); changed(); }); + client.on("user-left", (user) => { remote.delete(user.uid); changed(); }); + client.on("user-published", (user, media) => { + if (closed || own(user)) return; + client.subscribe(user, media).then(() => { + if (closed || !client.remoteUsers.some((peer) => peer.uid === user.uid)) return; + const peer = entry(user); + peer.user = user; peer[media] = true; + if (media === "audio") user.audioTrack?.play(); + changed(); + }).catch(() => { if (!closed) onError("One person's audio or video could not load. It will retry when they share it again."); }); + }); + client.on("user-unpublished", (user, media) => { + if (closed || own(user)) return; + if (media === "video") user.videoTrack?.stop(); + if (media === "audio") user.audioTrack?.stop(); + const peer = remote.get(user.uid); + if (peer) { peer[media] = false; changed(); } + }); + client.on("connection-state-change", (next, previous) => { if (next === "DISCONNECTED" && previous !== "DISCONNECTING" && joined) end("The call disconnected. Join again when you're ready."); }); + watchToken(client); + } + async function enable(kind) { + const track = kind === "audio" ? await AgoraRTC.createMicrophoneAudioTrack() : await AgoraRTC.createCameraVideoTrack({ encoderConfig: "480p_1" }); + if (closed) { track.close(); return; } + if (kind === "audio") audio = track; else video = track; + try { await client.publish(track); } + catch (error) { track.close(); if (kind === "audio") audio = null; else video = null; throw error; } + } + async function stopScreen() { + const current = screen; + if (!current) return; + screen = null; + current.client.removeAllListeners(); + for (const track of current.tracks) { track.stop(); track.close(); } + await current.client.leave().catch(() => {}); + } + async function leave() { + if (closed) return; + closed = true; + // Devices are released at once, even while a join or publish is still pending. + for (const track of [audio, video, ...(screen?.tracks || [])]) track?.close(); + await work.catch(() => {}); + await Promise.allSettled([stopScreen(), (async () => { client?.removeAllListeners(); await client?.leave(); })()]); + for (const track of [audio, video]) track?.close(); + audio = null; video = null; joined = false; remote.clear(); + } + return { + snapshot, + join: ({ audio: withAudio = false, video: withVideo = false } = {}) => operate(async () => { + if (!AgoraRTC.checkSystemRequirements()) throw Object.assign(new Error("This browser can't join calls. Try a current Chrome, Edge, Firefox, or Safari."), { status: 400 }); + AgoraRTC.onAutoplayFailed = () => { audioBlocked = true; changed(); }; + credentials = await fetchCredentials(); + if (closed) return; + client = AgoraRTC.createClient({ mode: "rtc", codec: "vp8" }); + watch(); + try { await client.join(credentials.appId, credentials.channel, credentials.token, credentials.uid); } + catch (error) { client.removeAllListeners(); await client.leave().catch(() => {}); client = null; throw error; } + if (closed) return; + joined = true; + for (const [kind, wanted] of [["audio", withAudio], ["video", withVideo]]) { + if (wanted && !closed) { try { await enable(kind); } catch (error) { onError(mediaError(error)); } } + } + }), + toggle: (kind) => operate(async () => { + if (!joined) return; + const track = kind === "audio" ? audio : video; + if (!track) { await enable(kind); return; } + await track.setEnabled(!track.enabled); + if (kind === "video" && !track.enabled) track.stop(); + }), + share: () => operate(async () => { + if (!joined) return; + if (screen) { await stopScreen(); return; } + // Capture during the click, before any network work, as browsers require. + const captured = await AgoraRTC.createScreenVideoTrack({ encoderConfig: "1080p_1" }, "auto"); + const tracks = Array.isArray(captured) ? captured : [captured]; + if (closed) { for (const track of tracks) track.close(); return; } + const second = AgoraRTC.createClient({ mode: "rtc", codec: "vp8" }); + screen = { client: second, tracks }; + tracks[0].on("track-ended", () => { if (!closed && screen?.client === second) stopScreen().then(changed); }); + try { + await renew(); + if (closed || screen?.client !== second) return; + watchToken(second); + await second.join(credentials.appId, credentials.channel, credentials.screenToken, credentials.screenUid); + if (closed || screen?.client !== second) return; + await second.publish(tracks); + } catch (error) { await stopScreen(); throw error; } + }), + // Plays someone's video into an element; false when there is nothing to show. + play(uid, element) { + if (closed) return false; + const peer = remote.get(uid); + const track = uid === credentials?.uid ? (video?.enabled ? video : null) : uid === credentials?.screenUid ? screen?.tracks[0] : peer?.video ? peer.user.videoTrack : null; + if (!track) return false; + track.play(element, { fit: isScreen(uid) ? "contain" : "cover" }); + return true; + }, + resumeAudio() { for (const user of client?.remoteUsers || []) user.audioTrack?.play(); audioBlocked = false; changed(); }, + leave + }; +} From 390189879281f7d8b938ec400a46c0f43c47e4d7 Mon Sep 17 00:00:00 2001 From: Brent G <deliberatekids@gmail.com> Date: Thu, 8 Oct 2026 19:11:47 +0000 Subject: [PATCH 53/71] Harden RTC client leave and share races MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🤖 Built with SMT <smt@agora.build> --- tests/rtc-client.test.mjs | 38 ++++++++++++++++++++++++++++++++- world/rtc-client.js | 45 ++++++++++++++++++++++++--------------- 2 files changed, 65 insertions(+), 18 deletions(-) diff --git a/tests/rtc-client.test.mjs b/tests/rtc-client.test.mjs index b62f7dc..bea38ce 100644 --- a/tests/rtc-client.test.mjs +++ b/tests/rtc-client.test.mjs @@ -113,7 +113,8 @@ test("leaving while still joining releases everything and never joins", async () await tick(); const leaving = rtc.leave(); release(credentials()); - await leaving; await joining; + await leaving; + assert.equal(await joining, false); assert.equal(sdk.clients.length, 0); assert.equal(sdk.tracks.length, 0); }); @@ -142,3 +143,38 @@ test("an unexpected disconnect ends the call", async () => { assert.deepEqual(ended, ["The call disconnected. Join again when you're ready."]); assert.equal(rtc.snapshot().joined, false); }); +test("leaving starts leaving the connection before a pending join settles", async () => { + const sdk = fakeAgora(), create = sdk.createClient; + let finish; + sdk.createClient = () => { const client = create(); client.join = () => new Promise((resolve) => { finish = resolve; }); return client; }; + const { rtc } = make({ sdk }); + const joining = rtc.join({ audio: true }); + for (let index = 0; index < 3; index += 1) await tick(); + const leaving = rtc.leave(); + assert.equal(rtc.leave(), leaving, "a second leave shares the first"); + await tick(); + assert.equal(sdk.clients[0].left, true); + finish(); + await leaving; + assert.equal(await joining, false); +}); +test("the browser ending a share while its client is joining reports no error", async () => { + const sdk = fakeAgora(), create = sdk.createClient; + let finish; + sdk.createClient = () => { const client = create(); if (sdk.clients.length === 2) client.join = () => new Promise((resolve) => { finish = resolve; }); return client; }; + const { rtc, errors } = make({ sdk }); + await rtc.join({}); + const sharing = rtc.share(); + for (let index = 0; index < 5; index += 1) await tick(); + sdk.tracks.at(-1).listeners["track-ended"](); + await tick(); + finish(); + assert.equal(await sharing, false); + assert.deepEqual(errors, []); + assert.equal(rtc.snapshot().screen, false); +}); +test("actions before joining resolve false", async () => { + const { rtc } = make(); + assert.equal(await rtc.toggle("audio"), false); + assert.equal(await rtc.share(), false); +}); diff --git a/world/rtc-client.js b/world/rtc-client.js index 79c7bf0..15c1040 100644 --- a/world/rtc-client.js +++ b/world/rtc-client.js @@ -9,7 +9,7 @@ export function mediaError(error) { } export function createRtcClient({ AgoraRTC, fetchCredentials, onChange = () => {}, onError = () => {}, onEnded = () => {} }) { - let client = null, credentials = null, audio = null, video = null, screen = null, joined = false, busy = false, closed = false, renewing = null, audioBlocked = false, work = Promise.resolve(); + let client = null, credentials = null, audio = null, video = null, screen = null, joined = false, busy = false, closed = false, renewing = null, audioBlocked = false, work = Promise.resolve(), leaving = null; const remote = new Map(); const snapshot = () => ({ joined, busy, closed, uid: credentials?.uid || null, screenUid: credentials?.screenUid || null, audio: Boolean(audio?.enabled), video: Boolean(video?.enabled), screen: Boolean(screen), audioBlocked, peers: [...remote.values()].map((peer) => ({ uid: peer.uid, audio: peer.audio, video: peer.video, screen: isScreen(peer.uid) })) }); const changed = () => { if (!closed) onChange(snapshot()); }; @@ -18,7 +18,7 @@ export function createRtcClient({ AgoraRTC, fetchCredentials, onChange = () => { if (busy || closed) return Promise.resolve(false); busy = true; changed(); work = (async () => { - try { await action(); return true; } + try { return (await action()) !== false && !closed; } catch (error) { if (!closed) onError(mediaError(error)); return false; } finally { busy = false; changed(); } })(); @@ -82,15 +82,19 @@ export function createRtcClient({ AgoraRTC, fetchCredentials, onChange = () => { for (const track of current.tracks) { track.stop(); track.close(); } await current.client.leave().catch(() => {}); } - async function leave() { - if (closed) return; + function leave() { + if (leaving) return leaving; closed = true; - // Devices are released at once, even while a join or publish is still pending. - for (const track of [audio, video, ...(screen?.tracks || [])]) track?.close(); - await work.catch(() => {}); - await Promise.allSettled([stopScreen(), (async () => { client?.removeAllListeners(); await client?.leave(); })()]); - for (const track of [audio, video]) track?.close(); - audio = null; video = null; joined = false; remote.clear(); + leaving = (async () => { + // Devices are released and the connections start leaving at once, even while a join or publish is still pending. + for (const track of [audio, video, ...(screen?.tracks || [])]) track?.close(); + const early = [client?.leave().catch(() => {}), screen?.client.leave().catch(() => {})]; + await Promise.allSettled([...early, work.catch(() => {})]); + await Promise.allSettled([stopScreen(), (async () => { client?.removeAllListeners(); await client?.leave(); })()]); + for (const track of [audio, video]) track?.close(); + audio = null; video = null; joined = false; remote.clear(); + })(); + return leaving; } return { snapshot, @@ -102,7 +106,7 @@ export function createRtcClient({ AgoraRTC, fetchCredentials, onChange = () => { client = AgoraRTC.createClient({ mode: "rtc", codec: "vp8" }); watch(); try { await client.join(credentials.appId, credentials.channel, credentials.token, credentials.uid); } - catch (error) { client.removeAllListeners(); await client.leave().catch(() => {}); client = null; throw error; } + catch (error) { client.removeAllListeners(); await client.leave().catch(() => {}); client = null; credentials = null; throw error; } if (closed) return; joined = true; for (const [kind, wanted] of [["audio", withAudio], ["video", withVideo]]) { @@ -110,30 +114,37 @@ export function createRtcClient({ AgoraRTC, fetchCredentials, onChange = () => { } }), toggle: (kind) => operate(async () => { - if (!joined) return; + if (!joined) return false; const track = kind === "audio" ? audio : video; if (!track) { await enable(kind); return; } await track.setEnabled(!track.enabled); if (kind === "video" && !track.enabled) track.stop(); }), share: () => operate(async () => { - if (!joined) return; + if (!joined) return false; if (screen) { await stopScreen(); return; } // Capture during the click, before any network work, as browsers require. const captured = await AgoraRTC.createScreenVideoTrack({ encoderConfig: "1080p_1" }, "auto"); const tracks = Array.isArray(captured) ? captured : [captured]; - if (closed) { for (const track of tracks) track.close(); return; } + if (closed) { for (const track of tracks) track.close(); return false; } const second = AgoraRTC.createClient({ mode: "rtc", codec: "vp8" }); screen = { client: second, tracks }; + second.on("connection-state-change", (next, previous) => { if (next === "DISCONNECTED" && previous !== "DISCONNECTING" && !closed && screen?.client === second) stopScreen().then(changed); }); tracks[0].on("track-ended", () => { if (!closed && screen?.client === second) stopScreen().then(changed); }); try { await renew(); - if (closed || screen?.client !== second) return; + if (closed || screen?.client !== second) return false; watchToken(second); await second.join(credentials.appId, credentials.channel, credentials.screenToken, credentials.screenUid); - if (closed || screen?.client !== second) return; + if (closed || screen?.client !== second) return false; await second.publish(tracks); - } catch (error) { await stopScreen(); throw error; } + } catch (error) { + // Already stopped elsewhere (the browser ended it, or the call closed): nothing to report. + const stopped = screen?.client !== second; + await stopScreen(); + if (stopped) return false; + throw error; + } }), // Plays someone's video into an element; false when there is nothing to show. play(uid, element) { From ba6c5a725b6ec5f8d95d51d66a06d68399aa00d4 Mon Sep 17 00:00:00 2001 From: Brent G <deliberatekids@gmail.com> Date: Thu, 8 Oct 2026 19:12:50 +0000 Subject: [PATCH 54/71] Report screen-share join failures MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🤖 Built with SMT <smt@agora.build> --- tests/rtc-client.test.mjs | 9 +++++++++ world/rtc-client.js | 7 +++++-- 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/tests/rtc-client.test.mjs b/tests/rtc-client.test.mjs index bea38ce..67ce578 100644 --- a/tests/rtc-client.test.mjs +++ b/tests/rtc-client.test.mjs @@ -178,3 +178,12 @@ test("actions before joining resolve false", async () => { assert.equal(await rtc.toggle("audio"), false); assert.equal(await rtc.share(), false); }); +test("a screen client that disconnects while failing to join is reported once", async () => { + const sdk = fakeAgora(), create = sdk.createClient; + sdk.createClient = () => { const client = create(); if (sdk.clients.length === 2) client.join = async () => { client.emit("connection-state-change", "DISCONNECTED", "CONNECTING"); throw new Error("join failed"); }; return client; }; + const { rtc, errors } = make({ sdk }); + await rtc.join({}); + assert.equal(await rtc.share(), false); + assert.equal(errors.length, 1); + assert.equal(rtc.snapshot().screen, false); +}); diff --git a/world/rtc-client.js b/world/rtc-client.js index 15c1040..3e84c67 100644 --- a/world/rtc-client.js +++ b/world/rtc-client.js @@ -110,7 +110,7 @@ export function createRtcClient({ AgoraRTC, fetchCredentials, onChange = () => { if (closed) return; joined = true; for (const [kind, wanted] of [["audio", withAudio], ["video", withVideo]]) { - if (wanted && !closed) { try { await enable(kind); } catch (error) { onError(mediaError(error)); } } + if (wanted && !closed) { try { await enable(kind); } catch (error) { if (!closed) onError(mediaError(error)); } } } }), toggle: (kind) => operate(async () => { @@ -129,7 +129,9 @@ export function createRtcClient({ AgoraRTC, fetchCredentials, onChange = () => { if (closed) { for (const track of tracks) track.close(); return false; } const second = AgoraRTC.createClient({ mode: "rtc", codec: "vp8" }); screen = { client: second, tracks }; - second.on("connection-state-change", (next, previous) => { if (next === "DISCONNECTED" && previous !== "DISCONNECTING" && !closed && screen?.client === second) stopScreen().then(changed); }); + // Only a live share reacts to a disconnect; a failed join is reported by the catch below. + let live = false; + second.on("connection-state-change", (next, previous) => { if (live && next === "DISCONNECTED" && previous !== "DISCONNECTING" && !closed && screen?.client === second) stopScreen().then(changed); }); tracks[0].on("track-ended", () => { if (!closed && screen?.client === second) stopScreen().then(changed); }); try { await renew(); @@ -137,6 +139,7 @@ export function createRtcClient({ AgoraRTC, fetchCredentials, onChange = () => { watchToken(second); await second.join(credentials.appId, credentials.channel, credentials.screenToken, credentials.screenUid); if (closed || screen?.client !== second) return false; + live = true; await second.publish(tracks); } catch (error) { // Already stopped elsewhere (the browser ended it, or the call closed): nothing to report. From cb4d5953dc9b872f9036e35c138fdc3b81e427b4 Mon Sep 17 00:00:00 2001 From: Brent G <deliberatekids@gmail.com> Date: Thu, 8 Oct 2026 19:13:41 +0000 Subject: [PATCH 55/71] List your spaces and give owners member names MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🤖 Built with SMT <smt@agora.build> --- scripts/spaces/routes.mjs | 1 + scripts/spaces/service.mjs | 16 +++++++++++++++- tests/spaces-api.test.mjs | 10 ++++++++++ tests/spaces-service.test.mjs | 26 ++++++++++++++++++++++++++ 4 files changed, 52 insertions(+), 1 deletion(-) diff --git a/scripts/spaces/routes.mjs b/scripts/spaces/routes.mjs index e204a07..f5f162c 100644 --- a/scripts/spaces/routes.mjs +++ b/scripts/spaces/routes.mjs @@ -7,6 +7,7 @@ export async function handleSpaces({ path, method, url, token, read: readBody, s const read = async () => { const data = await readBody(); return data && typeof data === "object" && !Array.isArray(data) ? data : {}; }; if (path === "/api/spaces" && method === "GET") return { status: 200, body: await spaces.list({ q: url.searchParams.get("q") || "" }) }; if (path === "/api/spaces" && method === "POST") { limit("spaces:" + ip, 20, 3600000); return { status: 201, body: { space: await spaces.create(token, await read()) } }; } + if (path === "/api/spaces/mine" && method === "GET") return { status: 200, body: await spaces.mine(token) }; if (path === "/api/signaling/token" && method === "POST") { limit("signaling:" + ip, 30, 60000); return { status: 200, body: await spaces.signalingToken(token) }; } if (path === "/api/themes" && method === "GET") return { status: 200, body: { themes } }; const world = /^\/api\/worlds\/([a-z]+)$/.exec(path); diff --git a/scripts/spaces/service.mjs b/scripts/spaces/service.mjs index 7baa887..1114aab 100644 --- a/scripts/spaces/service.mjs +++ b/scripts/spaces/service.mjs @@ -55,7 +55,13 @@ export function createSpaces({ store, worlds, signaling, calls, secret = "", adm const visible = (state, space, actor, invite) => can(actor, "see", space, { invited: Boolean(invitationFor(space, invite)), present: present(state, space, actor) }); const findVisible = (state, id, actor, invite) => { const space = find(state, id); if (!visible(state, space, actor, invite)) throw notFound(); return space; }; const occupants = (state, space) => leasesFor(state, space.id, now()).sort((a, b) => a.enteredAt - b.enteredAt).map((lease) => ({ id: lease.actorId, name: lease.name })); - const view = (state, space, actor) => publicSpace(space, { occupants: occupants(state, space), manage: can(actor, "edit", space) }); + const nameOf = (state, id) => state.people.find((person) => person.id === id)?.name || (state.accounts || []).find((account) => account.id === id)?.name || "Builder"; + const roster = (state, ids) => ids.map((id) => ({ id, name: nameOf(state, id) })); + // Owners manage members by name, so their view carries names for the member and blocked lists. + const view = (state, space, actor) => { + const manage = can(actor, "edit", space), result = publicSpace(space, { occupants: occupants(state, space), manage }); + return manage ? { ...result, roster: roster(state, space.members), blockedRoster: roster(state, space.blocked) } : result; + }; const owned = (state, actor) => state.spaces.filter((space) => !system(space) && actor.ids.includes(space.ownerId)); const offerFor = (state, actor) => ({ spaces: state.spaces.filter((space) => !system(space) && space.members.some((id) => actor.ids.includes(id))).map((space) => ({ id: space.id, title: space.title, visibility: space.visibility, path: "/stoa/s/" + space.id })), canCreate: owned(state, actor).length < entitlements(actor.plan).spaces }); const access = (space) => ({ channel: channelName(secret, space), key: spaceKey(secret, space), blocked: space.blocked, hostId: space.hostId }); @@ -89,6 +95,14 @@ export function createSpaces({ store, worlds, signaling, calls, secret = "", adm return view(state, space, actor); }); }, + async mine(token) { + const actor = signedIn(await actorFor(token)); + await ready(); + const state = await store.snapshot(); + const list = state.spaces.filter((space) => !system(space) && space.members.some((id) => actor.ids.includes(id))).sort((a, b) => b.createdAt.localeCompare(a.createdAt)); + const owns = owned(state, actor).length, limit = entitlements(actor.plan).spaces; + return { spaces: list.map((space) => ({ id: space.id, title: space.title, purpose: space.purpose, visibility: space.visibility, access: space.access, capacity: space.capacity, themeId: space.themeId, owner: actor.ids.includes(space.ownerId), occupancy: leasesFor(state, space.id, now()).length, path: "/stoa/s/" + space.id })), owned: owns, limit, canCreate: owns < limit }; + }, async get(token, id, invite) { await ready(); const actor = await actorFor(token); diff --git a/tests/spaces-api.test.mjs b/tests/spaces-api.test.mjs index ab2b863..d55c085 100644 --- a/tests/spaces-api.test.mjs +++ b/tests/spaces-api.test.mjs @@ -193,3 +193,13 @@ test("the live Stoa page has its panel controls and serves every live module", a } } finally { await close(instance); } }); +test("your spaces needs sign-in and lists what you own", async () => { + const { instance, url } = await start(); + try { + assert.equal((await request(url, "/api/spaces/mine")).status, 401); + const ada = await join(url, "Ada"); + const created = await (await post(url, "/api/spaces", ada, { title: "Ada's lab" })).json(); + const mine = await (await request(url, "/api/spaces/mine", { cookie: ada })).json(); + assert.deepEqual(mine.spaces.map((space) => [space.id, space.owner]), [[created.space.id, true]]); + } finally { await close(instance); } +}); diff --git a/tests/spaces-service.test.mjs b/tests/spaces-service.test.mjs index 47581e8..8706662 100644 --- a/tests/spaces-service.test.mjs +++ b/tests/spaces-service.test.mjs @@ -336,3 +336,29 @@ test("sweep without expired leases never writes", async (t) => { await spaces.sweep(); assert.equal(count, 1); }); +test("your spaces lists spaces you own or belong to, newest first, with your allowance", async (t) => { + const { spaces, clock, person } = await setup(t); + const owner = await person("Owner"), friend = await person("Friend"), stranger = await person("Stranger"); + const first = await spaces.create(owner.token, { title: "First space" }); + clock.now += 1000; + const second = await spaces.create(owner.token, { title: "Second space", visibility: "private" }); + await spaces.addMember(owner.token, first.id, friend.id); + const mine = await spaces.mine(owner.token); + assert.deepEqual(mine.spaces.map((space) => [space.title, space.owner, space.path]), [["Second space", true, "/stoa/s/" + second.id], ["First space", true, "/stoa/s/" + first.id]]); + assert.deepEqual([mine.owned, mine.canCreate], [2, mine.limit > 2]); + assert.deepEqual((await spaces.mine(friend.token)).spaces.map((space) => [space.title, space.owner, space.occupancy]), [["First space", false, 0]]); + assert.deepEqual((await spaces.mine(stranger.token)).spaces, []); + await assert.rejects(spaces.mine(null), { status: 401 }); +}); +test("owners see member and blocked names; others see neither", async (t) => { + const { spaces, person } = await setup(t); + const owner = await person("Owner"), friend = await person("Friend"); + const space = await spaces.create(owner.token, { title: "Named space" }); + await spaces.addMember(owner.token, space.id, friend.id); + const seen = await spaces.get(owner.token, space.id); + assert.deepEqual(seen.roster, [{ id: owner.id, name: "Owner" }, { id: friend.id, name: "Friend" }]); + assert.deepEqual(seen.blockedRoster, []); + const asFriend = await spaces.get(friend.token, space.id); + assert.equal(asFriend.roster, undefined); + assert.equal(asFriend.blockedRoster, undefined); +}); From 9e5b3dd68c8a3d73fe009e593c3f66a9443681b4 Mon Sep 17 00:00:00 2001 From: Brent G <deliberatekids@gmail.com> Date: Thu, 8 Oct 2026 19:16:54 +0000 Subject: [PATCH 56/71] Add calls inside Stoa rooms with theme-chosen video layouts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🤖 Built with SMT <smt@agora.build> --- scripts/serve.mjs | 2 +- stoa.html | 15 ++++- stoa.js | 16 +++-- stoa/call.js | 124 ++++++++++++++++++++++++++++++++++++++ stoa/stage.js | 10 ++- styles.css | 17 ++++++ tests/spaces-api.test.mjs | 4 +- tests/stoa-call.test.mjs | 25 ++++++++ tests/stoa-stage.test.mjs | 26 +++++++- 9 files changed, 225 insertions(+), 14 deletions(-) create mode 100644 stoa/call.js create mode 100644 tests/stoa-call.test.mjs diff --git a/scripts/serve.mjs b/scripts/serve.mjs index d30dac1..b8e6827 100644 --- a/scripts/serve.mjs +++ b/scripts/serve.mjs @@ -23,7 +23,7 @@ import { loadThemes } from "./spaces/themes.mjs"; const root = fileURLToPath(new URL("../", import.meta.url)); // Vendored browser SDKs are served from node_modules in development and from dist/assets after a build. const vendor = { "assets/agora-rtc.js": createRequire(import.meta.url).resolve("agora-rtc-sdk-ng"), "assets/agora-rtm.js": createRequire(import.meta.url).resolve("agora-rtm-sdk") }; -const publicFiles = new Set(["index.html", "explore.html", "services.html", "radar.html", "meetings.html", "account.html", "account.js", "stoa.html", "stoa.js", "world/map.js", "world/kinds.js", "world/themes.js", "world/camera.js", "world/motion.js", "world/renderer-canvas.js", "world/engine.js", "world/sdk.js", "world/rtc-client.js", "stoa/panel.js", "stoa/stage.js", "stoa/place.js", "stoa/lease.js", "world/protocol.js", "world/crypto.js", "world/signaling.js", "world/presence.js", "styles.css", "script.js", "people.js", "activity.js", "explore.js", "services.js", "radar.js", "meetings.js", "call.js", "assets/agora-rtc.js", "assets/agora-rtm.js", "assets/favicon.svg", "assets/guohai.jpg"]); +const publicFiles = new Set(["index.html", "explore.html", "services.html", "radar.html", "meetings.html", "account.html", "account.js", "stoa.html", "stoa.js", "world/map.js", "world/kinds.js", "world/themes.js", "world/camera.js", "world/motion.js", "world/renderer-canvas.js", "world/engine.js", "world/sdk.js", "world/rtc-client.js", "stoa/panel.js", "stoa/stage.js", "stoa/call.js", "stoa/place.js", "stoa/lease.js", "world/protocol.js", "world/crypto.js", "world/signaling.js", "world/presence.js", "styles.css", "script.js", "people.js", "activity.js", "explore.js", "services.js", "radar.js", "meetings.js", "call.js", "assets/agora-rtc.js", "assets/agora-rtm.js", "assets/favicon.svg", "assets/guohai.jpg"]); const types = { ".html": "text/html; charset=utf-8", ".css": "text/css; charset=utf-8", ".js": "text/javascript; charset=utf-8", ".svg": "image/svg+xml", ".jpg": "image/jpeg" }; function json(response, status, body, headers = {}) { diff --git a/stoa.html b/stoa.html index c9912a3..c48cb01 100644 --- a/stoa.html +++ b/stoa.html @@ -21,7 +21,7 @@ <section class="stoa" id="stoa" aria-labelledby="stoa-title"> <div class="stoa-heading"><div><p class="kicker">THE STOA / PUBLIC PLAZA</p><h1 id="stoa-title">Walk into the plaza.</h1></div><button class="button button-secondary button-small" type="button" id="stoa-minimal" aria-pressed="false">Minimal view</button></div> <div class="stoa-layout"> - <div class="stoa-stage"><canvas id="stoa-canvas" tabindex="0" aria-label="Plaza map" aria-describedby="stoa-help"></canvas><p class="stoa-help" id="stoa-help">Click or tap to walk. Select the map to use the arrow keys or WASD.</p></div> + <div class="stoa-stage"><div class="stoa-view"><canvas id="stoa-canvas" tabindex="0" aria-label="Plaza map" aria-describedby="stoa-help"></canvas><div class="stoa-video" id="stoa-video" data-layout="strip" role="region" aria-label="Call video" hidden></div></div><p class="stoa-help" id="stoa-help">Click or tap to walk. Select the map to use the arrow keys or WASD.</p></div> <aside class="stoa-panel" aria-label="Around you"> <p class="stoa-status" id="stoa-status" role="status" aria-live="polite">Opening the plaza...</p> <section class="stoa-here" id="stoa-here" hidden aria-labelledby="stoa-here-title"> @@ -33,6 +33,19 @@ <h2 id="stoa-here-title"></h2> <label>Tags <span class="optional">up to 5, separated by commas</span><input name="tags" maxlength="160" autocomplete="off"></label> <button class="button button-small" type="submit">Set topic</button> </form> + <section class="stoa-call" id="stoa-call" hidden aria-labelledby="stoa-call-title"> + <h3 class="stoa-panel-title" id="stoa-call-title">Call</h3> + <p class="stoa-call-status" id="stoa-call-status" role="status" aria-live="polite"></p> + <div class="stoa-call-preflight" id="stoa-call-preflight"><label><input type="checkbox" id="stoa-call-mic" checked> Microphone on</label><label><input type="checkbox" id="stoa-call-camera"> Camera on</label></div> + <div class="stoa-call-controls"> + <button class="button button-small" type="button" id="stoa-call-join">Join call</button> + <button class="button button-secondary button-small" type="button" id="stoa-call-audio" aria-pressed="false" hidden>Turn mic on</button> + <button class="button button-secondary button-small" type="button" id="stoa-call-video" aria-pressed="false" hidden>Turn camera on</button> + <button class="button button-secondary button-small" type="button" id="stoa-call-share" aria-pressed="false" hidden>Share screen</button> + <button class="button button-secondary button-small" type="button" id="stoa-call-resume" hidden>Play call audio</button> + <button class="button button-secondary button-small stoa-call-leave" type="button" id="stoa-call-leave" hidden>Leave call</button> + </div> + </section> <button class="button button-secondary button-small" type="button" id="stoa-leave">Back to the plaza</button> </section> <section class="stoa-offer" id="stoa-offer" hidden aria-labelledby="stoa-offer-title"> diff --git a/stoa.js b/stoa.js index 5f42b0f..67b66cb 100644 --- a/stoa.js +++ b/stoa.js @@ -4,6 +4,7 @@ import { createEngine } from "./world/engine.js"; import { createLive } from "./world/signaling.js"; import { loadAgora } from "./world/sdk.js"; import { createPanel } from "./stoa/panel.js"; +import { createCallView } from "./stoa/call.js"; import { createStage, outsideDoor } from "./stoa/stage.js"; const root = document.querySelector("#stoa"); @@ -32,7 +33,7 @@ failure(spaceResult, "This space"); failure(worldResult, "The map"); failure(the const space = spaceResult.value?.space || null; if (space) { document.querySelector("#stoa-title").textContent = space.title; document.title = space.title + " | Agora Build"; } -let map = null, engine = null, minimal = stored.get() === "true", preview = null, plazaBounds = null; +let map = null, engine = null, minimal = stored.get() === "true", preview = null, plazaBounds = null, active = () => null; try { if (worldResult.status === "fulfilled") map = parseMap(worldResult.value); } catch (error) { problems.push("The map is not valid (" + error.message + ")."); } let summaries = new Map((spacesResult.value?.rooms || []).map((room) => [room.slug, room])); const lot = roomSlug && map ? map.lots.find((entry) => entry.slug === roomSlug) || null : null; @@ -45,7 +46,7 @@ if (map && themesResult.status === "fulfilled" && (!spaceId || space)) { const wanted = new URLSearchParams(location.search).get("theme"); preview = wanted !== null && byId.has(wanted) ? byId.get(wanted) : null; const chosen = preview || byId.get(space?.themeId) || byId.get("agora"); - const active = () => minimal ? byId.get("minimal") : chosen; + active = () => minimal ? byId.get("minimal") : chosen; engine = createEngine({ canvas, map, theme: active(), start: lot ? outsideDoor(map, lot) : map.spawns[0], reducedMotion: matchMedia("(prefers-reduced-motion: reduce)").matches }); const zone = map.zones.find((entry) => entry.name === "plaza"); if (zone && !spaceId) { @@ -62,6 +63,7 @@ if (map && themesResult.status === "fulfilled" && (!spaceId || space)) { minimalToggle.setAttribute("aria-pressed", String(minimal)); applyUi(active()); engine.setTheme(active()); + call?.setLayout(active().video); }); new ResizeObserver(() => engine.resize()).observe(canvas); // Canvas text uses the web font only once it has loaded, so draw again then. @@ -73,10 +75,10 @@ if (map && themesResult.status === "fulfilled" && (!spaceId || space)) { } await sessionReady; -const self = { id: state.account?.id || state.profile?.id || null, name: state.profile?.name || state.account?.name || "You", signedIn: Boolean(state.account || state.profile) }; +const self = { id: state.account?.id || state.profile?.id || null, ids: [state.account?.id, state.profile?.id].filter(Boolean), name: state.profile?.name || state.account?.name || "You", signedIn: Boolean(state.account || state.profile) }; panel.setPeople([], self.name); panel.canTalk(self.signedIn); -let live = null, stage = null; +let live = null, stage = null, call = null; const unavailable = "The Stoa view isn't available, so there's nothing to walk or talk in."; // Controls work even without the canvas: Go to explains where the door is, Sign in and Start a space still do their jobs. panel.on({ @@ -126,10 +128,12 @@ async function connectLive() { } if (engine) { engine.setSelf({ id: self.id || undefined, name: self.name }); - stage = createStage({ api, map, engine, panel, live: null, self, plaza: spaceId ? null : { bounds: plazaBounds, channel: null } }); + call = createCallView({ engine, api, self, people: () => stage?.people() || [], say: (text) => panel.say(text) }); + call.setLayout(active().video); + stage = createStage({ api, map, engine, panel, live: null, self, plaza: spaceId ? null : { bounds: plazaBounds, channel: null }, onRoom: (room) => room ? call.enter(room.id) : call.exit(), onPeople: () => call.refresh() }); engine.on("arrive", (tile) => stage.onArrive(tile)); // Leaving the page releases the entry lease and, as far as the browser allows, the Signaling login. - addEventListener("pagehide", () => { stage.unload(); live?.close().catch(() => {}); }); + addEventListener("pagehide", () => { call.exit(); stage.unload(); live?.close().catch(() => {}); }); const entering = spaceId ? stage.openSpace(spaceId, invite) : stage.toPlaza(null, minimal ? "Minimal view is on." : preview ? "Previewing the " + preview.name + " theme. Only you see it." : undefined); connectLive(); await entering; diff --git a/stoa/call.js b/stoa/call.js new file mode 100644 index 0000000..03f4516 --- /dev/null +++ b/stoa/call.js @@ -0,0 +1,124 @@ +import { createRtcClient, isScreen } from "../world/rtc-client.js"; +import { loadAgora } from "../world/sdk.js"; + +// Calls inside rooms: native controls in the panel, video tiles over the map in the layout the theme chooses. +// RTC uids start with the person's actor UUID, so tiles are named and placed from presence. +export const actorUuid = (uid) => String(uid).split("_")[0]; +export function nameFor(uid, { people, self, snapshot }) { + if (uid === snapshot.uid) return self.name + " (you)"; + if (uid === snapshot.screenUid) return "Your screen"; + const person = people.find((entry) => String(entry.id).split(":")[1] === actorUuid(uid)); + return (person?.name || "Builder") + (isScreen(uid) ? "'s screen" : ""); +} +export function tileList(snapshot, { people, self }) { + if (!snapshot?.joined) return []; + const own = [{ uid: snapshot.uid, video: snapshot.video, screen: false, mine: true }, ...(snapshot.screen ? [{ uid: snapshot.screenUid, video: true, screen: true, mine: true }] : [])]; + return [...own, ...snapshot.peers.map((peer) => ({ uid: peer.uid, video: peer.video, screen: peer.screen, mine: false }))].map((tile) => ({ ...tile, name: nameFor(tile.uid, { people, self, snapshot }) })); +} +// Where a tile floats in over-avatar layout: just above the person's head, in canvas pixels. Null when they aren't drawn. +export function overAvatar(tile, frame) { + if (tile.screen || !frame) return null; + const avatar = frame.avatars.find((entry) => tile.mine ? entry.self : String(entry.id).split(":")[1] === actorUuid(tile.uid)); + if (!avatar) return null; + return { left: ((avatar.x + 0.5) * frame.tileSize - frame.camera.x) * frame.camera.zoom, top: (avatar.y * frame.tileSize - frame.camera.y) * frame.camera.zoom }; +} + +export function createCallView({ doc = document, engine, api, self, people = () => [], say = () => {}, loadSdk = loadAgora, secure = () => globalThis.isSecureContext !== false }) { + const $ = (selector) => doc.querySelector(selector); + const section = $("#stoa-call"), status = $("#stoa-call-status"), preflight = $("#stoa-call-preflight"), layer = $("#stoa-video"); + const buttons = { join: $("#stoa-call-join"), audio: $("#stoa-call-audio"), video: $("#stoa-call-video"), share: $("#stoa-call-share"), resume: $("#stoa-call-resume"), leave: $("#stoa-call-leave") }; + const tiles = new Map(); + let spaceId = null, rtc = null, layout = "strip", frame = null; + + function place() { + for (const view of tiles.values()) { + const spot = layout === "over-avatar" ? overAvatar(view.tile, frame) : null; + view.card.classList.toggle("is-floating", Boolean(spot)); + view.card.style.transform = spot ? "translate(" + Math.round(spot.left) + "px, " + Math.round(spot.top) + "px) translate(-50%, -100%)" : ""; + // Over avatars, a person without video is already shown by their avatar. + view.card.hidden = layout === "over-avatar" && !view.tile.screen && !view.playing; + } + } + function render() { + const now = rtc ? rtc.snapshot() : null, joined = Boolean(now?.joined); + buttons.join.hidden = joined; preflight.hidden = joined; + buttons.join.disabled = !secure() || Boolean(now?.busy); + for (const key of ["audio", "video", "share", "leave"]) { buttons[key].hidden = !joined; buttons[key].disabled = Boolean(now?.busy); } + if (!globalThis.navigator?.mediaDevices?.getDisplayMedia) buttons.share.disabled = true; + const toggle = (button, on, onText, offText) => { button.textContent = on ? onText : offText; button.setAttribute("aria-pressed", String(on)); }; + toggle(buttons.audio, Boolean(now?.audio), "Mute mic", "Turn mic on"); + toggle(buttons.video, Boolean(now?.video), "Turn camera off", "Turn camera on"); + toggle(buttons.share, Boolean(now?.screen), "Stop sharing", "Share screen"); + buttons.resume.hidden = !now?.audioBlocked; + layer.hidden = !joined; + layer.dataset.layout = layout; + const wanted = joined ? tileList(now, { people: people(), self }) : []; + for (const [uid, view] of tiles) if (!wanted.some((tile) => tile.uid === uid)) { view.card.remove(); tiles.delete(uid); } + for (const tile of wanted) { + let view = tiles.get(tile.uid); + if (!view) { + const card = doc.createElement("article"), player = doc.createElement("div"), label = doc.createElement("span"); + card.className = "stoa-tile" + (tile.screen ? " is-screen" : ""); player.className = "stoa-tile-player"; label.className = "stoa-tile-name"; + card.append(player, label); + layer.append(card); + view = { card, player, label, playing: false, tile }; + tiles.set(tile.uid, view); + } + view.tile = tile; + if (view.label.textContent !== tile.name) { view.label.textContent = tile.name; view.card.setAttribute("aria-label", tile.name); } + if (tile.video && !view.playing) view.playing = rtc.play(tile.uid, view.player); + if (!tile.video) view.playing = false; + view.card.classList.toggle("has-video", view.playing); + } + place(); + } + function reset(message) { + for (const view of tiles.values()) view.card.remove(); + tiles.clear(); + render(); + status.textContent = message; + } + async function join() { + if (rtc || !spaceId) return; + const id = spaceId; + status.textContent = "Connecting to the call..."; + let AgoraRTC; + try { ({ AgoraRTC } = await loadSdk()); } catch (error) { if (spaceId === id) status.textContent = "The call couldn't load (" + (error.message || "unknown error") + ")."; return; } + if (spaceId !== id || rtc) return; + AgoraRTC.setLogLevel?.(3); + const client = createRtcClient({ + AgoraRTC, + fetchCredentials: (uid) => api("/api/spaces/" + encodeURIComponent(id) + "/rtc-token", { method: "POST", body: JSON.stringify(uid ? { uid } : {}) }), + onChange: () => { if (rtc === client) render(); }, + onError: (message) => { if (rtc === client) say(message); }, + onEnded: (message) => { if (rtc === client) { rtc = null; reset(message); } } + }); + rtc = client; + render(); + await client.join({ audio: $("#stoa-call-mic").checked, video: $("#stoa-call-camera").checked }); + if (rtc !== client) return; + if (!client.snapshot().joined) { rtc = null; await client.leave(); reset("You're not in the call. Try joining again."); return; } + status.textContent = "You're in the call."; + } + async function leave(message) { const client = rtc; rtc = null; await client?.leave(); reset(message); } + + buttons.join.addEventListener("click", join); + buttons.audio.addEventListener("click", () => rtc?.toggle("audio")); + buttons.video.addEventListener("click", () => rtc?.toggle("video")); + buttons.share.addEventListener("click", () => rtc?.share()); + buttons.resume.addEventListener("click", () => rtc?.resumeAudio()); + buttons.leave.addEventListener("click", () => leave("You left the call.")); + engine.on("draw", (next) => { frame = next; if (layout === "over-avatar" && tiles.size) place(); }); + return { + enter(id) { + if (spaceId === id) return; + if (rtc) leave(""); + spaceId = id; + section.hidden = false; + reset(secure() ? "Join to talk with everyone in this room. Your mic and camera stay off until you choose." : "Open this page over HTTPS to use your microphone and camera."); + }, + exit() { spaceId = null; section.hidden = true; if (rtc) leave(""); else reset(""); }, + setLayout(next) { layout = next || "strip"; render(); }, + refresh() { if (rtc) render(); } + }; +} diff --git a/stoa/stage.js b/stoa/stage.js index dba1ffe..666ca5c 100644 --- a/stoa/stage.js +++ b/stoa/stage.js @@ -9,8 +9,8 @@ export const outsideDoor = (map, lot) => [[0, 1], [0, -1], [1, 0], [-1, 0]].map( // Moves the person between the plaza, lot rooms, and user spaces: entry leases on the server, channels on Signaling. const REFRESH_GAP = 5000; // `live` may be null at first and attached later with attachLive, so the page never waits on the SDK. -export function createStage({ api, map, engine, panel, live: firstLive, self, plaza, createLease = realLease, schedule = (callback) => typeof requestAnimationFrame === "function" ? requestAnimationFrame(callback) : setTimeout(callback, 16), now = () => Date.now(), later = (callback, delay) => setTimeout(callback, delay) }) { - let live = firstLive, places = [], lease = null, here = null, chain = Promise.resolve(), drawing = false; +export function createStage({ api, map, engine, panel, live: firstLive, self, plaza, onRoom = () => {}, onPeople = () => {}, createLease = realLease, schedule = (callback) => typeof requestAnimationFrame === "function" ? requestAnimationFrame(callback) : setTimeout(callback, 16), now = () => Date.now(), later = (callback, delay) => setTimeout(callback, delay) }) { + let live = firstLive, places = [], lease = null, here = null, chain = Promise.resolve(), drawing = false, everyone = [], roomId = null; // State changes run strictly one at a time, each re-checking its preconditions when it starts. const transition = (task) => (chain = chain.then(task, task).catch((error) => panel.say(error.message || "Something went wrong."))); const doors = new Map(map.lots.map((lot) => [lot.door.x + "," + lot.door.y, lot])); @@ -25,8 +25,10 @@ export function createStage({ api, map, engine, panel, live: firstLive, self, pl const seen = new Map(); for (const place of places) for (const person of place.people()) seen.set(person.id, person); const list = [...seen.values()]; + everyone = list; engine.setOthers(list); panel.setPeople(list, self.name); + onPeople(list); }); }; const showRoom = (space, hostId) => panel.showRoom({ kicker: here?.kind === "lot" ? "LOT ROOM" : space.visibility === "private" ? "PRIVATE SPACE" : "UNLISTED SPACE", title: space.title, topic: space.topic, tags: space.tags || [], host: Boolean(self.id) && hostId === self.id, leaveLabel: here?.kind === "lot" ? "Back to the plaza" : "Leave this space" }); @@ -34,6 +36,9 @@ export function createStage({ api, map, engine, panel, live: firstLive, self, pl // Every transition claims `here` before it awaits anything; a superseded one stops as soon as it notices. async function settle(next) { here = next; + // The call and the decor editor belong to a room, so the page hears about room changes, not channel changes. + const room = next.leased && (next.kind === "lot" || next.kind === "space") ? next.space : null; + if ((room?.id || null) !== roomId) { roomId = room?.id || null; onRoom(room); } lease?.stop(); lease = null; const old = places; places = []; await Promise.all(old.map((place) => place.close().catch(() => {}))); @@ -198,6 +203,7 @@ export function createStage({ api, map, engine, panel, live: firstLive, self, pl openSpace: (id, invite) => transition(() => openSpaceTask(id, invite)), leaveRoom, setTopic, + people: () => everyone, say(text) { if (here?.kind === "left") { panel.say("You're no longer in this space."); return false; } if (!places.length) { panel.say(self.signedIn ? "Messages need the live connection." : "Sign in to talk."); return false; } diff --git a/styles.css b/styles.css index 92fb625..3dc021a 100644 --- a/styles.css +++ b/styles.css @@ -581,3 +581,20 @@ dialog h2 { font-size: 30px; line-height: 1.25; font-weight: 500; letter-spacing .stoa-messages .is-self strong { color: var(--accent); } .stoa-say { display: grid; grid-template-columns: 1fr auto; gap: 8px; } .stoa-signin { font-size: 13px; color: var(--muted); } +.stoa-view { position: relative; } +.stoa-video { position: absolute; inset: 0; display: flex; flex-wrap: wrap; align-content: flex-start; gap: 8px; padding: 8px; pointer-events: none; overflow: hidden; } +.stoa-video[data-layout="grid"] { inset: 0 0 0 auto; width: min(46%, 520px); display: grid; grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); } +.stoa-tile { position: relative; width: 168px; aspect-ratio: 16 / 10; overflow: hidden; border: 1px solid var(--line); border-radius: 4px; background: #202824; pointer-events: auto; } +.stoa-video[data-layout="grid"] .stoa-tile { width: auto; } +.stoa-tile.is-screen { width: min(100%, 360px); aspect-ratio: 16 / 9; } +.stoa-tile.is-floating { position: absolute; left: 0; top: 0; width: 96px; } +.stoa-tile-player { position: absolute; inset: 0; } +.stoa-tile-name { position: absolute; left: 6px; bottom: 6px; max-width: calc(100% - 12px); padding: 2px 6px; border-radius: 3px; background: rgba(32, 40, 36, 0.78); color: #f3efe6; font: 11px var(--mono); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.stoa-call { margin: 4px 0 14px; } +.stoa-call-status { margin: 0 0 8px; font-size: 12px; color: var(--muted); } +.stoa-call-preflight { display: flex; flex-wrap: wrap; gap: 6px 14px; margin-bottom: 8px; font-size: 12px; } +.stoa-call-preflight label { display: flex; align-items: center; gap: 6px; } +.stoa-call-controls { display: flex; flex-wrap: wrap; gap: 6px; } +.stoa-call-controls [aria-pressed="true"] { background: #e6ebdc; border-color: #98a98a; } +.stoa-call-leave { color: var(--accent); } +@media (prefers-reduced-motion: no-preference) { .stoa-tile.is-floating { transition: opacity 0.2s; } } diff --git a/tests/spaces-api.test.mjs b/tests/spaces-api.test.mjs index d55c085..9f8427d 100644 --- a/tests/spaces-api.test.mjs +++ b/tests/spaces-api.test.mjs @@ -184,9 +184,9 @@ test("the live Stoa page has its panel controls and serves every live module", a const { instance, url } = await start(); try { const html = await (await request(url, "/stoa/")).text(); - for (const id of ["stoa-here", "stoa-topic-form", "stoa-leave", "stoa-offer", "stoa-start", "stoa-rooms-section", "stoa-messages", "stoa-say", "stoa-signin"]) assert.match(html, new RegExp("id=\"" + id + "\""), id); + for (const id of ["stoa-here", "stoa-topic-form", "stoa-leave", "stoa-offer", "stoa-start", "stoa-rooms-section", "stoa-messages", "stoa-say", "stoa-signin", "stoa-call", "stoa-call-join", "stoa-video"]) assert.match(html, new RegExp("id=\"" + id + "\""), id); assert.match(html, /id="stoa-messages" role="log"/); - for (const module of ["/stoa/panel.js", "/stoa/stage.js", "/stoa/place.js", "/stoa/lease.js", "/world/protocol.js", "/world/crypto.js", "/world/signaling.js", "/world/presence.js"]) { + for (const module of ["/stoa/panel.js", "/stoa/stage.js", "/stoa/place.js", "/stoa/lease.js", "/world/protocol.js", "/world/crypto.js", "/world/signaling.js", "/world/presence.js", "/stoa/call.js", "/world/rtc-client.js"]) { const response = await request(url, module); assert.equal(response.status, 200, module); assert.match(response.headers.get("content-type"), /text\/javascript/, module); diff --git a/tests/stoa-call.test.mjs b/tests/stoa-call.test.mjs new file mode 100644 index 0000000..4ed68b7 --- /dev/null +++ b/tests/stoa-call.test.mjs @@ -0,0 +1,25 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { actorUuid, nameFor, overAvatar, tileList } from "../stoa/call.js"; + +const ada = "72a639ba-3a45-4afe-936b-222222222222", me = "72a639ba-3a45-4afe-936b-111111111111"; +const people = [{ id: "account:" + ada, name: "Ada" }]; +const self = { id: "account:" + me, name: "Me" }; +const snapshot = { joined: true, uid: me + "_aaaaaaaaaaaa", screenUid: me + "_aaaaaaaaaaaa_screen", audio: true, video: false, screen: true, peers: [{ uid: ada + "_bbbbbbbbbbbb", audio: true, video: true, screen: false }, { uid: ada + "_bbbbbbbbbbbb_screen", audio: false, video: true, screen: true }, { uid: "99999999-0000-4000-8000-000000000000_cccccccccccc", audio: true, video: false, screen: false }] }; + +test("call identities map back to people by their UUID", () => { + assert.equal(actorUuid(ada + "_bbbbbbbbbbbb_screen"), ada); + const names = tileList(snapshot, { people, self }).map((tile) => [tile.name, tile.video, tile.screen, tile.mine]); + assert.deepEqual(names, [["Me (you)", false, false, true], ["Your screen", true, true, true], ["Ada", true, false, false], ["Ada's screen", true, true, false], ["Builder", false, false, false]]); + assert.deepEqual(tileList({ ...snapshot, joined: false }, { people, self }), []); + assert.equal(nameFor(snapshot.uid, { people, self, snapshot }), "Me (you)"); +}); +test("over-avatar tiles sit above the matching avatar; screens and people out of view do not float", () => { + const frame = { tileSize: 32, camera: { x: 100, y: 50, zoom: 2 }, avatars: [{ id: self.id, x: 5, y: 4, self: true }, { id: "account:" + ada, x: 7, y: 3, self: false }] }; + const [mine, screen, adaTile, , stranger] = tileList(snapshot, { people, self }); + assert.deepEqual(overAvatar(mine, frame), { left: ((5 + 0.5) * 32 - 100) * 2, top: (4 * 32 - 50) * 2 }); + assert.deepEqual(overAvatar(adaTile, frame), { left: ((7 + 0.5) * 32 - 100) * 2, top: (3 * 32 - 50) * 2 }); + assert.equal(overAvatar(screen, frame), null); + assert.equal(overAvatar(stranger, frame), null); + assert.equal(overAvatar(mine, null), null); +}); diff --git a/tests/stoa-stage.test.mjs b/tests/stoa-stage.test.mjs index 12023ad..2306d1f 100644 --- a/tests/stoa-stage.test.mjs +++ b/tests/stoa-stage.test.mjs @@ -10,7 +10,7 @@ const tick = () => new Promise((resolve) => setImmediate(resolve)); const deferred = () => { const d = {}; d.promise = new Promise((resolve, reject) => { d.resolve = resolve; d.reject = reject; }); return d; }; const space = (id, title = id) => ({ id, title, topic: null, tags: [], visibility: "public" }); -function setup({ api, signedIn = true, withLive = true, clock = { time: 0, timers: [] } } = {}) { +function setup({ api, signedIn = true, withLive = true, clock = { time: 0, timers: [] }, ...options } = {}) { const calls = { teleports: [], bounds: [], interactive: null, published: [], states: [], canTalk: [], reasons: [], showRoom: [], offer: 0, joined: [], left: [], others: 0, people: 0, handlers: {}, quiet: {} }; const frames = []; const live = !withLive ? null : { @@ -29,7 +29,7 @@ function setup({ api, signedIn = true, withLive = true, clock = { time: 0, timer }; const leases = []; const createLease = (options) => { const entry = { options, active: true, stop() { entry.active = false; }, async leave() { entry.active = false; }, leaveOnUnload() { entry.active = false; } }; leases.push(entry); return entry; }; - const stage = createStage({ api, map: plaza, engine, panel, live, self: { id: "a-me", name: "Me", signedIn }, plaza: { bounds: null, channel: "plaza-1" }, createLease, schedule: (callback) => frames.push(callback), now: () => clock.time, later: (callback, delay) => { clock.timers.push({ callback, delay }); return clock.timers.length; } }); + const stage = createStage({ api, map: plaza, engine, panel, live, self: { id: "a-me", name: "Me", signedIn }, plaza: { bounds: null, channel: "plaza-1" }, createLease, schedule: (callback) => frames.push(callback), now: () => clock.time, later: (callback, delay) => { clock.timers.push({ callback, delay }); return clock.timers.length; }, ...options }); const flushFrames = () => { for (const callback of frames.splice(0)) callback(); }; return { stage, calls, panel, leases, handlers, live, flushFrames, clock }; } @@ -329,3 +329,25 @@ test("connecting live while watching the plaza joins it quietly", async () => { await stage.attachLive(later, "plaza-1"); assert.deepEqual(joined, [["plaza-1", true]]); }); + +test("the call follows the room: one callback per room entered or left, not per channel change", async () => { + const rooms = []; + const api = async (path) => path === "/api/spaces/plaza/enter" ? access("plaza", "plaza-1") : path.endsWith("/enter") ? access("lot-" + lot.slug, "lot-ch") : {}; + const { stage, leases } = setup({ api, onRoom: (room) => rooms.push(room ? room.id : null) }); + await stage.toPlaza(null); + await stage.enterLot(lot); + leases.at(-1).options.onAccess({ channel: "lot-ch-2", key: null, blocked: [], hostId: "a-me" }); + await tick(); await tick(); + await stage.leaveRoom(); + assert.deepEqual(rooms, ["lot-" + lot.slug, null]); +}); +test("the merged people list is kept and reported each time it is rebuilt", async () => { + const lists = []; + const api = async (path) => path === "/api/spaces/plaza/enter" ? access("plaza", "plaza-1") : {}; + const { stage, calls, flushFrames } = setup({ api, onPeople: (list) => lists.push(list.map((person) => person.name)) }); + await stage.toPlaza(null); + calls.handlers["plaza-1"].presence({ type: "snapshot", people: [{ userId: "a-72a639ba-3a45-4afe-936b-222222222222", states: { x: String(lot.door.x), y: String(lot.door.y + 1), dir: "down", name: "Ada" } }] }); + flushFrames(); + assert.deepEqual(lists.at(-1), ["Ada"]); + assert.deepEqual(stage.people().map((person) => person.name), ["Ada"]); +}); From 640ec5f43064986f4fd873ebe50973e01be51a11 Mon Sep 17 00:00:00 2001 From: Brent G <deliberatekids@gmail.com> Date: Thu, 8 Oct 2026 19:20:22 +0000 Subject: [PATCH 57/71] Polish Stoa call controls and tiles MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🤖 Built with SMT <smt@agora.build> --- stoa.html | 4 ++-- stoa.js | 4 ++-- stoa/call.js | 33 +++++++++++++++++++++++---------- styles.css | 4 ++-- tests/stoa-call.test.mjs | 6 ++++++ 5 files changed, 35 insertions(+), 16 deletions(-) diff --git a/stoa.html b/stoa.html index c48cb01..c4ccb78 100644 --- a/stoa.html +++ b/stoa.html @@ -39,8 +39,8 @@ <h3 class="stoa-panel-title" id="stoa-call-title">Call</h3> <div class="stoa-call-preflight" id="stoa-call-preflight"><label><input type="checkbox" id="stoa-call-mic" checked> Microphone on</label><label><input type="checkbox" id="stoa-call-camera"> Camera on</label></div> <div class="stoa-call-controls"> <button class="button button-small" type="button" id="stoa-call-join">Join call</button> - <button class="button button-secondary button-small" type="button" id="stoa-call-audio" aria-pressed="false" hidden>Turn mic on</button> - <button class="button button-secondary button-small" type="button" id="stoa-call-video" aria-pressed="false" hidden>Turn camera on</button> + <button class="button button-secondary button-small" type="button" id="stoa-call-audio" aria-pressed="false" hidden>Microphone</button> + <button class="button button-secondary button-small" type="button" id="stoa-call-video" aria-pressed="false" hidden>Camera</button> <button class="button button-secondary button-small" type="button" id="stoa-call-share" aria-pressed="false" hidden>Share screen</button> <button class="button button-secondary button-small" type="button" id="stoa-call-resume" hidden>Play call audio</button> <button class="button button-secondary button-small stoa-call-leave" type="button" id="stoa-call-leave" hidden>Leave call</button> diff --git a/stoa.js b/stoa.js index 67b66cb..1ab2119 100644 --- a/stoa.js +++ b/stoa.js @@ -33,7 +33,7 @@ failure(spaceResult, "This space"); failure(worldResult, "The map"); failure(the const space = spaceResult.value?.space || null; if (space) { document.querySelector("#stoa-title").textContent = space.title; document.title = space.title + " | Agora Build"; } -let map = null, engine = null, minimal = stored.get() === "true", preview = null, plazaBounds = null, active = () => null; +let map = null, engine = null, minimal = stored.get() === "true", preview = null, plazaBounds = null, active = () => null, call = null; try { if (worldResult.status === "fulfilled") map = parseMap(worldResult.value); } catch (error) { problems.push("The map is not valid (" + error.message + ")."); } let summaries = new Map((spacesResult.value?.rooms || []).map((room) => [room.slug, room])); const lot = roomSlug && map ? map.lots.find((entry) => entry.slug === roomSlug) || null : null; @@ -78,7 +78,7 @@ await sessionReady; const self = { id: state.account?.id || state.profile?.id || null, ids: [state.account?.id, state.profile?.id].filter(Boolean), name: state.profile?.name || state.account?.name || "You", signedIn: Boolean(state.account || state.profile) }; panel.setPeople([], self.name); panel.canTalk(self.signedIn); -let live = null, stage = null, call = null; +let live = null, stage = null; const unavailable = "The Stoa view isn't available, so there's nothing to walk or talk in."; // Controls work even without the canvas: Go to explains where the door is, Sign in and Start a space still do their jobs. panel.on({ diff --git a/stoa/call.js b/stoa/call.js index 03f4516..be1c132 100644 --- a/stoa/call.js +++ b/stoa/call.js @@ -7,6 +7,7 @@ export const actorUuid = (uid) => String(uid).split("_")[0]; export function nameFor(uid, { people, self, snapshot }) { if (uid === snapshot.uid) return self.name + " (you)"; if (uid === snapshot.screenUid) return "Your screen"; + if ((self.ids || []).some((id) => String(id).split(":").pop() === actorUuid(uid))) return self.name + " (another tab)" + (isScreen(uid) ? "'s screen" : ""); const person = people.find((entry) => String(entry.id).split(":")[1] === actorUuid(uid)); return (person?.name || "Builder") + (isScreen(uid) ? "'s screen" : ""); } @@ -26,6 +27,7 @@ export function overAvatar(tile, frame) { export function createCallView({ doc = document, engine, api, self, people = () => [], say = () => {}, loadSdk = loadAgora, secure = () => globalThis.isSecureContext !== false }) { const $ = (selector) => doc.querySelector(selector); const section = $("#stoa-call"), status = $("#stoa-call-status"), preflight = $("#stoa-call-preflight"), layer = $("#stoa-video"); + let busyJoining = false; const buttons = { join: $("#stoa-call-join"), audio: $("#stoa-call-audio"), video: $("#stoa-call-video"), share: $("#stoa-call-share"), resume: $("#stoa-call-resume"), leave: $("#stoa-call-leave") }; const tiles = new Map(); let spaceId = null, rtc = null, layout = "strip", frame = null; @@ -36,19 +38,18 @@ export function createCallView({ doc = document, engine, api, self, people = () view.card.classList.toggle("is-floating", Boolean(spot)); view.card.style.transform = spot ? "translate(" + Math.round(spot.left) + "px, " + Math.round(spot.top) + "px) translate(-50%, -100%)" : ""; // Over avatars, a person without video is already shown by their avatar. - view.card.hidden = layout === "over-avatar" && !view.tile.screen && !view.playing; + view.card.hidden = layout === "over-avatar" && !view.tile.screen && (!view.playing || !spot); } } function render() { const now = rtc ? rtc.snapshot() : null, joined = Boolean(now?.joined); buttons.join.hidden = joined; preflight.hidden = joined; - buttons.join.disabled = !secure() || Boolean(now?.busy); + buttons.join.disabled = !secure() || busyJoining || Boolean(now?.busy); for (const key of ["audio", "video", "share", "leave"]) { buttons[key].hidden = !joined; buttons[key].disabled = Boolean(now?.busy); } if (!globalThis.navigator?.mediaDevices?.getDisplayMedia) buttons.share.disabled = true; - const toggle = (button, on, onText, offText) => { button.textContent = on ? onText : offText; button.setAttribute("aria-pressed", String(on)); }; - toggle(buttons.audio, Boolean(now?.audio), "Mute mic", "Turn mic on"); - toggle(buttons.video, Boolean(now?.video), "Turn camera off", "Turn camera on"); - toggle(buttons.share, Boolean(now?.screen), "Stop sharing", "Share screen"); + buttons.audio.setAttribute("aria-pressed", String(Boolean(now?.audio))); + buttons.video.setAttribute("aria-pressed", String(Boolean(now?.video))); + buttons.share.setAttribute("aria-pressed", String(Boolean(now?.screen))); buttons.resume.hidden = !now?.audioBlocked; layer.hidden = !joined; layer.dataset.layout = layout; @@ -79,8 +80,12 @@ export function createCallView({ doc = document, engine, api, self, people = () status.textContent = message; } async function join() { - if (rtc || !spaceId) return; + if (rtc || !spaceId || busyJoining) return; const id = spaceId; + busyJoining = true; render(); + try { await connect(id); } finally { busyJoining = false; render(); } + } + async function connect(id) { status.textContent = "Connecting to the call..."; let AgoraRTC; try { ({ AgoraRTC } = await loadSdk()); } catch (error) { if (spaceId === id) status.textContent = "The call couldn't load (" + (error.message || "unknown error") + ")."; return; } @@ -99,15 +104,23 @@ export function createCallView({ doc = document, engine, api, self, people = () if (rtc !== client) return; if (!client.snapshot().joined) { rtc = null; await client.leave(); reset("You're not in the call. Try joining again."); return; } status.textContent = "You're in the call."; + buttons.audio.focus(); + } + async function leave(message, focus = false) { + const client = rtc, id = spaceId; + rtc = null; render(); + await client?.leave(); + if (spaceId !== id) return; + reset(message); + if (focus) buttons.join.focus(); } - async function leave(message) { const client = rtc; rtc = null; await client?.leave(); reset(message); } buttons.join.addEventListener("click", join); buttons.audio.addEventListener("click", () => rtc?.toggle("audio")); buttons.video.addEventListener("click", () => rtc?.toggle("video")); buttons.share.addEventListener("click", () => rtc?.share()); buttons.resume.addEventListener("click", () => rtc?.resumeAudio()); - buttons.leave.addEventListener("click", () => leave("You left the call.")); + buttons.leave.addEventListener("click", () => leave("You left the call.", true)); engine.on("draw", (next) => { frame = next; if (layout === "over-avatar" && tiles.size) place(); }); return { enter(id) { @@ -115,7 +128,7 @@ export function createCallView({ doc = document, engine, api, self, people = () if (rtc) leave(""); spaceId = id; section.hidden = false; - reset(secure() ? "Join to talk with everyone in this room. Your mic and camera stay off until you choose." : "Open this page over HTTPS to use your microphone and camera."); + reset(secure() ? "Join to talk with everyone in this room. Choose your mic and camera first; nothing turns on until you join." : "Open this page over HTTPS to use your microphone and camera."); }, exit() { spaceId = null; section.hidden = true; if (rtc) leave(""); else reset(""); }, setLayout(next) { layout = next || "strip"; render(); }, diff --git a/styles.css b/styles.css index 3dc021a..18c8dca 100644 --- a/styles.css +++ b/styles.css @@ -582,9 +582,9 @@ dialog h2 { font-size: 30px; line-height: 1.25; font-weight: 500; letter-spacing .stoa-say { display: grid; grid-template-columns: 1fr auto; gap: 8px; } .stoa-signin { font-size: 13px; color: var(--muted); } .stoa-view { position: relative; } -.stoa-video { position: absolute; inset: 0; display: flex; flex-wrap: wrap; align-content: flex-start; gap: 8px; padding: 8px; pointer-events: none; overflow: hidden; } +.stoa-video { position: absolute; inset: 0; display: flex; flex-wrap: nowrap; align-content: flex-start; gap: 8px; padding: 8px; pointer-events: none; overflow: hidden; } .stoa-video[data-layout="grid"] { inset: 0 0 0 auto; width: min(46%, 520px); display: grid; grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); } -.stoa-tile { position: relative; width: 168px; aspect-ratio: 16 / 10; overflow: hidden; border: 1px solid var(--line); border-radius: 4px; background: #202824; pointer-events: auto; } +.stoa-tile { position: relative; width: 168px; aspect-ratio: 16 / 10; overflow: hidden; border: 1px solid var(--line); border-radius: 4px; background: #202824; pointer-events: none; } .stoa-video[data-layout="grid"] .stoa-tile { width: auto; } .stoa-tile.is-screen { width: min(100%, 360px); aspect-ratio: 16 / 9; } .stoa-tile.is-floating { position: absolute; left: 0; top: 0; width: 96px; } diff --git a/tests/stoa-call.test.mjs b/tests/stoa-call.test.mjs index 4ed68b7..c9038cf 100644 --- a/tests/stoa-call.test.mjs +++ b/tests/stoa-call.test.mjs @@ -23,3 +23,9 @@ test("over-avatar tiles sit above the matching avatar; screens and people out of assert.equal(overAvatar(stranger, frame), null); assert.equal(overAvatar(mine, null), null); }); +test("another tab of the same person is named as such", () => { + const mine = { ...self, ids: [self.id, "member:" + me] }; + assert.equal(nameFor(me + "_dddddddddddd", { people, self: mine, snapshot }), "Me (another tab)"); + assert.equal(nameFor(me + "_dddddddddddd_screen", { people, self: mine, snapshot }), "Me (another tab)'s screen"); + assert.equal(nameFor(snapshot.uid, { people, self: mine, snapshot }), "Me (you)"); +}); From 0497b31c724b96c41c699ad5885b76908ef7a456 Mon Sep 17 00:00:00 2001 From: Brent G <deliberatekids@gmail.com> Date: Thu, 8 Oct 2026 19:21:23 +0000 Subject: [PATCH 58/71] Keep strip tiles full size and guard late call resets MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🤖 Built with SMT <smt@agora.build> --- stoa/call.js | 6 ++++-- styles.css | 2 +- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/stoa/call.js b/stoa/call.js index be1c132..8ea680f 100644 --- a/stoa/call.js +++ b/stoa/call.js @@ -104,13 +104,15 @@ export function createCallView({ doc = document, engine, api, self, people = () if (rtc !== client) return; if (!client.snapshot().joined) { rtc = null; await client.leave(); reset("You're not in the call. Try joining again."); return; } status.textContent = "You're in the call."; - buttons.audio.focus(); + // Focus follows the hidden Join button only if the person hasn't moved on during a slow join. + if (!doc.activeElement || doc.activeElement === doc.body || doc.activeElement === buttons.join) buttons.audio.focus(); } async function leave(message, focus = false) { const client = rtc, id = spaceId; rtc = null; render(); await client?.leave(); - if (spaceId !== id) return; + // A rejoin or another room while this leave was pending owns the view now. + if (spaceId !== id || rtc) return; reset(message); if (focus) buttons.join.focus(); } diff --git a/styles.css b/styles.css index 18c8dca..6b18aa5 100644 --- a/styles.css +++ b/styles.css @@ -584,7 +584,7 @@ dialog h2 { font-size: 30px; line-height: 1.25; font-weight: 500; letter-spacing .stoa-view { position: relative; } .stoa-video { position: absolute; inset: 0; display: flex; flex-wrap: nowrap; align-content: flex-start; gap: 8px; padding: 8px; pointer-events: none; overflow: hidden; } .stoa-video[data-layout="grid"] { inset: 0 0 0 auto; width: min(46%, 520px); display: grid; grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); } -.stoa-tile { position: relative; width: 168px; aspect-ratio: 16 / 10; overflow: hidden; border: 1px solid var(--line); border-radius: 4px; background: #202824; pointer-events: none; } +.stoa-tile { position: relative; flex: none; width: 168px; aspect-ratio: 16 / 10; overflow: hidden; border: 1px solid var(--line); border-radius: 4px; background: #202824; pointer-events: none; } .stoa-video[data-layout="grid"] .stoa-tile { width: auto; } .stoa-tile.is-screen { width: min(100%, 360px); aspect-ratio: 16 / 9; } .stoa-tile.is-floating { position: absolute; left: 0; top: 0; width: 96px; } From 8bd6844d4bbeb4e4a47c6750b767c4645eadf456 Mon Sep 17 00:00:00 2001 From: Brent G <deliberatekids@gmail.com> Date: Thu, 8 Oct 2026 19:23:17 +0000 Subject: [PATCH 59/71] Add decorate mode for room hosts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🤖 Built with SMT <smt@agora.build> --- scripts/serve.mjs | 2 +- stoa.html | 9 +++ stoa.js | 13 +++- stoa/decor.js | 124 ++++++++++++++++++++++++++++++++++++++ stoa/panel.js | 6 +- stoa/stage.js | 31 ++++++++-- styles.css | 9 +++ tests/stoa-decor.test.mjs | 47 +++++++++++++++ tests/stoa-panel.test.mjs | 12 ++++ tests/stoa-stage.test.mjs | 46 +++++++++++++- 10 files changed, 286 insertions(+), 13 deletions(-) create mode 100644 stoa/decor.js create mode 100644 tests/stoa-decor.test.mjs diff --git a/scripts/serve.mjs b/scripts/serve.mjs index b8e6827..ff2bc51 100644 --- a/scripts/serve.mjs +++ b/scripts/serve.mjs @@ -23,7 +23,7 @@ import { loadThemes } from "./spaces/themes.mjs"; const root = fileURLToPath(new URL("../", import.meta.url)); // Vendored browser SDKs are served from node_modules in development and from dist/assets after a build. const vendor = { "assets/agora-rtc.js": createRequire(import.meta.url).resolve("agora-rtc-sdk-ng"), "assets/agora-rtm.js": createRequire(import.meta.url).resolve("agora-rtm-sdk") }; -const publicFiles = new Set(["index.html", "explore.html", "services.html", "radar.html", "meetings.html", "account.html", "account.js", "stoa.html", "stoa.js", "world/map.js", "world/kinds.js", "world/themes.js", "world/camera.js", "world/motion.js", "world/renderer-canvas.js", "world/engine.js", "world/sdk.js", "world/rtc-client.js", "stoa/panel.js", "stoa/stage.js", "stoa/call.js", "stoa/place.js", "stoa/lease.js", "world/protocol.js", "world/crypto.js", "world/signaling.js", "world/presence.js", "styles.css", "script.js", "people.js", "activity.js", "explore.js", "services.js", "radar.js", "meetings.js", "call.js", "assets/agora-rtc.js", "assets/agora-rtm.js", "assets/favicon.svg", "assets/guohai.jpg"]); +const publicFiles = new Set(["index.html", "explore.html", "services.html", "radar.html", "meetings.html", "account.html", "account.js", "stoa.html", "stoa.js", "world/map.js", "world/kinds.js", "world/themes.js", "world/camera.js", "world/motion.js", "world/renderer-canvas.js", "world/engine.js", "world/sdk.js", "world/rtc-client.js", "stoa/panel.js", "stoa/stage.js", "stoa/call.js", "stoa/decor.js", "stoa/place.js", "stoa/lease.js", "world/protocol.js", "world/crypto.js", "world/signaling.js", "world/presence.js", "styles.css", "script.js", "people.js", "activity.js", "explore.js", "services.js", "radar.js", "meetings.js", "call.js", "assets/agora-rtc.js", "assets/agora-rtm.js", "assets/favicon.svg", "assets/guohai.jpg"]); const types = { ".html": "text/html; charset=utf-8", ".css": "text/css; charset=utf-8", ".js": "text/javascript; charset=utf-8", ".svg": "image/svg+xml", ".jpg": "image/jpeg" }; function json(response, status, body, headers = {}) { diff --git a/stoa.html b/stoa.html index c4ccb78..2ddccfb 100644 --- a/stoa.html +++ b/stoa.html @@ -33,6 +33,15 @@ <h2 id="stoa-here-title"></h2> <label>Tags <span class="optional">up to 5, separated by commas</span><input name="tags" maxlength="160" autocomplete="off"></label> <button class="button button-small" type="submit">Set topic</button> </form> + <button class="button button-secondary button-small" type="button" id="stoa-decor-open" hidden>Decorate</button> + <section class="stoa-decor" id="stoa-decor" hidden aria-labelledby="stoa-decor-title"> + <h3 class="stoa-panel-title" id="stoa-decor-title">Decorate</h3> + <p class="stoa-decor-selected" id="stoa-decor-selected" aria-live="polite"></p> + <div class="stoa-palette" id="stoa-palette" role="group" aria-label="Decorations"></div> + <button class="button button-secondary button-small" type="button" id="stoa-decor-here">Place in front of me</button> + <ul class="stoa-decor-list" id="stoa-decor-list" aria-label="Placed decorations"></ul> + <div class="stoa-decor-actions"><button class="button button-small" type="button" id="stoa-decor-save">Save decorations</button><button class="button button-secondary button-small" type="button" id="stoa-decor-cancel">Cancel</button></div> + </section> <section class="stoa-call" id="stoa-call" hidden aria-labelledby="stoa-call-title"> <h3 class="stoa-panel-title" id="stoa-call-title">Call</h3> <p class="stoa-call-status" id="stoa-call-status" role="status" aria-live="polite"></p> diff --git a/stoa.js b/stoa.js index 1ab2119..fd2ba39 100644 --- a/stoa.js +++ b/stoa.js @@ -5,6 +5,7 @@ import { createLive } from "./world/signaling.js"; import { loadAgora } from "./world/sdk.js"; import { createPanel } from "./stoa/panel.js"; import { createCallView } from "./stoa/call.js"; +import { createDecorEditor } from "./stoa/decor.js"; import { createStage, outsideDoor } from "./stoa/stage.js"; const root = document.querySelector("#stoa"); @@ -33,7 +34,7 @@ failure(spaceResult, "This space"); failure(worldResult, "The map"); failure(the const space = spaceResult.value?.space || null; if (space) { document.querySelector("#stoa-title").textContent = space.title; document.title = space.title + " | Agora Build"; } -let map = null, engine = null, minimal = stored.get() === "true", preview = null, plazaBounds = null, active = () => null, call = null; +let map = null, engine = null, minimal = stored.get() === "true", preview = null, plazaBounds = null, active = () => null, call = null, decor = null; try { if (worldResult.status === "fulfilled") map = parseMap(worldResult.value); } catch (error) { problems.push("The map is not valid (" + error.message + ")."); } let summaries = new Map((spacesResult.value?.rooms || []).map((room) => [room.slug, room])); const lot = roomSlug && map ? map.lots.find((entry) => entry.slug === roomSlug) || null : null; @@ -93,6 +94,13 @@ panel.on({ say: (text) => stage ? stage.say(text) : (panel.say(unavailable), false), topic: (topic, tags) => stage?.setTopic(topic, tags), leave: () => stage?.leaveRoom(), + decorate: () => { + if (!stage?.canManage()) { panel.say("Only the host can decorate."); return; } + stage.setEditing(true); + decor?.open({ items: stage.decor(), area: stage.decorArea() }); + panel.say("Decor mode is on. Choose a decoration, then click the map or use Place in front of me."); + canvas.focus(); + }, start: async () => { try { const created = await api("/api/spaces", { method: "POST", body: JSON.stringify({ title: self.name + "'s space" }) }); location.assign("/stoa/s/" + created.space.id); } catch (error) { panel.say(error.message); } }, signin: () => openSignIn() }); @@ -130,7 +138,8 @@ if (engine) { engine.setSelf({ id: self.id || undefined, name: self.name }); call = createCallView({ engine, api, self, people: () => stage?.people() || [], say: (text) => panel.say(text) }); call.setLayout(active().video); - stage = createStage({ api, map, engine, panel, live: null, self, plaza: spaceId ? null : { bounds: plazaBounds, channel: null }, onRoom: (room) => room ? call.enter(room.id) : call.exit(), onPeople: () => call.refresh() }); + decor = createDecorEditor({ canvas, engine, map, say: (text) => panel.say(text), onSave: (items) => stage.saveDecor(items), onClose: () => stage.setEditing(false) }); + stage = createStage({ api, map, engine, panel, live: null, self, plaza: spaceId ? null : { bounds: plazaBounds, channel: null }, onRoom: (room) => { decor?.abort(); if (room) call.enter(room.id); else call.exit(); }, onPeople: () => call.refresh() }); engine.on("arrive", (tile) => stage.onArrive(tile)); // Leaving the page releases the entry lease and, as far as the browser allows, the Signaling login. addEventListener("pagehide", () => { call.exit(); stage.unload(); live?.close().catch(() => {}); }); diff --git a/stoa/decor.js b/stoa/decor.js new file mode 100644 index 0000000..30f67f9 --- /dev/null +++ b/stoa/decor.js @@ -0,0 +1,124 @@ +import { walkable } from "../world/map.js"; +import { DECOR_KINDS } from "../world/kinds.js"; +import { KEYS, STEP } from "../world/motion.js"; + +export const DECOR_LABELS = { plant: "Plant", lamp: "Lamp", rug: "Rug", sofa: "Sofa", chair: "Chair", table: "Table", whiteboard: "Whiteboard", bookshelf: "Bookshelf", screen: "Screen", banner: "Banner", poster: "Poster", statue: "Statue", fountain: "Fountain" }; +const LIMIT = 60; + +// The host's working copy of a room's decorations. The server checks the whole arrangement on save; this keeps edits sensible. +export function createArrangement({ map, area, items = [], makeId = () => "d" + Math.random().toString(36).slice(2, 10) }) { + let list = items.map((item) => ({ ...item })), selected = null; + const inside = (tile) => tile.x >= area.x && tile.y >= area.y && tile.x < area.x + area.width && tile.y < area.y + area.height; + const free = (tile, except = null) => inside(tile) && walkable(map, tile.x, tile.y) && !list.some((item) => item !== except && item.x === tile.x && item.y === tile.y); + const current = () => list.find((item) => item.id === selected) || null; + const arrangement = { + items: () => list.map((item) => ({ ...item })), + selected: () => current() ? { ...current() } : null, + select(id) { selected = list.some((item) => item.id === id) ? id : null; return Boolean(selected); }, + deselect() { selected = null; }, + // A click on a placed item selects it; elsewhere it moves the selected item there, or places the chosen kind. + pick(tile, kind) { + const hit = list.find((item) => item.x === tile.x && item.y === tile.y); + if (hit) { selected = hit.id; return "selected"; } + const item = current(); + if (item) { if (!free(tile, item)) return "blocked"; item.x = tile.x; item.y = tile.y; return "moved"; } + return kind ? arrangement.place(kind, tile) : "none"; + }, + place(kind, tile) { + if (!DECOR_KINDS.includes(kind)) return "unknown"; + if (list.length >= LIMIT) return "full"; + if (!free(tile)) return "blocked"; + const item = { id: makeId(), kind, x: tile.x, y: tile.y, rotation: 0, variant: 0 }; + list.push(item); selected = item.id; + return "placed"; + }, + move(dir) { + const item = current(); + if (!item) return "none"; + const [dx, dy] = STEP[dir], next = { x: item.x + dx, y: item.y + dy }; + if (!free(next, item)) return "blocked"; + item.x = next.x; item.y = next.y; + return "moved"; + }, + rotate() { const item = current(); if (!item) return "none"; item.rotation = (item.rotation + 90) % 360; return "rotated"; }, + remove() { const item = current(); if (!item) return "none"; list = list.filter((entry) => entry !== item); selected = null; return "removed"; } + }; + return arrangement; +} + +const OUTCOMES = { blocked: "That tile is taken, blocked, or outside the room.", full: "A room holds up to 60 decorations.", none: "Choose a decoration first, or select one on the map." }; +// Decor mode: the map picks tiles instead of walking, and the panel lists every item for keyboard and screen reader users. +export function createDecorEditor({ doc = document, canvas, engine, map, onSave = async () => false, onClose = () => {}, say = () => {} }) { + const $ = (selector) => doc.querySelector(selector); + const section = $("#stoa-decor"), palette = $("#stoa-palette"), list = $("#stoa-decor-list"), save = $("#stoa-decor-save"); + let arrangement = null, original = [], kind = null; + const describe = (item) => DECOR_LABELS[item.kind] + " at " + item.x + ", " + item.y + (item.rotation ? ", turned " + item.rotation + "°" : ""); + const kindButtons = DECOR_KINDS.map((entry) => { + const button = doc.createElement("button"); + button.type = "button"; button.className = "button button-secondary button-small"; button.textContent = DECOR_LABELS[entry]; + button.setAttribute("aria-pressed", "false"); + button.addEventListener("click", () => { kind = kind === entry ? null : entry; arrangement?.deselect(); render(); }); + palette.append(button); + return [entry, button]; + }); + function render() { + if (!arrangement) return; + engine.setDecor(arrangement.items()); + for (const [entry, button] of kindButtons) button.setAttribute("aria-pressed", String(kind === entry)); + const chosen = arrangement.selected(); + list.replaceChildren(...arrangement.items().map((item) => { + const row = doc.createElement("li"), button = doc.createElement("button"); + button.type = "button"; button.textContent = describe(item); + if (chosen?.id === item.id) button.setAttribute("aria-current", "true"); + button.addEventListener("click", () => { arrangement.select(item.id); render(); canvas.focus(); }); + row.append(button); + return row; + })); + $("#stoa-decor-selected").textContent = chosen ? "Selected: " + describe(chosen) + ". Arrow keys move it, R turns it, Delete removes it." : kind ? "Click the map to place a " + DECOR_LABELS[kind].toLowerCase() + "." : "Choose a decoration, or select one to move it."; + } + const report = (outcome) => { if (OUTCOMES[outcome]) say(OUTCOMES[outcome]); render(); }; + const onKey = (event) => { + if (!arrangement || event.ctrlKey || event.metaKey || event.altKey) return; + const dir = KEYS[event.key]; + if (dir) { event.preventDefault(); report(arrangement.move(dir)); } + else if (event.key === "r" || event.key === "R") { event.preventDefault(); report(arrangement.rotate()); } + else if (event.key === "Delete" || event.key === "Backspace") { event.preventDefault(); report(arrangement.remove()); } + else if (event.key === "Escape") { arrangement.deselect(); kind = null; render(); } + }; + function finish(restore) { + if (!arrangement) return; + canvas.removeEventListener("keydown", onKey); + engine.setPicking(null); + engine.setInteractive(true); + if (restore) engine.setDecor(original); + arrangement = null; kind = null; + section.hidden = true; + onClose(); + } + $("#stoa-decor-here").addEventListener("click", () => { + if (!arrangement) return; + const at = engine.position(), [dx, dy] = STEP[engine.facing()] || [0, 1]; + report(kind ? arrangement.place(kind, { x: at.x + dx, y: at.y + dy }) : "none"); + }); + save.addEventListener("click", async () => { + if (!arrangement) return; + save.disabled = true; + try { if (await onSave(arrangement.items())) finish(false); } finally { save.disabled = false; } + }); + $("#stoa-decor-cancel").addEventListener("click", () => finish(true)); + return { + open({ items = [], area }) { + if (arrangement) return; + original = items.map((item) => ({ ...item })); + arrangement = createArrangement({ map, area: area || { x: 0, y: 0, width: map.width, height: map.height }, items: original }); + engine.setInteractive(false); + engine.setPicking((tile) => report(arrangement.pick(tile, kind))); + canvas.addEventListener("keydown", onKey); + section.hidden = false; + render(); + }, + close: () => finish(true), + abort: () => finish(false), + isOpen: () => Boolean(arrangement) + }; +} diff --git a/stoa/panel.js b/stoa/panel.js index 89b7e55..4bb7be4 100644 --- a/stoa/panel.js +++ b/stoa/panel.js @@ -8,11 +8,12 @@ export function createPanel(doc = document) { const status = $("#stoa-status"), here = $("#stoa-here"), topicForm = $("#stoa-topic-form"), offer = $("#stoa-offer"), roomsSection = $("#stoa-rooms-section"), rooms = $("#stoa-rooms"), people = $("#stoa-people"), messages = $("#stoa-messages"), sayForm = $("#stoa-say"), sayInput = $("#stoa-say-text"), signin = $("#stoa-signin"), signinText = $("#stoa-signin-text"), signinButton = $("#stoa-signin-button"); let shownPeople = null; const roomRows = new Map(); - const handlers = { go() {}, say() { return false; }, topic() {}, leave() {}, start() {}, signin() {} }; + const handlers = { go() {}, say() { return false; }, topic() {}, leave() {}, start() {}, signin() {}, decorate() {} }; sayForm.addEventListener("submit", (event) => { event.preventDefault(); if (handlers.say(sayInput.value)) sayInput.value = ""; }); topicForm.addEventListener("submit", (event) => { event.preventDefault(); handlers.topic(topicForm.elements.topic.value, topicForm.elements.tags.value); }); $("#stoa-leave").addEventListener("click", () => handlers.leave()); $("#stoa-start").addEventListener("click", () => handlers.start()); + $("#stoa-decor-open").addEventListener("click", () => handlers.decorate()); signinButton.addEventListener("click", () => handlers.signin()); return { on(next) { Object.assign(handlers, next); }, @@ -61,10 +62,11 @@ export function createPanel(doc = document) { $("#stoa-here-title").textContent = title; $("#stoa-here-topic").textContent = topic ? topic + (tags.length ? " · " + tags.join(", ") : "") : host ? "You're the host. Set a topic so people know what this room is about." : "No topic yet."; topicForm.hidden = !host; + $("#stoa-decor-open").hidden = !host; $("#stoa-leave").textContent = leaveLabel; sayInput.placeholder = "Say something to the room"; }, - showPlaza() { here.hidden = true; topicForm.hidden = true; roomsSection.hidden = false; sayInput.placeholder = "Say something on the plaza"; }, + showPlaza() { here.hidden = true; $("#stoa-decor-open").hidden = true; topicForm.hidden = true; roomsSection.hidden = false; sayInput.placeholder = "Say something on the plaza"; }, showOffer({ spaces = [], canCreate = false } = {}) { offer.hidden = false; $("#stoa-offer-list").replaceChildren(...spaces.map((space) => { const item = node("li"), link = node("a", space.title); link.href = space.path; item.append(link); return item; })); diff --git a/stoa/stage.js b/stoa/stage.js index 666ca5c..a8ccee3 100644 --- a/stoa/stage.js +++ b/stoa/stage.js @@ -10,7 +10,7 @@ export const outsideDoor = (map, lot) => [[0, 1], [0, -1], [1, 0], [-1, 0]].map( const REFRESH_GAP = 5000; // `live` may be null at first and attached later with attachLive, so the page never waits on the SDK. export function createStage({ api, map, engine, panel, live: firstLive, self, plaza, onRoom = () => {}, onPeople = () => {}, createLease = realLease, schedule = (callback) => typeof requestAnimationFrame === "function" ? requestAnimationFrame(callback) : setTimeout(callback, 16), now = () => Date.now(), later = (callback, delay) => setTimeout(callback, delay) }) { - let live = firstLive, places = [], lease = null, here = null, chain = Promise.resolve(), drawing = false, everyone = [], roomId = null; + let live = firstLive, places = [], lease = null, here = null, chain = Promise.resolve(), drawing = false, everyone = [], roomId = null, currentDecor = [], editing = false; // State changes run strictly one at a time, each re-checking its preconditions when it starts. const transition = (task) => (chain = chain.then(task, task).catch((error) => panel.say(error.message || "Something went wrong."))); const doors = new Map(map.lots.map((lot) => [lot.door.x + "," + lot.door.y, lot])); @@ -31,14 +31,18 @@ export function createStage({ api, map, engine, panel, live: firstLive, self, pl onPeople(list); }); }; - const showRoom = (space, hostId) => panel.showRoom({ kicker: here?.kind === "lot" ? "LOT ROOM" : space.visibility === "private" ? "PRIVATE SPACE" : "UNLISTED SPACE", title: space.title, topic: space.topic, tags: space.tags || [], host: Boolean(self.id) && hostId === self.id, leaveLabel: here?.kind === "lot" ? "Back to the plaza" : "Leave this space" }); + const mine = (id) => Boolean(id) && (self.ids || [self.id]).includes(id); + const canManage = () => Boolean(here && (here.kind === "lot" || here.kind === "space") && here.leased && self.signedIn && (mine(here.access?.hostId) || mine(here.space?.ownerId))); + const showDecor = (items) => { currentDecor = items || []; if (!editing) engine.setDecor(currentDecor); }; + const showRoom = (space, hostId) => panel.showRoom({ kicker: here?.kind === "lot" ? "LOT ROOM" : space.visibility === "private" ? "PRIVATE SPACE" : "UNLISTED SPACE", title: space.title, topic: space.topic, tags: space.tags || [], host: Boolean(self.id) && (mine(hostId) || mine(space.ownerId)), leaveLabel: here?.kind === "lot" ? "Back to the plaza" : "Leave this space" }); // Every transition claims `here` before it awaits anything; a superseded one stops as soon as it notices. async function settle(next) { here = next; // The call and the decor editor belong to a room, so the page hears about room changes, not channel changes. const room = next.leased && (next.kind === "lot" || next.kind === "space") ? next.space : null; - if ((room?.id || null) !== roomId) { roomId = room?.id || null; onRoom(room); } + if ((room?.id || null) !== roomId) { roomId = room?.id || null; editing = false; onRoom(room); } + showDecor(next.space?.decor); lease?.stop(); lease = null; const old = places; places = []; await Promise.all(old.map((place) => place.close().catch(() => {}))); @@ -48,7 +52,7 @@ export function createStage({ api, map, engine, panel, live: firstLive, self, pl showPeople(); if (live && next.access?.channel) { try { - const place = await openPlace({ live, map, engine, self, quiet: Boolean(next.watch), channel: { name: next.access.channel, key: next.access.key || null, spaceId: next.space.id }, onPeople: showPeople, onSay: (entry) => { if (entry.failed) panel.say("That message didn't send."); else panel.addMessage(entry); }, onRefresh: (from) => { if (here === next && from && from === next.access?.hostId) refreshRoom(); } }); + const place = await openPlace({ live, map, engine, self, quiet: Boolean(next.watch), channel: { name: next.access.channel, key: next.access.key || null, spaceId: next.space.id }, onPeople: showPeople, onSay: (entry) => { if (entry.failed) panel.say("That message didn't send."); else panel.addMessage(entry); }, onRefresh: (from) => { if (here === next && from && from === next.access?.hostId) refreshRoom(); }, onDecor: (from) => { if (here === next && from && (from === next.access?.hostId || from === next.space?.ownerId)) refreshRoom(); } }); if (here !== next) { await place.close().catch(() => {}); return; } places = [place]; place.setBlocked(next.access.blocked || []); @@ -118,7 +122,7 @@ export function createStage({ api, map, engine, panel, live: firstLive, self, pl if (!at || at.kind === "plaza" || !at.space) return; try { const { space } = await api("/api/spaces/" + at.space.id + (at.invite ? "?invite=" + encodeURIComponent(at.invite) : "")); - if (here === at) showRoom(space, space.hostId); + if (here === at) { showRoom(space, space.hostId); showDecor(space.decor); } } catch { /* the next heartbeat reports a lost lease */ } } @@ -204,6 +208,23 @@ export function createStage({ api, map, engine, panel, live: firstLive, self, pl leaveRoom, setTopic, people: () => everyone, + canManage, + decor: () => currentDecor, + decorArea: () => here?.kind === "lot" ? here.lot.interior : here?.kind === "space" ? { x: 0, y: 0, width: map.width, height: map.height } : null, + setEditing(value) { editing = Boolean(value); if (!editing) engine.setDecor(currentDecor); }, + async saveDecor(items) { + const at = here; + if (!canManage()) { panel.say("Only the host can decorate."); return false; } + try { + const result = await api("/api/spaces/" + encodeURIComponent(at.space.id) + "/decor", { method: "PUT", body: JSON.stringify({ items }) }); + if (here !== at) return false; + editing = false; + showDecor(result.decor); + for (const place of places) place.decorChanged(result.version); + panel.say("Decorations saved."); + return true; + } catch (error) { if (here === at) panel.say(error.message); return false; } + }, say(text) { if (here?.kind === "left") { panel.say("You're no longer in this space."); return false; } if (!places.length) { panel.say(self.signedIn ? "Messages need the live connection." : "Sign in to talk."); return false; } diff --git a/styles.css b/styles.css index 6b18aa5..2a05617 100644 --- a/styles.css +++ b/styles.css @@ -598,3 +598,12 @@ dialog h2 { font-size: 30px; line-height: 1.25; font-weight: 500; letter-spacing .stoa-call-controls [aria-pressed="true"] { background: #e6ebdc; border-color: #98a98a; } .stoa-call-leave { color: var(--accent); } @media (prefers-reduced-motion: no-preference) { .stoa-tile.is-floating { transition: opacity 0.2s; } } + +.stoa-decor { margin: 10px 0 14px; padding: 12px; border: 1px solid var(--line); border-radius: 4px; } +.stoa-decor-selected { margin: 0 0 8px; font-size: 12px; color: var(--muted); } +.stoa-palette { display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 8px; } +.stoa-palette [aria-pressed="true"] { background: #e6ebdc; border-color: #98a98a; } +.stoa-decor-list { list-style: none; margin: 8px 0; padding: 0; max-height: 160px; overflow-y: auto; font-size: 12px; } +.stoa-decor-list button { background: none; border: 0; padding: 3px 0; font: inherit; color: inherit; cursor: pointer; text-align: left; } +.stoa-decor-list [aria-current="true"] { color: var(--accent); font-weight: 600; } +.stoa-decor-actions { display: flex; gap: 6px; } diff --git a/tests/stoa-decor.test.mjs b/tests/stoa-decor.test.mjs new file mode 100644 index 0000000..dca32ac --- /dev/null +++ b/tests/stoa-decor.test.mjs @@ -0,0 +1,47 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; +import { parseMap, walkable } from "../world/map.js"; +import { createArrangement } from "../stoa/decor.js"; + +const room = parseMap(JSON.parse(await readFile(new URL("../worlds/room/map.json", import.meta.url), "utf8"))); +const area = { x: 0, y: 0, width: room.width, height: room.height }; +// The first three open tiles in a row on the room map. +const open = (() => { for (let y = 0; y < room.height; y += 1) for (let x = 0; x < room.width - 2; x += 1) if ([0, 1, 2].every((dx) => walkable(room, x + dx, y))) return { x, y }; throw new Error("no open row"); })(); +let next = 0; +const make = (items = []) => createArrangement({ map: room, area, items, makeId: () => "d" + (next += 1) }); + +test("placing, selecting, moving, rotating, and removing decorations", () => { + const decor = make(); + assert.equal(decor.pick(open, "plant"), "placed"); + assert.deepEqual(decor.selected(), { id: "d1", kind: "plant", x: open.x, y: open.y, rotation: 0, variant: 0 }); + assert.equal(decor.move("right"), "moved"); + assert.deepEqual([decor.selected().x, decor.selected().y], [open.x + 1, open.y]); + assert.equal(decor.rotate(), "rotated"); + assert.equal(decor.selected().rotation, 90); + decor.deselect(); + assert.equal(decor.pick(open, "sofa"), "placed"); + assert.equal(decor.pick({ x: open.x + 1, y: open.y }), "selected", "a click on a placed item selects it"); + assert.equal(decor.selected().kind, "plant"); + assert.equal(decor.move("left"), "blocked", "the sofa is in the way"); + assert.equal(decor.pick({ x: open.x + 2, y: open.y }), "moved", "a click elsewhere moves the selected item"); + assert.equal(decor.remove(), "removed"); + assert.deepEqual(decor.items().map((item) => item.kind), ["sofa"]); + assert.equal(decor.remove(), "none"); +}); +test("decorations stay on open floor inside the area, one per tile, at most 60", () => { + const decor = createArrangement({ map: room, area: { x: open.x, y: open.y, width: 1, height: 1 }, items: [] , makeId: () => "x" + (next += 1) }); + assert.equal(decor.place("plant", { x: open.x + 1, y: open.y }), "blocked", "outside the area"); + assert.equal(decor.place("dragon", open), "unknown"); + assert.equal(decor.place("plant", open), "placed"); + assert.equal(decor.place("lamp", open), "blocked", "one per tile"); + const full = make(Array.from({ length: 60 }, (_, index) => ({ id: "f" + index, kind: "plant", x: 0, y: index, rotation: 0, variant: 0 }))); + assert.equal(full.place("plant", open), "full"); + assert.equal(full.items().length, 60); +}); +test("the working copy never changes the items it was given", () => { + const items = [{ id: "a", kind: "lamp", x: open.x, y: open.y, rotation: 0, variant: 0 }]; + const decor = make(items); + decor.select("a"); decor.rotate(); + assert.equal(items[0].rotation, 0); +}); diff --git a/tests/stoa-panel.test.mjs b/tests/stoa-panel.test.mjs index bd207b0..ab71304 100644 --- a/tests/stoa-panel.test.mjs +++ b/tests/stoa-panel.test.mjs @@ -50,3 +50,15 @@ test("a reason replaces the sign-in prompt for people who are signed in", () => panel.canTalk(true); assert.equal(signin.hidden, true); }); + +test("the Decorate button shows only for the host and calls the decorate handler", () => { + const doc = fakeDocument(), panel = createPanel(doc); + let opened = 0; + panel.on({ decorate: () => { opened += 1; } }); + panel.showRoom({ kicker: "LOT ROOM", title: "AI agents", topic: null, tags: [], host: false, leaveLabel: "Back" }); + assert.equal(doc.querySelector("#stoa-decor-open").hidden, true); + panel.showRoom({ kicker: "LOT ROOM", title: "AI agents", topic: null, tags: [], host: true, leaveLabel: "Back" }); + assert.equal(doc.querySelector("#stoa-decor-open").hidden, false); + doc.querySelector("#stoa-decor-open").listeners.click(); + assert.equal(opened, 1); +}); diff --git a/tests/stoa-stage.test.mjs b/tests/stoa-stage.test.mjs index 2306d1f..ac0f3ef 100644 --- a/tests/stoa-stage.test.mjs +++ b/tests/stoa-stage.test.mjs @@ -11,7 +11,7 @@ const deferred = () => { const d = {}; d.promise = new Promise((resolve, reject) const space = (id, title = id) => ({ id, title, topic: null, tags: [], visibility: "public" }); function setup({ api, signedIn = true, withLive = true, clock = { time: 0, timers: [] }, ...options } = {}) { - const calls = { teleports: [], bounds: [], interactive: null, published: [], states: [], canTalk: [], reasons: [], showRoom: [], offer: 0, joined: [], left: [], others: 0, people: 0, handlers: {}, quiet: {} }; + const calls = { decor: [], teleports: [], bounds: [], interactive: null, published: [], states: [], canTalk: [], reasons: [], showRoom: [], offer: 0, joined: [], left: [], others: 0, people: 0, handlers: {}, quiet: {} }; const frames = []; const live = !withLive ? null : { userId: "a-me", guest: false, renew: async () => {}, @@ -20,7 +20,7 @@ function setup({ api, signedIn = true, withLive = true, clock = { time: 0, timer }; const handlers = {}, pos = { x: lot.door.x, y: lot.door.y }; const engine = { - teleport: (tile) => { calls.teleports.push(tile); }, setBounds: (bounds) => { calls.bounds.push(bounds); }, setOthers() { calls.others += 1; }, position: () => pos, setInteractive: (value) => { calls.interactive = value; }, facing: () => "down", say() {}, walkTo: () => true, + teleport: (tile) => { calls.teleports.push(tile); }, setBounds: (bounds) => { calls.bounds.push(bounds); }, setOthers() { calls.others += 1; }, position: () => pos, setDecor: (list) => { calls.decor.push(list); }, setInteractive: (value) => { calls.interactive = value; }, facing: () => "down", say() {}, walkTo: () => true, on(name, handler) { handlers[name] = handler; return () => {}; } }; const panel = { @@ -28,7 +28,7 @@ function setup({ api, signedIn = true, withLive = true, clock = { time: 0, timer canTalk: (value, reason) => { calls.canTalk.push(value); calls.reasons.push(reason); }, showRoom: (room) => calls.showRoom.push(room), showOffer: () => { calls.offer += 1; } }; const leases = []; - const createLease = (options) => { const entry = { options, active: true, stop() { entry.active = false; }, async leave() { entry.active = false; }, leaveOnUnload() { entry.active = false; } }; leases.push(entry); return entry; }; + const createLease = (options) => { const entry = { options, active: true, stop() { entry.active = false; }, async leave() { entry.active = false; }, leaveOnUnload() { entry.active = false; }, beatNow() { entry.beats = (entry.beats || 0) + 1; return Promise.resolve(); } }; leases.push(entry); return entry; }; const stage = createStage({ api, map: plaza, engine, panel, live, self: { id: "a-me", name: "Me", signedIn }, plaza: { bounds: null, channel: "plaza-1" }, createLease, schedule: (callback) => frames.push(callback), now: () => clock.time, later: (callback, delay) => { clock.timers.push({ callback, delay }); return clock.timers.length; }, ...options }); const flushFrames = () => { for (const callback of frames.splice(0)) callback(); }; return { stage, calls, panel, leases, handlers, live, flushFrames, clock }; @@ -351,3 +351,43 @@ test("the merged people list is kept and reported each time it is rebuilt", asyn assert.deepEqual(lists.at(-1), ["Ada"]); assert.deepEqual(stage.people().map((person) => person.name), ["Ada"]); }); + +test("a room's saved decorations are shown on entry and cleared on the plaza", async () => { + const items = [{ id: "d1", kind: "plant", x: lot.entry.x + 1, y: lot.entry.y, rotation: 0, variant: 0 }]; + const api = async (path) => path === "/api/spaces/plaza/enter" ? access("plaza", "plaza-1") : path.endsWith("/enter") ? { ...access("lot-" + lot.slug, "lot-ch"), space: { ...space("lot-" + lot.slug), decor: items } } : {}; + const { stage, calls } = setup({ api }); + await stage.toPlaza(null); + await stage.enterLot(lot); + assert.deepEqual(calls.decor.at(-1), items); + assert.deepEqual(stage.decor(), items); + await stage.leaveRoom(); + assert.deepEqual(calls.decor.at(-1), []); +}); +test("saving decorations stores them, shows them, and tells the room the new version", async () => { + const saved = [{ id: "d2", kind: "lamp", x: lot.entry.x, y: lot.entry.y - 1, rotation: 0, variant: 0 }], puts = []; + const api = async (path, options) => { + if (path === "/api/spaces/plaza/enter") return access("plaza", "plaza-1"); + if (path.endsWith("/enter")) return access("lot-" + lot.slug, "lot-ch"); + if (path.endsWith("/decor")) { puts.push(JSON.parse(options.body)); return { decor: saved, version: 4 }; } + return {}; + }; + const { stage, calls } = setup({ api }); + await stage.toPlaza(null); + await stage.enterLot(lot); + assert.equal(stage.canManage(), true, "the first one in hosts the lot"); + assert.deepEqual(stage.decorArea(), lot.interior); + assert.equal(await stage.saveDecor(saved), true); + assert.deepEqual(puts, [{ items: saved }]); + assert.deepEqual(calls.decor.at(-1), saved); + assert.ok(calls.published.includes(JSON.stringify({ t: "decor", version: 4 }))); +}); +test("a decor notice from the host refetches the room; from anyone else it is ignored", async () => { + const { gets, calls } = await inLot(); + const handlers = calls.handlers["lot-ch"]; + handlers.message(guestUser, JSON.stringify({ t: "decor", version: 2 })); + await tick(); + assert.equal(gets.length, 0); + handlers.message(hostUser, JSON.stringify({ t: "decor", version: 2 })); + await tick(); + assert.equal(gets.length, 1); +}); From bc2391614b88c4bd6a77243c1fcb8aaa892e600e Mon Sep 17 00:00:00 2001 From: Brent G <deliberatekids@gmail.com> Date: Thu, 8 Oct 2026 19:27:23 +0000 Subject: [PATCH 60/71] Tighten decor mode rules and lifecycle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🤖 Built with SMT <smt@agora.build> --- stoa.html | 2 +- stoa.js | 7 +++--- stoa/decor.js | 41 ++++++++++++++++++++++-------- stoa/panel.js | 10 +++++--- stoa/stage.js | 26 +++++++++++++------ styles.css | 4 +-- tests/stoa-decor.test.mjs | 53 +++++++++++++++++++++++++++++++++++++++ tests/stoa-stage.test.mjs | 27 ++++++++++++++++++++ 8 files changed, 141 insertions(+), 29 deletions(-) diff --git a/stoa.html b/stoa.html index 2ddccfb..098837b 100644 --- a/stoa.html +++ b/stoa.html @@ -40,7 +40,7 @@ <h3 class="stoa-panel-title" id="stoa-decor-title">Decorate</h3> <div class="stoa-palette" id="stoa-palette" role="group" aria-label="Decorations"></div> <button class="button button-secondary button-small" type="button" id="stoa-decor-here">Place in front of me</button> <ul class="stoa-decor-list" id="stoa-decor-list" aria-label="Placed decorations"></ul> - <div class="stoa-decor-actions"><button class="button button-small" type="button" id="stoa-decor-save">Save decorations</button><button class="button button-secondary button-small" type="button" id="stoa-decor-cancel">Cancel</button></div> + <div class="stoa-decor-actions"><button class="button button-secondary button-small" type="button" id="stoa-decor-turn" disabled>Turn</button><button class="button button-secondary button-small" type="button" id="stoa-decor-remove" disabled>Remove</button><button class="button button-small" type="button" id="stoa-decor-save">Save decorations</button><button class="button button-secondary button-small" type="button" id="stoa-decor-cancel">Cancel</button></div> </section> <section class="stoa-call" id="stoa-call" hidden aria-labelledby="stoa-call-title"> <h3 class="stoa-panel-title" id="stoa-call-title">Call</h3> diff --git a/stoa.js b/stoa.js index fd2ba39..1cb75b7 100644 --- a/stoa.js +++ b/stoa.js @@ -97,7 +97,8 @@ panel.on({ decorate: () => { if (!stage?.canManage()) { panel.say("Only the host can decorate."); return; } stage.setEditing(true); - decor?.open({ items: stage.decor(), area: stage.decorArea() }); + decor?.open({ items: stage.decor(), ...stage.decorRegion() }); + panel.decorating(decor?.isOpen()); panel.say("Decor mode is on. Choose a decoration, then click the map or use Place in front of me."); canvas.focus(); }, @@ -138,8 +139,8 @@ if (engine) { engine.setSelf({ id: self.id || undefined, name: self.name }); call = createCallView({ engine, api, self, people: () => stage?.people() || [], say: (text) => panel.say(text) }); call.setLayout(active().video); - decor = createDecorEditor({ canvas, engine, map, say: (text) => panel.say(text), onSave: (items) => stage.saveDecor(items), onClose: () => stage.setEditing(false) }); - stage = createStage({ api, map, engine, panel, live: null, self, plaza: spaceId ? null : { bounds: plazaBounds, channel: null }, onRoom: (room) => { decor?.abort(); if (room) call.enter(room.id); else call.exit(); }, onPeople: () => call.refresh() }); + decor = createDecorEditor({ canvas, engine, map, say: (text) => panel.say(text), onSave: (items) => stage.saveDecor(items), onClose: () => { stage.setEditing(false); panel.decorating(false); } }); + stage = createStage({ api, map, engine, panel, live: null, self, plaza: spaceId ? null : { bounds: plazaBounds, channel: null }, onRoom: (room) => { decor?.abort(); if (room) call.enter(room.id); else call.exit(); }, onPeople: () => call.refresh(), onManage: (can) => { if (!can) decor?.abort(); } }); engine.on("arrive", (tile) => stage.onArrive(tile)); // Leaving the page releases the entry lease and, as far as the browser allows, the Signaling login. addEventListener("pagehide", () => { call.exit(); stage.unload(); live?.close().catch(() => {}); }); diff --git a/stoa/decor.js b/stoa/decor.js index 30f67f9..7e56a47 100644 --- a/stoa/decor.js +++ b/stoa/decor.js @@ -1,15 +1,27 @@ -import { walkable } from "../world/map.js"; -import { DECOR_KINDS } from "../world/kinds.js"; +import { reachable, roleAt, walkable } from "../world/map.js"; +import { BLOCKING_DECOR, DECOR_KINDS } from "../world/kinds.js"; import { KEYS, STEP } from "../world/motion.js"; export const DECOR_LABELS = { plant: "Plant", lamp: "Lamp", rug: "Rug", sofa: "Sofa", chair: "Chair", table: "Table", whiteboard: "Whiteboard", bookshelf: "Bookshelf", screen: "Screen", banner: "Banner", poster: "Poster", statue: "Statue", fountain: "Fountain" }; const LIMIT = 60; // The host's working copy of a room's decorations. The server checks the whole arrangement on save; this keeps edits sensible. -export function createArrangement({ map, area, items = [], makeId = () => "d" + Math.random().toString(36).slice(2, 10) }) { +export function createArrangement({ map, area, items = [], start = null, protect = [], makeId = () => "d" + Math.random().toString(36).slice(2, 10) }) { let list = items.map((item) => ({ ...item })), selected = null; const inside = (tile) => tile.x >= area.x && tile.y >= area.y && tile.x < area.x + area.width && tile.y < area.y + area.height; - const free = (tile, except = null) => inside(tile) && walkable(map, tile.x, tile.y) && !list.some((item) => item !== except && item.x === tile.x && item.y === tile.y); + const keep = new Set(protect.map(({ x, y }) => x + "," + y)); + const seats = []; + for (let y = area.y; y < area.y + area.height; y += 1) for (let x = area.x; x < area.x + area.width; x += 1) if (roleAt(map, x, y) === "seat") seats.push({ x, y }); + // Mirrors the server's checks: open floor in the area, one per tile, doors and entrances clear, and blocking kinds never cut off a door, entrance, or seat. + const verdict = (tile, kind, except = null) => { + if (!inside(tile) || !walkable(map, tile.x, tile.y) || list.some((item) => item !== except && item.x === tile.x && item.y === tile.y)) return "blocked"; + if (keep.has(tile.x + "," + tile.y)) return "protected"; + if (!start || !BLOCKING_DECOR.has(kind)) return null; + const walls = new Set(list.filter((item) => item !== except && BLOCKING_DECOR.has(item.kind)).map((item) => item.x + "," + item.y)); + walls.add(tile.x + "," + tile.y); + const open = reachable(map, start, walls); + return [...protect, ...seats].some(({ x, y }) => !open.has(x + "," + y)) ? "unreachable" : null; + }; const current = () => list.find((item) => item.id === selected) || null; const arrangement = { items: () => list.map((item) => ({ ...item })), @@ -21,13 +33,14 @@ export function createArrangement({ map, area, items = [], makeId = () => "d" + const hit = list.find((item) => item.x === tile.x && item.y === tile.y); if (hit) { selected = hit.id; return "selected"; } const item = current(); - if (item) { if (!free(tile, item)) return "blocked"; item.x = tile.x; item.y = tile.y; return "moved"; } + if (item) { const bad = verdict(tile, item.kind, item); if (bad) return bad; item.x = tile.x; item.y = tile.y; return "moved"; } return kind ? arrangement.place(kind, tile) : "none"; }, place(kind, tile) { if (!DECOR_KINDS.includes(kind)) return "unknown"; if (list.length >= LIMIT) return "full"; - if (!free(tile)) return "blocked"; + const bad = verdict(tile, kind); + if (bad) return bad; const item = { id: makeId(), kind, x: tile.x, y: tile.y, rotation: 0, variant: 0 }; list.push(item); selected = item.id; return "placed"; @@ -36,7 +49,8 @@ export function createArrangement({ map, area, items = [], makeId = () => "d" + const item = current(); if (!item) return "none"; const [dx, dy] = STEP[dir], next = { x: item.x + dx, y: item.y + dy }; - if (!free(next, item)) return "blocked"; + const bad = verdict(next, item.kind, item); + if (bad) return bad; item.x = next.x; item.y = next.y; return "moved"; }, @@ -46,11 +60,11 @@ export function createArrangement({ map, area, items = [], makeId = () => "d" + return arrangement; } -const OUTCOMES = { blocked: "That tile is taken, blocked, or outside the room.", full: "A room holds up to 60 decorations.", none: "Choose a decoration first, or select one on the map." }; +const OUTCOMES = { protected: "Keep doors and entrances clear.", unreachable: "That would block a door, entrance, or seat.", blocked: "That tile is taken, blocked, or outside the room.", full: "A room holds up to 60 decorations.", none: "Choose a decoration first, or select one on the map." }; // Decor mode: the map picks tiles instead of walking, and the panel lists every item for keyboard and screen reader users. export function createDecorEditor({ doc = document, canvas, engine, map, onSave = async () => false, onClose = () => {}, say = () => {} }) { const $ = (selector) => doc.querySelector(selector); - const section = $("#stoa-decor"), palette = $("#stoa-palette"), list = $("#stoa-decor-list"), save = $("#stoa-decor-save"); + const section = $("#stoa-decor"), palette = $("#stoa-palette"), list = $("#stoa-decor-list"), save = $("#stoa-decor-save"), turn = $("#stoa-decor-turn"), erase = $("#stoa-decor-remove"); let arrangement = null, original = [], kind = null; const describe = (item) => DECOR_LABELS[item.kind] + " at " + item.x + ", " + item.y + (item.rotation ? ", turned " + item.rotation + "°" : ""); const kindButtons = DECOR_KINDS.map((entry) => { @@ -66,6 +80,7 @@ export function createDecorEditor({ doc = document, canvas, engine, map, onSave engine.setDecor(arrangement.items()); for (const [entry, button] of kindButtons) button.setAttribute("aria-pressed", String(kind === entry)); const chosen = arrangement.selected(); + turn.disabled = erase.disabled = !chosen; list.replaceChildren(...arrangement.items().map((item) => { const row = doc.createElement("li"), button = doc.createElement("button"); button.type = "button"; button.textContent = describe(item); @@ -80,6 +95,7 @@ export function createDecorEditor({ doc = document, canvas, engine, map, onSave const onKey = (event) => { if (!arrangement || event.ctrlKey || event.metaKey || event.altKey) return; const dir = KEYS[event.key]; + if (!arrangement.selected() && (dir || /^[rR]$/.test(event.key) || event.key === "Delete" || event.key === "Backspace")) { event.preventDefault(); return; } if (dir) { event.preventDefault(); report(arrangement.move(dir)); } else if (event.key === "r" || event.key === "R") { event.preventDefault(); report(arrangement.rotate()); } else if (event.key === "Delete" || event.key === "Backspace") { event.preventDefault(); report(arrangement.remove()); } @@ -99,7 +115,10 @@ export function createDecorEditor({ doc = document, canvas, engine, map, onSave if (!arrangement) return; const at = engine.position(), [dx, dy] = STEP[engine.facing()] || [0, 1]; report(kind ? arrangement.place(kind, { x: at.x + dx, y: at.y + dy }) : "none"); + canvas.focus(); }); + turn.addEventListener("click", () => { if (arrangement) { report(arrangement.rotate()); canvas.focus(); } }); + erase.addEventListener("click", () => { if (arrangement) { report(arrangement.remove()); canvas.focus(); } }); save.addEventListener("click", async () => { if (!arrangement) return; save.disabled = true; @@ -107,10 +126,10 @@ export function createDecorEditor({ doc = document, canvas, engine, map, onSave }); $("#stoa-decor-cancel").addEventListener("click", () => finish(true)); return { - open({ items = [], area }) { + open({ items = [], area, start = null, protect = [] }) { if (arrangement) return; original = items.map((item) => ({ ...item })); - arrangement = createArrangement({ map, area: area || { x: 0, y: 0, width: map.width, height: map.height }, items: original }); + arrangement = createArrangement({ map, area: area || { x: 0, y: 0, width: map.width, height: map.height }, items: original, start, protect }); engine.setInteractive(false); engine.setPicking((tile) => report(arrangement.pick(tile, kind))); canvas.addEventListener("keydown", onKey); diff --git a/stoa/panel.js b/stoa/panel.js index 4bb7be4..0e04604 100644 --- a/stoa/panel.js +++ b/stoa/panel.js @@ -8,6 +8,8 @@ export function createPanel(doc = document) { const status = $("#stoa-status"), here = $("#stoa-here"), topicForm = $("#stoa-topic-form"), offer = $("#stoa-offer"), roomsSection = $("#stoa-rooms-section"), rooms = $("#stoa-rooms"), people = $("#stoa-people"), messages = $("#stoa-messages"), sayForm = $("#stoa-say"), sayInput = $("#stoa-say-text"), signin = $("#stoa-signin"), signinText = $("#stoa-signin-text"), signinButton = $("#stoa-signin-button"); let shownPeople = null; const roomRows = new Map(); + let canDecorate = false, decorating = false; + const syncDecor = () => { $("#stoa-decor-open").hidden = !canDecorate || decorating; }; const handlers = { go() {}, say() { return false; }, topic() {}, leave() {}, start() {}, signin() {}, decorate() {} }; sayForm.addEventListener("submit", (event) => { event.preventDefault(); if (handlers.say(sayInput.value)) sayInput.value = ""; }); topicForm.addEventListener("submit", (event) => { event.preventDefault(); handlers.topic(topicForm.elements.topic.value, topicForm.elements.tags.value); }); @@ -16,6 +18,8 @@ export function createPanel(doc = document) { $("#stoa-decor-open").addEventListener("click", () => handlers.decorate()); signinButton.addEventListener("click", () => handlers.signin()); return { + // The Decorate button is for hosts, and hidden while the editor is open. + decorating(on) { decorating = Boolean(on); syncDecor(); }, on(next) { Object.assign(handlers, next); }, say(message) { status.textContent = message; }, // Rows are created once per room and updated in place, so a focused Go to button keeps focus across polls. @@ -56,17 +60,17 @@ export function createPanel(doc = document) { clearMessages() { messages.replaceChildren(); }, // When talking isn't possible, a reason (for someone already signed in) replaces the sign-in prompt. canTalk(allowed, reason = "") { sayForm.hidden = !allowed; signin.hidden = allowed; signinText.textContent = reason || "Sign in to walk and talk."; signinButton.hidden = Boolean(reason); }, - showRoom({ kicker, title, topic, tags = [], host, leaveLabel }) { + showRoom({ kicker, title, topic, tags = [], host, decorate = host, leaveLabel }) { here.hidden = false; roomsSection.hidden = true; $("#stoa-here-kicker").textContent = kicker; $("#stoa-here-title").textContent = title; $("#stoa-here-topic").textContent = topic ? topic + (tags.length ? " · " + tags.join(", ") : "") : host ? "You're the host. Set a topic so people know what this room is about." : "No topic yet."; topicForm.hidden = !host; - $("#stoa-decor-open").hidden = !host; + canDecorate = Boolean(decorate); syncDecor(); $("#stoa-leave").textContent = leaveLabel; sayInput.placeholder = "Say something to the room"; }, - showPlaza() { here.hidden = true; $("#stoa-decor-open").hidden = true; topicForm.hidden = true; roomsSection.hidden = false; sayInput.placeholder = "Say something on the plaza"; }, + showPlaza() { here.hidden = true; canDecorate = false; syncDecor(); topicForm.hidden = true; roomsSection.hidden = false; sayInput.placeholder = "Say something on the plaza"; }, showOffer({ spaces = [], canCreate = false } = {}) { offer.hidden = false; $("#stoa-offer-list").replaceChildren(...spaces.map((space) => { const item = node("li"), link = node("a", space.title); link.href = space.path; item.append(link); return item; })); diff --git a/stoa/stage.js b/stoa/stage.js index a8ccee3..14f8889 100644 --- a/stoa/stage.js +++ b/stoa/stage.js @@ -1,4 +1,4 @@ -import { walkable } from "../world/map.js"; +import { roleAt, walkable } from "../world/map.js"; import { openPlace } from "./place.js"; import { createLease as realLease } from "./lease.js"; @@ -9,8 +9,8 @@ export const outsideDoor = (map, lot) => [[0, 1], [0, -1], [1, 0], [-1, 0]].map( // Moves the person between the plaza, lot rooms, and user spaces: entry leases on the server, channels on Signaling. const REFRESH_GAP = 5000; // `live` may be null at first and attached later with attachLive, so the page never waits on the SDK. -export function createStage({ api, map, engine, panel, live: firstLive, self, plaza, onRoom = () => {}, onPeople = () => {}, createLease = realLease, schedule = (callback) => typeof requestAnimationFrame === "function" ? requestAnimationFrame(callback) : setTimeout(callback, 16), now = () => Date.now(), later = (callback, delay) => setTimeout(callback, delay) }) { - let live = firstLive, places = [], lease = null, here = null, chain = Promise.resolve(), drawing = false, everyone = [], roomId = null, currentDecor = [], editing = false; +export function createStage({ api, map, engine, panel, live: firstLive, self, plaza, onRoom = () => {}, onPeople = () => {}, onManage = () => {}, createLease = realLease, schedule = (callback) => typeof requestAnimationFrame === "function" ? requestAnimationFrame(callback) : setTimeout(callback, 16), now = () => Date.now(), later = (callback, delay) => setTimeout(callback, delay) }) { + let live = firstLive, places = [], lease = null, here = null, chain = Promise.resolve(), drawing = false, everyone = [], roomId = null, currentDecor = [], editing = false, manageNow = false; // State changes run strictly one at a time, each re-checking its preconditions when it starts. const transition = (task) => (chain = chain.then(task, task).catch((error) => panel.say(error.message || "Something went wrong."))); const doors = new Map(map.lots.map((lot) => [lot.door.x + "," + lot.door.y, lot])); @@ -33,8 +33,10 @@ export function createStage({ api, map, engine, panel, live: firstLive, self, pl }; const mine = (id) => Boolean(id) && (self.ids || [self.id]).includes(id); const canManage = () => Boolean(here && (here.kind === "lot" || here.kind === "space") && here.leased && self.signedIn && (mine(here.access?.hostId) || mine(here.space?.ownerId))); + const checkManage = () => { const can = canManage(); if (can !== manageNow) { manageNow = can; onManage(can); } }; + const doorTiles = (rect) => { const tiles = []; for (let y = rect.y; y < rect.y + rect.height; y += 1) for (let x = rect.x; x < rect.x + rect.width; x += 1) if (roleAt(map, x, y) === "door") tiles.push({ x, y }); return tiles; }; const showDecor = (items) => { currentDecor = items || []; if (!editing) engine.setDecor(currentDecor); }; - const showRoom = (space, hostId) => panel.showRoom({ kicker: here?.kind === "lot" ? "LOT ROOM" : space.visibility === "private" ? "PRIVATE SPACE" : "UNLISTED SPACE", title: space.title, topic: space.topic, tags: space.tags || [], host: Boolean(self.id) && (mine(hostId) || mine(space.ownerId)), leaveLabel: here?.kind === "lot" ? "Back to the plaza" : "Leave this space" }); + const showRoom = (space, hostId) => { panel.showRoom({ kicker: here?.kind === "lot" ? "LOT ROOM" : space.visibility === "private" ? "PRIVATE SPACE" : "UNLISTED SPACE", title: space.title, topic: space.topic, tags: space.tags || [], host: Boolean(self.id) && (mine(hostId) || mine(space.ownerId)), leaveLabel: here?.kind === "lot" ? "Back to the plaza" : "Leave this space", decorate: canManage() }); checkManage(); }; // Every transition claims `here` before it awaits anything; a superseded one stops as soon as it notices. async function settle(next) { @@ -65,6 +67,7 @@ export function createStage({ api, map, engine, panel, live: firstLive, self, pl lease = made; } const talk = Boolean(next.leased) && self.signedIn; + checkManage(); panel.canTalk(talk, talk || !self.signedIn ? undefined : next.full ? "The plaza is full \u2014 you're watching." : next.kind === "left" ? "You're no longer in this space." : "You're watching."); } @@ -79,7 +82,7 @@ export function createStage({ api, map, engine, panel, live: firstLive, self, pl return; } for (const place of places) place.setBlocked(access.blocked || []); - if (here.kind !== "plaza" && access.hostId !== here.access.hostId) { here.access = { ...here.access, ...access }; refreshRoom(); } + if (here.kind !== "plaza" && access.hostId !== here.access.hostId) { here.access = { ...here.access, ...access }; checkManage(); refreshRoom(); } } // A lease the server dropped (410) gets one re-entry into the same place; removed or hidden (403, 404) does not. @@ -122,7 +125,7 @@ export function createStage({ api, map, engine, panel, live: firstLive, self, pl if (!at || at.kind === "plaza" || !at.space) return; try { const { space } = await api("/api/spaces/" + at.space.id + (at.invite ? "?invite=" + encodeURIComponent(at.invite) : "")); - if (here === at) { showRoom(space, space.hostId); showDecor(space.decor); } + if (here === at) { here.space = { ...here.space, ...space }; showRoom(space, space.hostId); showDecor(space.decor); } } catch { /* the next heartbeat reports a lost lease */ } } @@ -210,6 +213,12 @@ export function createStage({ api, map, engine, panel, live: firstLive, self, pl people: () => everyone, canManage, decor: () => currentDecor, + decorRegion() { + if (here?.kind === "lot") return { area: here.lot.interior, start: here.lot.entry, protect: [here.lot.entry, ...doorTiles(here.lot.interior)] }; + if (here?.kind !== "space") return null; + const area = { x: 0, y: 0, width: map.width, height: map.height }; + return { area, start: map.spawns[0], protect: [...map.spawns, ...doorTiles(area)] }; + }, decorArea: () => here?.kind === "lot" ? here.lot.interior : here?.kind === "space" ? { x: 0, y: 0, width: map.width, height: map.height } : null, setEditing(value) { editing = Boolean(value); if (!editing) engine.setDecor(currentDecor); }, async saveDecor(items) { @@ -217,13 +226,14 @@ export function createStage({ api, map, engine, panel, live: firstLive, self, pl if (!canManage()) { panel.say("Only the host can decorate."); return false; } try { const result = await api("/api/spaces/" + encodeURIComponent(at.space.id) + "/decor", { method: "PUT", body: JSON.stringify({ items }) }); - if (here !== at) return false; + if (!(here?.space?.id === at.space.id && here.leased)) return false; editing = false; + here.space = { ...here.space, decor: result.decor }; showDecor(result.decor); for (const place of places) place.decorChanged(result.version); panel.say("Decorations saved."); return true; - } catch (error) { if (here === at) panel.say(error.message); return false; } + } catch (error) { if (here?.space?.id === at.space.id) panel.say(error.message); return false; } }, say(text) { if (here?.kind === "left") { panel.say("You're no longer in this space."); return false; } diff --git a/styles.css b/styles.css index 2a05617..591ca62 100644 --- a/styles.css +++ b/styles.css @@ -595,15 +595,13 @@ dialog h2 { font-size: 30px; line-height: 1.25; font-weight: 500; letter-spacing .stoa-call-preflight { display: flex; flex-wrap: wrap; gap: 6px 14px; margin-bottom: 8px; font-size: 12px; } .stoa-call-preflight label { display: flex; align-items: center; gap: 6px; } .stoa-call-controls { display: flex; flex-wrap: wrap; gap: 6px; } -.stoa-call-controls [aria-pressed="true"] { background: #e6ebdc; border-color: #98a98a; } .stoa-call-leave { color: var(--accent); } @media (prefers-reduced-motion: no-preference) { .stoa-tile.is-floating { transition: opacity 0.2s; } } .stoa-decor { margin: 10px 0 14px; padding: 12px; border: 1px solid var(--line); border-radius: 4px; } .stoa-decor-selected { margin: 0 0 8px; font-size: 12px; color: var(--muted); } .stoa-palette { display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 8px; } -.stoa-palette [aria-pressed="true"] { background: #e6ebdc; border-color: #98a98a; } .stoa-decor-list { list-style: none; margin: 8px 0; padding: 0; max-height: 160px; overflow-y: auto; font-size: 12px; } .stoa-decor-list button { background: none; border: 0; padding: 3px 0; font: inherit; color: inherit; cursor: pointer; text-align: left; } .stoa-decor-list [aria-current="true"] { color: var(--accent); font-weight: 600; } -.stoa-decor-actions { display: flex; gap: 6px; } +.stoa-decor-actions { display: flex; flex-wrap: wrap; gap: 6px; } diff --git a/tests/stoa-decor.test.mjs b/tests/stoa-decor.test.mjs index dca32ac..617a637 100644 --- a/tests/stoa-decor.test.mjs +++ b/tests/stoa-decor.test.mjs @@ -45,3 +45,56 @@ test("the working copy never changes the items it was given", () => { decor.select("a"); decor.rotate(); assert.equal(items[0].rotation, 0); }); +test("protected tiles stay clear and blocking kinds cannot cut off an entrance or a seat", () => { + const decor = createArrangement({ map: room, area, items: [], start: { x: 10, y: 10 }, protect: [{ x: 1, y: 2 }], makeId: () => "p" + (next += 1) }); + assert.equal(decor.place("plant", { x: 1, y: 2 }), "protected"); + assert.equal(decor.place("sofa", { x: 2, y: 2 }), "placed", "the entrance still has another way in"); + assert.equal(decor.place("sofa", { x: 1, y: 3 }), "unreachable", "that would seal the entrance"); + assert.equal(decor.place("plant", { x: 1, y: 3 }), "placed", "a plant does not block"); + assert.equal(decor.move("down"), "moved", "non-blocking kinds move freely"); + const seated = createArrangement({ map: room, area, items: [], start: { x: 10, y: 10 }, makeId: () => "q" + (next += 1) }); + const around = [{ x: 7, y: 4 }, { x: 8, y: 3 }, { x: 9, y: 3 }, { x: 10, y: 3 }, { x: 11, y: 3 }, { x: 12, y: 4 }]; + const results = around.map((tile) => seated.place("statue", tile)); + assert.equal(results.at(-1), "unreachable", "the last statue would wall in the row of seats"); + assert.deepEqual(results.slice(0, -1), Array(5).fill("placed")); +}); + +// A tiny fake document, enough for the editor's buttons, lists, and canvas. +class Element { + constructor(tag) { Object.assign(this, { tagName: tag, children: [], listeners: {}, attrs: {}, hidden: false, disabled: false, textContent: "", className: "" }); } + append(...items) { this.children.push(...items); } + replaceChildren(...items) { this.children = items; } + addEventListener(type, listener) { this.listeners[type] = listener; } + removeEventListener(type) { delete this.listeners[type]; } + setAttribute(name, value) { this.attrs[name] = value; } + focus() { this.focused = (this.focused || 0) + 1; } +} +test("the editor takes over the map while open and gives it back on abort or close", async () => { + const { createDecorEditor } = await import("../stoa/decor.js"); + const registry = new Map(), doc = { querySelector: (selector) => { if (!registry.has(selector)) registry.set(selector, new Element(selector)); return registry.get(selector); }, createElement: (tag) => new Element(tag) }; + const canvas = new Element("canvas"), log = { decor: [], picking: [], interactive: [], closed: 0 }; + const engine = { setDecor: (list) => log.decor.push(list), setPicking: (handler) => log.picking.push(handler), setInteractive: (value) => log.interactive.push(value), position: () => open, facing: () => "right" }; + const editor = createDecorEditor({ doc, canvas, engine, map: room, say() {}, onClose: () => { log.closed += 1; } }); + const saved = [{ id: "a", kind: "lamp", x: open.x, y: open.y, rotation: 0, variant: 0 }]; + editor.open({ items: saved, area }); + assert.equal(editor.isOpen(), true); + assert.equal(log.interactive.at(-1), false); + assert.equal(typeof log.picking.at(-1), "function"); + assert.equal(registry.get("#stoa-decor").hidden, false); + assert.equal(registry.get("#stoa-decor-turn").disabled, true, "nothing is selected yet"); + log.picking.at(-1)({ x: open.x, y: open.y }); + assert.equal(registry.get("#stoa-decor-turn").disabled, false); + canvas.listeners.keydown({ key: "Delete", preventDefault() {} }); + assert.deepEqual(log.decor.at(-1), [], "the lamp was removed from the working copy"); + editor.close(); + assert.deepEqual(log.decor.at(-1), saved, "closing restores the saved arrangement"); + assert.equal(log.closed, 1); + assert.equal(editor.isOpen(), false); + editor.open({ items: saved, area }); + const before = log.decor.length; + editor.abort(); + assert.equal(log.picking.at(-1), null); + assert.equal(log.interactive.at(-1), true); + assert.equal(log.closed, 2); + assert.equal(log.decor.length, before, "abort leaves the engine's decorations alone"); +}); diff --git a/tests/stoa-stage.test.mjs b/tests/stoa-stage.test.mjs index ac0f3ef..71b83ea 100644 --- a/tests/stoa-stage.test.mjs +++ b/tests/stoa-stage.test.mjs @@ -391,3 +391,30 @@ test("a decor notice from the host refetches the room; from anyone else it is ig await tick(); assert.equal(gets.length, 1); }); +test("saved decorations survive a same-room channel change and a save that races a settle", async () => { + const saved = [{ id: "d2", kind: "lamp", x: lot.entry.x, y: lot.entry.y - 1, rotation: 0, variant: 0 }]; + const api = async (path) => path === "/api/spaces/plaza/enter" ? access("plaza", "plaza-1") : path.endsWith("/enter") ? access("lot-" + lot.slug, "lot-ch") : path.endsWith("/decor") ? { decor: saved, version: 2 } : {}; + const { stage, calls, leases } = setup({ api }); + await stage.toPlaza(null); + await stage.enterLot(lot); + assert.equal(await stage.saveDecor(saved), true); + leases.at(-1).options.onAccess({ channel: "lot-ch-2", key: null, blocked: [], hostId: "a-me" }); + await tick(); await tick(); + assert.deepEqual(calls.joined.at(-1), "lot-ch-2"); + assert.deepEqual(calls.decor.at(-1), saved); +}); +test("onManage reports when hosting is gained or lost, and the region protects doors and the entry", async () => { + const managed = []; + const api = async (path) => path === "/api/spaces/plaza/enter" ? access("plaza", "plaza-1") : path.endsWith("/enter") ? access("lot-" + lot.slug, "lot-ch") : {}; + const { stage, leases } = setup({ api, onManage: (can) => managed.push(can) }); + await stage.toPlaza(null); + await stage.enterLot(lot); + assert.deepEqual(managed, [true]); + const region = stage.decorRegion(); + assert.deepEqual(region.start, lot.entry); + assert.deepEqual(region.protect[0], lot.entry); + leases.at(-1).options.onAccess({ channel: "lot-ch", hostId: "someone-else", blocked: [] }); + assert.deepEqual(managed, [true, false]); + await stage.leaveRoom(); + assert.equal(stage.decorRegion(), null); +}); From 29bbf1abca86a9f04c721f605cc280f75d9b8de9 Mon Sep 17 00:00:00 2001 From: Brent G <deliberatekids@gmail.com> Date: Thu, 8 Oct 2026 19:28:32 +0000 Subject: [PATCH 61/71] Take the host from room refreshes too MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🤖 Built with SMT <smt@agora.build> --- stoa/stage.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/stoa/stage.js b/stoa/stage.js index 14f8889..f6148e5 100644 --- a/stoa/stage.js +++ b/stoa/stage.js @@ -125,7 +125,7 @@ export function createStage({ api, map, engine, panel, live: firstLive, self, pl if (!at || at.kind === "plaza" || !at.space) return; try { const { space } = await api("/api/spaces/" + at.space.id + (at.invite ? "?invite=" + encodeURIComponent(at.invite) : "")); - if (here === at) { here.space = { ...here.space, ...space }; showRoom(space, space.hostId); showDecor(space.decor); } + if (here === at) { here.space = { ...here.space, ...space }; if (here.access) here.access = { ...here.access, hostId: space.hostId }; showRoom(space, space.hostId); showDecor(space.decor); } } catch { /* the next heartbeat reports a lost lease */ } } From 265ac619e7312fdbe003bcf2da1aa25951f5b6c0 Mon Sep 17 00:00:00 2001 From: Brent G <deliberatekids@gmail.com> Date: Thu, 8 Oct 2026 19:29:42 +0000 Subject: [PATCH 62/71] Let hosts hand over hosting and remove people MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🤖 Built with SMT <smt@agora.build> --- stoa.js | 2 ++ stoa/panel.js | 22 ++++++++++++++------ stoa/stage.js | 34 ++++++++++++++++++++++++++++--- styles.css | 3 +++ tests/stoa-panel.test.mjs | 25 ++++++++++++++++++++++- tests/stoa-stage.test.mjs | 43 +++++++++++++++++++++++++++++++++++++++ 6 files changed, 119 insertions(+), 10 deletions(-) diff --git a/stoa.js b/stoa.js index 1cb75b7..0f97ce5 100644 --- a/stoa.js +++ b/stoa.js @@ -94,6 +94,8 @@ panel.on({ say: (text) => stage ? stage.say(text) : (panel.say(unavailable), false), topic: (topic, tags) => stage?.setTopic(topic, tags), leave: () => stage?.leaveRoom(), + makeHost: (person) => stage?.makeHost(person), + remove: (person) => { if (confirm("Remove " + person.name + " from this room? They can't come back until the room empties or the owner allows them back.")) stage?.removePerson(person); }, decorate: () => { if (!stage?.canManage()) { panel.say("Only the host can decorate."); return; } stage.setEditing(true); diff --git a/stoa/panel.js b/stoa/panel.js index 0e04604..d93345f 100644 --- a/stoa/panel.js +++ b/stoa/panel.js @@ -1,5 +1,5 @@ -// People are rebuilt only when who is here (or a name) changes, so a busy plaza doesn't churn the page. -export const peopleKey = (list, selfName) => selfName + "\n" + list.map((person) => person.id + "\u0000" + person.name).sort().join("\n"); +// People are rebuilt only when who is here, a name, the host, or your host tools change, so a busy plaza doesn't churn the page. +export const peopleKey = (list, selfName, { manage = false, hostId = null } = {}) => [selfName, manage ? "manage" : "", hostId || ""].join("\u0001") + "\n" + list.map((person) => person.id + "\u0000" + person.name).sort().join("\n"); // The Stoa side panel: everything the canvas offers, as plain controls for keyboard and screen reader users. export function createPanel(doc = document) { @@ -10,7 +10,7 @@ export function createPanel(doc = document) { const roomRows = new Map(); let canDecorate = false, decorating = false; const syncDecor = () => { $("#stoa-decor-open").hidden = !canDecorate || decorating; }; - const handlers = { go() {}, say() { return false; }, topic() {}, leave() {}, start() {}, signin() {}, decorate() {} }; + const handlers = { go() {}, say() { return false; }, topic() {}, leave() {}, start() {}, signin() {}, decorate() {}, makeHost() {}, remove() {} }; sayForm.addEventListener("submit", (event) => { event.preventDefault(); if (handlers.say(sayInput.value)) sayInput.value = ""; }); topicForm.addEventListener("submit", (event) => { event.preventDefault(); handlers.topic(topicForm.elements.topic.value, topicForm.elements.tags.value); }); $("#stoa-leave").addEventListener("click", () => handlers.leave()); @@ -45,11 +45,21 @@ export function createPanel(doc = document) { const now = [...rooms.children]; if (now.length !== wanted.length || wanted.some((entry, index) => entry !== now[index])) rooms.replaceChildren(...wanted); }, - setPeople(list, selfName) { - const key = peopleKey(list, selfName); + setPeople(list, selfName, options = {}) { + const { manage = false, hostId = null, selfId = null } = options; + const key = peopleKey(list, selfName, options); if (key === shownPeople) return; shownPeople = key; - people.replaceChildren(node("li", selfName + " (you)"), ...list.map((person) => node("li", person.name))); + const row = (name, isHost) => { const item = node("li", "", "stoa-person"); item.append(node("span", name)); if (isHost) item.append(node("span", "host", "stoa-host-badge")); return item; }; + const tool = (text, label, action) => { const button = node("button", text, "button button-secondary button-small"); button.type = "button"; button.setAttribute("aria-label", label); button.addEventListener("click", action); return button; }; + people.replaceChildren(row(selfName + " (you)", Boolean(selfId) && selfId === hostId), ...list.map((person) => { + const item = row(person.name, person.id === hostId); + if (manage) { + if (person.id !== hostId) item.append(tool("Make host", "Make " + person.name + " the host", () => handlers.makeHost(person))); + item.append(tool("Remove", "Remove " + person.name + " from this room", () => handlers.remove(person))); + } + return item; + })); }, addMessage({ name, text, self }) { const entry = node("li", "", self ? "is-self" : ""); diff --git a/stoa/stage.js b/stoa/stage.js index f6148e5..87aa6bd 100644 --- a/stoa/stage.js +++ b/stoa/stage.js @@ -27,7 +27,7 @@ export function createStage({ api, map, engine, panel, live: firstLive, self, pl const list = [...seen.values()]; everyone = list; engine.setOthers(list); - panel.setPeople(list, self.name); + panel.setPeople(list, self.name, { manage: canManage(), hostId: here?.kind === "lot" || here?.kind === "space" ? here.access?.hostId || null : null, selfId: self.id }); onPeople(list); }); }; @@ -36,7 +36,7 @@ export function createStage({ api, map, engine, panel, live: firstLive, self, pl const checkManage = () => { const can = canManage(); if (can !== manageNow) { manageNow = can; onManage(can); } }; const doorTiles = (rect) => { const tiles = []; for (let y = rect.y; y < rect.y + rect.height; y += 1) for (let x = rect.x; x < rect.x + rect.width; x += 1) if (roleAt(map, x, y) === "door") tiles.push({ x, y }); return tiles; }; const showDecor = (items) => { currentDecor = items || []; if (!editing) engine.setDecor(currentDecor); }; - const showRoom = (space, hostId) => { panel.showRoom({ kicker: here?.kind === "lot" ? "LOT ROOM" : space.visibility === "private" ? "PRIVATE SPACE" : "UNLISTED SPACE", title: space.title, topic: space.topic, tags: space.tags || [], host: Boolean(self.id) && (mine(hostId) || mine(space.ownerId)), leaveLabel: here?.kind === "lot" ? "Back to the plaza" : "Leave this space", decorate: canManage() }); checkManage(); }; + const showRoom = (space, hostId) => { panel.showRoom({ kicker: here?.kind === "lot" ? "LOT ROOM" : space.visibility === "private" ? "PRIVATE SPACE" : "UNLISTED SPACE", title: space.title, topic: space.topic, tags: space.tags || [], host: Boolean(self.id) && (mine(hostId) || mine(space.ownerId)), leaveLabel: here?.kind === "lot" ? "Back to the plaza" : "Leave this space", decorate: canManage() }); checkManage(); showPeople(); }; // Every transition claims `here` before it awaits anything; a superseded one stops as soon as it notices. async function settle(next) { @@ -54,7 +54,7 @@ export function createStage({ api, map, engine, panel, live: firstLive, self, pl showPeople(); if (live && next.access?.channel) { try { - const place = await openPlace({ live, map, engine, self, quiet: Boolean(next.watch), channel: { name: next.access.channel, key: next.access.key || null, spaceId: next.space.id }, onPeople: showPeople, onSay: (entry) => { if (entry.failed) panel.say("That message didn't send."); else panel.addMessage(entry); }, onRefresh: (from) => { if (here === next && from && from === next.access?.hostId) refreshRoom(); }, onDecor: (from) => { if (here === next && from && (from === next.access?.hostId || from === next.space?.ownerId)) refreshRoom(); } }); + const place = await openPlace({ live, map, engine, self, quiet: Boolean(next.watch), channel: { name: next.access.channel, key: next.access.key || null, spaceId: next.space.id }, onPeople: showPeople, onSay: (entry) => { if (entry.failed) panel.say("That message didn't send."); else panel.addMessage(entry); }, onRefresh: (from) => { if (here === next && from && from === next.access?.hostId) refreshRoom(); }, onRekey: (from) => { if (here === next && from && (from === next.access?.hostId || from === next.space?.ownerId)) lease?.beatNow(); }, onDecor: (from) => { if (here === next && from && (from === next.access?.hostId || from === next.space?.ownerId)) refreshRoom(); } }); if (here !== next) { await place.close().catch(() => {}); return; } places = [place]; place.setBlocked(next.access.blocked || []); @@ -204,12 +204,40 @@ export function createStage({ api, map, engine, panel, live: firstLive, self, pl } catch (error) { if (here === at) panel.say(error.message); } } + async function makeHost(person) { + const at = here; + if (!canManage()) return; + try { + const { hostId } = await api("/api/spaces/" + encodeURIComponent(at.space.id) + "/host", { method: "POST", body: JSON.stringify({ actorId: person.id }) }); + if (here !== at) return; + at.access = { ...at.access, hostId }; + showRoom(at.space, hostId); + for (const place of places) place.refresh(); + panel.say(person.name + " is the host now."); + } catch (error) { if (here === at) panel.say(error.message); } + } + // Removal blocks the person and, in private and unlisted spaces, moves everyone else to a new channel. + async function removePerson(person) { + const at = here; + if (!canManage()) return; + try { + const access = await api("/api/spaces/" + encodeURIComponent(at.space.id) + "/remove", { method: "POST", body: JSON.stringify({ actorId: person.id }) }); + if (here !== at) return; + await Promise.all(places.map((place) => place.rekey())); + panel.say(person.name + " was removed from this room."); + onAccess(access, at); + await chain; + } catch (error) { if (here === at) panel.say(error.message); } + } + return { toPlaza: (start, welcome) => transition(() => toPlazaTask(start, welcome)), enterLot, openSpace: (id, invite) => transition(() => openSpaceTask(id, invite)), leaveRoom, setTopic, + makeHost, + removePerson, people: () => everyone, canManage, decor: () => currentDecor, diff --git a/styles.css b/styles.css index 591ca62..0034836 100644 --- a/styles.css +++ b/styles.css @@ -565,6 +565,9 @@ dialog h2 { font-size: 30px; line-height: 1.25; font-weight: 500; letter-spacing .stoa-room strong { font-weight: 500; } .stoa-room span { grid-column: 1; font-size: 12px; color: var(--muted); } .stoa-room button { grid-row: 1 / span 2; grid-column: 2; } +.stoa-person { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; } +.stoa-person > span:first-child { margin-right: auto; } +.stoa-host-badge { font: 10px var(--mono); letter-spacing: 1px; text-transform: uppercase; color: var(--accent); border: 1px solid currentColor; border-radius: 3px; padding: 1px 5px; } .stoa-people li { font-size: 14px; padding: 6px 0; } @media (max-width: 900px) { .stoa-layout { grid-template-columns: 1fr; } #stoa-canvas { height: 62vh; min-height: 340px; } } .stoa-here { padding-bottom: 16px; margin-bottom: 8px; border-bottom: 1px solid var(--line); } diff --git a/tests/stoa-panel.test.mjs b/tests/stoa-panel.test.mjs index ab71304..c120220 100644 --- a/tests/stoa-panel.test.mjs +++ b/tests/stoa-panel.test.mjs @@ -4,7 +4,7 @@ import { createPanel, peopleKey } from "../stoa/panel.js"; // A minimal document: just enough of the element API for the panel to build lists and wire forms. class Element { - constructor(tag) { Object.assign(this, { tagName: tag, children: [], listeners: {}, attrs: {}, hidden: false, textContent: "", className: "", replaced: 0, elements: { topic: { value: "" }, tags: { value: "" } } }); } + constructor(tag) { Object.assign(this, { tagName: tag, children: [], listeners: {}, attrs: {}, hidden: false, textContent: "", className: "", replaced: 0, elements: { topic: { value: "" }, tags: { value: "" } }, classList: { toggle() {}, add() {} }, type: "" }); } append(...items) { this.children.push(...items); } replaceChildren(...items) { this.children = items; this.replaced += 1; } addEventListener(type, listener) { this.listeners[type] = listener; } @@ -62,3 +62,26 @@ test("the Decorate button shows only for the host and calls the decorate handler doc.querySelector("#stoa-decor-open").listeners.click(); assert.equal(opened, 1); }); + +test("hosts get Make host and Remove for everyone else; the host is marked", () => { + const doc = fakeDocument(), panel = createPanel(doc), calls = []; + panel.on({ makeHost: (person) => calls.push(["host", person.id]), remove: (person) => calls.push(["remove", person.id]) }); + const list = [{ id: "account:a", name: "Ada" }, { id: "account:b", name: "Bo" }]; + panel.setPeople(list, "Me", { manage: false, hostId: "account:a", selfId: "account:me" }); + const people = doc.querySelector("#stoa-people"); + const text = (item) => [item.textContent, ...item.children.map((child) => child.textContent)].join("|"); + assert.match(text(people.children[1]), /host/, "Ada is marked as host"); + assert.equal(people.children[1].children.some((child) => child.tagName === "button"), false, "no tools without manage"); + panel.setPeople(list, "Me", { manage: true, hostId: "account:me", selfId: "account:me" }); + assert.match(text(people.children[0]), /host/, "you are marked as host"); + const buttons = people.children[1].children.filter((child) => child.tagName === "button"); + assert.deepEqual(buttons.map((button) => button.textContent), ["Make host", "Remove"]); + buttons[0].listeners.click(); buttons[1].listeners.click(); + assert.deepEqual(calls, [["host", "account:a"], ["remove", "account:a"]]); + assert.match(buttons[1].attrs["aria-label"], /Remove Ada/); +}); +test("the people key changes with manage and host", () => { + const list = [{ id: "account:a", name: "Ada" }]; + assert.notEqual(peopleKey(list, "Me", { manage: true }), peopleKey(list, "Me", { manage: false })); + assert.notEqual(peopleKey(list, "Me", { hostId: "account:a" }), peopleKey(list, "Me", { hostId: null })); +}); diff --git a/tests/stoa-stage.test.mjs b/tests/stoa-stage.test.mjs index 71b83ea..02ef84d 100644 --- a/tests/stoa-stage.test.mjs +++ b/tests/stoa-stage.test.mjs @@ -418,3 +418,46 @@ test("onManage reports when hosting is gained or lost, and the region protects d await stage.leaveRoom(); assert.equal(stage.decorRegion(), null); }); + +test("the host can hand over hosting; the room stops offering host tools", async () => { + const posts = [], managed = []; + const api = async (path, options) => { + if (path === "/api/spaces/plaza/enter") return access("plaza", "plaza-1"); + if (path.endsWith("/enter")) return access("lot-" + lot.slug, "lot-ch"); + if (path.endsWith("/host")) { posts.push(JSON.parse(options.body)); return { hostId: "account:72a639ba-3a45-4afe-936b-222222222222" }; } + return {}; + }; + const { stage, calls } = setup({ api, onManage: (can) => managed.push(can) }); + await stage.toPlaza(null); + await stage.enterLot(lot); + assert.equal(stage.canManage(), true); + await stage.makeHost({ id: "account:72a639ba-3a45-4afe-936b-222222222222", name: "Ada" }); + assert.deepEqual(posts, [{ actorId: "account:72a639ba-3a45-4afe-936b-222222222222" }]); + assert.equal(calls.showRoom.at(-1).host, false); + assert.equal(calls.showRoom.at(-1).decorate, false); + assert.equal(stage.canManage(), false); + assert.equal(managed.at(-1), false, "the decor editor is told to close"); + assert.ok(calls.published.includes(JSON.stringify({ t: "refresh" })), "the room is told to refresh"); +}); +test("removing someone tells the room to check access, then reopens on the new channel", async () => { + const api = async (path) => { + if (path === "/api/spaces/plaza/enter") return access("plaza", "plaza-1"); + if (path.endsWith("/enter")) return access("space-1", "space-ch"); + if (path.endsWith("/remove")) return { removed: true, channel: "space-ch-2", key: null, blocked: ["account:bad"], hostId: "a-me" }; + return {}; + }; + const { stage, calls } = setup({ api }); + await stage.openSpace("space-1"); + await stage.removePerson({ id: "account:bad", name: "Bad" }); + assert.ok(calls.published.includes(JSON.stringify({ t: "rekey" }))); + assert.deepEqual(calls.joined.slice(-1), ["space-ch-2"]); + assert.ok(calls.left.includes("space-ch")); +}); +test("a rekey from the host makes the lease check in now; from anyone else it is ignored", async () => { + const { calls, leases } = await inLot(); + const handlers = calls.handlers["lot-ch"], lease = leases.at(-1); + handlers.message(guestUser, JSON.stringify({ t: "rekey" })); + assert.equal(lease.beats || 0, 0); + handlers.message(hostUser, JSON.stringify({ t: "rekey" })); + assert.equal(lease.beats, 1); +}); From 8ffa3f6121bccbf5604981910a57a8490092cb20 Mon Sep 17 00:00:00 2001 From: Brent G <deliberatekids@gmail.com> Date: Thu, 8 Oct 2026 19:32:21 +0000 Subject: [PATCH 63/71] Match removal wording to the room and tidy host tools MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🤖 Built with SMT <smt@agora.build> --- stoa.js | 2 +- stoa/panel.js | 6 +++--- stoa/stage.js | 9 ++++++--- tests/stoa-panel.test.mjs | 5 +++++ tests/stoa-stage.test.mjs | 13 +++++++++++++ 5 files changed, 28 insertions(+), 7 deletions(-) diff --git a/stoa.js b/stoa.js index 0f97ce5..acb1947 100644 --- a/stoa.js +++ b/stoa.js @@ -95,7 +95,7 @@ panel.on({ topic: (topic, tags) => stage?.setTopic(topic, tags), leave: () => stage?.leaveRoom(), makeHost: (person) => stage?.makeHost(person), - remove: (person) => { if (confirm("Remove " + person.name + " from this room? They can't come back until the room empties or the owner allows them back.")) stage?.removePerson(person); }, + remove: (person) => { if (stage && confirm(stage.removeNotice(person))) stage.removePerson(person); }, decorate: () => { if (!stage?.canManage()) { panel.say("Only the host can decorate."); return; } stage.setEditing(true); diff --git a/stoa/panel.js b/stoa/panel.js index d93345f..068be51 100644 --- a/stoa/panel.js +++ b/stoa/panel.js @@ -1,5 +1,5 @@ // People are rebuilt only when who is here, a name, the host, or your host tools change, so a busy plaza doesn't churn the page. -export const peopleKey = (list, selfName, { manage = false, hostId = null } = {}) => [selfName, manage ? "manage" : "", hostId || ""].join("\u0001") + "\n" + list.map((person) => person.id + "\u0000" + person.name).sort().join("\n"); +export const peopleKey = (list, selfName, { manage = false, hostId = null, ownerId = null } = {}) => [selfName, manage ? "manage" : "", hostId || "", ownerId || ""].join("\u0001") + "\n" + list.map((person) => person.id + "\u0000" + person.name).sort().join("\n"); // The Stoa side panel: everything the canvas offers, as plain controls for keyboard and screen reader users. export function createPanel(doc = document) { @@ -46,7 +46,7 @@ export function createPanel(doc = document) { if (now.length !== wanted.length || wanted.some((entry, index) => entry !== now[index])) rooms.replaceChildren(...wanted); }, setPeople(list, selfName, options = {}) { - const { manage = false, hostId = null, selfId = null } = options; + const { manage = false, hostId = null, selfId = null, ownerId = null } = options; const key = peopleKey(list, selfName, options); if (key === shownPeople) return; shownPeople = key; @@ -56,7 +56,7 @@ export function createPanel(doc = document) { const item = row(person.name, person.id === hostId); if (manage) { if (person.id !== hostId) item.append(tool("Make host", "Make " + person.name + " the host", () => handlers.makeHost(person))); - item.append(tool("Remove", "Remove " + person.name + " from this room", () => handlers.remove(person))); + if (person.id !== ownerId) item.append(tool("Remove", "Remove " + person.name + " from this room", () => handlers.remove(person))); } return item; })); diff --git a/stoa/stage.js b/stoa/stage.js index 87aa6bd..ea98908 100644 --- a/stoa/stage.js +++ b/stoa/stage.js @@ -27,7 +27,7 @@ export function createStage({ api, map, engine, panel, live: firstLive, self, pl const list = [...seen.values()]; everyone = list; engine.setOthers(list); - panel.setPeople(list, self.name, { manage: canManage(), hostId: here?.kind === "lot" || here?.kind === "space" ? here.access?.hostId || null : null, selfId: self.id }); + panel.setPeople(list, self.name, { manage: canManage(), hostId: here?.kind === "lot" || here?.kind === "space" ? here.access?.hostId || null : null, ownerId: here?.kind === "lot" || here?.kind === "space" ? here.space?.ownerId || null : null, selfId: mine(here?.access?.hostId) ? here.access.hostId : self.id }); onPeople(list); }); }; @@ -54,7 +54,7 @@ export function createStage({ api, map, engine, panel, live: firstLive, self, pl showPeople(); if (live && next.access?.channel) { try { - const place = await openPlace({ live, map, engine, self, quiet: Boolean(next.watch), channel: { name: next.access.channel, key: next.access.key || null, spaceId: next.space.id }, onPeople: showPeople, onSay: (entry) => { if (entry.failed) panel.say("That message didn't send."); else panel.addMessage(entry); }, onRefresh: (from) => { if (here === next && from && from === next.access?.hostId) refreshRoom(); }, onRekey: (from) => { if (here === next && from && (from === next.access?.hostId || from === next.space?.ownerId)) lease?.beatNow(); }, onDecor: (from) => { if (here === next && from && (from === next.access?.hostId || from === next.space?.ownerId)) refreshRoom(); } }); + const place = await openPlace({ live, map, engine, self, quiet: Boolean(next.watch), channel: { name: next.access.channel, key: next.access.key || null, spaceId: next.space.id }, onPeople: showPeople, onSay: (entry) => { if (entry.failed) panel.say("That message didn't send."); else panel.addMessage(entry); }, onRefresh: (from) => { if (here === next && from && (from === next.access?.hostId || from === next.space?.ownerId)) refreshRoom(); }, onRekey: (from) => { if (here === next && from && (from === next.access?.hostId || from === next.space?.ownerId)) lease?.beatNow(); }, onDecor: (from) => { if (here === next && from && (from === next.access?.hostId || from === next.space?.ownerId)) refreshRoom(); } }); if (here !== next) { await place.close().catch(() => {}); return; } places = [place]; place.setBlocked(next.access.blocked || []); @@ -223,13 +223,15 @@ export function createStage({ api, map, engine, panel, live: firstLive, self, pl try { const access = await api("/api/spaces/" + encodeURIComponent(at.space.id) + "/remove", { method: "POST", body: JSON.stringify({ actorId: person.id }) }); if (here !== at) return; - await Promise.all(places.map((place) => place.rekey())); + await Promise.all(places.map((place) => place.rekey().catch(() => false))); panel.say(person.name + " was removed from this room."); onAccess(access, at); await chain; } catch (error) { if (here === at) panel.say(error.message); } } + const removeNotice = (person) => here?.kind === "lot" ? "Remove " + person.name + " from this room? They can't come back until the room empties." : "Remove " + person.name + " from this space? They can't come back unless the owner allows them back."; + return { toPlaza: (start, welcome) => transition(() => toPlazaTask(start, welcome)), enterLot, @@ -238,6 +240,7 @@ export function createStage({ api, map, engine, panel, live: firstLive, self, pl setTopic, makeHost, removePerson, + removeNotice, people: () => everyone, canManage, decor: () => currentDecor, diff --git a/tests/stoa-panel.test.mjs b/tests/stoa-panel.test.mjs index c120220..a3331b6 100644 --- a/tests/stoa-panel.test.mjs +++ b/tests/stoa-panel.test.mjs @@ -85,3 +85,8 @@ test("the people key changes with manage and host", () => { assert.notEqual(peopleKey(list, "Me", { manage: true }), peopleKey(list, "Me", { manage: false })); assert.notEqual(peopleKey(list, "Me", { hostId: "account:a" }), peopleKey(list, "Me", { hostId: null })); }); +test("the space owner is never offered Remove", () => { + const doc = fakeDocument(), panel = createPanel(doc); + panel.setPeople([{ id: "account:o", name: "Owner" }], "Me", { manage: true, hostId: "account:me", ownerId: "account:o", selfId: "account:me" }); + assert.deepEqual(doc.querySelector("#stoa-people").children[1].children.filter((child) => child.tagName === "button").map((button) => button.textContent), ["Make host"]); +}); diff --git a/tests/stoa-stage.test.mjs b/tests/stoa-stage.test.mjs index 02ef84d..68b6414 100644 --- a/tests/stoa-stage.test.mjs +++ b/tests/stoa-stage.test.mjs @@ -461,3 +461,16 @@ test("a rekey from the host makes the lease check in now; from anyone else it is handlers.message(hostUser, JSON.stringify({ t: "rekey" })); assert.equal(lease.beats, 1); }); +test("remove wording follows the room kind, and a refresh from the owner is heard", async () => { + const env = await inLot(); + assert.match(env.stage.removeNotice({ name: "Bo" }), /Remove Bo from this room\? .*until the room empties\./); + const api = async (path) => path === "/api/spaces/plaza/enter" ? access("plaza", "plaza-1") : path.endsWith("/enter") ? { ...access("space-1", "space-ch"), space: { ...space("space-1"), ownerId: hostActor }, hostId: "account:someone" } : { space: space("space-1") }; + const { stage, calls } = setup({ api }); + await stage.openSpace("space-1"); + assert.match(stage.removeNotice({ name: "Bo" }), /from this space\? .*unless the owner allows them back\./); + calls.handlers["space-ch"].presence({ type: "snapshot", people: [present(hostUser, "Owner")] }); + const before = calls.showRoom.length; + calls.handlers["space-ch"].message(hostUser, JSON.stringify({ t: "refresh" })); + await tick(); await tick(); + assert.ok(calls.showRoom.length > before, "owner refresh triggers a room fetch"); +}); From e7172e334760a81dd810233172318acf20a3b40f Mon Sep 17 00:00:00 2001 From: Brent G <deliberatekids@gmail.com> Date: Thu, 8 Oct 2026 19:42:13 +0000 Subject: [PATCH 64/71] Create, invite to, and manage your own Stoa spaces MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🤖 Built with SMT <smt@agora.build> --- scripts/serve.mjs | 2 +- stoa.html | 54 +++++++++++++++++- stoa.js | 19 ++++++- stoa/panel.js | 12 ++-- stoa/spaces.js | 109 +++++++++++++++++++++++++++++++++++++ stoa/stage.js | 2 +- styles.css | 10 ++++ tests/spaces-api.test.mjs | 4 +- tests/stoa-panel.test.mjs | 17 ++++++ tests/stoa-spaces.test.mjs | 19 +++++++ 10 files changed, 237 insertions(+), 11 deletions(-) create mode 100644 stoa/spaces.js create mode 100644 tests/stoa-spaces.test.mjs diff --git a/scripts/serve.mjs b/scripts/serve.mjs index ff2bc51..ae99851 100644 --- a/scripts/serve.mjs +++ b/scripts/serve.mjs @@ -23,7 +23,7 @@ import { loadThemes } from "./spaces/themes.mjs"; const root = fileURLToPath(new URL("../", import.meta.url)); // Vendored browser SDKs are served from node_modules in development and from dist/assets after a build. const vendor = { "assets/agora-rtc.js": createRequire(import.meta.url).resolve("agora-rtc-sdk-ng"), "assets/agora-rtm.js": createRequire(import.meta.url).resolve("agora-rtm-sdk") }; -const publicFiles = new Set(["index.html", "explore.html", "services.html", "radar.html", "meetings.html", "account.html", "account.js", "stoa.html", "stoa.js", "world/map.js", "world/kinds.js", "world/themes.js", "world/camera.js", "world/motion.js", "world/renderer-canvas.js", "world/engine.js", "world/sdk.js", "world/rtc-client.js", "stoa/panel.js", "stoa/stage.js", "stoa/call.js", "stoa/decor.js", "stoa/place.js", "stoa/lease.js", "world/protocol.js", "world/crypto.js", "world/signaling.js", "world/presence.js", "styles.css", "script.js", "people.js", "activity.js", "explore.js", "services.js", "radar.js", "meetings.js", "call.js", "assets/agora-rtc.js", "assets/agora-rtm.js", "assets/favicon.svg", "assets/guohai.jpg"]); +const publicFiles = new Set(["index.html", "explore.html", "services.html", "radar.html", "meetings.html", "account.html", "account.js", "stoa.html", "stoa.js", "world/map.js", "world/kinds.js", "world/themes.js", "world/camera.js", "world/motion.js", "world/renderer-canvas.js", "world/engine.js", "world/sdk.js", "world/rtc-client.js", "stoa/panel.js", "stoa/stage.js", "stoa/call.js", "stoa/decor.js", "stoa/place.js", "stoa/lease.js", "stoa/spaces.js", "world/protocol.js", "world/crypto.js", "world/signaling.js", "world/presence.js", "styles.css", "script.js", "people.js", "activity.js", "explore.js", "services.js", "radar.js", "meetings.js", "call.js", "assets/agora-rtc.js", "assets/agora-rtm.js", "assets/favicon.svg", "assets/guohai.jpg"]); const types = { ".html": "text/html; charset=utf-8", ".css": "text/css; charset=utf-8", ".js": "text/javascript; charset=utf-8", ".svg": "image/svg+xml", ".jpg": "image/jpeg" }; function json(response, status, body, headers = {}) { diff --git a/stoa.html b/stoa.html index 098837b..d495ee7 100644 --- a/stoa.html +++ b/stoa.html @@ -19,7 +19,7 @@ </header> <main id="main" class="main-content stoa-main"> <section class="stoa" id="stoa" aria-labelledby="stoa-title"> - <div class="stoa-heading"><div><p class="kicker">THE STOA / PUBLIC PLAZA</p><h1 id="stoa-title">Walk into the plaza.</h1></div><button class="button button-secondary button-small" type="button" id="stoa-minimal" aria-pressed="false">Minimal view</button></div> + <div class="stoa-heading"><div><p class="kicker" id="stoa-kicker">THE STOA / PUBLIC PLAZA</p><h1 id="stoa-title">Walk into the plaza.</h1></div><button class="button button-secondary button-small" type="button" id="stoa-minimal" aria-pressed="false">Minimal view</button></div> <div class="stoa-layout"> <div class="stoa-stage"><div class="stoa-view"><canvas id="stoa-canvas" tabindex="0" aria-label="Plaza map" aria-describedby="stoa-help"></canvas><div class="stoa-video" id="stoa-video" data-layout="strip" role="region" aria-label="Call video" hidden></div></div><p class="stoa-help" id="stoa-help">Click or tap to walk. Select the map to use the arrow keys or WASD.</p></div> <aside class="stoa-panel" aria-label="Around you"> @@ -34,6 +34,7 @@ <h2 id="stoa-here-title"></h2> <button class="button button-small" type="submit">Set topic</button> </form> <button class="button button-secondary button-small" type="button" id="stoa-decor-open" hidden>Decorate</button> + <button class="button button-secondary button-small" type="button" id="stoa-manage" hidden>Manage space</button> <section class="stoa-decor" id="stoa-decor" hidden aria-labelledby="stoa-decor-title"> <h3 class="stoa-panel-title" id="stoa-decor-title">Decorate</h3> <p class="stoa-decor-selected" id="stoa-decor-selected" aria-live="polite"></p> @@ -66,6 +67,12 @@ <h2 class="stoa-panel-title" id="stoa-offer-title">The plaza is full</h2> <h2 class="stoa-panel-title" id="stoa-rooms-title">Rooms</h2> <ul class="stoa-rooms" id="stoa-rooms"></ul> </section> + <section id="stoa-mine-section" hidden aria-labelledby="stoa-mine-title"> + <h2 class="stoa-panel-title" id="stoa-mine-title">Your spaces</h2> + <ul class="stoa-rooms" id="stoa-mine"></ul> + <p class="stoa-mine-note" id="stoa-mine-note"></p> + <button class="button button-small" type="button" id="stoa-new">Start a space</button> + </section> <h2 class="stoa-panel-title">Here now</h2> <ul class="stoa-people" id="stoa-people"></ul> <h2 class="stoa-panel-title" id="stoa-talk-title">Talk</h2> @@ -101,6 +108,51 @@ <h2 id="join-title">Pull up a chair.</h2> <h2 id="room-title">Meet around a purpose.</h2><p class="dialog-intro">Create a group room, share the invite, and open a video call together.</p> <form id="room-form" class="stack-form"><label>Room name<input name="title" required minlength="2" maxlength="80" placeholder="Voice agents build session"></label><label>What are we meeting for?<textarea name="intent" required minlength="2" maxlength="300" rows="3" placeholder="Debug a problem, plan a demo day, or bring a whiteboard session into the next sprint."></textarea></label><p class="form-note">Room names, purposes, and rosters are public. Audio, video, and screen sharing stay in this workspace, powered by Agora.</p><p id="room-error" class="form-error" role="alert"></p><button type="submit" class="button">Create meeting room <span aria-hidden="true">↗</span></button></form> </dialog> + <dialog id="space-dialog" aria-labelledby="space-title"> + <div class="dialog-head"><span class="kicker">A PLACE OF YOUR OWN</span><button class="close-button" type="button" data-close aria-label="Close space form">×</button></div> + <h2 id="space-title">Start a space.</h2> + <p class="dialog-intro">Your space is unlisted or private: people find it through you. You host it whenever you're there.</p> + <form id="space-form" class="stack-form"> + <label>Name<input name="title" required minlength="2" maxlength="80" placeholder="Voice agents build session"></label> + <label>What is it for? <span class="optional">optional</span><textarea name="purpose" maxlength="300" rows="2" placeholder="Pair on interruption handling every Thursday."></textarea></label> + <div class="form-row"> + <label>Who can find it<select name="visibility"><option value="unlisted">Anyone with the link</option><option value="private">Members and invited people</option></select></label> + <label>Who can walk in<select name="access"><option value="open">Anyone signed in</option><option value="house">House members</option><option value="members">Members only</option></select></label> + </div> + <div class="form-row"> + <label>Capacity<input name="capacity" type="number" min="2" max="50" required></label> + <label>Theme<select name="themeId"><option value="agora">Agora</option><option value="minimal">Minimal</option><option value="cyberpunk">Cyberpunk</option></select></label> + </div> + <p id="space-error" class="form-error" role="alert"></p> + <button type="submit" class="button">Create space</button> + </form> + </dialog> + <dialog id="manage-dialog" aria-labelledby="manage-title"> + <div class="dialog-head"><span class="kicker">YOUR SPACE</span><button class="close-button" type="button" data-close aria-label="Close manage dialog">×</button></div> + <h2 id="manage-title">Manage space</h2> + <form id="manage-form" class="stack-form"> + <label>Name<input name="title" required minlength="2" maxlength="80"></label> + <label>What is it for? <span class="optional">optional</span><textarea name="purpose" maxlength="300" rows="2"></textarea></label> + <div class="form-row"> + <label>Who can find it<select name="visibility"><option value="unlisted">Anyone with the link</option><option value="private">Members and invited people</option></select></label> + <label>Who can walk in<select name="access"><option value="open">Anyone signed in</option><option value="house">House members</option><option value="members">Members only</option></select></label> + </div> + <div class="form-row"> + <label>Capacity<input name="capacity" type="number" min="2" max="50" required></label> + <label>Theme<select name="themeId"><option value="agora">Agora</option><option value="minimal">Minimal</option><option value="cyberpunk">Cyberpunk</option></select></label> + </div> + <button type="submit" class="button button-small">Save settings</button> + </form> + <h3 class="stoa-panel-title">Invite</h3> + <div class="manage-invite"><button class="button button-secondary button-small" type="button" id="manage-invite">Create an invite link</button><input id="manage-invite-url" readonly hidden aria-label="Invite link"><button class="button button-secondary button-small" type="button" id="manage-copy" hidden>Copy link</button></div> + <p class="form-note" id="manage-invite-note"></p> + <h3 class="stoa-panel-title">Members</h3> + <ul class="manage-people" id="manage-members"></ul> + <h3 class="stoa-panel-title">Removed</h3> + <ul class="manage-people" id="manage-blocked"></ul> + <p id="manage-error" class="form-error" role="alert"></p> + <div class="dialog-actions"><button class="delete-button" type="button" id="manage-delete">Delete this space</button></div> + </dialog> <div id="toast" role="status" aria-live="polite"></div> </body> </html> diff --git a/stoa.js b/stoa.js index acb1947..a319521 100644 --- a/stoa.js +++ b/stoa.js @@ -6,6 +6,7 @@ import { loadAgora } from "./world/sdk.js"; import { createPanel } from "./stoa/panel.js"; import { createCallView } from "./stoa/call.js"; import { createDecorEditor } from "./stoa/decor.js"; +import { createSpacesUi } from "./stoa/spaces.js"; import { createStage, outsideDoor } from "./stoa/stage.js"; const root = document.querySelector("#stoa"); @@ -32,9 +33,10 @@ const [spaceResult, worldResult, themesResult, spacesResult] = await Promise.all ]); failure(spaceResult, "This space"); failure(worldResult, "The map"); failure(themesResult, "Themes"); failure(spacesResult, "The room list"); const space = spaceResult.value?.space || null; +document.querySelector("#stoa-kicker").textContent = roomSlug ? "THE STOA / ROOM" : space ? (space.visibility === "private" ? "THE STOA / PRIVATE SPACE" : "THE STOA / UNLISTED SPACE") : "THE STOA / PUBLIC PLAZA"; if (space) { document.querySelector("#stoa-title").textContent = space.title; document.title = space.title + " | Agora Build"; } -let map = null, engine = null, minimal = stored.get() === "true", preview = null, plazaBounds = null, active = () => null, call = null, decor = null; +let map = null, engine = null, minimal = stored.get() === "true", preview = null, plazaBounds = null, active = () => null, call = null, decor = null, spacesUi = null; try { if (worldResult.status === "fulfilled") map = parseMap(worldResult.value); } catch (error) { problems.push("The map is not valid (" + error.message + ")."); } let summaries = new Map((spacesResult.value?.rooms || []).map((room) => [room.slug, room])); const lot = roomSlug && map ? map.lots.find((entry) => entry.slug === roomSlug) || null : null; @@ -77,10 +79,13 @@ if (map && themesResult.status === "fulfilled" && (!spaceId || space)) { await sessionReady; const self = { id: state.account?.id || state.profile?.id || null, ids: [state.account?.id, state.profile?.id].filter(Boolean), name: state.profile?.name || state.account?.name || "You", signedIn: Boolean(state.account || state.profile) }; +panel.setSignedIn(self.signedIn); +spacesUi = createSpacesUi({ api, say: (text) => panel.say(text) }); panel.setPeople([], self.name); panel.canTalk(self.signedIn); let live = null, stage = null; const unavailable = "The Stoa view isn't available, so there's nothing to walk or talk in."; +const startSpace = (prefill) => self.signedIn ? spacesUi.openCreate(prefill) : (panel.say("Sign in to start a space."), openSignIn()); // Controls work even without the canvas: Go to explains where the door is, Sign in and Start a space still do their jobs. panel.on({ go: (target) => { @@ -104,7 +109,8 @@ panel.on({ panel.say("Decor mode is on. Choose a decoration, then click the map or use Place in front of me."); canvas.focus(); }, - start: async () => { try { const created = await api("/api/spaces", { method: "POST", body: JSON.stringify({ title: self.name + "'s space" }) }); location.assign("/stoa/s/" + created.space.id); } catch (error) { panel.say(error.message); } }, + start: () => startSpace(), + manage: () => spaceId && spacesUi.openManage(spaceId), signin: () => openSignIn() }); // Signaling loads and connects in the background: the page is already usable, and the stage picks the connection up when it is ready. @@ -159,3 +165,12 @@ if (engine) { } if (problems.length) panel.say((engine && !self.signedIn && !spaceId ? ["You're watching the plaza. Sign in to walk and talk."] : []).concat(problems).join(" ")); else if (!engine) panel.say("The Stoa view is not available."); +// Your own spaces show on the plaza; ?start= (from the home page) opens the create dialog with that name. +if (!spaceId && self.signedIn) api("/api/spaces/mine").then((mine) => spacesUi.showMine(mine)).catch(() => {}); +const params = new URLSearchParams(location.search); +if (!spaceId && params.has("start")) { + const title = params.get("start"); + params.delete("start"); + history.replaceState(null, "", location.pathname + (params.size ? "?" + params : "") + location.hash); + startSpace({ title }); +} diff --git a/stoa/panel.js b/stoa/panel.js index 068be51..fc6940a 100644 --- a/stoa/panel.js +++ b/stoa/panel.js @@ -8,18 +8,21 @@ export function createPanel(doc = document) { const status = $("#stoa-status"), here = $("#stoa-here"), topicForm = $("#stoa-topic-form"), offer = $("#stoa-offer"), roomsSection = $("#stoa-rooms-section"), rooms = $("#stoa-rooms"), people = $("#stoa-people"), messages = $("#stoa-messages"), sayForm = $("#stoa-say"), sayInput = $("#stoa-say-text"), signin = $("#stoa-signin"), signinText = $("#stoa-signin-text"), signinButton = $("#stoa-signin-button"); let shownPeople = null; const roomRows = new Map(); - let canDecorate = false, decorating = false; + let signedIn = false, canDecorate = false, decorating = false; const syncDecor = () => { $("#stoa-decor-open").hidden = !canDecorate || decorating; }; - const handlers = { go() {}, say() { return false; }, topic() {}, leave() {}, start() {}, signin() {}, decorate() {}, makeHost() {}, remove() {} }; + const handlers = { go() {}, say() { return false; }, topic() {}, leave() {}, start() {}, signin() {}, decorate() {}, manage() {}, makeHost() {}, remove() {} }; sayForm.addEventListener("submit", (event) => { event.preventDefault(); if (handlers.say(sayInput.value)) sayInput.value = ""; }); topicForm.addEventListener("submit", (event) => { event.preventDefault(); handlers.topic(topicForm.elements.topic.value, topicForm.elements.tags.value); }); $("#stoa-leave").addEventListener("click", () => handlers.leave()); $("#stoa-start").addEventListener("click", () => handlers.start()); + $("#stoa-new").addEventListener("click", () => handlers.start()); $("#stoa-decor-open").addEventListener("click", () => handlers.decorate()); + $("#stoa-manage").addEventListener("click", () => handlers.manage()); signinButton.addEventListener("click", () => handlers.signin()); return { // The Decorate button is for hosts, and hidden while the editor is open. decorating(on) { decorating = Boolean(on); syncDecor(); }, + setSignedIn(value) { signedIn = Boolean(value); }, on(next) { Object.assign(handlers, next); }, say(message) { status.textContent = message; }, // Rows are created once per room and updated in place, so a focused Go to button keeps focus across polls. @@ -70,17 +73,18 @@ export function createPanel(doc = document) { clearMessages() { messages.replaceChildren(); }, // When talking isn't possible, a reason (for someone already signed in) replaces the sign-in prompt. canTalk(allowed, reason = "") { sayForm.hidden = !allowed; signin.hidden = allowed; signinText.textContent = reason || "Sign in to walk and talk."; signinButton.hidden = Boolean(reason); }, - showRoom({ kicker, title, topic, tags = [], host, decorate = host, leaveLabel }) { + showRoom({ kicker, title, topic, tags = [], host, decorate = host, owner = false, leaveLabel }) { here.hidden = false; roomsSection.hidden = true; $("#stoa-here-kicker").textContent = kicker; $("#stoa-here-title").textContent = title; $("#stoa-here-topic").textContent = topic ? topic + (tags.length ? " · " + tags.join(", ") : "") : host ? "You're the host. Set a topic so people know what this room is about." : "No topic yet."; topicForm.hidden = !host; canDecorate = Boolean(decorate); syncDecor(); + $("#stoa-manage").hidden = !owner; $("#stoa-mine-section").hidden = true; $("#stoa-leave").textContent = leaveLabel; sayInput.placeholder = "Say something to the room"; }, - showPlaza() { here.hidden = true; canDecorate = false; syncDecor(); topicForm.hidden = true; roomsSection.hidden = false; sayInput.placeholder = "Say something on the plaza"; }, + showPlaza() { here.hidden = true; $("#stoa-manage").hidden = true; $("#stoa-mine-section").hidden = !signedIn; canDecorate = false; syncDecor(); topicForm.hidden = true; roomsSection.hidden = false; sayInput.placeholder = "Say something on the plaza"; }, showOffer({ spaces = [], canCreate = false } = {}) { offer.hidden = false; $("#stoa-offer-list").replaceChildren(...spaces.map((space) => { const item = node("li"), link = node("a", space.title); link.href = space.path; item.append(link); return item; })); diff --git a/stoa/spaces.js b/stoa/spaces.js new file mode 100644 index 0000000..4aea3f9 --- /dev/null +++ b/stoa/spaces.js @@ -0,0 +1,109 @@ +// Your own spaces: the list on the plaza, the create dialog, and the owner's manage dialog (settings, invites, members). +export const THEME_CHOICES = [["agora", "Agora"], ["minimal", "Minimal"], ["cyberpunk", "Cyberpunk"]]; +const ACCESS_WORDS = { open: "anyone with the link", house: "house members", members: "members only" }; +export function spacePayload(values) { + const visibility = values.visibility === "private" ? "private" : "unlisted"; + const access = visibility === "private" ? "members" : Object.hasOwn(ACCESS_WORDS, values.access) ? values.access : "members"; + return { title: String(values.title || "").trim(), purpose: String(values.purpose || "").trim(), visibility, access, capacity: Number.parseInt(values.capacity, 10), themeId: THEME_CHOICES.some(([id]) => id === values.themeId) ? values.themeId : "agora" }; +} +export const describeSpace = (space) => [space.visibility === "private" ? "Private" : "Unlisted", ACCESS_WORDS[space.access] || "members only", space.occupancy + " here"].join(" · "); +// Member ids look like account:<uuid>; the server matches the colon literally, so it must not be percent-encoded. +export const memberPath = (id) => encodeURIComponent(id).replace(/%3A/gi, ":"); +export const inviteUrl = (origin, path) => new URL(path, origin).href; + +export function createSpacesUi({ doc = document, api, say = () => {}, navigate = (path) => location.assign(path), confirm = (text) => window.confirm(text), copy = (text) => navigator.clipboard.writeText(text), origin = location.origin }) { + const $ = (selector) => doc.querySelector(selector); + const node = (tag, text, className) => { const element = doc.createElement(tag); if (text) element.textContent = text; if (className) element.className = className; return element; }; + const createDialog = $("#space-dialog"), createForm = $("#space-form"), manageDialog = $("#manage-dialog"), manageForm = $("#manage-form"); + let managing = null; + // Private spaces admit members and invited people only, so the access choice is locked to members. + const lockAccess = (form) => { const priv = form.elements.visibility.value === "private"; if (priv) form.elements.access.value = "members"; form.elements.access.disabled = priv; }; + for (const form of [createForm, manageForm]) form.elements.visibility.addEventListener("change", () => lockAccess(form)); + const fill = (form, space) => { for (const field of ["title", "purpose", "visibility", "access", "capacity", "themeId"]) form.elements[field].value = space[field] ?? ""; lockAccess(form); }; + const values = (form) => spacePayload(Object.fromEntries(["title", "purpose", "visibility", "access", "capacity", "themeId"].map((field) => [field, form.elements[field].value]))); + + createForm.addEventListener("submit", async (event) => { + event.preventDefault(); + const button = createForm.querySelector("[type=submit]"), error = $("#space-error"); + button.disabled = true; error.textContent = ""; + try { const { space } = await api("/api/spaces", { method: "POST", body: JSON.stringify(values(createForm)) }); navigate("/stoa/s/" + space.id); } + catch (failure) { error.textContent = failure.message; } + finally { button.disabled = false; } + }); + + // `keep` is a person who gets no button: the server keeps the owner a member. + function people(list, selector, label, action, keep = null) { + $(selector).replaceChildren(...list.map((person) => { + const item = node("li", "", "manage-person"); + item.append(node("span", person.name)); + if (action && person.id !== keep) { const button = node("button", label, "button button-secondary button-small"); button.type = "button"; button.setAttribute("aria-label", label + ": " + person.name); button.addEventListener("click", () => action(person)); item.append(button); } + return item; + })); + if (!list.length) $(selector).replaceChildren(node("li", "Nobody yet.", "muted")); + } + async function load(id) { + const { space } = await api("/api/spaces/" + encodeURIComponent(id)); + managing = space; + fill(manageForm, space); + $("#manage-title").textContent = "Manage " + space.title; + people(space.roster || [], "#manage-members", "Remove", async (person) => { + if (!confirm("Remove " + person.name + " from the members of " + space.title + "?")) return; + try { await api("/api/spaces/" + encodeURIComponent(space.id) + "/members/" + memberPath(person.id), { method: "DELETE" }); await load(space.id); say(person.name + " is no longer a member."); } + catch (error) { $("#manage-error").textContent = error.message; } + }, space.ownerId); + people(space.blockedRoster || [], "#manage-blocked", "Allow back", async (person) => { + try { await api("/api/spaces/" + encodeURIComponent(space.id) + "/members", { method: "POST", body: JSON.stringify({ memberId: person.id }) }); await load(space.id); say(person.name + " can come back."); } + catch (error) { $("#manage-error").textContent = error.message; } + }); + } + manageForm.addEventListener("submit", async (event) => { + event.preventDefault(); + const error = $("#manage-error"); + error.textContent = ""; + try { const { space } = await api("/api/spaces/" + encodeURIComponent(managing.id), { method: "PUT", body: JSON.stringify(values(manageForm)) }); managing = { ...managing, ...space }; say("Space settings saved."); } + catch (failure) { error.textContent = failure.message; } + }); + $("#manage-invite").addEventListener("click", async () => { + try { + const invite = await api("/api/spaces/" + encodeURIComponent(managing.id) + "/invitations", { method: "POST", body: "{}" }); + const field = $("#manage-invite-url"); + field.value = inviteUrl(origin, invite.path); + field.hidden = false; $("#manage-copy").hidden = false; + $("#manage-invite-note").textContent = "Works " + invite.usesLeft + " times until " + new Date(invite.expiresAt).toLocaleString() + ". Anyone with it can join as a member."; + } catch (error) { $("#manage-error").textContent = error.message; } + }); + $("#manage-copy").addEventListener("click", async () => { + try { await copy($("#manage-invite-url").value); say("Invite link copied."); } + catch { $("#manage-invite-url").select?.(); say("Copy the selected link."); } + }); + $("#manage-delete").addEventListener("click", async () => { + if (!managing || !confirm("Delete " + managing.title + " for everyone? This can't be undone.")) return; + try { await api("/api/spaces/" + encodeURIComponent(managing.id), { method: "DELETE" }); navigate("/stoa/"); } + catch (error) { $("#manage-error").textContent = error.message; } + }); + + return { + showMine({ spaces = [], canCreate = false, owned = 0, limit = 0 } = {}) { + $("#stoa-mine").replaceChildren(...(spaces.length ? spaces.map((space) => { + const item = node("li", "", "stoa-room"), link = node("a", space.title); + link.href = space.path; + item.append(link, node("span", describeSpace(space) + (space.owner ? "" : " · member"))); + return item; + }) : [node("li", "You don't have any spaces yet.", "muted")])); + $("#stoa-new").disabled = !canCreate; + $("#stoa-mine-note").textContent = canCreate ? "" : "You own " + owned + " of " + limit + " spaces your plan allows."; + }, + openCreate(prefill = {}) { + createForm.reset(); + fill(createForm, { title: "", purpose: "", visibility: "unlisted", access: "members", capacity: 12, themeId: "agora", ...prefill }); + $("#space-error").textContent = ""; + createDialog.showModal(); + }, + async openManage(id) { + $("#manage-error").textContent = ""; + $("#manage-invite-url").hidden = true; $("#manage-copy").hidden = true; $("#manage-invite-note").textContent = ""; + try { await load(id); manageDialog.showModal(); } + catch (error) { say(error.message); } + } + }; +} diff --git a/stoa/stage.js b/stoa/stage.js index ea98908..1e30313 100644 --- a/stoa/stage.js +++ b/stoa/stage.js @@ -36,7 +36,7 @@ export function createStage({ api, map, engine, panel, live: firstLive, self, pl const checkManage = () => { const can = canManage(); if (can !== manageNow) { manageNow = can; onManage(can); } }; const doorTiles = (rect) => { const tiles = []; for (let y = rect.y; y < rect.y + rect.height; y += 1) for (let x = rect.x; x < rect.x + rect.width; x += 1) if (roleAt(map, x, y) === "door") tiles.push({ x, y }); return tiles; }; const showDecor = (items) => { currentDecor = items || []; if (!editing) engine.setDecor(currentDecor); }; - const showRoom = (space, hostId) => { panel.showRoom({ kicker: here?.kind === "lot" ? "LOT ROOM" : space.visibility === "private" ? "PRIVATE SPACE" : "UNLISTED SPACE", title: space.title, topic: space.topic, tags: space.tags || [], host: Boolean(self.id) && (mine(hostId) || mine(space.ownerId)), leaveLabel: here?.kind === "lot" ? "Back to the plaza" : "Leave this space", decorate: canManage() }); checkManage(); showPeople(); }; + const showRoom = (space, hostId) => { panel.showRoom({ kicker: here?.kind === "lot" ? "LOT ROOM" : space.visibility === "private" ? "PRIVATE SPACE" : "UNLISTED SPACE", title: space.title, topic: space.topic, tags: space.tags || [], host: Boolean(self.id) && (mine(hostId) || mine(space.ownerId)), leaveLabel: here?.kind === "lot" ? "Back to the plaza" : "Leave this space", decorate: canManage(), owner: here?.kind === "space" && mine(space.ownerId) }); checkManage(); showPeople(); }; // Every transition claims `here` before it awaits anything; a superseded one stops as soon as it notices. async function settle(next) { diff --git a/styles.css b/styles.css index 0034836..a979bd8 100644 --- a/styles.css +++ b/styles.css @@ -608,3 +608,13 @@ dialog h2 { font-size: 30px; line-height: 1.25; font-weight: 500; letter-spacing .stoa-decor-list button { background: none; border: 0; padding: 3px 0; font: inherit; color: inherit; cursor: pointer; text-align: left; } .stoa-decor-list [aria-current="true"] { color: var(--accent); font-weight: 600; } .stoa-decor-actions { display: flex; flex-wrap: wrap; gap: 6px; } + +.stoa-mine-note { font-size: 12px; color: var(--muted); } +.manage-invite { display: flex; flex-wrap: wrap; gap: 6px; align-items: center; } +.manage-invite input { flex: 1 1 220px; font: 12px var(--mono); padding: 7px 8px; border: 1px solid var(--line); border-radius: 4px; } +.manage-people { list-style: none; margin: 0 0 8px; padding: 0; } +.manage-person { display: flex; justify-content: space-between; align-items: center; gap: 8px; padding: 6px 0; border-bottom: 1px solid var(--line); font-size: 14px; } +#stoa-mine a { color: inherit; font-weight: 500; } +.stack-form select { display: block; width: 100%; margin-top: 7px; padding: 11px 28px 11px 12px; font-size: 14px; } +.stack-form .form-row > label { min-width: 0; } +@media (max-width: 560px) { #space-dialog .form-row, #manage-dialog .form-row { grid-template-columns: 1fr; gap: 18px; } } diff --git a/tests/spaces-api.test.mjs b/tests/spaces-api.test.mjs index 9f8427d..935a4b8 100644 --- a/tests/spaces-api.test.mjs +++ b/tests/spaces-api.test.mjs @@ -184,9 +184,9 @@ test("the live Stoa page has its panel controls and serves every live module", a const { instance, url } = await start(); try { const html = await (await request(url, "/stoa/")).text(); - for (const id of ["stoa-here", "stoa-topic-form", "stoa-leave", "stoa-offer", "stoa-start", "stoa-rooms-section", "stoa-messages", "stoa-say", "stoa-signin", "stoa-call", "stoa-call-join", "stoa-video"]) assert.match(html, new RegExp("id=\"" + id + "\""), id); + for (const id of ["stoa-here", "stoa-topic-form", "stoa-leave", "stoa-offer", "stoa-start", "stoa-rooms-section", "stoa-messages", "stoa-say", "stoa-signin", "stoa-call", "stoa-call-join", "stoa-video", "stoa-mine-section", "stoa-new", "space-dialog", "manage-dialog", "stoa-manage", "stoa-kicker"]) assert.match(html, new RegExp("id=\"" + id + "\""), id); assert.match(html, /id="stoa-messages" role="log"/); - for (const module of ["/stoa/panel.js", "/stoa/stage.js", "/stoa/place.js", "/stoa/lease.js", "/world/protocol.js", "/world/crypto.js", "/world/signaling.js", "/world/presence.js", "/stoa/call.js", "/world/rtc-client.js"]) { + for (const module of ["/stoa/panel.js", "/stoa/stage.js", "/stoa/place.js", "/stoa/lease.js", "/world/protocol.js", "/world/crypto.js", "/world/signaling.js", "/world/presence.js", "/stoa/call.js", "/world/rtc-client.js", "/stoa/spaces.js"]) { const response = await request(url, module); assert.equal(response.status, 200, module); assert.match(response.headers.get("content-type"), /text\/javascript/, module); diff --git a/tests/stoa-panel.test.mjs b/tests/stoa-panel.test.mjs index a3331b6..7845d51 100644 --- a/tests/stoa-panel.test.mjs +++ b/tests/stoa-panel.test.mjs @@ -90,3 +90,20 @@ test("the space owner is never offered Remove", () => { panel.setPeople([{ id: "account:o", name: "Owner" }], "Me", { manage: true, hostId: "account:me", ownerId: "account:o", selfId: "account:me" }); assert.deepEqual(doc.querySelector("#stoa-people").children[1].children.filter((child) => child.tagName === "button").map((button) => button.textContent), ["Make host"]); }); + +test("Manage shows only for the owner; your spaces only for signed-in people on the plaza", () => { + const doc = fakeDocument(), panel = createPanel(doc); + let managed = 0; + panel.on({ manage: () => { managed += 1; } }); + panel.showRoom({ kicker: "UNLISTED SPACE", title: "Lab", topic: null, tags: [], host: true, owner: false, leaveLabel: "Leave" }); + assert.equal(doc.querySelector("#stoa-manage").hidden, true); + panel.showRoom({ kicker: "UNLISTED SPACE", title: "Lab", topic: null, tags: [], host: true, owner: true, leaveLabel: "Leave" }); + assert.equal(doc.querySelector("#stoa-manage").hidden, false); + doc.querySelector("#stoa-manage").listeners.click(); + assert.equal(managed, 1); + panel.showPlaza(); + assert.equal(doc.querySelector("#stoa-mine-section").hidden, true); + panel.setSignedIn(true); + panel.showPlaza(); + assert.equal(doc.querySelector("#stoa-mine-section").hidden, false); +}); diff --git a/tests/stoa-spaces.test.mjs b/tests/stoa-spaces.test.mjs new file mode 100644 index 0000000..918a919 --- /dev/null +++ b/tests/stoa-spaces.test.mjs @@ -0,0 +1,19 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { describeSpace, inviteUrl, memberPath, spacePayload } from "../stoa/spaces.js"; + +test("form values become a valid space request", () => { + assert.deepEqual(spacePayload({ title: " Lab ", purpose: " Pairing ", visibility: "private", access: "open", capacity: "8", themeId: "cyberpunk" }), { title: "Lab", purpose: "Pairing", visibility: "private", access: "members", capacity: 8, themeId: "cyberpunk" }); + assert.deepEqual(spacePayload({ title: "Lab", visibility: "listed", access: "house", capacity: "12" }), { title: "Lab", purpose: "", visibility: "unlisted", access: "house", capacity: 12, themeId: "agora" }); + assert.equal(spacePayload({ title: "Lab", access: "everyone", capacity: "x" }).access, "members"); + assert.ok(Number.isNaN(spacePayload({ title: "Lab", capacity: "x" }).capacity), "the server reports a bad capacity"); +}); +test("spaces are described in plain words, and invite links are absolute", () => { + assert.equal(describeSpace({ visibility: "private", access: "members", occupancy: 2 }), "Private · members only · 2 here"); + assert.equal(describeSpace({ visibility: "unlisted", access: "open", occupancy: 0 }), "Unlisted · anyone with the link · 0 here"); + assert.equal(inviteUrl("https://agora.build", "/stoa/s/abc?invite=xyz"), "https://agora.build/stoa/s/abc?invite=xyz"); +}); +test("member ids keep their colon in the path, which the server matches literally", () => { + assert.equal(memberPath("account:0b1c2d3e-0000-4000-8000-000000000000"), "account:0b1c2d3e-0000-4000-8000-000000000000"); + assert.equal(memberPath("a/b"), "a%2Fb"); +}); From fdf98a95fa677bc08a76fb77e8c721d97e316c43 Mon Sep 17 00:00:00 2001 From: Brent G <deliberatekids@gmail.com> Date: Thu, 8 Oct 2026 19:44:59 +0000 Subject: [PATCH 65/71] Keep the space view in step with its settings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🤖 Built with SMT <smt@agora.build> --- stoa.js | 28 +++++++++++++++++----------- stoa/spaces.js | 22 +++++++++++++++------- stoa/stage.js | 1 + tests/stoa-spaces.test.mjs | 2 +- 4 files changed, 34 insertions(+), 19 deletions(-) diff --git a/stoa.js b/stoa.js index a319521..8a5aa3a 100644 --- a/stoa.js +++ b/stoa.js @@ -33,13 +33,14 @@ const [spaceResult, worldResult, themesResult, spacesResult] = await Promise.all ]); failure(spaceResult, "This space"); failure(worldResult, "The map"); failure(themesResult, "Themes"); failure(spacesResult, "The room list"); const space = spaceResult.value?.space || null; -document.querySelector("#stoa-kicker").textContent = roomSlug ? "THE STOA / ROOM" : space ? (space.visibility === "private" ? "THE STOA / PRIVATE SPACE" : "THE STOA / UNLISTED SPACE") : "THE STOA / PUBLIC PLAZA"; +const kickerFor = (entry) => entry ? (entry.visibility === "private" ? "THE STOA / PRIVATE SPACE" : "THE STOA / UNLISTED SPACE") : "THE STOA / SPACE"; if (space) { document.querySelector("#stoa-title").textContent = space.title; document.title = space.title + " | Agora Build"; } let map = null, engine = null, minimal = stored.get() === "true", preview = null, plazaBounds = null, active = () => null, call = null, decor = null, spacesUi = null; try { if (worldResult.status === "fulfilled") map = parseMap(worldResult.value); } catch (error) { problems.push("The map is not valid (" + error.message + ")."); } let summaries = new Map((spacesResult.value?.rooms || []).map((room) => [room.slug, room])); const lot = roomSlug && map ? map.lots.find((entry) => entry.slug === roomSlug) || null : null; +document.querySelector("#stoa-kicker").textContent = spaceId ? kickerFor(space) : roomSlug ? (lot ? "THE STOA / ROOM" : "THE STOA") : "THE STOA / PUBLIC PLAZA"; const labels = () => map.lots.map((entry) => ({ x: entry.door.x, y: entry.door.y - 1, text: summaries.get(entry.slug)?.topic || entry.title })); if (map && !spaceId) panel.setRooms(map.lots, summaries); @@ -80,7 +81,12 @@ if (map && themesResult.status === "fulfilled" && (!spaceId || space)) { await sessionReady; const self = { id: state.account?.id || state.profile?.id || null, ids: [state.account?.id, state.profile?.id].filter(Boolean), name: state.profile?.name || state.account?.name || "You", signedIn: Boolean(state.account || state.profile) }; panel.setSignedIn(self.signedIn); -spacesUi = createSpacesUi({ api, say: (text) => panel.say(text) }); +spacesUi = createSpacesUi({ api, say: (text) => panel.say(text), onSaved: (saved, themeChanged) => { + document.querySelector("#stoa-title").textContent = saved.title; document.title = saved.title + " | Agora Build"; + document.querySelector("#stoa-kicker").textContent = kickerFor(saved); + stage?.refresh(); + if (themeChanged) panel.say("The new theme shows after you reload."); +} }); panel.setPeople([], self.name); panel.canTalk(self.signedIn); let live = null, stage = null; @@ -113,6 +119,15 @@ panel.on({ manage: () => spaceId && spacesUi.openManage(spaceId), signin: () => openSignIn() }); +// Your own spaces show on the plaza right away (with or without the canvas); ?start= (from the home page) opens the create dialog with that name. +if (!spaceId && self.signedIn) { if (!engine) panel.showPlaza(); api("/api/spaces/mine").then((mine) => spacesUi.showMine(mine)).catch(() => {}); } +const params = new URLSearchParams(location.search); +if (!spaceId && params.has("start")) { + const title = params.get("start"); + params.delete("start"); + history.replaceState(null, "", location.pathname + (params.size ? "?" + params : "") + location.hash); + startSpace({ title }); +} // Signaling loads and connects in the background: the page is already usable, and the stage picks the connection up when it is ready. async function connectLive() { let candidate = null, timer = null, abandoned = false, connecting = null; @@ -165,12 +180,3 @@ if (engine) { } if (problems.length) panel.say((engine && !self.signedIn && !spaceId ? ["You're watching the plaza. Sign in to walk and talk."] : []).concat(problems).join(" ")); else if (!engine) panel.say("The Stoa view is not available."); -// Your own spaces show on the plaza; ?start= (from the home page) opens the create dialog with that name. -if (!spaceId && self.signedIn) api("/api/spaces/mine").then((mine) => spacesUi.showMine(mine)).catch(() => {}); -const params = new URLSearchParams(location.search); -if (!spaceId && params.has("start")) { - const title = params.get("start"); - params.delete("start"); - history.replaceState(null, "", location.pathname + (params.size ? "?" + params : "") + location.hash); - startSpace({ title }); -} diff --git a/stoa/spaces.js b/stoa/spaces.js index 4aea3f9..99df8d7 100644 --- a/stoa/spaces.js +++ b/stoa/spaces.js @@ -1,6 +1,6 @@ // Your own spaces: the list on the plaza, the create dialog, and the owner's manage dialog (settings, invites, members). export const THEME_CHOICES = [["agora", "Agora"], ["minimal", "Minimal"], ["cyberpunk", "Cyberpunk"]]; -const ACCESS_WORDS = { open: "anyone with the link", house: "house members", members: "members only" }; +const ACCESS_WORDS = { open: "anyone signed in", house: "house members", members: "members only" }; export function spacePayload(values) { const visibility = values.visibility === "private" ? "private" : "unlisted"; const access = visibility === "private" ? "members" : Object.hasOwn(ACCESS_WORDS, values.access) ? values.access : "members"; @@ -11,7 +11,7 @@ export const describeSpace = (space) => [space.visibility === "private" ? "Priva export const memberPath = (id) => encodeURIComponent(id).replace(/%3A/gi, ":"); export const inviteUrl = (origin, path) => new URL(path, origin).href; -export function createSpacesUi({ doc = document, api, say = () => {}, navigate = (path) => location.assign(path), confirm = (text) => window.confirm(text), copy = (text) => navigator.clipboard.writeText(text), origin = location.origin }) { +export function createSpacesUi({ onSaved = () => {}, doc = document, api, say = () => {}, navigate = (path) => location.assign(path), confirm = (text) => window.confirm(text), copy = (text) => navigator.clipboard.writeText(text), origin = location.origin }) { const $ = (selector) => doc.querySelector(selector); const node = (tag, text, className) => { const element = doc.createElement(tag); if (text) element.textContent = text; if (className) element.className = className; return element; }; const createDialog = $("#space-dialog"), createForm = $("#space-form"), manageDialog = $("#manage-dialog"), manageForm = $("#manage-form"); @@ -48,36 +48,44 @@ export function createSpacesUi({ doc = document, api, say = () => {}, navigate = $("#manage-title").textContent = "Manage " + space.title; people(space.roster || [], "#manage-members", "Remove", async (person) => { if (!confirm("Remove " + person.name + " from the members of " + space.title + "?")) return; + $("#manage-error").textContent = ""; try { await api("/api/spaces/" + encodeURIComponent(space.id) + "/members/" + memberPath(person.id), { method: "DELETE" }); await load(space.id); say(person.name + " is no longer a member."); } catch (error) { $("#manage-error").textContent = error.message; } }, space.ownerId); people(space.blockedRoster || [], "#manage-blocked", "Allow back", async (person) => { + $("#manage-error").textContent = ""; try { await api("/api/spaces/" + encodeURIComponent(space.id) + "/members", { method: "POST", body: JSON.stringify({ memberId: person.id }) }); await load(space.id); say(person.name + " can come back."); } catch (error) { $("#manage-error").textContent = error.message; } }); } manageForm.addEventListener("submit", async (event) => { event.preventDefault(); - const error = $("#manage-error"); - error.textContent = ""; - try { const { space } = await api("/api/spaces/" + encodeURIComponent(managing.id), { method: "PUT", body: JSON.stringify(values(manageForm)) }); managing = { ...managing, ...space }; say("Space settings saved."); } + const error = $("#manage-error"), button = manageForm.querySelector("[type=submit]"); + error.textContent = ""; button.disabled = true; + try { const { space } = await api("/api/spaces/" + encodeURIComponent(managing.id), { method: "PUT", body: JSON.stringify(values(manageForm)) }); const themeChanged = space.themeId !== managing.themeId; managing = { ...managing, ...space }; say("Space settings saved."); onSaved(managing, themeChanged); } catch (failure) { error.textContent = failure.message; } + finally { button.disabled = false; } }); - $("#manage-invite").addEventListener("click", async () => { + $("#manage-invite").addEventListener("click", async (event) => { + const button = event.currentTarget; + $("#manage-error").textContent = ""; button.disabled = true; try { const invite = await api("/api/spaces/" + encodeURIComponent(managing.id) + "/invitations", { method: "POST", body: "{}" }); const field = $("#manage-invite-url"); field.value = inviteUrl(origin, invite.path); field.hidden = false; $("#manage-copy").hidden = false; - $("#manage-invite-note").textContent = "Works " + invite.usesLeft + " times until " + new Date(invite.expiresAt).toLocaleString() + ". Anyone with it can join as a member."; + $("#manage-invite-note").textContent = (invite.usesLeft === 1 ? "Works once" : "Works " + invite.usesLeft + " times") + " until " + new Date(invite.expiresAt).toLocaleString() + ". Anyone with it can join as a member."; } catch (error) { $("#manage-error").textContent = error.message; } + finally { button.disabled = false; } }); $("#manage-copy").addEventListener("click", async () => { + $("#manage-error").textContent = ""; try { await copy($("#manage-invite-url").value); say("Invite link copied."); } catch { $("#manage-invite-url").select?.(); say("Copy the selected link."); } }); $("#manage-delete").addEventListener("click", async () => { if (!managing || !confirm("Delete " + managing.title + " for everyone? This can't be undone.")) return; + $("#manage-error").textContent = ""; try { await api("/api/spaces/" + encodeURIComponent(managing.id), { method: "DELETE" }); navigate("/stoa/"); } catch (error) { $("#manage-error").textContent = error.message; } }); diff --git a/stoa/stage.js b/stoa/stage.js index 1e30313..265ef64 100644 --- a/stoa/stage.js +++ b/stoa/stage.js @@ -237,6 +237,7 @@ export function createStage({ api, map, engine, panel, live: firstLive, self, pl enterLot, openSpace: (id, invite) => transition(() => openSpaceTask(id, invite)), leaveRoom, + refresh: refreshRoom, setTopic, makeHost, removePerson, diff --git a/tests/stoa-spaces.test.mjs b/tests/stoa-spaces.test.mjs index 918a919..f2b4161 100644 --- a/tests/stoa-spaces.test.mjs +++ b/tests/stoa-spaces.test.mjs @@ -10,7 +10,7 @@ test("form values become a valid space request", () => { }); test("spaces are described in plain words, and invite links are absolute", () => { assert.equal(describeSpace({ visibility: "private", access: "members", occupancy: 2 }), "Private · members only · 2 here"); - assert.equal(describeSpace({ visibility: "unlisted", access: "open", occupancy: 0 }), "Unlisted · anyone with the link · 0 here"); + assert.equal(describeSpace({ visibility: "unlisted", access: "open", occupancy: 0 }), "Unlisted · anyone signed in · 0 here"); assert.equal(inviteUrl("https://agora.build", "/stoa/s/abc?invite=xyz"), "https://agora.build/stoa/s/abc?invite=xyz"); }); test("member ids keep their colon in the path, which the server matches literally", () => { From b8c46fe5089df867e7eeea04d3e9df663e480d6c Mon Sep 17 00:00:00 2001 From: Brent G <deliberatekids@gmail.com> Date: Thu, 8 Oct 2026 19:49:38 +0000 Subject: [PATCH 66/71] Retire the meetings page in favour of Stoa spaces MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Old /meet links and meetings.html redirect into the Stoa, the /api/rooms routes answer 410, and starting a room from any page opens the Stoa's Start a space dialog. 🤖 Built with SMT <smt@agora.build> --- README.md | 22 +-- account.html | 5 - call.js | 316 -------------------------------------- explore.html | 5 - index.html | 7 +- meetings.html | 60 -------- meetings.js | 69 --------- radar.html | 5 - script.js | 36 +---- scripts/auth.mjs | 3 +- scripts/build.mjs | 2 +- scripts/calls.mjs | 4 - scripts/serve.mjs | 22 +-- services.html | 5 - stoa.html | 5 - styles.css | 58 +------ tests/auth.test.mjs | 9 +- tests/calls.test.mjs | 18 +-- tests/server.test.mjs | 96 ++---------- tests/spaces-api.test.mjs | 2 +- 20 files changed, 61 insertions(+), 688 deletions(-) delete mode 100644 call.js delete mode 100644 meetings.html delete mode 100644 meetings.js diff --git a/README.md b/README.md index 66b79cc..b267fc3 100644 --- a/README.md +++ b/README.md @@ -34,7 +34,7 @@ Without `DATABASE_URL`, state is stored in `/app/.data`. Mount a volume there so - `index.html` / `people.js`: people discovery, skills, natural-language search, and public profiles. - `activity.js` / `scripts/activity.mjs`: public GitHub events and actual community actions, with timestamps and source links. - `explore.html` / `explore.js`: 16 verified GitHub projects, categories, search, and sorting. -- `meetings.html` / `meetings.js` / `call.js`: shared group rooms, invite links, member rosters, and embedded Agora audio/video calls. +- `stoa.html` / `stoa.js` / `stoa/` / `world/`: the Stoa — a walkable plaza with first-come rooms and your own spaces, live presence and messages over Agora Signaling, and calls over Agora RTC. - `radar.html` / `radar.js`: intent-driven web research with source links and reasons. - `services.html` / `services.js`: language-model and text-to-speech playgrounds, plus verified offers. - `account.html` / `account.js`: shared identity, credits, membership plans, purchases, and Agora Console connections. @@ -53,11 +53,11 @@ Shared navigation, dialogs, sign-in, and browser sessions live in `script.js`; s The homepage polls `/api/activity` every minute while visible. The server reads public Agora-Build GitHub events at most once every two minutes, shares concurrent requests, uses ETags, and backs off on rate limits. The feed includes real pushes, pull requests, issues, and releases from repositories in the project directory. GitHub can delay event delivery; timestamps show when actions happened, not when they were fetched. `data/activity.json` is a dated GitHub snapshot used when live sync is unavailable, with that state labeled explicitly. -Joining the foundry, changing an intent, creating a room, and joining a room roster also produce public activity entries. Repeated roster joins do not create new entries. Profile removal clears the member's activity and references to their deleted rooms. Activity indicates public work and participation in the directory; it does not imply that someone is online or currently on a call. +Joining the foundry and changing an intent also produce public activity entries. Activity from legacy meeting rooms may remain in the feed. Profile removal clears the member's activity and references to their deleted rooms. Activity indicates public work and participation in the directory; it does not imply that someone is online or currently on a call. ## Profiles and ownership -Joining requires a name, bio, skills, a public contact link, and a real intent. Profiles and meeting rosters are public. Sign-in creates a private account first; nothing is published until the person supplies their intent and saves a profile. Returning to the same provider identity on another device restores the same profile, room ownership, and private radar. +Joining requires a name, bio, skills, a public contact link, and a real intent. Profiles are public. Sign-in creates a private account first; nothing is published until the person supplies their intent and saves a profile. Returning to the same provider identity on another device restores the same profile, room ownership, and private radar. The sign-in dialog supports Google and GitHub OAuth, Sign in with Apple, and a configurable Agora OIDC identity service. Members can explicitly connect another provider to the same account. An existing browser-only profile can connect a login while preserving its ID and rooms; its old browser token is then invalidated. Accounts are never merged by email. Provider subjects and session hashes stay private. Account sessions last 30 days and logout revokes the current session. Removing a public profile also removes its radar and owned rooms, while the private login account remains available to start a new profile. @@ -99,23 +99,25 @@ Shared inference is limited to six calls per minute per client IP and `MODEL_REQ ## Agora group calls -Create an Agora project with App Certificate authentication enabled. Set `AGORA_APP_ID` and `AGORA_APP_CERTIFICATE` in the server's `.env` and restart. Both must be 32-character hexadecimal values. The certificate stays on the server; authenticated room members receive short-lived, channel-specific AccessToken2 credentials from `POST /api/rooms/:id/call`. `AGORA_TOKEN_TTL_SECONDS` defaults to 3600 and is bounded to 60–3600 seconds. The SDK renews credentials before they expire; renewals require the same browser profile and current room membership. +Create an Agora project with App Certificate authentication enabled. Set `AGORA_APP_ID` and `AGORA_APP_CERTIFICATE` in the server's `.env` and restart. Both must be 32-character hexadecimal values. The certificate stays on the server; signed-in people receive short-lived, channel-specific AccessToken2 credentials from `POST /api/spaces/<id>/rtc-token`, and only while they are currently in that room. `AGORA_TOKEN_TTL_SECONDS` defaults to 3600 and is bounded to 60–3600 seconds. The SDK renews credentials before they expire; renewals require the same browser profile and current presence in the room. -Every room has a purpose and a permanent shareable URL, `/meet/<room-id>`. Room creation returns that path and opens the room; its card displays the full invite URL and a copy action. A direct link opens that room, preserves the invitation through login, and lets new members complete their real-intent profile before joining. Existing `meetings.html?room=<id>` invitations remain supported. +Calls happen inside the Stoa's lot rooms and your own spaces. Video layouts follow the theme: over avatars (Agora), a strip (Cyberpunk), or a grid (Minimal). Members choose whether to enable devices, toggle microphone/camera, share a screen, and leave the call. Screen sharing uses a separate Agora publisher so it can run alongside the camera; browser support determines whether screen audio is available. Camera, microphone, and screen tracks close on leave and page exit. Separate tabs get distinct media identities. -Members join audio/video inside Agora.Build, choose whether to enable devices, toggle microphone/camera, share a screen, and leave the call. Screen sharing uses a separate Agora publisher so it can run alongside the camera; browser support determines whether screen audio is available. Camera, microphone, and screen tracks close on leave and page exit. Separate tabs get distinct media identities. The participant count inside a call comes from actual Agora peers; the directory roster refreshes every 15 seconds and is not live call attendance. +Old `/meet/<id>` links and `/meetings.html?room=<id>` redirect to `/stoa/s/<id>`, where legacy rooms now live as spaces with the same ids. The `/api/rooms` routes answer `410 Gone`. -Use HTTPS for remote callers, or `http://localhost:3002` for local development. Browsers restrict media devices on plain remote HTTP. No Agora credentials are bundled in the site. Without valid configuration, rooms and invites remain available and calls show an unavailable state. Existing room IDs and rosters continue working, and old external call URLs are omitted from API responses. Removing a profile or room blocks new tokens and renewal; an already-issued Agora token remains valid until its short expiration. +Use HTTPS for remote callers, or `http://localhost:3002` for local development. Browsers restrict media devices on plain remote HTTP. No Agora credentials are bundled in the site. Without valid configuration, rooms stay usable and calls show an unavailable state. Leaving a room or removing a profile blocks new tokens and renewal; an already-issued Agora token remains valid until its short expiration. ## Stoa spaces The Stoa is a walkable plaza (`/stoa/`) with first-come rooms (`/stoa/room/<slug>`) and unlisted or private spaces (`/stoa/s/<id>`). Calls use Agora RTC; movement, presence, and live messages use Agora Signaling. Enable Signaling for the Agora project and set `SPACE_CHANNEL_SECRET` to at least 32 random characters. The built-in maps in `worlds/` are generated by `npm run maps`. -Built-in themes live in `themes/` (Agora, Minimal, Cyberpunk) and are validated at startup. Visitors can switch their own view to Minimal; `/stoa/?theme=cyberpunk` previews a theme. Movement works with click or tap and with the arrow keys or WASD; the side panel offers the same actions for keyboard and screen reader users. +Built-in themes live in `themes/` (Agora, Minimal, Cyberpunk) and are validated at startup. Hosts can hand over hosting, remove people from the room, and decorate it. Signed-in people can start their own spaces (`/stoa/?start=<title>` opens the dialog with a title filled in), invite others with links, manage members, change settings, and delete a space. Free accounts can own up to 3 spaces; premium plans can own up to 20. + +Visitors can switch their own view to Minimal; `/stoa/?theme=cyberpunk` previews a theme. Movement works with click or tap and with the arrow keys or WASD; the side panel offers the same actions for keyboard and screen reader users. ## The physical forge -Demo days, hackathons, and whiteboard sessions are core community activities. The homepage offers a session-planning entry point for each. Create a room with your real intent, include a place and time in the session purpose, and share the invite with collaborators. Use the same workspace to prepare online and reconnect after meeting in person. These are planning tools; the site does not advertise invented events, venues, or attendees. +Demo days, hackathons, and whiteboard sessions are core community activities. The homepage offers a session-planning entry point for each. Start a space in the Stoa with your real intent, include a place and time in the session purpose, and share the invite with collaborators. Use the same workspace to prepare online and reconnect after meeting in person. These are planning tools; the site does not advertise invented events, venues, or attendees. ## Community offers @@ -123,4 +125,4 @@ Demo days, hackathons, and whiteboard sessions are core community activities. Th ## Verification -`npm test` uses the Node test runner with isolated temporary data and mocked providers. It covers public-file restrictions, profiles and ownership, cross-device sign-in, OAuth state/PKCE/JWT verification, Apple's POST callback, safe account linking, session expiry and logout, direct meeting URLs, persistence, search, sourced research, service limits, Agora token signatures/renewal, provider migration, activity caching/deletion, and production output. For UI changes, check all pages on desktop and mobile, keyboard navigation, reduced motion, dialogs, empty states, filters, sourced activity, provider availability, and two browser sessions joining one room. With configured provider registrations, verify live logins and Agora media, token renewal, and device release on leave. +`npm test` uses the Node test runner with isolated temporary data and mocked providers. It covers public-file restrictions, profiles and ownership, cross-device sign-in, OAuth state/PKCE/JWT verification, Apple's POST callback, safe account linking, session expiry and logout, old meeting-link redirects, persistence, search, sourced research, service limits, Agora token signatures/renewal, provider migration, activity caching/deletion, and production output. For UI changes, check all pages on desktop and mobile, keyboard navigation, reduced motion, dialogs, empty states, filters, sourced activity, provider availability, and two browser sessions entering one room. With configured provider registrations, verify live logins and Agora media, token renewal, and device release on leave. diff --git a/account.html b/account.html index 5a3bf5b..b82e3ad 100644 --- a/account.html +++ b/account.html @@ -49,11 +49,6 @@ <h2 id="join-title">Pull up a chair.</h2> </form> </dialog> <dialog id="person-dialog" aria-labelledby="person-title"><div class="dialog-head"><span class="kicker">BUILDER PROFILE</span><button class="close-button" type="button" data-close aria-label="Close builder profile">×</button></div><div id="profile-content"></div></dialog> - <dialog id="room-dialog" aria-labelledby="room-title"> - <div class="dialog-head"><span class="kicker">MAKE ROOM FOR A CONVERSATION</span><button class="close-button" type="button" data-close aria-label="Close room form">×</button></div> - <h2 id="room-title">Meet around a purpose.</h2><p class="dialog-intro">Create a group room, share the invite, and open a video call together.</p> - <form id="room-form" class="stack-form"><label>Room name<input name="title" required minlength="2" maxlength="80" placeholder="Voice agents build session"></label><label>What are we meeting for?<textarea name="intent" required minlength="2" maxlength="300" rows="3" placeholder="Debug a problem, plan a demo day, or bring a whiteboard session into the next sprint."></textarea></label><p class="form-note">Room names, purposes, and rosters are public. Audio, video, and screen sharing stay in this workspace, powered by Agora.</p><p id="room-error" class="form-error" role="alert"></p><button type="submit" class="button">Create meeting room <span aria-hidden="true">↗</span></button></form> - </dialog> <div id="toast" role="status" aria-live="polite"></div> </body> </html> diff --git a/call.js b/call.js deleted file mode 100644 index e2ab9f2..0000000 --- a/call.js +++ /dev/null @@ -1,316 +0,0 @@ -import { api, el, state, notify } from "./script.js"; - -const panel = document.querySelector("#call-panel"); -const stage = document.querySelector("#call-stage"); -const status = document.querySelector("#call-status"); -const join = document.querySelector("#call-join"); -const mic = document.querySelector("#call-mic"); -const camera = document.querySelector("#call-camera"); -const share = document.querySelector("#call-share"); -const leave = document.querySelector("#call-leave"); -const resume = document.querySelector("#call-resume"); -let current; -let sdkPromise; - -function loadSDK() { - if (window.AgoraRTC) return Promise.resolve(window.AgoraRTC); - if (!sdkPromise) sdkPromise = new Promise((resolve, reject) => { - const script = el("script", { src: "/assets/agora-rtc.js" }); - const timer = setTimeout(() => fail(), 15000); - const fail = () => { clearTimeout(timer); script.remove(); sdkPromise = null; reject(new Error("The call engine could not load. Try again.")); }; - script.onerror = fail; - script.onload = () => { clearTimeout(timer); if (window.AgoraRTC) resolve(window.AgoraRTC); else fail(); }; - document.head.append(script); - }); - return sdkPromise; -} - -function mediaError(error) { - if (error.status) return error.message; - if (/PERMISSION|NOT_ALLOWED|NotAllowed/i.test(error.code || error.name || "")) return "Device access was declined. Check your browser permissions and try again."; - if (/NOT_FOUND|NotFound/i.test(error.code || error.name || "")) return "That microphone or camera could not be found. You can still listen to the call."; - return "The call could not complete that action. Check your connection and try again."; -} - -function controls(ctx) { - const connected = ctx.joined && !ctx.closed; - join.disabled = ctx.busy || connected || !ctx.ready || !window.isSecureContext; - join.hidden = connected; - document.querySelector("#call-preflight").hidden = connected; - for (const button of [mic, camera, share]) button.disabled = !connected || ctx.busy; - const audioOn = Boolean(ctx.audio?.enabled); - const videoOn = Boolean(ctx.video?.enabled); - mic.textContent = audioOn ? "Mute mic" : "Turn mic on"; - camera.textContent = videoOn ? "Turn camera off" : "Turn camera on"; - mic.setAttribute("aria-pressed", String(audioOn)); - camera.setAttribute("aria-pressed", String(videoOn)); - share.textContent = ctx.screen ? "Stop sharing" : "Share screen"; - share.setAttribute("aria-pressed", String(Boolean(ctx.screen))); - share.disabled ||= !navigator.mediaDevices?.getDisplayMedia; - leave.textContent = connected ? "Leave call" : "Close"; -} - -function personName(uid, ctx) { - if (uid === ctx.credentials?.uid || uid === ctx.credentials?.screenUid) return "You"; - const id = "member:" + String(uid).split("_")[0]; - return state.people.find((person) => person.id === id)?.name || "Builder"; -} - -async function refreshNames(ctx) { - if (ctx.peopleRequest) return ctx.peopleRequest; - ctx.peopleRequest = (async () => { - const result = await api("/api/people"); - if (ctx.closed) return; - state.people = result.people; - for (const [uid, view] of ctx.tiles) { - const screen = String(uid).endsWith("_screen"); - const name = personName(uid, ctx) + (screen ? " / screen" : ""); - view.card.setAttribute("aria-label", name); - view.label.textContent = name; - if (!screen) view.placeholder.textContent = name.split(/\s+/).slice(0, 2).map((part) => part[0]).join(""); - } - })(); - try { await ctx.peopleRequest; } finally { ctx.peopleRequest = null; } -} - -function tile(ctx, uid) { - if (ctx.tiles.has(uid)) return ctx.tiles.get(uid); - const screen = String(uid).endsWith("_screen"); - const name = personName(uid, ctx) + (screen ? " / screen" : ""); - const player = el("div", { class: "call-player" }); - const placeholder = el("div", { class: "call-placeholder", "aria-hidden": "true", text: screen ? "[ ]" : name.split(/\s+/).slice(0, 2).map((part) => part[0]).join("") }); - const label = el("span", { class: "call-tile-name", text: name }); - const card = el("article", { class: "call-tile" + (screen ? " is-screen" : ""), "aria-label": name }, [player, placeholder, label]); - stage.append(card); - const value = { card, player, placeholder, label }; - ctx.tiles.set(uid, value); - return value; -} - -function showVideo(ctx, uid, track) { - const view = tile(ctx, uid); - view.placeholder.hidden = Boolean(track); - if (track) track.play(view.player, { fit: String(uid).endsWith("_screen") ? "contain" : "cover" }); -} - -function count(ctx) { - const peers = ctx.client?.remoteUsers.filter((user) => !String(user.uid).endsWith("_screen")) || []; - document.querySelector("#call-count").textContent = ctx.joined ? (peers.length + 1) + " in this call" : "Not connected"; -} - -async function renew(ctx) { - if (ctx.closed) return; - if (ctx.renewing) return ctx.renewing; - ctx.renewing = (async () => { - const credentials = await api("/api/rooms/" + ctx.room.id + "/call", { method: "POST", body: JSON.stringify({ uid: ctx.credentials.uid }) }); - if (ctx.closed) return; - ctx.credentials = credentials; - await ctx.client.renewToken(credentials.token); - if (ctx.screen?.client.connectionState === "CONNECTED") await ctx.screen.client.renewToken(credentials.screenToken); - })(); - try { await ctx.renewing; } finally { ctx.renewing = null; } -} - -function tokenEvents(client, ctx) { - const refresh = () => renew(ctx).catch(async (error) => { - if (ctx.closed) return; - notify(error.status ? error.message : "Call access could not be renewed. Please rejoin."); - await leaveCall(); - }); - client.on("token-privilege-will-expire", refresh); - client.on("token-privilege-did-expire", refresh); -} - -function clientEvents(ctx) { - const client = ctx.client; - const ownScreen = (user) => user.uid === ctx.credentials.screenUid; - client.on("user-joined", (user) => { - if (ctx.closed || ownScreen(user)) return; - tile(ctx, user.uid); count(ctx); - if (personName(user.uid, ctx) === "Builder") refreshNames(ctx).catch(() => {}); - }); - client.on("user-left", (user) => { - ctx.tiles.get(user.uid)?.card.remove(); ctx.tiles.delete(user.uid); count(ctx); - }); - client.on("user-published", (user, media) => { - if (ctx.closed || ownScreen(user)) return; - client.subscribe(user, media).then(() => { - if (ctx.closed || !client.remoteUsers.some((peer) => peer.uid === user.uid)) return; - if (media === "video") showVideo(ctx, user.uid, user.videoTrack); - if (media === "audio") user.audioTrack?.play(); - count(ctx); - }).catch(() => { if (!ctx.closed) notify("One participant's media could not be loaded. Agora will retry when they republish."); }); - }); - client.on("user-unpublished", (user, media) => { - if (ctx.closed || ownScreen(user)) return; - if (media === "video") { user.videoTrack?.stop(); showVideo(ctx, user.uid, null); } - if (media === "audio") user.audioTrack?.stop(); - }); - client.on("connection-state-change", (next, previous) => { - if (ctx.closed) return; - if (next === "RECONNECTING") status.textContent = "Connection interrupted. Reconnecting..."; - if (next === "CONNECTED") status.textContent = "Connected / Agora RTC"; - if (next === "DISCONNECTED" && previous !== "DISCONNECTING" && ctx.joined) { - notify("The call disconnected. Open the room to rejoin."); - leaveCall(); - } - }); - tokenEvents(client, ctx); -} - -async function enableTrack(ctx, kind) { - const track = kind === "audio" ? await ctx.sdk.createMicrophoneAudioTrack() : await ctx.sdk.createCameraVideoTrack({ encoderConfig: "480p_1" }); - if (ctx.closed) { track.close(); return; } - ctx[kind] = track; - try { await ctx.client.publish(track); } - catch (error) { track.close(); ctx[kind] = null; throw error; } - if (kind === "video" && !ctx.closed) showVideo(ctx, ctx.credentials.uid, track); -} - -async function stopScreen(ctx) { - const screen = ctx.screen; - if (!screen) return; - ctx.screen = null; - screen.client.removeAllListeners(); - for (const track of screen.tracks) { track.stop(); track.close(); } - ctx.tiles.get(ctx.credentials.screenUid)?.card.remove(); - ctx.tiles.delete(ctx.credentials.screenUid); - await screen.client.leave(); - ctx.screenEnded = false; -} - -async function operate(operation) { - const ctx = current; - if (!ctx || ctx.busy || ctx.closed) return; - ctx.busy = true; controls(ctx); - ctx.work = (async () => { - try { await operation(ctx); } - catch (error) { if (!ctx.closed) notify(mediaError(error)); } - finally { - ctx.busy = false; - if (!ctx.closed) { - controls(ctx); - if (ctx.screenEnded) operate(stopScreen); - } - } - })(); - await ctx.work; -} - -export function openCall(room, ready) { - if (current) { - if (current.room.id !== room.id) { notify("Leave your current call before opening another room."); return; } - } else { - current = { room, ready, busy: false, joined: false, closed: false, tiles: new Map() }; - stage.replaceChildren(); - document.querySelector("#call-title").textContent = room.title; - document.querySelector("#call-purpose").textContent = room.intent; - status.textContent = !ready ? "Calls are not connected yet. You can still share this room's invite." : !window.isSecureContext ? "Open this page over HTTPS or localhost to use your microphone and camera." : "Ready when you are. Choose your devices, then join."; - document.querySelector("#call-error").textContent = ""; - resume.hidden = true; - count(current); controls(current); - } - panel.hidden = false; - panel.scrollIntoView({ block: "start", behavior: window.matchMedia("(prefers-reduced-motion: reduce)").matches ? "auto" : "smooth" }); - document.querySelector("#call-title").focus({ preventScroll: true }); -} - -async function leaveCall() { - const ctx = current; - if (!ctx || ctx.closed) return; - ctx.closed = true; - status.textContent = "Leaving the call..."; - // Release devices immediately, including when a join or publish is still pending. - for (const track of [ctx.audio, ctx.video, ...(ctx.screen?.tracks || [])]) track?.close(); - await ctx.work; - await Promise.allSettled([stopScreen(ctx), (async () => { ctx.client?.removeAllListeners(); await ctx.client?.leave(); })()]); - for (const track of [ctx.audio, ctx.video]) track?.close(); - if (current === ctx) { - current = null; - stage.replaceChildren(); panel.hidden = true; - document.querySelector("#rooms-title").focus({ preventScroll: true }); - } -} - -join.addEventListener("click", () => operate(async (ctx) => { - status.textContent = "Connecting to the room..."; - document.querySelector("#call-error").textContent = ""; - try { - ctx.sdk = await loadSDK(); - if (ctx.closed) return; - ctx.sdk.setLogLevel(3); - if (!ctx.sdk.checkSystemRequirements()) throw new Error("unsupported"); - ctx.sdk.onAutoplayFailed = () => { if (!ctx.closed) resume.hidden = false; }; - ctx.credentials = await api("/api/rooms/" + ctx.room.id + "/call", { method: "POST", body: "{}" }); - if (ctx.closed) return; - await refreshNames(ctx).catch(() => {}); - if (ctx.closed) return; - ctx.client = ctx.sdk.createClient({ mode: "rtc", codec: "vp8" }); - clientEvents(ctx); - await ctx.client.join(ctx.credentials.appId, ctx.credentials.channel, ctx.credentials.token, ctx.credentials.uid); - if (ctx.closed) return; - ctx.joined = true; - tile(ctx, ctx.credentials.uid); count(ctx); - for (const [kind, selector] of [["audio", "#call-start-mic"], ["video", "#call-start-camera"]]) { - if (!ctx.closed && document.querySelector(selector).checked) { - try { await enableTrack(ctx, kind); } catch (error) { notify(mediaError(error)); } - } - } - if (!ctx.closed) status.textContent = "Connected / Agora RTC"; - } catch (error) { - if (!ctx.closed) { - ctx.client?.removeAllListeners(); await ctx.client?.leave().catch(() => {}); - status.textContent = "Could not connect. You can try joining again."; - document.querySelector("#call-error").textContent = error.message === "unsupported" ? "This browser does not support Agora calls. Try a current Chrome, Edge, Firefox, or Safari." : mediaError(error); - } - } -})); - -for (const [button, kind] of [[mic, "audio"], [camera, "video"]]) button.addEventListener("click", () => operate(async (ctx) => { - if (!ctx[kind]) await enableTrack(ctx, kind); - else { - await ctx[kind].setEnabled(!ctx[kind].enabled); - if (kind === "video") { - if (!ctx.video.enabled) ctx.video.stop(); - showVideo(ctx, ctx.credentials.uid, ctx.video.enabled ? ctx.video : null); - } - } -})); - -share.addEventListener("click", () => operate(async (ctx) => { - if (ctx.screen) { await stopScreen(ctx); return; } - // Capture on the click gesture before token/network work, as browsers require. - const captured = await ctx.sdk.createScreenVideoTrack({ encoderConfig: "1080p_1" }, "auto"); - const tracks = Array.isArray(captured) ? captured : [captured]; - if (ctx.closed) { for (const track of tracks) track.close(); return; } - const client = ctx.sdk.createClient({ mode: "rtc", codec: "vp8" }); - ctx.screen = { client, tracks }; - tracks[0].on("track-ended", () => { if (!ctx.closed) { ctx.screenEnded = true; if (!ctx.busy) operate(stopScreen); } }); - try { - await renew(ctx); - if (ctx.closed) return; - tokenEvents(client, ctx); - await client.join(ctx.credentials.appId, ctx.credentials.channel, ctx.credentials.screenToken, ctx.credentials.screenUid); - if (ctx.closed) return; - await client.publish(tracks); - if (!ctx.closed) showVideo(ctx, ctx.credentials.screenUid, tracks[0]); - } catch (error) { await stopScreen(ctx); throw error; } -})); - -resume.addEventListener("click", () => { - for (const user of current?.client?.remoteUsers || []) user.audioTrack?.play(); - resume.hidden = true; -}); -leave.addEventListener("click", leaveCall); -document.addEventListener("house:session", () => { if (current && !state.profile) leaveCall(); }); -window.addEventListener("pagehide", () => { - const ctx = current; - if (!ctx) return; - ctx.closed = true; - for (const track of [ctx.audio, ctx.video, ...(ctx.screen?.tracks || [])]) track?.close(); - ctx.client?.leave().catch(() => {}); - ctx.screen?.client.leave().catch(() => {}); - current = null; - panel.hidden = true; - stage.replaceChildren(); -}); diff --git a/explore.html b/explore.html index 2bc3dba..688ca08 100644 --- a/explore.html +++ b/explore.html @@ -41,11 +41,6 @@ <h2 id="join-title">Pull up a chair.</h2> </form> </dialog> <dialog id="person-dialog" aria-labelledby="person-title"><div class="dialog-head"><span class="kicker">BUILDER PROFILE</span><button class="close-button" type="button" data-close aria-label="Close builder profile">×</button></div><div id="profile-content"></div></dialog> - <dialog id="room-dialog" aria-labelledby="room-title"> - <div class="dialog-head"><span class="kicker">MAKE ROOM FOR A CONVERSATION</span><button class="close-button" type="button" data-close aria-label="Close room form">×</button></div> - <h2 id="room-title">Meet around a purpose.</h2><p class="dialog-intro">Create a group room, share the invite, and open a video call together.</p> - <form id="room-form" class="stack-form"><label>Room name<input name="title" required minlength="2" maxlength="80" placeholder="Voice agents build session"></label><label>What are we meeting for?<textarea name="intent" required minlength="2" maxlength="300" rows="3" placeholder="Debug a problem, plan a demo day, or bring a whiteboard session into the next sprint."></textarea></label><p class="form-note">Room names, purposes, and rosters are public. Audio, video, and screen sharing stay in this workspace, powered by Agora.</p><p id="room-error" class="form-error" role="alert"></p><button type="submit" class="button">Create meeting room <span aria-hidden="true">↗</span></button></form> - </dialog> <div id="toast" role="status" aria-live="polite"></div> </body> </html> diff --git a/index.html b/index.html index 6eb2fe0..52355cc 100644 --- a/index.html +++ b/index.html @@ -51,7 +51,7 @@ <p class="section-note">Public GitHub contributors are labeled. Their collaboration intent is shared only when they join.</p> </section> <section class="bench-tools" aria-labelledby="bench-heading"><div class="bench-label"><span class="kicker">03 / MAKE YOURSELF AT HOME</span><h2 id="bench-heading">More than <br>a directory.</h2><p>Pull up a chair.<br>Put something in motion.</p></div> - <a class="tool-note note-room" href="/meetings.html"><span class="note-index mono">01 / THE COMMON ROOM</span><h3>Get around <br>the same table. <span aria-hidden="true">↗</span></h3><p>A room, a purpose, a few good people. Talk it through. Share your screen. Build together.</p><span class="note-bottom mono">OPEN A WORKING SESSION →</span></a> + <a class="tool-note note-room" href="/stoa/"><span class="note-index mono">01 / THE COMMON ROOM</span><h3>Get around <br>the same table. <span aria-hidden="true">↗</span></h3><p>Walk into the Stoa, pick a room, and meet a few good people. Talk it through. Share your screen. Build together.</p><span class="note-bottom mono">WALK INTO THE STOA →</span></a> <a class="tool-note note-radar" href="/radar.html"><span class="note-index mono">02 / YOUR RADAR</span><h3>Keep an eye on <br>what comes next. <span aria-hidden="true">↗</span></h3><p>Public signals, people to know, resources to use. Guided by what you actually want to do.</p><span class="note-bottom mono">PUT YOUR INTENT TO WORK →</span></a> <a class="tool-note note-service" href="/services.html"><span class="note-index mono">03 / SHARED TOOLS</span><h3>Less setup. <br>More making. <span aria-hidden="true">↗</span></h3><p>Language models, speech, and confirmed community perks. Ready when providers are connected.</p><span class="note-bottom mono">EXPLORE THE WORKBENCH →</span></a> </section> @@ -86,11 +86,6 @@ <h2 id="join-title">Pull up a chair.</h2> </form> </dialog> <dialog id="person-dialog" aria-labelledby="person-title"><div class="dialog-head"><span class="kicker">BUILDER PROFILE</span><button class="close-button" type="button" data-close aria-label="Close builder profile">×</button></div><div id="profile-content"></div></dialog> - <dialog id="room-dialog" aria-labelledby="room-title"> - <div class="dialog-head"><span class="kicker">MAKE ROOM FOR A CONVERSATION</span><button class="close-button" type="button" data-close aria-label="Close room form">×</button></div> - <h2 id="room-title">Meet around a purpose.</h2><p class="dialog-intro">Create a group room, share the invite, and open a video call together.</p> - <form id="room-form" class="stack-form"><label>Room name<input name="title" required minlength="2" maxlength="80" placeholder="Voice agents build session"></label><label>What are we meeting for?<textarea name="intent" required minlength="2" maxlength="300" rows="3" placeholder="Debug a problem, plan a demo day, or bring a whiteboard session into the next sprint."></textarea></label><p class="form-note">Room names, purposes, and rosters are public. Audio, video, and screen sharing stay in this workspace, powered by Agora.</p><p id="room-error" class="form-error" role="alert"></p><button type="submit" class="button">Create meeting room <span aria-hidden="true">↗</span></button></form> - </dialog> <div id="toast" role="status" aria-live="polite"></div> </body> </html> diff --git a/meetings.html b/meetings.html deleted file mode 100644 index 6443a24..0000000 --- a/meetings.html +++ /dev/null @@ -1,60 +0,0 @@ -<!doctype html> -<html lang="en"> -<head> - <meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"> - <meta name="theme-color" content="#202824"> - <meta name="description" content="Agora Build is the Builder's Foundry, a hacker house for people building real-time communication, voice AI, and useful tools. Find collaborators, share your intent, and build together."> - <title>Meeting rooms | Agora Build - - - - - -
      -
      - -

      THE COMMON ROOM

      More than a one-to-one.
      Make it a working session.

      Bring a few people together. Compare ideas, share your screen, debug a problem, or build something side by side.

      -
      Group video callsAudio & screen sharingShareable room invitesPOWERED BY AGORA
      -

      Checking the call connection...

      - -
      MEET AROUND A PURPOSE

      Meeting rooms.

      Loading...

      Someone has to start the conversation.

      Create a room with a clear purpose. Share the link with the people you want around the table.

      -
      HOW IT WORKS

      One room. Several people.

      Join the house, create a room, and copy its invite link. Open a call here to build together, plan a demo day, or carry a whiteboard session into the next sprint.

      THE CALL

      Your work stays here.

      Agora powers audio, video, and screen sharing inside this workspace. The call shows connected participants; directory rosters show everyone who joined the room. Microphone and camera access requires HTTPS or localhost.

      - -
      - -
      YOUR PLACE IN THE HOUSE
      -

      Pull up a chair.

      -

      Good connections start with honest intent. Your profile and participation in rooms are public. Sign in to keep access across devices, or continue with this browser.

      -
      -
      - - - - - - -

      Your profile and room activity are shared with the foundry. Model search may use its public text. Save your intent here; keep personal or sensitive details out.

      - -
      -
      -
      -
      BUILDER PROFILE
      - -
      MAKE ROOM FOR A CONVERSATION
      -

      Meet around a purpose.

      Create a group room, share the invite, and open a video call together.

      -

      Room names, purposes, and rosters are public. Audio, video, and screen sharing stay in this workspace, powered by Agora.

      -
      -
      - - diff --git a/meetings.js b/meetings.js deleted file mode 100644 index 7abeb0c..0000000 --- a/meetings.js +++ /dev/null @@ -1,69 +0,0 @@ -import { api, el, avatar, state, notify, openJoin, sessionReady } from "./script.js"; -import { openCall } from "./call.js"; -const grid = document.querySelector("#rooms-grid"); -let revealedInvite = false; -async function refresh() { - await sessionReady; - const [result, people] = await Promise.all([api("/api/rooms"), api("/api/people")]); - state.people = people.people; - document.querySelector("#meeting-availability").textContent = result.calls.ready ? "Agora calls are configured. Open a room when you are ready." : "Agora calls are being connected. Create a room and share its invite in the meantime."; - const directRoom = /^\/meet\/([a-f0-9-]{36})$/.exec(location.pathname)?.[1]; - const sharedRoom = directRoom || new URLSearchParams(location.search).get("room"); - const rooms = directRoom ? result.rooms.filter((room) => room.id === directRoom) : result.rooms; - const invited = result.rooms.find((room) => room.id === sharedRoom); - if (state.profile && invited && sessionStorage.getItem("foundry-room-join") === invited.id) { - sessionStorage.removeItem("foundry-room-join"); - await api("/api/rooms/" + invited.id + "/join", { method: "POST" }); - await refresh(); return; - } - document.querySelector("#room-count").textContent = rooms.length + (rooms.length === 1 ? " room" : " rooms"); - document.querySelector("#rooms-empty").hidden = rooms.length > 0; - if (directRoom && invited) { - document.title = invited.title + " | Agora Build"; - document.querySelector(".meeting-intro h1").textContent = invited.title; - document.querySelector(".meeting-intro > div > p:last-child").textContent = invited.intent; - } - grid.replaceChildren(...rooms.map((room) => { - const members = room.participants.map((id) => state.people.find((person) => person.id === id)).filter(Boolean); - const joined = state.profile && room.participants.includes(state.profile.id); - return el("article", { class: "room-card" + (sharedRoom === room.id ? " is-highlighted" : ""), id: "room-" + room.id }, [ - el("div", { class: "room-top" }, [el("span", { class: "kicker", text: "GROUP ROOM" }), el("span", { class: "room-provider", text: "AGORA RTC" })]), - el("h3", { text: room.title }), el("p", { class: "room-intent", text: room.intent }), - el("a", { class: "room-url mono", href: room.path, text: new URL(room.path, location.origin).href }), - el("div", { class: "room-roster" }, [el("div", { class: "avatar-stack" }, members.slice(0, 5).map((person) => el("button", { class: "roster-person", type: "button", "data-profile-id": person.id, "aria-label": "View " + person.name + " profile", title: person.name }, [avatar(person, "avatar-small")]))), el("span", { class: "muted", text: members.length + (members.length === 1 ? " person on the roster" : " people on the roster") })]), - el("div", { class: "room-actions" }, [ - joined ? el("button", { class: "button", type: "button", text: "Open call", onclick: () => openCall(room, result.calls.ready) }) : el("button", { class: "button", type: "button", text: "Join the room", onclick: async () => { - if (!state.profile) { - sessionStorage.setItem("foundry-room-join", room.id); - openJoin(() => refresh().catch((error) => notify(error.message))); return; - } - try { await api("/api/rooms/" + room.id + "/join", { method: "POST" }); await refresh(); notify("You are on the room roster. Open the call when you are ready."); } catch (error) { notify(error.message); } - } }), - el("button", { class: "button button-secondary", type: "button", text: "Copy invite link", onclick: async () => { - const link = new URL(room.path, location.origin).href; - try { - if (navigator.clipboard && window.isSecureContext) await navigator.clipboard.writeText(link); - else { - const input = el("textarea", { class: "copy-buffer", text: link }); - document.body.append(input); input.select(); - const copied = document.execCommand("copy"); input.remove(); - if (!copied) throw new Error(); - } - notify("Room invite link copied."); - } catch { window.prompt("Copy this room invite link:", link); } - } }) - ]) - ]); - })); - if (!revealedInvite && sharedRoom && result.rooms.some((room) => room.id === sharedRoom)) { - document.querySelector("#room-" + sharedRoom)?.scrollIntoView({ block: "nearest" }); - revealedInvite = true; - if (state.profile && invited?.participants.includes(state.profile.id)) openCall(invited, result.calls.ready); - } -} -await refresh().catch((error) => notify(error.message)); -document.addEventListener("house:session", () => refresh().catch((error) => notify(error.message))); -const rosterTimer = setInterval(() => { - if (!document.hidden) refresh().catch(() => {}); -}, 15000); -window.addEventListener("pagehide", () => clearInterval(rosterTimer), { once: true }); diff --git a/radar.html b/radar.html index 7c0a001..47d2b3c 100644 --- a/radar.html +++ b/radar.html @@ -42,11 +42,6 @@

      Pull up a chair.

      BUILDER PROFILE
      - -
      MAKE ROOM FOR A CONVERSATION
      -

      Meet around a purpose.

      Create a group room, share the invite, and open a video call together.

      -

      Room names, purposes, and rosters are public. Audio, video, and screen sharing stay in this workspace, powered by Agora.

      -
      diff --git a/script.js b/script.js index 2d976b6..9f9642b 100644 --- a/script.js +++ b/script.js @@ -95,18 +95,9 @@ export function openJoin(callback, browserOnly = false) { document.querySelector("#profile-error").textContent = ""; document.querySelector("#join-dialog").showModal(); } +// Rooms are Stoa spaces now: starting one opens the Stoa's create dialog. export function newRoom(person, title = "") { - if (!state.profile) { - sessionStorage.setItem("foundry-room-draft", JSON.stringify({ title: person ? "Connect with " + person.name : title })); - openJoin(() => newRoom(person, title)); return; - } - sessionStorage.removeItem("foundry-room-draft"); - const form = document.querySelector("#room-form"); - form.reset(); - form.elements.title.value = person ? "Connect with " + person.name : title; - form.elements.intent.value = state.profile.intent.slice(0, 300); - document.querySelector("#room-error").textContent = ""; - document.querySelector("#room-dialog").showModal(); + location.assign("/stoa/?start=" + encodeURIComponent(person ? "Connect with " + person.name : title)); } export async function openProfile(id) { let person = state.people.find((person) => person.id === id); @@ -128,7 +119,7 @@ export async function openProfile(id) { ...(projects.length ? [el("div", { class: "profile-projects" }, [el("span", { class: "kicker", text: "PUBLIC PROJECTS" }), ...projects])] : []), el("div", { class: "dialog-actions" }, [ el("a", { class: "button", href: person.contact, target: "_blank", rel: "noopener noreferrer", text: person.source === "github" ? "View on GitHub ↗" : "Get in touch ↗" }), - el("button", { class: "button button-secondary", type: "button", text: "Start a room", onclick: () => { document.querySelector("#person-dialog").close(); newRoom(person); } }) + el("button", { class: "button button-secondary", type: "button", text: "Start a space", onclick: () => { document.querySelector("#person-dialog").close(); newRoom(person); } }) ]) ); document.querySelector("#person-dialog").showModal(); @@ -195,17 +186,6 @@ document.querySelector("#profile-delete").addEventListener("click", async () => notify("Your profile has been removed."); } catch (error) { document.querySelector("#profile-error").textContent = error.message; } }); -document.querySelector("#room-form").addEventListener("submit", async (event) => { - event.preventDefault(); - const form = event.currentTarget; - const button = form.querySelector("[type=submit]"); - button.disabled = true; - try { - const result = await api("/api/rooms", { method: "POST", body: JSON.stringify(Object.fromEntries(new FormData(form))) }); - window.location.href = result.room.path; - } catch (error) { document.querySelector("#room-error").textContent = error.message; } - finally { button.disabled = false; } -}); export const sessionReady = refreshSession().catch((error) => { notify(error.message); return null; }); sessionReady.then(() => { const url = new URL(location.href); @@ -215,12 +195,6 @@ sessionReady.then(() => { history.replaceState(null, "", url.pathname + url.search + url.hash); if (result === "failed") { notify("Sign-in could not be completed. Please try again."); return; } if (state.account && !state.profile && location.pathname !== "/account.html" && location.pathname !== "/services.html") { - let draft; - try { draft = JSON.parse(sessionStorage.getItem("foundry-room-draft")); } catch { sessionStorage.removeItem("foundry-room-draft"); } - openJoin(draft ? () => newRoom(undefined, draft.title || "") : undefined); - } - else if (state.profile && sessionStorage.getItem("foundry-room-draft")) { - try { newRoom(undefined, JSON.parse(sessionStorage.getItem("foundry-room-draft")).title || ""); } - catch { sessionStorage.removeItem("foundry-room-draft"); } - } else notify("You are signed in. Your profile and rooms are ready."); + openJoin(); + } else notify("You are signed in. Your profile and spaces are ready."); }); diff --git a/scripts/auth.mjs b/scripts/auth.mjs index 0b0b003..96f70dc 100644 --- a/scripts/auth.mjs +++ b/scripts/auth.mjs @@ -51,7 +51,8 @@ export function authConfig(env = process.env) { export function returnPath(value) { if (typeof value !== "string" || value.length > 1500 || !value.startsWith("/") || value.startsWith("//") || /[\\\r\n]/.test(value)) return "/"; const url = new URL(value, "https://foundry.invalid"); - if (!["/", "/index.html", "/explore.html", "/meetings.html", "/radar.html", "/services.html", "/account.html", "/oauth/authorize"].includes(url.pathname) && !/^\/meet\/[a-f0-9-]{36}$/.test(url.pathname)) return "/"; + const stoa = /^\/stoa\/(?:room\/[a-z0-9-]{2,40}|s\/[a-f0-9-]{36})?$/.test(url.pathname); + if (!["/", "/index.html", "/explore.html", "/radar.html", "/services.html", "/account.html", "/oauth/authorize"].includes(url.pathname) && !stoa && !/^\/meet\/[a-f0-9-]{36}$/.test(url.pathname)) return "/"; url.searchParams.delete("signin"); return url.pathname + url.search + url.hash; } diff --git a/scripts/build.mjs b/scripts/build.mjs index 8982abc..00eb1d8 100644 --- a/scripts/build.mjs +++ b/scripts/build.mjs @@ -6,7 +6,7 @@ const output = new URL("dist/", root); // Start clean so stale private state under dist/ can never be packaged. await rm(output, { recursive: true, force: true }); await mkdir(output, { recursive: true }); -await Promise.all(["index.html", "explore.html", "services.html", "radar.html", "meetings.html", "account.html", "account.js", "stoa.html", "stoa.js", "styles.css", "script.js", "people.js", "activity.js", "explore.js", "services.js", "radar.js", "meetings.js", "call.js", "package.json", "package-lock.json", ".env.example"].map((file) => copyFile(new URL(file, root), new URL(file, output)))); +await Promise.all(["index.html", "explore.html", "services.html", "radar.html", "account.html", "account.js", "stoa.html", "stoa.js", "styles.css", "script.js", "people.js", "activity.js", "explore.js", "services.js", "radar.js", "package.json", "package-lock.json", ".env.example"].map((file) => copyFile(new URL(file, root), new URL(file, output)))); await cp(new URL("assets/", root), new URL("assets/", output), { recursive: true }); await copyFile(createRequire(import.meta.url).resolve("agora-rtc-sdk-ng"), new URL("assets/agora-rtc.js", output)); await copyFile(createRequire(import.meta.url).resolve("agora-rtm-sdk"), new URL("assets/agora-rtm.js", output)); diff --git a/scripts/calls.mjs b/scripts/calls.mjs index 77c708f..7c205a0 100644 --- a/scripts/calls.mjs +++ b/scripts/calls.mjs @@ -21,10 +21,6 @@ export function createAgoraCalls(config = agoraConfig(), { now = Date.now } = {} } return { ready: config.ready, - issue(room, person, input = {}) { - if (!room.participants.includes(person.id)) throw new AppError(403, "Join this room's roster before entering the call."); - return credentials("agora-build-" + room.id, person.id.replace(/^member:/, "") + "_", input); - }, issueSpace(space, actor, input = {}) { return credentials("agora-build-space-" + space.id, actor.id.replace(/^(account|member):/, "") + "_", input); } diff --git a/scripts/serve.mjs b/scripts/serve.mjs index ae99851..700847f 100644 --- a/scripts/serve.mjs +++ b/scripts/serve.mjs @@ -23,7 +23,7 @@ import { loadThemes } from "./spaces/themes.mjs"; const root = fileURLToPath(new URL("../", import.meta.url)); // Vendored browser SDKs are served from node_modules in development and from dist/assets after a build. const vendor = { "assets/agora-rtc.js": createRequire(import.meta.url).resolve("agora-rtc-sdk-ng"), "assets/agora-rtm.js": createRequire(import.meta.url).resolve("agora-rtm-sdk") }; -const publicFiles = new Set(["index.html", "explore.html", "services.html", "radar.html", "meetings.html", "account.html", "account.js", "stoa.html", "stoa.js", "world/map.js", "world/kinds.js", "world/themes.js", "world/camera.js", "world/motion.js", "world/renderer-canvas.js", "world/engine.js", "world/sdk.js", "world/rtc-client.js", "stoa/panel.js", "stoa/stage.js", "stoa/call.js", "stoa/decor.js", "stoa/place.js", "stoa/lease.js", "stoa/spaces.js", "world/protocol.js", "world/crypto.js", "world/signaling.js", "world/presence.js", "styles.css", "script.js", "people.js", "activity.js", "explore.js", "services.js", "radar.js", "meetings.js", "call.js", "assets/agora-rtc.js", "assets/agora-rtm.js", "assets/favicon.svg", "assets/guohai.jpg"]); +const publicFiles = new Set(["index.html", "explore.html", "services.html", "radar.html", "account.html", "account.js", "stoa.html", "stoa.js", "world/map.js", "world/kinds.js", "world/themes.js", "world/camera.js", "world/motion.js", "world/renderer-canvas.js", "world/engine.js", "world/sdk.js", "world/rtc-client.js", "stoa/panel.js", "stoa/stage.js", "stoa/call.js", "stoa/decor.js", "stoa/place.js", "stoa/lease.js", "stoa/spaces.js", "world/protocol.js", "world/crypto.js", "world/signaling.js", "world/presence.js", "styles.css", "script.js", "people.js", "activity.js", "explore.js", "services.js", "radar.js", "assets/agora-rtc.js", "assets/agora-rtm.js", "assets/favicon.svg", "assets/guohai.jpg"]); const types = { ".html": "text/html; charset=utf-8", ".css": "text/css; charset=utf-8", ".js": "text/javascript; charset=utf-8", ".svg": "image/svg+xml", ".jpg": "image/jpeg" }; function json(response, status, body, headers = {}) { @@ -320,19 +320,8 @@ export function createAppServer(directory = root, options = {}) { limit("radar:" + person.id, 1, 60000); allowance(request); json(response, 200, { result: await scan(person) }); - } else if (path === "/api/rooms" && request.method === "GET") json(response, 200, { rooms: await store.rooms(), calls: { provider: "agora", ready: calls.ready } }); - else if (path === "/api/rooms" && request.method === "POST") { - await member(); limit("rooms:" + request.socket.remoteAddress, 10, 3600000); - json(response, 201, { room: await store.createRoom(token, await body(request)) }); - } else if (/^\/api\/rooms\/[a-f0-9-]+\/join$/.test(path) && request.method === "POST") { - json(response, 200, { room: await store.joinRoom(token, path.split("/")[3]) }); - } else if (/^\/api\/rooms\/[a-f0-9-]+\/call$/.test(path) && request.method === "POST") { - const person = await member(); - const room = (await store.rooms()).find((room) => room.id === path.split("/")[3]); - if (!room) throw new AppError(404, "This room was not found."); - limit("call:" + person.id, 30, 60000); - json(response, 200, calls.issue(room, person, await body(request))); - } else { + } else if (path === "/api/rooms" || path.startsWith("/api/rooms/")) throw new AppError(410, "Meeting rooms are now Stoa spaces. Start one from the Stoa."); + else { const handled = await handleSpaces({ path, method: request.method, url: requestUrl, token, read: () => body(request), spaces, worlds, themes, limit, ip: request.socket.remoteAddress }); if (!handled) throw new AppError(404, "This page or service was not found."); json(response, handled.status, handled.body); @@ -341,10 +330,11 @@ export function createAppServer(directory = root, options = {}) { } if (!["GET", "HEAD"].includes(request.method)) { response.writeHead(405, { Allow: "GET, HEAD" }).end("Method not allowed"); return; } const meeting = /^\/meet\/([a-f0-9-]{36})$/.exec(path); - if (meeting && !(await store.rooms()).some((room) => room.id === meeting[1])) { response.writeHead(404, { "Content-Type": "text/plain; charset=utf-8" }).end("This meeting room was not found."); return; } + if (meeting) { response.writeHead(308, { Location: "/stoa/s/" + meeting[1] + requestUrl.search }).end(); return; } + if (path === "/meetings.html") { const room = requestUrl.searchParams.get("room"); response.writeHead(308, { Location: /^[a-f0-9-]{36}$/.test(room || "") ? "/stoa/s/" + room : "/stoa/" }).end(); return; } const stoa = /^\/stoa(?:\/|\/room\/([a-z0-9-]{2,40})|\/s\/([a-f0-9-]{36}))$/.exec(path); if (stoa && ((stoa[1] && !worlds.plaza.map.lots.some((lot) => lot.slug === stoa[1])) || (stoa[2] && !(await spaces.visible(token, stoa[2], requestUrl.searchParams.get("invite")))))) { response.writeHead(404, { "Content-Type": "text/plain; charset=utf-8" }).end("This space was not found."); return; } - const filename = stoa ? "stoa.html" : meeting ? "meetings.html" : path === "/" ? "index.html" : path.slice(1); + const filename = stoa ? "stoa.html" : path === "/" ? "index.html" : path.slice(1); if (!publicFiles.has(filename)) { response.writeHead(404, { "Content-Type": "text/plain; charset=utf-8" }).end("Not found"); return; } const file = resolve(directory, filename); const content = await readFile(Object.hasOwn(vendor, filename) && !existsSync(file) ? vendor[filename] : file); diff --git a/services.html b/services.html index e13c9ac..f7d2b90 100644 --- a/services.html +++ b/services.html @@ -42,11 +42,6 @@

      Pull up a chair.

      BUILDER PROFILE
      - -
      MAKE ROOM FOR A CONVERSATION
      -

      Meet around a purpose.

      Create a group room, share the invite, and open a video call together.

      -

      Room names, purposes, and rosters are public. Audio, video, and screen sharing stay in this workspace, powered by Agora.

      -
      diff --git a/stoa.html b/stoa.html index d495ee7..28e3c10 100644 --- a/stoa.html +++ b/stoa.html @@ -103,11 +103,6 @@

      Pull up a chair.

      BUILDER PROFILE
      - -
      MAKE ROOM FOR A CONVERSATION
      -

      Meet around a purpose.

      Create a group room, share the invite, and open a video call together.

      -

      Room names, purposes, and rosters are public. Audio, video, and screen sharing stay in this workspace, powered by Agora.

      -
      A PLACE OF YOUR OWN

      Start a space.

      diff --git a/styles.css b/styles.css index a979bd8..a7558ff 100644 --- a/styles.css +++ b/styles.css @@ -377,66 +377,12 @@ select { padding: 10px 28px 10px 10px; border: 1px solid #cdd3bd; background: va .radar-footnote { display: flex; align-items: flex-start; gap: 35px; margin-top: 37px; padding-top: 26px; border-top: 1px solid var(--line); } .radar-footnote strong { flex-shrink: 0; font-size: 14px; font-weight: 500; } .radar-footnote p { font-size: 12px; color: var(--muted); } -.meeting-intro { display: flex; justify-content: space-between; align-items: flex-end; gap: 30px; max-width: none; } -.meeting-strip { display: flex; align-items: center; flex-wrap: wrap; gap: 30px; background: #eceee1; border: 1px solid #d1d7bf; border-radius: 4px; padding: 17px 23px; margin-bottom: 36px; font-size: 12px; color: #687f4e; } -.meeting-strip > span { display: inline-flex; align-items: center; gap: 10px; } -.meeting-strip strong { font-weight: 500; } -.meeting-strip .mono { margin-left: auto; font-size: 9px; } -.rooms-grid { display: flex; align-items: flex-start; flex-wrap: wrap; gap: 30px; padding: 8px 5px 0; } -.room-card { width: calc(50% - 15px); padding: 29px; background: var(--surface); border: 1px solid #d1d7bd; border-radius: 4px; box-shadow: 3px 6px 0 #dfe2d14d; transform: rotate(-.5deg); } -.room-card:nth-child(even) { margin-top: 24px; background: #eeeee1; transform: rotate(.6deg); } -.room-card.is-highlighted { border-color: #99ac7d; box-shadow: 0 0 0 2px #b9cda860; } -.room-top { display: flex; justify-content: space-between; align-items: center; gap: 20px; } -.room-top .kicker { font-size: 9px; } -.room-provider { font: 8px var(--mono); color: #8da176; } -.room-card h3 { font-size: 25px; font-weight: 500; line-height: 1.4; letter-spacing: -.6px; margin-top: 20px; overflow-wrap: anywhere; } -.room-intent { font-size: 14px; color: var(--muted); margin-top: 12px; overflow-wrap: anywhere; } -.room-url { display: block; margin-top: 14px; font-size: 10px; color: var(--green); overflow-wrap: anywhere; } -.room-url:hover { color: var(--accent); } -.room-roster { display: flex; align-items: center; gap: 12px; margin: 26px 0; font-size: 12px; } -.avatar-stack { display: flex; padding-left: 5px; } -.avatar-small { width: 33px; height: 33px; font-size: 11px; border: 2px solid #fffef9; margin-left: -5px; } -.roster-person { display: flex; border: 0; background: none; padding: 0; border-radius: 4px; } -.roster-person:hover { filter: brightness(.9); } -.room-actions { display: flex; align-items: center; flex-wrap: wrap; gap: 11px; border-top: 1px dashed #cdd4ba; padding-top: 22px; } -.room-actions .button { min-height: 39px; padding: 9px 14px; font-size: 12px; } -.rooms-empty { padding: 60px 30px; } -.rooms-empty p { max-width: 440px; } -.meeting-notes { display: grid; grid-template-columns: 1fr 1fr; gap: 45px; margin-top: 45px; padding-top: 27px; border-top: 1px solid var(--line); } -.meeting-notes h3 { font-size: 20px; font-weight: 500; letter-spacing: -.4px; margin-top: 11px; } -.meeting-notes p { font-size: 13px; color: var(--muted); margin-top: 11px; } -.meeting-availability { margin: -20px 0 32px; font-size: 12px; color: var(--muted); } -.call-panel { border: 1px solid #b7c2a9; border-radius: 5px; padding: 28px; background: var(--surface); margin-bottom: 38px; scroll-margin-top: 24px; } -.call-heading { display: flex; justify-content: space-between; align-items: flex-start; gap: 20px; } -.call-heading h2 { margin: 8px 0; font-size: 27px; overflow-wrap: anywhere; } -.call-heading p { max-width: 680px; font-size: 13px; color: var(--muted); overflow-wrap: anywhere; } -.call-heading > span { font-size: 10px; color: var(--green); white-space: nowrap; } -.call-status { margin: 20px 0; padding-left: 10px; border-left: 2px solid var(--accent); color: var(--muted); font: 11px/1.7 var(--mono); } -.call-stage { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 260px), 1fr)); gap: 12px; } -.call-tile { position: relative; min-height: 180px; aspect-ratio: 16 / 10; overflow: hidden; border: 1px solid #404b3c; border-radius: 4px; color: var(--paper); background: #272e27; } -.call-tile.is-screen { grid-column: 1 / -1; aspect-ratio: 16 / 9; } -.call-player, .call-placeholder { position: absolute; inset: 0; } -.call-placeholder { display: grid; place-items: center; font: 28px var(--mono); color: #b3c0a3; background: radial-gradient(ellipse at 50% 40%, #3e483a, #272e27); } -.call-tile-name { position: absolute; left: 12px; bottom: 12px; max-width: calc(100% - 24px); padding: 4px 8px; border-radius: 3px; background: #202820cc; font: 10px var(--mono); overflow-wrap: anywhere; } -.call-preflight { display: flex; flex-wrap: wrap; gap: 14px 24px; font-size: 12px; padding: 8px 0 18px; } -.call-preflight label { display: flex; align-items: center; gap: 8px; } -.call-preflight input { accent-color: var(--accent); } -.call-preflight span { width: 100%; color: var(--muted); font-size: 11px; } -.call-controls { display: flex; flex-wrap: wrap; gap: 10px; margin-top: 20px; padding-top: 20px; border-top: 1px solid var(--line); } -.call-controls .button { min-height: 40px; padding: 9px 14px; font-size: 12px; } -.call-controls [aria-pressed="true"] { background: #e6ebdc; border-color: #98a98a; } -.call-controls .call-leave { margin-left: auto; color: var(--accent); border-color: #d5b39e; } -.copy-buffer { position: fixed; top: -1000px; } @media (max-width: 768px) { .forge-heading { flex-direction: column; gap: 22px; } .forge-heading > p { max-width: 600px; } .forge-sessions { flex-direction: column; gap: 28px; } .forge-session { width: 100%; } .forge-session:nth-child(n) { margin-top: 0; } - .call-panel { padding: 20px; } - .call-heading { flex-direction: column; gap: 12px; } - .call-heading h2 { font-size: 23px; } - .call-controls .call-leave { margin-left: 0; } } dialog { width: min(610px, calc(100% - 28px)); max-height: calc(100dvh - 40px); padding: 29px; color: var(--ink); background: #fffef6; border: 1px solid #ced3bc; border-radius: 5px; box-shadow: 0 18px 70px #202c1933; overflow-y: auto; } dialog::backdrop { background: #20282099; } @@ -490,7 +436,6 @@ dialog h2 { font-size: 30px; line-height: 1.25; font-weight: 500; letter-spacing .desk-code { width: 230px; }.desk-voice { width: 179px; padding: 16px; }.desk-rtc { width: 235px; }.desk-annotation { display: none; } .person-card { width: calc(50% - 18px); }.bench-tools { gap: 21px; flex-wrap: wrap; }.bench-label { width: 100%; display: flex; align-items: center; gap: 30px; }.bench-label h2 { margin-top: 0; font-size: 25px; }.bench-label p { margin: 0 0 0 auto; }.bench-label h2 br { display: none; } .speech-controls { flex-wrap: wrap; }.speech-controls label { flex-basis: 100%; } - .meeting-intro { align-items: flex-start; flex-direction: column; } } @media (max-width: 1000px) { .site-header { width: calc(100% - 40px); }.main-content, .site-footer { width: calc(100% - 64px); } @@ -511,7 +456,6 @@ dialog h2 { font-size: 30px; line-height: 1.25; font-weight: 500; letter-spacing .intent-symbol { display: none; }.intent-banner { padding: 26px 0; }.intent-banner h2 { font-size: 23px; } .services-grid { grid-template-columns: 1fr; }.service-panel:nth-child(2) { margin-top: 0; }.speech-controls { flex-wrap: nowrap; }.speech-controls label { flex-basis: auto; } .radar-method { gap: 15px; }.radar-method .mono { font-size: 9px; }.radar-method p { font-size: 11px; } - .meeting-strip { gap: 20px; }.room-card { padding: 23px; }.room-roster { flex-wrap: wrap; } } @media (max-width: 600px) { html { scroll-padding-top: 100px; } @@ -532,7 +476,7 @@ dialog h2 { font-size: 30px; line-height: 1.25; font-weight: 500; letter-spacing .directory-toolbar { flex-wrap: wrap; gap: 14px; }.directory-search { padding: 10px 12px; }.directory-search input { font-size: 13px; }.directory-filters { gap: 1px; margin-bottom: 23px; }.directory-filters button { font-size: 10px; padding: 6px 8px; }.directory-filters button > span { font-size: 8px; }.directory-grid { columns: 1; padding: 5px; }.directory-card { padding: 25px; margin-bottom: 24px; }.directory-card h3 { font-size: 20px; } .services-grid { padding: 5px 3px; gap: 25px; }.service-panel { padding: 25px; }.service-panel h2 { font-size: 27px; }.speech-controls { flex-wrap: wrap; }.speech-controls label { flex-basis: 100%; }.perks-section { margin-top: 35px; }.perks-section .section-heading { flex-wrap: wrap; }.offer-card { width: 100%; } .radar-intent-panel { padding: 24px; }.radar-intent-panel .panel-top { flex-wrap: wrap; gap: 10px; }.radar-intent-panel > p { font-size: 20px; }.radar-method { flex-direction: column; align-items: flex-start; gap: 13px; padding: 25px 0; margin-bottom: 10px; }.radar-method .method-arrow { display: none; }.radar-method > div { padding-left: 13px; border-left: 2px solid #c2d1a9; }.radar-method .mono { font-size: 10px; }.radar-method p { font-size: 12px; }.radar-card { width: 100%; }.radar-footnote { display: block; padding-top: 20px; }.radar-footnote p { margin-top: 11px; } - .meeting-strip { padding: 16px; gap: 15px; font-size: 11px; margin-bottom: 30px; }.meeting-strip .mono { margin-left: 0; font-size: 8px; }.rooms-grid { padding: 5px; gap: 25px; }.room-card { width: 100%; padding: 25px; }.room-card:nth-child(even) { margin-top: 0; }.meeting-notes { grid-template-columns: 1fr; gap: 28px; margin-top: 35px; }.empty-panel { padding: 33px 23px; }.empty-panel h3 { font-size: 20px; }.empty-panel > p { font-size: 13px; }.rooms-empty { padding: 42px 24px; } + .empty-panel { padding: 33px 23px; }.empty-panel h3 { font-size: 20px; }.empty-panel > p { font-size: 13px; } dialog { padding: 23px; max-height: calc(100dvh - 24px); }.dialog-head .kicker { font-size: 8px; }.profile-identity { gap: 15px; }.avatar-large { width: 60px; height: 60px; }.profile-identity h2 { font-size: 26px; }#toast { width: calc(100% - 32px); bottom: 16px; font-size: 12px; } } @media (max-width: 380px) { diff --git a/tests/auth.test.mjs b/tests/auth.test.mjs index 3cdd7d9..84e13d6 100644 --- a/tests/auth.test.mjs +++ b/tests/auth.test.mjs @@ -65,6 +65,11 @@ test("provider readiness requires a safe public origin and actual provider confi assert.equal(authConfig({ SITE_URL: "https://portal.example", AGORA_APP_ID: "a".repeat(32), AGORA_APP_CERTIFICATE: "b".repeat(32) }).providers.agora.ready, false); for (const value of ["https://evil.example", "//evil.example", "/\\evil.example", "/auth/google", "/api/profile", "/\ninvalid"]) assert.equal(returnPath(value), "/"); assert.equal(returnPath("/meet/11111111-1111-1111-1111-111111111111?signin=failed"), "/meet/11111111-1111-1111-1111-111111111111"); + assert.equal(returnPath("/stoa/"), "/stoa/"); + assert.equal(returnPath("/stoa/room/ai-agents?x=1"), "/stoa/room/ai-agents?x=1"); + assert.equal(returnPath("/stoa/s/11111111-1111-1111-1111-111111111111?invite=abc&signin=failed"), "/stoa/s/11111111-1111-1111-1111-111111111111?invite=abc"); + assert.equal(returnPath("/meetings.html"), "/"); + assert.equal(returnPath("/stoa/s/not-a-uuid"), "/"); }); for (const id of ["google", "github", "apple", "agora"]) test(id + " authenticates using a code flow with bound state and verified identity", async () => { @@ -194,12 +199,12 @@ test("HTTP sign-in sets a browser-bound state cookie, rotates authenticated sess assert.equal(replay.headers.get("location"), "/?signin=failed"); assert.ok(!replay.headers.getSetCookie().some(cookie => cookie.startsWith("house_session="))); assert.equal((await fetch(base + "/scripts/auth.mjs")).status, 404); - const appleStart = await fetch(base + "/auth/apple?returnTo=/meetings.html", { redirect: "manual" }); + const appleStart = await fetch(base + "/auth/apple?returnTo=/stoa/", { redirect: "manual" }); const appleUrl = new URL(appleStart.headers.get("location")); nonce = appleUrl.searchParams.get("nonce"); const appleCookie = appleStart.headers.get("set-cookie"); assert.match(appleCookie, /SameSite=None; Secure/); const appleResult = await fetch(base + "/auth/apple/callback", { method: "POST", headers: { Cookie: appleCookie.split(";")[0], "Content-Type": "application/x-www-form-urlencoded", Origin: "https://appleid.apple.com" }, body: new URLSearchParams({ code: "test-code", state: appleUrl.searchParams.get("state") }), redirect: "manual" }); - assert.equal(appleResult.headers.get("location"), "/meetings.html?signin=success"); + assert.equal(appleResult.headers.get("location"), "/stoa/?signin=success"); assert.ok(appleResult.headers.getSetCookie().some(cookie => cookie.startsWith("house_session="))); assert.equal((await fetch(base + "/api/auth/logout", { method: "POST", headers: { Cookie: sessionCookie.split(";")[0], Origin: "https://untrusted.example" } })).status, 403); const logout = await fetch(base + "/api/auth/logout", { method: "POST", headers: { Cookie: sessionCookie.split(";")[0] } }); diff --git a/tests/calls.test.mjs b/tests/calls.test.mjs index 7b71467..7a40437 100644 --- a/tests/calls.test.mjs +++ b/tests/calls.test.mjs @@ -5,7 +5,7 @@ import { agoraConfig, createAgoraCalls } from "../scripts/calls.mjs"; const config = agoraConfig({ AGORA_APP_ID: "a".repeat(32), AGORA_APP_CERTIFICATE: "b".repeat(32), AGORA_TOKEN_TTL_SECONDS: "600" }); const person = { id: "member:72a639ba-3a45-4afe-936b-111111111111" }; -const room = { id: "fd616fdb-aa48-4c67-bd26-222222222222", participants: [person.id] }; +const room = { id: "fd616fdb-aa48-4c67-bd26-222222222222" }; test("Agora configuration requires certificate authentication and bounds the lifetime of both media privileges", () => { assert.equal(agoraConfig({}).ready, false); @@ -18,7 +18,7 @@ test("Agora configuration requires certificate authentication and bounds the lif }); test("camera and screen tokens have valid signatures, exact channels and identities, and expiring publisher privileges", () => { const calls = createAgoraCalls(config, { now: () => 1700000000000 }); - const credentials = calls.issue(room, person); + const credentials = calls.issueSpace(room, person); assert.equal(credentials.expiresAt, new Date(1700000000000 + 600000).toISOString()); for (const [value, uid] of [[credentials.token, credentials.uid], [credentials.screenToken, credentials.screenUid]]) { const parsed = new tokenTypes.AccessToken2(); @@ -29,19 +29,17 @@ test("camera and screen tokens have valid signatures, exact channels and identit assert.equal(parsed.expire, 600); assert.equal(parsed.services.length, 1); const rtc = parsed.services[0]; - assert.equal(rtc.__channel_name.toString(), "agora-build-" + room.id); + assert.equal(rtc.__channel_name.toString(), "agora-build-space-" + room.id); assert.equal(rtc.__uid.toString(), uid); assert.deepEqual(rtc.__privileges, { 1: 600, 2: 600, 3: 600, 4: 600 }); } }); -test("renewals reject other members, screen identities, and malformed identifiers; separate tabs do not collide", () => { +test("renewals reject other identities, screen identities, and malformed identifiers; separate tabs do not collide", () => { const calls = createAgoraCalls(config); - const first = calls.issue(room, person); - assert.notEqual(calls.issue(room, person).uid, first.uid); - assert.equal(calls.issue(room, person, { uid: first.uid }).uid, first.uid); - for (const uid of [null, 12, {}, "another-member_123456789abc", first.uid + "extra", first.screenUid]) assert.throws(() => calls.issue(room, person, { uid }), { status: 422 }); - assert.throws(() => calls.issue(room, { id: "member:stranger" }), { status: 403 }); - assert.throws(() => createAgoraCalls(agoraConfig({})).issue(room, person), { status: 503 }); + const first = calls.issueSpace(room, person); + assert.notEqual(calls.issueSpace(room, person).uid, first.uid); + assert.equal(calls.issueSpace(room, person, { uid: first.uid }).uid, first.uid); + for (const uid of [null, 12, {}, "another-member_123456789abc", first.uid + "extra", first.screenUid]) assert.throws(() => calls.issueSpace(room, person, { uid }), { status: 422 }); }); test("space calls use the space channel and an identity derived from the account", () => { const calls = createAgoraCalls(config); diff --git a/tests/server.test.mjs b/tests/server.test.mjs index aa51540..d81b393 100644 --- a/tests/server.test.mjs +++ b/tests/server.test.mjs @@ -33,7 +33,7 @@ before(async () => { temporary = await mkdtemp(resolve(tmpdir(), "agora-house-te after(async () => { await close(server); await rm(temporary, { recursive: true, force: true }); }); test("six pages, internal links, assets, and section anchors load", async () => { - for (const page of ["/", "/explore.html", "/services.html", "/radar.html", "/meetings.html", "/account.html"]) { + for (const page of ["/", "/explore.html", "/services.html", "/radar.html", "/account.html"]) { const response = await request(page); assert.equal(response.status, 200); assert.match(response.headers.get("content-type"), /text\/html/); @@ -129,81 +129,20 @@ test("unconfigured model services report their status and do not fake results", assert.equal((await request("/api/services/tts", { method: "POST", data: { text: "Hello" } })).status, 503); assert.equal((await request("/api/radar", { method: "POST" })).status, 503); }); -test("multiple members join the same meeting without impersonation or duplicate roster entries", async () => { - const isolated = await start(); - try { - const a = await join("Room host", isolated.url); - const b = await join("Room guest", isolated.url); - assert.equal((await request("/api/rooms", { base: isolated.url, method: "POST", data: { title: "Unauthorized", intent: "Test" } })).status, 401); - const response = await request("/api/rooms", { base: isolated.url, cookie: a.cookie, method: "POST", data: { title: "Voice workshop", intent: "Compare interruption handling" } }); - assert.equal(response.status, 201); - const { room } = await response.json(); - assert.equal(room.callProvider, "agora"); - assert.ok(!("videoUrl" in room)); - assert.equal(room.path, "/meet/" + room.id); - const page = await request(room.path, { base: isolated.url }); - assert.equal(page.status, 200); - assert.match(await page.text(), /src="\/meetings.js"/); - assert.equal((await request("/meet/11111111-1111-1111-1111-111111111111", { base: isolated.url })).status, 404); - assert.deepEqual(room.participants, [a.person.id]); - const path = "/api/rooms/" + room.id + "/join"; - assert.equal((await request(path, { base: isolated.url, method: "POST" })).status, 401); - for (let index = 0; index < 2; index++) await request(path, { base: isolated.url, cookie: b.cookie, method: "POST", data: { memberId: a.person.id } }); - const rooms = await (await request("/api/rooms", { base: isolated.url })).json(); - assert.deepEqual(rooms.rooms[0].participants, [a.person.id, b.person.id]); - assert.equal(rooms.rooms[0].callProvider, "agora"); - assert.deepEqual(rooms.calls, { provider: "agora", ready: false }); - await request("/api/profile", { base: isolated.url, cookie: b.cookie, method: "DELETE" }); - assert.deepEqual((await (await request("/api/rooms", { base: isolated.url })).json()).rooms[0].participants, [a.person.id]); - await request("/api/profile", { base: isolated.url, cookie: a.cookie, method: "DELETE" }); - assert.equal((await (await request("/api/rooms", { base: isolated.url })).json()).rooms.length, 0); - } finally { await close(isolated.instance); } -}); -test("Agora call tokens require room membership, bind identities to sessions, and renew without exposing the certificate", async () => { - const config = agoraConfig({ AGORA_APP_ID: "a".repeat(32), AGORA_APP_CERTIFICATE: "b".repeat(32) }); - const isolated = await start({ calls: createAgoraCalls(config) }); - try { - const a = await join("Call host", isolated.url); - const b = await join("Call guest", isolated.url); - const { room } = await (await request("/api/rooms", { base: isolated.url, cookie: a.cookie, method: "POST", data: { title: "Agora workshop", intent: "Work on voice routing" } })).json(); - const path = "/api/rooms/" + room.id + "/call"; - assert.equal((await request(path, { base: isolated.url, method: "POST", data: {} })).status, 401); - assert.equal((await request(path, { base: isolated.url, cookie: b.cookie, method: "POST", data: {} })).status, 403); - assert.equal((await request(path, { base: isolated.url, cookie: a.cookie, method: "POST", data: {}, headers: { Origin: "https://elsewhere.example" } })).status, 403); - const response = await request(path, { base: isolated.url, cookie: a.cookie, method: "POST", data: {} }); - assert.equal(response.status, 200); - assert.equal(response.headers.get("cache-control"), "no-store"); - const host = await response.json(); - assert.equal(host.provider, "agora"); - assert.equal(host.channel, "agora-build-" + room.id); - assert.match(host.token, /^007/); - assert.match(host.screenToken, /^007/); - assert.equal(host.screenUid, host.uid + "_screen"); - assert.ok(!JSON.stringify(host).includes(config.certificate)); - const renewed = await (await request(path, { base: isolated.url, cookie: a.cookie, method: "POST", data: { uid: host.uid, channel: "unrelated-room", appId: "untrusted" } })).json(); - assert.equal(renewed.uid, host.uid); - assert.equal(renewed.channel, host.channel); - assert.equal(renewed.appId, config.appId); - await request("/api/rooms/" + room.id + "/join", { base: isolated.url, cookie: b.cookie, method: "POST" }); - const guest = await (await request(path, { base: isolated.url, cookie: b.cookie, method: "POST", data: {} })).json(); - assert.equal(guest.channel, host.channel); - assert.notEqual(guest.uid, host.uid); - assert.equal((await request(path, { base: isolated.url, cookie: b.cookie, method: "POST", data: { uid: host.uid } })).status, 422); - const anotherTab = await (await request(path, { base: isolated.url, cookie: a.cookie, method: "POST", data: {} })).json(); - assert.notEqual(anotherTab.uid, host.uid); - await request("/api/profile", { base: isolated.url, cookie: a.cookie, method: "DELETE" }); - assert.equal((await request(path, { base: isolated.url, cookie: b.cookie, method: "POST", data: { uid: guest.uid } })).status, 404); - for (const file of ["/scripts/calls.mjs", "/node_modules/agora-token/index.js"]) assert.equal((await request(file, { base: isolated.url })).status, 404); - } finally { await close(isolated.instance); } -}); -test("unconfigured Agora calls keep rooms usable and reject token issuance with an honest status", async () => { - const a = await join("Unconfigured caller"); - const { room } = await (await request("/api/rooms", { cookie: a.cookie, method: "POST", data: { title: "Future workshop", intent: "Plan a demo day" } })).json(); - const response = await request("/api/rooms/" + room.id + "/call", { cookie: a.cookie, method: "POST", data: {} }); - assert.equal(response.status, 503); - assert.match((await response.json()).error, /Agora calls are not connected/); - assert.equal((await request("/assets/agora-rtc.js")).status, 200); - assert.equal((await request("/call.js")).status, 200); +test("old meeting links and the rooms API point people to the Stoa", async () => { + const id = "11111111-1111-1111-1111-111111111111"; + const meet = await fetch(origin + "/meet/" + id + "?invite=abc", { redirect: "manual" }); + assert.deepEqual([meet.status, meet.headers.get("location")], [308, "/stoa/s/" + id + "?invite=abc"]); + const page = await fetch(origin + "/meetings.html?room=" + id, { redirect: "manual" }); + assert.deepEqual([page.status, page.headers.get("location")], [308, "/stoa/s/" + id]); + const bare = await fetch(origin + "/meetings.html", { redirect: "manual" }); + assert.deepEqual([bare.status, bare.headers.get("location")], [308, "/stoa/"]); + for (const [path, method] of [["/api/rooms", "GET"], ["/api/rooms", "POST"], ["/api/rooms/" + id + "/join", "POST"], ["/api/rooms/" + id + "/call", "POST"]]) { + const response = await fetch(origin + path, { method, headers: { "Content-Type": "application/json" }, ...(method === "POST" ? { body: "{}" } : {}) }); + assert.equal(response.status, 410, method + " " + path); + assert.match((await response.json()).error, /Stoa/); + } + for (const file of ["/meetings.js", "/call.js"]) assert.equal((await fetch(origin + file)).status, 404, file); }); test("existing room IDs and rosters survive the call provider change without publishing old external call links", async () => { const storageFile = resolve(temporary, "legacy-rooms.json"); @@ -272,16 +211,15 @@ test("automatic radar scans only opted-in members and respects the scan interval test("production output includes six pages, platform backend, seed data, and no private state", async () => { await import("../scripts/build.mjs"); for (const file of ["account.html", "account.js", "scripts/identity.mjs", "scripts/ledger.mjs", "scripts/credits.mjs", "scripts/billing.mjs", "scripts/connections.mjs", "scripts/plans.mjs", "scripts/persistence.mjs", "scripts/platform-admin.mjs"]) assert.deepEqual(await readFile(resolve(root, "dist", file)), await readFile(resolve(root, file)), file); - const files = ["index.html", "explore.html", "services.html", "radar.html", "meetings.html", "styles.css", "script.js", "people.js", "activity.js", "explore.js", "services.js", "radar.js", "meetings.js", "call.js", "package.json", "package-lock.json", "assets/favicon.svg", "assets/guohai.jpg", "scripts/serve.mjs", "scripts/store.mjs", "scripts/models.mjs", "scripts/activity.mjs", "scripts/calls.mjs", "scripts/auth.mjs", "stoa.html", "stoa.js", "world/engine.js", "world/sdk.js", "world/map.js", "world/kinds.js", "stoa/stage.js", "world/signaling.js", "worlds/plaza/map.json", "worlds/room/map.json", "scripts/spaces/service.mjs", "scripts/spaces/routes.mjs", "data/people.json", "data/projects.json", "data/offers.json", "data/activity.json", "themes/agora/theme.json", "themes/minimal/theme.json", "themes/cyberpunk/theme.json"]; + const files = ["index.html", "explore.html", "services.html", "radar.html", "styles.css", "script.js", "people.js", "activity.js", "explore.js", "services.js", "radar.js", "package.json", "package-lock.json", "assets/favicon.svg", "assets/guohai.jpg", "scripts/serve.mjs", "scripts/store.mjs", "scripts/models.mjs", "scripts/activity.mjs", "scripts/calls.mjs", "scripts/auth.mjs", "stoa.html", "stoa.js", "world/engine.js", "world/sdk.js", "world/map.js", "world/kinds.js", "stoa/stage.js", "world/signaling.js", "worlds/plaza/map.json", "worlds/room/map.json", "scripts/spaces/service.mjs", "scripts/spaces/routes.mjs", "data/people.json", "data/projects.json", "data/offers.json", "data/activity.json", "themes/agora/theme.json", "themes/minimal/theme.json", "themes/cyberpunk/theme.json"]; for (const file of files) assert.deepEqual(await readFile(resolve(root, "dist", file)), await readFile(resolve(root, file)), file); for (const file of [".env", ".data/community.json"]) await assert.rejects(readFile(resolve(root, "dist", file)), { code: "ENOENT" }); const built = await start({}, resolve(root, "dist")); try { - assert.equal((await request("/meetings.html", { base: built.url })).status, 200); assert.equal((await request("/account.html", { base: built.url })).status, 200); assert.equal((await request("/assets/agora-rtc.js", { base: built.url })).status, 200); assert.equal((await request("/assets/agora-rtm.js", { base: built.url })).status, 200); - assert.equal((await request("/call.js", { base: built.url })).status, 200); + for (const file of ["meetings.html", "meetings.js", "call.js"]) await assert.rejects(readFile(resolve(root, "dist", file)), { code: "ENOENT" }); assert.equal((await request("/stoa/", { base: built.url })).status, 200); assert.equal((await (await request("/api/projects", { base: built.url })).json()).projects.length, 16); } finally { await close(built.instance); } diff --git a/tests/spaces-api.test.mjs b/tests/spaces-api.test.mjs index 935a4b8..4e5bc81 100644 --- a/tests/spaces-api.test.mjs +++ b/tests/spaces-api.test.mjs @@ -171,7 +171,7 @@ test("the Signaling SDK and its loader are served, and every page links to the S assert.match(await sdk.text(), /AgoraRTM/); const loader = await request(url, "/world/sdk.js"); assert.equal(loader.status, 200); - for (const page of ["/", "/explore.html", "/services.html", "/radar.html", "/meetings.html", "/account.html", "/stoa/"]) { + for (const page of ["/", "/explore.html", "/services.html", "/radar.html", "/account.html", "/stoa/"]) { const html = await (await request(url, page)).text(); assert.match(html, /]*>Stoa<\/a>/, page); assert.doesNotMatch(html, />Rooms<\/a>/, page); From b98726f5a416004c8ab408ccb5b48df432deb517 Mon Sep 17 00:00:00 2001 From: Brent G Date: Thu, 8 Oct 2026 19:51:36 +0000 Subject: [PATCH 67/71] Say spaces in the sign-out notice MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🤖 Built with SMT --- script.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/script.js b/script.js index 9f9642b..995eabd 100644 --- a/script.js +++ b/script.js @@ -47,7 +47,7 @@ const loginStatus = el("p", { class: "login-status", role: "status", text: "Chec const guest = el("button", { class: "inline-link login-guest", type: "button", text: "Continue with this browser", onclick: () => { loginDialog.close(); openJoin(afterJoin, true); } }); const signOut = el("button", { class: "button button-secondary", type: "button", text: "Sign out", onclick: async () => { signOut.disabled = true; - try { await api("/api/auth/logout", { method: "POST" }); await refreshSession(); loginDialog.close(); notify("You are signed out. Your public profile and rooms remain available."); } + try { await api("/api/auth/logout", { method: "POST" }); await refreshSession(); loginDialog.close(); notify("You are signed out. Your public profile and spaces remain available."); } catch (error) { notify(error.message); } finally { signOut.disabled = false; } } }); From 8d22a7fb7fa71f7a6ed66660a112f3a8a53ee505 Mon Sep 17 00:00:00 2001 From: Brent G Date: Thu, 8 Oct 2026 20:08:43 +0000 Subject: [PATCH 68/71] Close the Stoa rooms review: rekeys, reachable tiles, fresh access MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Removing a member from the manage dialog rekeys the room you are in, like the in-room Remove button (stage.rekeyRoom). - Call tiles: screen shares first, then your camera, then video; strip and grid scroll within their own band of the map. - Heartbeat access is always kept, so a later live connection opens the current channel and key. - Extra heartbeats run one at a time, at most one every two seconds unless forced. - Heading follows the stage's room; ?start= survives signing in; bfcache restores reload. - Host topic form needs a live lease; a full plaza lists your spaces once; draw frames are built only while tiles float. - stage.fromHost replaces three copies of the host check; dead decorArea removed. 🤖 Built with SMT --- stoa.js | 22 ++++++++++++++------ stoa/call.js | 19 +++++++++++++----- stoa/lease.js | 14 +++++++++---- stoa/panel.js | 5 +++-- stoa/spaces.js | 4 ++-- stoa/stage.js | 24 +++++++++++++++++----- styles.css | 7 +++++-- tests/stoa-call.test.mjs | 9 +++++++-- tests/stoa-panel.test.mjs | 10 ++++++++++ tests/stoa-place.test.mjs | 21 ++++++++++++++++++- tests/stoa-stage.test.mjs | 40 +++++++++++++++++++++++++++++++++++-- tests/world-engine.test.mjs | 10 ++++++++++ world/engine.js | 2 ++ 13 files changed, 156 insertions(+), 31 deletions(-) diff --git a/stoa.js b/stoa.js index 8a5aa3a..8353267 100644 --- a/stoa.js +++ b/stoa.js @@ -23,6 +23,9 @@ function applyUi(theme) { root.style.fontFamily = fontStack(theme.ui.font); } +// A page restored from the back/forward cache holds a stale lease and channel, so it loads afresh. +addEventListener("pageshow", (event) => { if (event.persisted) location.reload(); }); + const [, roomSlug, spaceId] = /^\/stoa\/(?:room\/([a-z0-9-]{2,40})|s\/([a-f0-9-]{36}))?$/.exec(location.pathname) || []; const invite = new URLSearchParams(location.search).get("invite"); const problems = []; @@ -36,11 +39,18 @@ const space = spaceResult.value?.space || null; const kickerFor = (entry) => entry ? (entry.visibility === "private" ? "THE STOA / PRIVATE SPACE" : "THE STOA / UNLISTED SPACE") : "THE STOA / SPACE"; if (space) { document.querySelector("#stoa-title").textContent = space.title; document.title = space.title + " | Agora Build"; } -let map = null, engine = null, minimal = stored.get() === "true", preview = null, plazaBounds = null, active = () => null, call = null, decor = null, spacesUi = null; +let map = null, engine = null, minimal = stored.get() === "true", preview = null, plazaBounds = null, active = () => null, call = null, decor = null, spacesUi = null, roomNow = null; try { if (worldResult.status === "fulfilled") map = parseMap(worldResult.value); } catch (error) { problems.push("The map is not valid (" + error.message + ")."); } let summaries = new Map((spacesResult.value?.rooms || []).map((room) => [room.slug, room])); const lot = roomSlug && map ? map.lots.find((entry) => entry.slug === roomSlug) || null : null; -document.querySelector("#stoa-kicker").textContent = spaceId ? kickerFor(space) : roomSlug ? (lot ? "THE STOA / ROOM" : "THE STOA") : "THE STOA / PUBLIC PLAZA"; +// The heading names where you are: the URL's space at first, then whichever room the stage puts you in. +function heading(room) { + const lotHere = room && map?.lots.find((entry) => "lot-" + entry.slug === room.id); + const [kicker, title] = lotHere ? ["THE STOA / ROOM", lotHere.title] : room ? [kickerFor(room), room.title] : ["THE STOA / PUBLIC PLAZA", "Walk into the plaza."]; + document.querySelector("#stoa-kicker").textContent = kicker; document.querySelector("#stoa-title").textContent = title; + document.title = (room ? title : "Stoa") + " | Agora Build"; +} +if (spaceId) document.querySelector("#stoa-kicker").textContent = kickerFor(space); const labels = () => map.lots.map((entry) => ({ x: entry.door.x, y: entry.door.y - 1, text: summaries.get(entry.slug)?.topic || entry.title })); if (map && !spaceId) panel.setRooms(map.lots, summaries); @@ -86,7 +96,7 @@ spacesUi = createSpacesUi({ api, say: (text) => panel.say(text), onSaved: (saved document.querySelector("#stoa-kicker").textContent = kickerFor(saved); stage?.refresh(); if (themeChanged) panel.say("The new theme shows after you reload."); -} }); +}, onMembersChanged: (id) => { if (roomNow?.id === id) stage?.rekeyRoom(); } }); panel.setPeople([], self.name); panel.canTalk(self.signedIn); let live = null, stage = null; @@ -122,10 +132,10 @@ panel.on({ // Your own spaces show on the plaza right away (with or without the canvas); ?start= (from the home page) opens the create dialog with that name. if (!spaceId && self.signedIn) { if (!engine) panel.showPlaza(); api("/api/spaces/mine").then((mine) => spacesUi.showMine(mine)).catch(() => {}); } const params = new URLSearchParams(location.search); +// Signed out, the parameter stays in the address: sign-in returns to this URL, and the dialog opens then. if (!spaceId && params.has("start")) { const title = params.get("start"); - params.delete("start"); - history.replaceState(null, "", location.pathname + (params.size ? "?" + params : "") + location.hash); + if (self.signedIn) { params.delete("start"); history.replaceState(null, "", location.pathname + (params.size ? "?" + params : "") + location.hash); } startSpace({ title }); } // Signaling loads and connects in the background: the page is already usable, and the stage picks the connection up when it is ready. @@ -163,7 +173,7 @@ if (engine) { call = createCallView({ engine, api, self, people: () => stage?.people() || [], say: (text) => panel.say(text) }); call.setLayout(active().video); decor = createDecorEditor({ canvas, engine, map, say: (text) => panel.say(text), onSave: (items) => stage.saveDecor(items), onClose: () => { stage.setEditing(false); panel.decorating(false); } }); - stage = createStage({ api, map, engine, panel, live: null, self, plaza: spaceId ? null : { bounds: plazaBounds, channel: null }, onRoom: (room) => { decor?.abort(); if (room) call.enter(room.id); else call.exit(); }, onPeople: () => call.refresh(), onManage: (can) => { if (!can) decor?.abort(); } }); + stage = createStage({ api, map, engine, panel, live: null, self, plaza: spaceId ? null : { bounds: plazaBounds, channel: null }, onRoom: (room) => { roomNow = room; decor?.abort(); if (room) call.enter(room.id); else call.exit(); if (room || !spaceId) heading(room); }, onPeople: () => call.refresh(), onManage: (can) => { if (!can) decor?.abort(); } }); engine.on("arrive", (tile) => stage.onArrive(tile)); // Leaving the page releases the entry lease and, as far as the browser allows, the Signaling login. addEventListener("pagehide", () => { call.exit(); stage.unload(); live?.close().catch(() => {}); }); diff --git a/stoa/call.js b/stoa/call.js index 8ea680f..76247b0 100644 --- a/stoa/call.js +++ b/stoa/call.js @@ -11,10 +11,12 @@ export function nameFor(uid, { people, self, snapshot }) { const person = people.find((entry) => String(entry.id).split(":")[1] === actorUuid(uid)); return (person?.name || "Builder") + (isScreen(uid) ? "'s screen" : ""); } +// Screen shares lead, then your own camera, then people with video, then the rest, so what matters stays in view when tiles overflow. +const tileRank = (tile) => tile.screen ? 0 : tile.mine ? 1 : tile.video ? 2 : 3; export function tileList(snapshot, { people, self }) { if (!snapshot?.joined) return []; const own = [{ uid: snapshot.uid, video: snapshot.video, screen: false, mine: true }, ...(snapshot.screen ? [{ uid: snapshot.screenUid, video: true, screen: true, mine: true }] : [])]; - return [...own, ...snapshot.peers.map((peer) => ({ uid: peer.uid, video: peer.video, screen: peer.screen, mine: false }))].map((tile) => ({ ...tile, name: nameFor(tile.uid, { people, self, snapshot }) })); + return [...own, ...snapshot.peers.map((peer) => ({ uid: peer.uid, video: peer.video, screen: peer.screen, mine: false }))].sort((a, b) => tileRank(a) - tileRank(b)).map((tile) => ({ ...tile, name: nameFor(tile.uid, { people, self, snapshot }) })); } // Where a tile floats in over-avatar layout: just above the person's head, in canvas pixels. Null when they aren't drawn. export function overAvatar(tile, frame) { @@ -30,7 +32,7 @@ export function createCallView({ doc = document, engine, api, self, people = () let busyJoining = false; const buttons = { join: $("#stoa-call-join"), audio: $("#stoa-call-audio"), video: $("#stoa-call-video"), share: $("#stoa-call-share"), resume: $("#stoa-call-resume"), leave: $("#stoa-call-leave") }; const tiles = new Map(); - let spaceId = null, rtc = null, layout = "strip", frame = null; + let spaceId = null, rtc = null, layout = "strip", frame = null, unlisten = null; function place() { for (const view of tiles.values()) { @@ -55,7 +57,7 @@ export function createCallView({ doc = document, engine, api, self, people = () layer.dataset.layout = layout; const wanted = joined ? tileList(now, { people: people(), self }) : []; for (const [uid, view] of tiles) if (!wanted.some((tile) => tile.uid === uid)) { view.card.remove(); tiles.delete(uid); } - for (const tile of wanted) { + for (const [index, tile] of wanted.entries()) { let view = tiles.get(tile.uid); if (!view) { const card = doc.createElement("article"), player = doc.createElement("div"), label = doc.createElement("span"); @@ -65,13 +67,21 @@ export function createCallView({ doc = document, engine, api, self, people = () view = { card, player, label, playing: false, tile }; tiles.set(tile.uid, view); } - view.tile = tile; + // CSS order keeps tileList's order without moving (and pausing) playing video elements. + view.tile = tile; view.card.style.order = String(index); if (view.label.textContent !== tile.name) { view.label.textContent = tile.name; view.card.setAttribute("aria-label", tile.name); } if (tile.video && !view.playing) view.playing = rtc.play(tile.uid, view.player); if (!tile.video) view.playing = false; view.card.classList.toggle("has-video", view.playing); } place(); + follow(); + } + // The engine builds frame data only while someone listens, so tiles listen only when they float over avatars. + function follow() { + const wanted = layout === "over-avatar" && tiles.size > 0; + if (wanted && !unlisten) { unlisten = engine.on("draw", (next) => { frame = next; place(); }); engine.redraw?.(); } + else if (!wanted && unlisten) { unlisten(); unlisten = null; frame = null; } } function reset(message) { for (const view of tiles.values()) view.card.remove(); @@ -123,7 +133,6 @@ export function createCallView({ doc = document, engine, api, self, people = () buttons.share.addEventListener("click", () => rtc?.share()); buttons.resume.addEventListener("click", () => rtc?.resumeAudio()); buttons.leave.addEventListener("click", () => leave("You left the call.", true)); - engine.on("draw", (next) => { frame = next; if (layout === "over-avatar" && tiles.size) place(); }); return { enter(id) { if (spaceId === id) return; diff --git a/stoa/lease.js b/stoa/lease.js index 5f3304f..f3f4193 100644 --- a/stoa/lease.js +++ b/stoa/lease.js @@ -1,7 +1,7 @@ // Keeps an entry lease alive and reports when it ends. Leaving on page close uses a beacon so it still reaches the server. -export function createLease({ api, spaceId, onAccess = () => {}, onLost = () => {}, every = 20000, repeat = (callback, delay) => setInterval(callback, delay), stopRepeat = (handle) => clearInterval(handle), beacon = (url) => navigator.sendBeacon?.(url) }) { +export function createLease({ api, spaceId, onAccess = () => {}, onLost = () => {}, every = 20000, repeat = (callback, delay) => setInterval(callback, delay), stopRepeat = (handle) => clearInterval(handle), beacon = (url) => navigator.sendBeacon?.(url), now = () => Date.now() }) { const base = "/api/spaces/" + spaceId; - let timer = null, stopped = false; + let timer = null, stopped = false, quick = null, lastQuick = -Infinity; const stop = () => { stopped = true; if (timer !== null) { stopRepeat(timer); timer = null; } }; const beat = async () => { try { const access = await api(base + "/heartbeat", { method: "POST" }); if (!stopped) onAccess(access); } @@ -10,8 +10,14 @@ export function createLease({ api, spaceId, onAccess = () => {}, onLost = () => timer = repeat(beat, every); return { stop, - // An extra heartbeat right away, for when the host says access has changed. - beatNow: () => stopped ? Promise.resolve() : beat(), + // An extra heartbeat right away, for when access has changed. One runs at a time and, unless forced, at most one every two seconds, so a flood of rekey notices costs little. + beatNow({ force = false } = {}) { + if (stopped) return Promise.resolve(); + if (quick) return quick; + if (!force && now() - lastQuick < 2000) return Promise.resolve(); + lastQuick = now(); + return (quick = beat().finally(() => { quick = null; })); + }, async leave() { stop(); try { await api(base + "/leave", { method: "POST" }); } catch { /* the lease expires on its own */ } }, leaveOnUnload() { stop(); beacon(base + "/leave"); } }; diff --git a/stoa/panel.js b/stoa/panel.js index fc6940a..c057084 100644 --- a/stoa/panel.js +++ b/stoa/panel.js @@ -85,11 +85,12 @@ export function createPanel(doc = document) { sayInput.placeholder = "Say something to the room"; }, showPlaza() { here.hidden = true; $("#stoa-manage").hidden = true; $("#stoa-mine-section").hidden = !signedIn; canDecorate = false; syncDecor(); topicForm.hidden = true; roomsSection.hidden = false; sayInput.placeholder = "Say something on the plaza"; }, + // The offer lists your spaces, so the Your spaces section steps aside while it shows. showOffer({ spaces = [], canCreate = false } = {}) { - offer.hidden = false; + offer.hidden = false; $("#stoa-mine-section").hidden = true; $("#stoa-offer-list").replaceChildren(...spaces.map((space) => { const item = node("li"), link = node("a", space.title); link.href = space.path; item.append(link); return item; })); $("#stoa-start").hidden = !canCreate; }, - hideOffer() { offer.hidden = true; } + hideOffer() { offer.hidden = true; $("#stoa-mine-section").hidden = !signedIn || !here.hidden; } }; } diff --git a/stoa/spaces.js b/stoa/spaces.js index 99df8d7..78d1d4b 100644 --- a/stoa/spaces.js +++ b/stoa/spaces.js @@ -11,7 +11,7 @@ export const describeSpace = (space) => [space.visibility === "private" ? "Priva export const memberPath = (id) => encodeURIComponent(id).replace(/%3A/gi, ":"); export const inviteUrl = (origin, path) => new URL(path, origin).href; -export function createSpacesUi({ onSaved = () => {}, doc = document, api, say = () => {}, navigate = (path) => location.assign(path), confirm = (text) => window.confirm(text), copy = (text) => navigator.clipboard.writeText(text), origin = location.origin }) { +export function createSpacesUi({ onSaved = () => {}, onMembersChanged = () => {}, doc = document, api, say = () => {}, navigate = (path) => location.assign(path), confirm = (text) => window.confirm(text), copy = (text) => navigator.clipboard.writeText(text), origin = location.origin }) { const $ = (selector) => doc.querySelector(selector); const node = (tag, text, className) => { const element = doc.createElement(tag); if (text) element.textContent = text; if (className) element.className = className; return element; }; const createDialog = $("#space-dialog"), createForm = $("#space-form"), manageDialog = $("#manage-dialog"), manageForm = $("#manage-form"); @@ -49,7 +49,7 @@ export function createSpacesUi({ onSaved = () => {}, doc = document, api, say = people(space.roster || [], "#manage-members", "Remove", async (person) => { if (!confirm("Remove " + person.name + " from the members of " + space.title + "?")) return; $("#manage-error").textContent = ""; - try { await api("/api/spaces/" + encodeURIComponent(space.id) + "/members/" + memberPath(person.id), { method: "DELETE" }); await load(space.id); say(person.name + " is no longer a member."); } + try { await api("/api/spaces/" + encodeURIComponent(space.id) + "/members/" + memberPath(person.id), { method: "DELETE" }); onMembersChanged(space.id); await load(space.id); say(person.name + " is no longer a member."); } catch (error) { $("#manage-error").textContent = error.message; } }, space.ownerId); people(space.blockedRoster || [], "#manage-blocked", "Allow back", async (person) => { diff --git a/stoa/stage.js b/stoa/stage.js index 265ef64..4645e06 100644 --- a/stoa/stage.js +++ b/stoa/stage.js @@ -32,11 +32,15 @@ export function createStage({ api, map, engine, panel, live: firstLive, self, pl }); }; const mine = (id) => Boolean(id) && (self.ids || [self.id]).includes(id); - const canManage = () => Boolean(here && (here.kind === "lot" || here.kind === "space") && here.leased && self.signedIn && (mine(here.access?.hostId) || mine(here.space?.ownerId))); + // Host tools need a live lease in a lot or space as well as being its host or owner. + const inRoom = () => Boolean(here && (here.kind === "lot" || here.kind === "space") && here.leased && self.signedIn); + const canManage = () => inRoom() && (mine(here.access?.hostId) || mine(here.space?.ownerId)); + // Room notices count only from the current place's host or owner. + const fromHost = (next, from) => here === next && Boolean(from) && (from === next.access?.hostId || from === next.space?.ownerId); const checkManage = () => { const can = canManage(); if (can !== manageNow) { manageNow = can; onManage(can); } }; const doorTiles = (rect) => { const tiles = []; for (let y = rect.y; y < rect.y + rect.height; y += 1) for (let x = rect.x; x < rect.x + rect.width; x += 1) if (roleAt(map, x, y) === "door") tiles.push({ x, y }); return tiles; }; const showDecor = (items) => { currentDecor = items || []; if (!editing) engine.setDecor(currentDecor); }; - const showRoom = (space, hostId) => { panel.showRoom({ kicker: here?.kind === "lot" ? "LOT ROOM" : space.visibility === "private" ? "PRIVATE SPACE" : "UNLISTED SPACE", title: space.title, topic: space.topic, tags: space.tags || [], host: Boolean(self.id) && (mine(hostId) || mine(space.ownerId)), leaveLabel: here?.kind === "lot" ? "Back to the plaza" : "Leave this space", decorate: canManage(), owner: here?.kind === "space" && mine(space.ownerId) }); checkManage(); showPeople(); }; + const showRoom = (space, hostId) => { panel.showRoom({ kicker: here?.kind === "lot" ? "LOT ROOM" : space.visibility === "private" ? "PRIVATE SPACE" : "UNLISTED SPACE", title: space.title, topic: space.topic, tags: space.tags || [], host: inRoom() && (mine(hostId) || mine(space.ownerId)), leaveLabel: here?.kind === "lot" ? "Back to the plaza" : "Leave this space", decorate: canManage(), owner: here?.kind === "space" && mine(space.ownerId) }); checkManage(); showPeople(); }; // Every transition claims `here` before it awaits anything; a superseded one stops as soon as it notices. async function settle(next) { @@ -54,7 +58,7 @@ export function createStage({ api, map, engine, panel, live: firstLive, self, pl showPeople(); if (live && next.access?.channel) { try { - const place = await openPlace({ live, map, engine, self, quiet: Boolean(next.watch), channel: { name: next.access.channel, key: next.access.key || null, spaceId: next.space.id }, onPeople: showPeople, onSay: (entry) => { if (entry.failed) panel.say("That message didn't send."); else panel.addMessage(entry); }, onRefresh: (from) => { if (here === next && from && (from === next.access?.hostId || from === next.space?.ownerId)) refreshRoom(); }, onRekey: (from) => { if (here === next && from && (from === next.access?.hostId || from === next.space?.ownerId)) lease?.beatNow(); }, onDecor: (from) => { if (here === next && from && (from === next.access?.hostId || from === next.space?.ownerId)) refreshRoom(); } }); + const place = await openPlace({ live, map, engine, self, quiet: Boolean(next.watch), channel: { name: next.access.channel, key: next.access.key || null, spaceId: next.space.id }, onPeople: showPeople, onSay: (entry) => { if (entry.failed) panel.say("That message didn't send."); else panel.addMessage(entry); }, onRefresh: (from) => { if (fromHost(next, from)) refreshRoom(); }, onRekey: (from) => { if (fromHost(next, from)) lease?.beatNow(); }, onDecor: (from) => { if (fromHost(next, from)) refreshRoom(); } }); if (here !== next) { await place.close().catch(() => {}); return; } places = [place]; place.setBlocked(next.access.blocked || []); @@ -71,8 +75,11 @@ export function createStage({ api, map, engine, panel, live: firstLive, self, pl panel.canTalk(talk, talk || !self.signedIn ? undefined : next.full ? "The plaza is full \u2014 you're watching." : next.kind === "left" ? "You're no longer in this space." : "You're watching."); } + // Fresh access is always kept, so a later reopen (attachLive) uses the current channel, key, and removals. function onAccess(access, at) { if (here !== at) return; + const hostChanged = access.hostId !== here.access?.hostId; + here.access = { ...here.access, ...access }; if (places[0] && access.channel !== places[0].channel) { transition(async () => { if (here !== at || !places[0] || places[0].channel === access.channel) return; @@ -82,7 +89,7 @@ export function createStage({ api, map, engine, panel, live: firstLive, self, pl return; } for (const place of places) place.setBlocked(access.blocked || []); - if (here.kind !== "plaza" && access.hostId !== here.access.hostId) { here.access = { ...here.access, ...access }; checkManage(); refreshRoom(); } + if (here.kind !== "plaza" && hostChanged) { checkManage(); refreshRoom(); } } // A lease the server dropped (410) gets one re-entry into the same place; removed or hidden (403, 404) does not. @@ -230,6 +237,13 @@ export function createStage({ api, map, engine, panel, live: firstLive, self, pl } catch (error) { if (here === at) panel.say(error.message); } } + // After the owner removes a member from the manage dialog: everyone checks access, and this person picks up the new channel now. + async function rekeyRoom() { + if (!inRoom()) return; + await Promise.all(places.map((place) => place.rekey().catch(() => false))); + await lease?.beatNow({ force: true }); + } + const removeNotice = (person) => here?.kind === "lot" ? "Remove " + person.name + " from this room? They can't come back until the room empties." : "Remove " + person.name + " from this space? They can't come back unless the owner allows them back."; return { @@ -241,6 +255,7 @@ export function createStage({ api, map, engine, panel, live: firstLive, self, pl setTopic, makeHost, removePerson, + rekeyRoom, removeNotice, people: () => everyone, canManage, @@ -251,7 +266,6 @@ export function createStage({ api, map, engine, panel, live: firstLive, self, pl const area = { x: 0, y: 0, width: map.width, height: map.height }; return { area, start: map.spawns[0], protect: [...map.spawns, ...doorTiles(area)] }; }, - decorArea: () => here?.kind === "lot" ? here.lot.interior : here?.kind === "space" ? { x: 0, y: 0, width: map.width, height: map.height } : null, setEditing(value) { editing = Boolean(value); if (!editing) engine.setDecor(currentDecor); }, async saveDecor(items) { const at = here; diff --git a/styles.css b/styles.css index a7558ff..ea3b7bb 100644 --- a/styles.css +++ b/styles.css @@ -529,8 +529,11 @@ dialog h2 { font-size: 30px; line-height: 1.25; font-weight: 500; letter-spacing .stoa-say { display: grid; grid-template-columns: 1fr auto; gap: 8px; } .stoa-signin { font-size: 13px; color: var(--muted); } .stoa-view { position: relative; } -.stoa-video { position: absolute; inset: 0; display: flex; flex-wrap: nowrap; align-content: flex-start; gap: 8px; padding: 8px; pointer-events: none; overflow: hidden; } -.stoa-video[data-layout="grid"] { inset: 0 0 0 auto; width: min(46%, 520px); display: grid; grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); } +.stoa-video { position: absolute; inset: 0; display: flex; flex-wrap: nowrap; align-items: flex-start; align-content: flex-start; gap: 8px; padding: 8px; pointer-events: none; overflow: hidden; } +/* Strip and grid scroll when tiles overflow; each covers only its own band of the map, which is all that takes pointer input. */ +.stoa-video[data-layout="strip"] { inset: 0 0 auto 0; overflow-x: auto; overflow-y: hidden; pointer-events: auto; scrollbar-width: thin; } +.stoa-video[data-layout="grid"] { inset: 0 0 auto auto; max-height: 100%; width: min(46%, 520px); display: grid; grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); overflow-y: auto; pointer-events: auto; scrollbar-width: thin; } +.stoa-video[data-layout="grid"] .stoa-tile.is-screen { grid-column: 1 / -1; } .stoa-tile { position: relative; flex: none; width: 168px; aspect-ratio: 16 / 10; overflow: hidden; border: 1px solid var(--line); border-radius: 4px; background: #202824; pointer-events: none; } .stoa-video[data-layout="grid"] .stoa-tile { width: auto; } .stoa-tile.is-screen { width: min(100%, 360px); aspect-ratio: 16 / 9; } diff --git a/tests/stoa-call.test.mjs b/tests/stoa-call.test.mjs index c9038cf..c14a682 100644 --- a/tests/stoa-call.test.mjs +++ b/tests/stoa-call.test.mjs @@ -10,13 +10,13 @@ const snapshot = { joined: true, uid: me + "_aaaaaaaaaaaa", screenUid: me + "_aa test("call identities map back to people by their UUID", () => { assert.equal(actorUuid(ada + "_bbbbbbbbbbbb_screen"), ada); const names = tileList(snapshot, { people, self }).map((tile) => [tile.name, tile.video, tile.screen, tile.mine]); - assert.deepEqual(names, [["Me (you)", false, false, true], ["Your screen", true, true, true], ["Ada", true, false, false], ["Ada's screen", true, true, false], ["Builder", false, false, false]]); + assert.deepEqual(names, [["Your screen", true, true, true], ["Ada's screen", true, true, false], ["Me (you)", false, false, true], ["Ada", true, false, false], ["Builder", false, false, false]]); assert.deepEqual(tileList({ ...snapshot, joined: false }, { people, self }), []); assert.equal(nameFor(snapshot.uid, { people, self, snapshot }), "Me (you)"); }); test("over-avatar tiles sit above the matching avatar; screens and people out of view do not float", () => { const frame = { tileSize: 32, camera: { x: 100, y: 50, zoom: 2 }, avatars: [{ id: self.id, x: 5, y: 4, self: true }, { id: "account:" + ada, x: 7, y: 3, self: false }] }; - const [mine, screen, adaTile, , stranger] = tileList(snapshot, { people, self }); + const [screen, , mine, adaTile, stranger] = tileList(snapshot, { people, self }); assert.deepEqual(overAvatar(mine, frame), { left: ((5 + 0.5) * 32 - 100) * 2, top: (4 * 32 - 50) * 2 }); assert.deepEqual(overAvatar(adaTile, frame), { left: ((7 + 0.5) * 32 - 100) * 2, top: (3 * 32 - 50) * 2 }); assert.equal(overAvatar(screen, frame), null); @@ -29,3 +29,8 @@ test("another tab of the same person is named as such", () => { assert.equal(nameFor(me + "_dddddddddddd_screen", { people, self: mine, snapshot }), "Me (another tab)'s screen"); assert.equal(nameFor(snapshot.uid, { people, self: mine, snapshot }), "Me (you)"); }); +test("screen shares come first, then people with video, then the rest; your own camera leads the people", () => { + const quiet = { uid: "99999999-0000-4000-8000-000000000000_cccccccccccc", audio: true, video: false, screen: false }; + const order = tileList({ ...snapshot, screen: false, peers: [quiet, snapshot.peers[0], snapshot.peers[1]] }, { people, self }).map((tile) => tile.name); + assert.deepEqual(order, ["Ada's screen", "Me (you)", "Ada", "Builder"]); +}); diff --git a/tests/stoa-panel.test.mjs b/tests/stoa-panel.test.mjs index 7845d51..f37d3cd 100644 --- a/tests/stoa-panel.test.mjs +++ b/tests/stoa-panel.test.mjs @@ -107,3 +107,13 @@ test("Manage shows only for the owner; your spaces only for signed-in people on panel.showPlaza(); assert.equal(doc.querySelector("#stoa-mine-section").hidden, false); }); +test("a full plaza lists your spaces once: the offer replaces the Your spaces section until you are on the plaza", () => { + const doc = fakeDocument(), panel = createPanel(doc), mine = doc.querySelector("#stoa-mine-section"), offer = doc.querySelector("#stoa-offer"); + panel.setSignedIn(true); + panel.showPlaza(); + assert.equal(mine.hidden, false); + panel.showOffer({ spaces: [{ title: "Lab", path: "/stoa/s/1" }], canCreate: true }); + assert.deepEqual([offer.hidden, mine.hidden, doc.querySelector("#stoa-start").hidden], [false, true, false]); + panel.hideOffer(); + assert.deepEqual([offer.hidden, mine.hidden], [true, false]); +}); diff --git a/tests/stoa-place.test.mjs b/tests/stoa-place.test.mjs index bd5cd8b..11a92cf 100644 --- a/tests/stoa-place.test.mjs +++ b/tests/stoa-place.test.mjs @@ -218,11 +218,30 @@ test("a heartbeat can be run at once, and does nothing after the lease stops", a const accesses = [], losses = []; let replies = [{ channel: "c3", key: null, blocked: [], hostId: null }, Object.assign(new Error("Gone"), { status: 410 })]; const api = async () => { const reply = replies.shift(); if (reply instanceof Error) throw reply; return reply; }; - const lease = createLease({ api, spaceId: "lot-x", onAccess: (access) => accesses.push(access.channel), onLost: (error) => losses.push(error.status), repeat: () => 1, stopRepeat() {} }); + let time = 0; + const lease = createLease({ api, spaceId: "lot-x", onAccess: (access) => accesses.push(access.channel), onLost: (error) => losses.push(error.status), repeat: () => 1, stopRepeat() {}, now: () => time }); await lease.beatNow(); + time += 2000; await lease.beatNow(); assert.deepEqual([accesses, losses], [["c3"], [410]]); replies = [{ channel: "c4" }]; await lease.beatNow(); assert.deepEqual(accesses, ["c3"], "a stopped lease does not beat"); }); +test("extra heartbeats run one at a time and at most one every two seconds, unless forced", async () => { + let time = 0, calls = 0; + const pending = []; + const api = () => { calls += 1; return new Promise((resolve) => pending.push(resolve)); }; + const lease = createLease({ api, spaceId: "s", repeat: () => 1, stopRepeat() {}, now: () => time }); + const first = lease.beatNow(); + lease.beatNow(); lease.beatNow({ force: true }); + assert.equal(calls, 1, "one in flight at a time, even when forced"); + pending.shift()({ channel: "c" }); await first; + time += 1999; lease.beatNow(); + assert.equal(calls, 1, "a second request within two seconds is skipped"); + lease.beatNow({ force: true }); + assert.equal(calls, 2, "a forced one is not held back by the gap"); + pending.shift()({ channel: "c" }); await tick(); + time += 2000; lease.beatNow(); + assert.equal(calls, 3); +}); diff --git a/tests/stoa-stage.test.mjs b/tests/stoa-stage.test.mjs index 68b6414..193d2f5 100644 --- a/tests/stoa-stage.test.mjs +++ b/tests/stoa-stage.test.mjs @@ -28,7 +28,7 @@ function setup({ api, signedIn = true, withLive = true, clock = { time: 0, timer canTalk: (value, reason) => { calls.canTalk.push(value); calls.reasons.push(reason); }, showRoom: (room) => calls.showRoom.push(room), showOffer: () => { calls.offer += 1; } }; const leases = []; - const createLease = (options) => { const entry = { options, active: true, stop() { entry.active = false; }, async leave() { entry.active = false; }, leaveOnUnload() { entry.active = false; }, beatNow() { entry.beats = (entry.beats || 0) + 1; return Promise.resolve(); } }; leases.push(entry); return entry; }; + const createLease = (options) => { const entry = { options, active: true, stop() { entry.active = false; }, async leave() { entry.active = false; }, leaveOnUnload() { entry.active = false; }, beatNow(options) { entry.beats = (entry.beats || 0) + 1; entry.forced = options?.force || false; return Promise.resolve(); } }; leases.push(entry); return entry; }; const stage = createStage({ api, map: plaza, engine, panel, live, self: { id: "a-me", name: "Me", signedIn }, plaza: { bounds: null, channel: "plaza-1" }, createLease, schedule: (callback) => frames.push(callback), now: () => clock.time, later: (callback, delay) => { clock.timers.push({ callback, delay }); return clock.timers.length; }, ...options }); const flushFrames = () => { for (const callback of frames.splice(0)) callback(); }; return { stage, calls, panel, leases, handlers, live, flushFrames, clock }; @@ -375,7 +375,6 @@ test("saving decorations stores them, shows them, and tells the room the new ver await stage.toPlaza(null); await stage.enterLot(lot); assert.equal(stage.canManage(), true, "the first one in hosts the lot"); - assert.deepEqual(stage.decorArea(), lot.interior); assert.equal(await stage.saveDecor(saved), true); assert.deepEqual(puts, [{ items: saved }]); assert.deepEqual(calls.decor.at(-1), saved); @@ -474,3 +473,40 @@ test("remove wording follows the room kind, and a refresh from the owner is hear await tick(); await tick(); assert.ok(calls.showRoom.length > before, "owner refresh triggers a room fetch"); }); +test("rekeyRoom tells the room to check access and makes the lease check in at once", async () => { + const api = async (path) => path === "/api/spaces/plaza/enter" ? access("plaza", "plaza-1") : path.endsWith("/enter") ? access("space-1", "space-ch") : {}; + const { stage, calls, leases } = setup({ api }); + await stage.toPlaza(null); + await stage.rekeyRoom(); + assert.ok(!calls.published.includes(JSON.stringify({ t: "rekey" })), "nothing to rekey on the plaza"); + assert.equal(leases.at(-1).beats || 0, 0); + await stage.openSpace("space-1"); + await stage.rekeyRoom(); + assert.ok(calls.published.includes(JSON.stringify({ t: "rekey" }))); + assert.equal(leases.at(-1).beats, 1); + assert.equal(leases.at(-1).forced, true, "the owner's own change is not held back by the rekey gap"); +}); +test("fresh access from a heartbeat is kept, so a later live connection uses the new channel and key", async () => { + const api = async (path) => path.endsWith("/enter") ? access("space-1", "space-ch") : {}; + const { stage, leases, calls } = setup({ api, withLive: false }); + await stage.openSpace("space-1"); + leases.at(-1).options.onAccess({ channel: "space-ch-2", key: "k2", blocked: ["account:bad"], hostId: "a-me" }); + const later = setup({ api }).live, joined = []; + later.join = async (name, options, handlers) => { joined.push(name); calls.handlers[name] = handlers; }; + await stage.attachLive(later, null); + assert.deepEqual(joined, ["space-ch-2"]); +}); +test("host tools in the room panel need a live lease, not just ownership", async () => { + let gone = false; + const owned = { ...access("space-1", "space-ch"), space: { ...space("space-1"), ownerId: "a-me" }, hostId: "a-me" }; + const api = async (path) => { if (path.endsWith("/enter")) { if (gone) throw Object.assign(new Error("hidden"), { status: 404 }); return owned; } return { space: owned.space }; }; + const { stage, leases, calls } = setup({ api }); + await stage.openSpace("space-1"); + assert.equal(calls.showRoom.at(-1).host, true); + gone = true; + leases.at(-1).options.onLost({ status: 404 }); + for (let i = 0; i < 6; i += 1) await tick(); + stage.refresh(); + await tick(); await tick(); + assert.equal(calls.showRoom.at(-1).host, false, "a space you were dropped from offers no topic form"); +}); diff --git a/tests/world-engine.test.mjs b/tests/world-engine.test.mjs index 834c31f..702efb4 100644 --- a/tests/world-engine.test.mjs +++ b/tests/world-engine.test.mjs @@ -302,3 +302,13 @@ test("picking hands clicked tiles to the picker instead of walking, even with in click(); assert.equal(events.move.length, 1); }); +test("redraw draws one more frame, so a new draw listener hears the current view", () => { + const { engine, flush, frames } = setup(); + const heard = []; + engine.on("draw", (frame) => heard.push(frame)); + assert.equal(frames.length, 0, "an idle view requests no frames"); + engine.redraw(); + flush(); + assert.equal(heard.length, 1); + assert.equal(heard[0].avatars[0].self, true); +}); diff --git a/world/engine.js b/world/engine.js index e8ffe89..a08bb2d 100644 --- a/world/engine.js +++ b/world/engine.js @@ -124,6 +124,8 @@ export function createEngine({ canvas, map, theme, start, reducedMotion = false, teleport(tile, dir = "down") { held = null; self.keyboard = false; self.walk = { path: [{ x: tile.x, y: tile.y }], startedAt: now(), dir }; self.arrived = true; schedule(); }, on(type, listener) { if (!Object.hasOwn(listeners, type)) throw new Error("Unknown engine event: " + type); listeners[type].push(listener); return () => { listeners[type] = listeners[type].filter((item) => item !== listener); }; }, say(text, ms = 8000) { self.bubble = { text: String(text), until: now() + ms }; schedule(); }, + // Draws a frame soon, for a "draw" listener that has just started listening. + redraw() { schedule(); }, setPicking(handler) { picking = typeof handler === "function" ? handler : null; }, setInteractive(value) { interactive = Boolean(value); if (!interactive) { held = null; keyStop(); } }, setSelf({ id, name }) { if (id) self.id = id; self.name = name; schedule(); }, From 84d5c5a0bbc9156caa262def0e498bd96f4e5d0b Mon Sep 17 00:00:00 2001 From: Brent G Date: Thu, 8 Oct 2026 20:09:48 +0000 Subject: [PATCH 69/71] Check in at once after tightening a space's access MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🤖 Built with SMT --- stoa.js | 4 +++- stoa/spaces.js | 2 +- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/stoa.js b/stoa.js index 8353267..5e3edf9 100644 --- a/stoa.js +++ b/stoa.js @@ -91,10 +91,12 @@ if (map && themesResult.status === "fulfilled" && (!spaceId || space)) { await sessionReady; const self = { id: state.account?.id || state.profile?.id || null, ids: [state.account?.id, state.profile?.id].filter(Boolean), name: state.profile?.name || state.account?.name || "You", signedIn: Boolean(state.account || state.profile) }; panel.setSignedIn(self.signedIn); -spacesUi = createSpacesUi({ api, say: (text) => panel.say(text), onSaved: (saved, themeChanged) => { +spacesUi = createSpacesUi({ api, say: (text) => panel.say(text), onSaved: (saved, themeChanged, accessChanged) => { document.querySelector("#stoa-title").textContent = saved.title; document.title = saved.title + " | Agora Build"; document.querySelector("#stoa-kicker").textContent = kickerFor(saved); + // Tighter visibility or access can move everyone to a new channel, so the room checks in now rather than at the next heartbeat. stage?.refresh(); + if (accessChanged && roomNow?.id === saved.id) stage?.rekeyRoom(); if (themeChanged) panel.say("The new theme shows after you reload."); }, onMembersChanged: (id) => { if (roomNow?.id === id) stage?.rekeyRoom(); } }); panel.setPeople([], self.name); diff --git a/stoa/spaces.js b/stoa/spaces.js index 78d1d4b..a856a73 100644 --- a/stoa/spaces.js +++ b/stoa/spaces.js @@ -62,7 +62,7 @@ export function createSpacesUi({ onSaved = () => {}, onMembersChanged = () => {} event.preventDefault(); const error = $("#manage-error"), button = manageForm.querySelector("[type=submit]"); error.textContent = ""; button.disabled = true; - try { const { space } = await api("/api/spaces/" + encodeURIComponent(managing.id), { method: "PUT", body: JSON.stringify(values(manageForm)) }); const themeChanged = space.themeId !== managing.themeId; managing = { ...managing, ...space }; say("Space settings saved."); onSaved(managing, themeChanged); } + try { const { space } = await api("/api/spaces/" + encodeURIComponent(managing.id), { method: "PUT", body: JSON.stringify(values(manageForm)) }); const themeChanged = space.themeId !== managing.themeId, accessChanged = space.visibility !== managing.visibility || space.access !== managing.access; managing = { ...managing, ...space }; say("Space settings saved."); onSaved(managing, themeChanged, accessChanged); } catch (failure) { error.textContent = failure.message; } finally { button.disabled = false; } }); From 51416dcd19c6a7529d2aa3264fe5c0eb27ce017e Mon Sep 17 00:00:00 2001 From: Brent G Date: Thu, 8 Oct 2026 20:13:17 +0000 Subject: [PATCH 70/71] Keep held-back heartbeats instead of dropping them MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A rekey that arrives inside the two-second gap, or while a heartbeat is in flight, now runs as one trailing beat, so a second removal never leaves the room split across channels. The strip of call tiles only covers the width its tiles need. 🤖 Built with SMT --- stoa/lease.js | 26 +++++++++++++++----------- styles.css | 2 +- tests/stoa-place.test.mjs | 29 ++++++++++++++++++----------- 3 files changed, 34 insertions(+), 23 deletions(-) diff --git a/stoa/lease.js b/stoa/lease.js index f3f4193..e549fec 100644 --- a/stoa/lease.js +++ b/stoa/lease.js @@ -1,23 +1,27 @@ // Keeps an entry lease alive and reports when it ends. Leaving on page close uses a beacon so it still reaches the server. -export function createLease({ api, spaceId, onAccess = () => {}, onLost = () => {}, every = 20000, repeat = (callback, delay) => setInterval(callback, delay), stopRepeat = (handle) => clearInterval(handle), beacon = (url) => navigator.sendBeacon?.(url), now = () => Date.now() }) { +export function createLease({ api, spaceId, onAccess = () => {}, onLost = () => {}, every = 20000, repeat = (callback, delay) => setInterval(callback, delay), stopRepeat = (handle) => clearInterval(handle), beacon = (url) => navigator.sendBeacon?.(url), now = () => Date.now(), later = (callback, delay) => setTimeout(callback, delay), cancel = (handle) => clearTimeout(handle) }) { const base = "/api/spaces/" + spaceId; - let timer = null, stopped = false, quick = null, lastQuick = -Infinity; - const stop = () => { stopped = true; if (timer !== null) { stopRepeat(timer); timer = null; } }; + let timer = null, stopped = false, quick = null, lastQuick = -Infinity, again = false, againForced = false, waiting = null; + const stop = () => { stopped = true; if (timer !== null) { stopRepeat(timer); timer = null; } if (waiting !== null) { cancel(waiting); waiting = null; } }; const beat = async () => { try { const access = await api(base + "/heartbeat", { method: "POST" }); if (!stopped) onAccess(access); } catch (error) { if (!stopped && [401, 403, 404, 410].includes(error.status)) { stop(); onLost(error); } } }; timer = repeat(beat, every); + function beatNow({ force = false } = {}) { + if (stopped) return Promise.resolve(); + if (quick) { again = true; againForced ||= force; return quick; } + const wait = force ? 0 : lastQuick + 2000 - now(); + if (wait > 0) { if (waiting === null) waiting = later(() => { waiting = null; beatNow({ force: true }); }, wait); return Promise.resolve(); } + if (waiting !== null) { cancel(waiting); waiting = null; } + lastQuick = now(); + return (quick = beat().finally(() => { quick = null; if (again) { const forced = againForced; again = againForced = false; beatNow({ force: forced }); } })); + } return { stop, - // An extra heartbeat right away, for when access has changed. One runs at a time and, unless forced, at most one every two seconds, so a flood of rekey notices costs little. - beatNow({ force = false } = {}) { - if (stopped) return Promise.resolve(); - if (quick) return quick; - if (!force && now() - lastQuick < 2000) return Promise.resolve(); - lastQuick = now(); - return (quick = beat().finally(() => { quick = null; })); - }, + // An extra heartbeat right away, for when access has changed. One runs at a time and, unless forced, at most one every two seconds, + // so a flood of rekey notices costs little; a request that has to wait is kept as one trailing beat, never dropped. + beatNow, async leave() { stop(); try { await api(base + "/leave", { method: "POST" }); } catch { /* the lease expires on its own */ } }, leaveOnUnload() { stop(); beacon(base + "/leave"); } }; diff --git a/styles.css b/styles.css index ea3b7bb..e720a00 100644 --- a/styles.css +++ b/styles.css @@ -531,7 +531,7 @@ dialog h2 { font-size: 30px; line-height: 1.25; font-weight: 500; letter-spacing .stoa-view { position: relative; } .stoa-video { position: absolute; inset: 0; display: flex; flex-wrap: nowrap; align-items: flex-start; align-content: flex-start; gap: 8px; padding: 8px; pointer-events: none; overflow: hidden; } /* Strip and grid scroll when tiles overflow; each covers only its own band of the map, which is all that takes pointer input. */ -.stoa-video[data-layout="strip"] { inset: 0 0 auto 0; overflow-x: auto; overflow-y: hidden; pointer-events: auto; scrollbar-width: thin; } +.stoa-video[data-layout="strip"] { inset: 0 auto auto 0; max-width: 100%; overflow-x: auto; overflow-y: hidden; pointer-events: auto; scrollbar-width: thin; } .stoa-video[data-layout="grid"] { inset: 0 0 auto auto; max-height: 100%; width: min(46%, 520px); display: grid; grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); overflow-y: auto; pointer-events: auto; scrollbar-width: thin; } .stoa-video[data-layout="grid"] .stoa-tile.is-screen { grid-column: 1 / -1; } .stoa-tile { position: relative; flex: none; width: 168px; aspect-ratio: 16 / 10; overflow: hidden; border: 1px solid var(--line); border-radius: 4px; background: #202824; pointer-events: none; } diff --git a/tests/stoa-place.test.mjs b/tests/stoa-place.test.mjs index 11a92cf..927b7cd 100644 --- a/tests/stoa-place.test.mjs +++ b/tests/stoa-place.test.mjs @@ -228,20 +228,27 @@ test("a heartbeat can be run at once, and does nothing after the lease stops", a await lease.beatNow(); assert.deepEqual(accesses, ["c3"], "a stopped lease does not beat"); }); -test("extra heartbeats run one at a time and at most one every two seconds, unless forced", async () => { +test("extra heartbeats run one at a time and at most one every two seconds, and a held-back one runs later", async () => { let time = 0, calls = 0; - const pending = []; + const pending = [], timers = []; const api = () => { calls += 1; return new Promise((resolve) => pending.push(resolve)); }; - const lease = createLease({ api, spaceId: "s", repeat: () => 1, stopRepeat() {}, now: () => time }); + const lease = createLease({ api, spaceId: "s", repeat: () => 1, stopRepeat() {}, now: () => time, later: (callback, delay) => { timers.push({ callback, delay }); return timers.length; }, cancel() {} }); const first = lease.beatNow(); - lease.beatNow(); lease.beatNow({ force: true }); - assert.equal(calls, 1, "one in flight at a time, even when forced"); - pending.shift()({ channel: "c" }); await first; - time += 1999; lease.beatNow(); - assert.equal(calls, 1, "a second request within two seconds is skipped"); + lease.beatNow(); lease.beatNow(); + assert.equal(calls, 1, "one in flight at a time"); + pending.shift()({ channel: "c" }); await first; await tick(); + assert.equal(calls, 1, "requests made while one was in flight become one trailing beat, which waits for the gap"); + assert.equal(timers.length, 1); + assert.equal(timers[0].delay, 2000); + time += 2000; timers.shift().callback(); + assert.equal(calls, 2, "the trailing beat runs once the gap has passed"); + pending.shift()({ channel: "c" }); await tick(); await tick(); + time += 500; lease.beatNow(); lease.beatNow(); + assert.deepEqual([calls, timers.length], [2, 1], "repeated requests inside the gap share one timer"); lease.beatNow({ force: true }); - assert.equal(calls, 2, "a forced one is not held back by the gap"); + assert.equal(calls, 3, "a forced one is not held back by the gap"); pending.shift()({ channel: "c" }); await tick(); - time += 2000; lease.beatNow(); - assert.equal(calls, 3); + lease.stop(); + assert.equal(await lease.beatNow(), undefined); + assert.equal(calls, 3, "nothing runs after stop"); }); From ef90f97369e186534454b1750d910aca336183c2 Mon Sep 17 00:00:00 2001 From: Brent G Date: Thu, 8 Oct 2026 20:22:03 +0000 Subject: [PATCH 71/71] Record the live Stoa rooms check MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🤖 Built with SMT --- docs/superpowers/plans/2026-10-08-stoa-rooms.md | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/docs/superpowers/plans/2026-10-08-stoa-rooms.md b/docs/superpowers/plans/2026-10-08-stoa-rooms.md index 1923d25..9ecfa28 100644 --- a/docs/superpowers/plans/2026-10-08-stoa-rooms.md +++ b/docs/superpowers/plans/2026-10-08-stoa-rooms.md @@ -2118,3 +2118,19 @@ Before committing, check `git status` shows only the intended files (no `.data/` - Plan D (Agora Chat): team messages and DMs, with the call panel's "chat" control (spec section 3) linking to the room's Chat group. - Selection highlight in decor mode needs a renderer hook (an outline at a tile). v1 names the selection in the panel only. - Proximity audio (spec section 2) can use `overAvatar`'s matching to decide which streams to subscribe to. + +## Live check (2026-10-08, real Agora RTC and Signaling) + +Two signed-in headless browsers with fake camera and microphone, against a throwaway server with the project's credentials in its environment only. All passed: +- Call in a lot room: each person's video appears over the other's avatar in about 0.2 s; Minimal switches to the grid; turning a camera off or on reaches the other side in about 0.24 s. +- Screen share: the other person sees the screen tile 0.9 s after the click; it goes 0.2 s after stopping. +- Decorating: a saved plant reaches the other person's view in about 0.2 s. "Place in front of me" refuses entrances and blocked tiles with the right message. +- Hand over, then remove: the new host gets host tools in about 3.3 s; a removal takes effect in about 0.28 s (room, call, and channel left; re-entry refused; the room's people and tiles drop them). +- Private space with an invite: created and shared from the dialogs; both inside within 2.2 s; the call shows exactly two tiles on each side. +- Back button: returns to a fresh, correct page. Headless Chromium never restored from the back/forward cache, so the reload-on-restore path is untested. +- No uncaught page errors. Logged errors were expected: extra RTC edge sockets closing, the removed person's 410 then 403, and 404s for a private space opened without an invite. + +Notes: +- Handing over hosting (about 3.3 s) is slower than removal because room refreshes are limited to one every 5 s. +- A private space opened without an invite gets the plain "This space was not found." page, by design: hidden spaces look exactly like missing ones. +- People entering a room still share its entry tile until someone moves (noted in Plan C1).