diff --git a/lith/Caddyfile b/lith/Caddyfile index 911a505b44..f164cc63c8 100644 --- a/lith/Caddyfile +++ b/lith/Caddyfile @@ -1057,7 +1057,7 @@ oskiewar.com, midi.oskiewar.com { # answers 304 against its ETag and loads from disk. Without the header the # browser's heuristic kept whole modules stale across deploys — a returning # visitor once ran a months-old round-room.mjs against the current relay. - @oskiewarmodules path /oskiewar.js /oskiewar-sfx.mjs /oskiewar-voice.mjs /oskiewar-midi.mjs /oskiewar-wizard.mjs + @oskiewarmodules path /oskiewar.js /oskiewar-sfx.mjs /oskiewar-voice.mjs /oskiewar-midi.mjs /oskiewar-wizard.mjs /oskiewar-map.mjs /oskiewar-workshop.mjs handle @oskiewarmodules { root * /opt/ac/xbox/live header Cache-Control no-cache diff --git a/session-server/oskiewar-live-manager.mjs b/session-server/oskiewar-live-manager.mjs index a82bfd9076..6416fbfbd9 100644 --- a/session-server/oskiewar-live-manager.mjs +++ b/session-server/oskiewar-live-manager.mjs @@ -6,6 +6,7 @@ import { oskiewarEvent, oskiewarSurface, } from "../system/public/aesthetic.computer/lib/oskiewar-analytics.mjs"; +import { validateMap } from "../xbox/live/oskiewar-map.mjs"; import { createPostHogEventCapture } from "../shared/posthog-event-capture.mjs"; const MATCH_WORD = "[bdfgklmnprstvz][aeiou][bdfgklmnprstvz][aeiou][bdfgklmnprstvz][aeiou]"; @@ -14,7 +15,7 @@ const MATCH_NAME = new RegExp( const MATCH_ID = new RegExp( `^ow-(?:${MATCH_WORD}-${MATCH_WORD}-${MATCH_WORD}|[a-z]{4,7}[0-9]{1,3})$`); const PHASES = new Set(["select", "intro", "fight", "round", "match", "replay"]); -const MAX_MESSAGE_BYTES = 8192; +const MAX_MESSAGE_BYTES = 16384; // Includes bounded workshop geometry for spectators. const MAX_VIEWERS = 64; // Agents watch the same fan-out as a phone but are counted and capped on their // own, so a room full of spectators can never lock a maintainer out of the @@ -129,6 +130,9 @@ function perf(value) { } export function validateOskiewarLiveState(value) { + if (value?.map?.workshop !== undefined) { + try { validateMap(value.map.workshop); } catch { return "Invalid workshop map"; } + } if (!value || value.format !== "ac.oskiewar.live" || value.version !== 1) return "Unsupported live state"; if (!integer(value.seq, 0, 2147483647) || !finite(value.at, 10000000000000)) @@ -358,7 +362,7 @@ export class OskiewarLiveManager { // wants a remote nudge. The relay forwards the bare instruction and // nothing else, at most once per five seconds per room, and the shell // decides when reloading is actually safe. - ws.on("message", (data) => this.nudge(room, data)); + ws.on("message", (data) => this.nudge(room, data, ws)); const remove = () => { room.agents.delete(ws); room.updatedAt = this.now(); @@ -477,10 +481,28 @@ export class OskiewarLiveManager { send(target, "oskiewar:net", content); } - nudge(room, data) { - if (Buffer.byteLength(data) > 512) return; + nudge(room, data, ws) { + if (Buffer.byteLength(data) > 16384) return; let message; try { message = JSON.parse(data.toString()); } catch { return; } + if (message.type === "oskiewar:workshop") { + const content = message.content; + if (!content || typeof content.id !== "string" || content.id.length > 80 || + !content.command || typeof content.command !== "object") return; + const now = this.now(); + room.workshopPending ||= new Map(); + for (const [id, entry] of room.workshopPending) + if (entry.expires < now) room.workshopPending.delete(id); + const refuse = error => send(ws, "oskiewar:workshop-result", + { id: content.id, ok: false, error }); + if (!room.publisher) return refuse("No live publisher"); + if (room.workshopPending.size >= 8) return refuse("Workshop is busy"); + const id = String(room.workshopSequence = (room.workshopSequence || 0) + 1); + room.workshopPending.set(id, { ws, id: content.id, expires: now + 15000 }); + send(room.publisher, "oskiewar:workshop", { id, command: content.command }); + return; + } + if (Buffer.byteLength(data) > 512) return; if (message.type === "oskiewar:reload") { const now = this.now(); if (room.nudgedAt && now - room.nudgedAt < 5000) return; @@ -511,8 +533,21 @@ export class OskiewarLiveManager { publish(room, ws, data) { if (room.publisher !== ws) return; + if (Buffer.byteLength(data) <= 32768) { + let reply; + try { reply = JSON.parse(data.toString()); } catch {} + if (reply?.type === "oskiewar:workshop-result") { + const entry = room.workshopPending?.get(reply.content?.id); + if (entry) { + room.workshopPending.delete(reply.content.id); + if (entry.expires >= this.now() && room.agents.has(entry.ws)) + send(entry.ws, reply.type, { ...reply.content, id: entry.id }); + } + return; + } + } if (Buffer.byteLength(data) > MAX_MESSAGE_BYTES) { - send(ws, "oskiewar:error", { message: "Live state exceeds 8 KiB" }); + send(ws, "oskiewar:error", { message: "Live state exceeds 16 KiB" }); return; } let message; diff --git a/session-server/oskiewar-live-manager.test.mjs b/session-server/oskiewar-live-manager.test.mjs index a93a13a87d..a3792eda1f 100644 --- a/session-server/oskiewar-live-manager.test.mjs +++ b/session-server/oskiewar-live-manager.test.mjs @@ -617,3 +617,30 @@ test("the rollback lane's packets pass seat to seat and never reach the grandsta assert.equal(host.sent.length, 2); assert.equal(host.sent[1].type, "oskiewar:input"); }); + +test('workshop routes acknowledgements only to the requesting agent', () => { + const manager = new OskiewarLiveManager(); + const host = new FakeSocket(), agent = new FakeSocket(), other = new FakeSocket(), viewer = new FakeSocket(); + for (const [ws, role] of [[host, 'publisher'], [agent, 'agent'], [other, 'agent'], [viewer, 'viewer']]) + manager.handleConnection(ws, { url: '/oskiewar-live?match=sezzi7&role=' + role }); + const packet = Buffer.from(JSON.stringify({ type: 'oskiewar:workshop', + content: { id: 'same', command: { op: 'inspect' } } })); + const count = host.sent.length; + viewer.emit('message', packet); + assert.equal(host.sent.length, count); + agent.emit('message', packet); + const first = host.sent.at(-1).content.id; + other.emit('message', packet); + const second = host.sent.at(-1).content.id; + assert.notEqual(first, second); + const reply = id => Buffer.from(JSON.stringify({ type: 'oskiewar:workshop-result', + content: { id, ok: true, result: { revision: 7 } } })); + agent.emit('message', reply(first)); // Agents cannot forge publisher replies. + assert.notEqual(agent.sent.at(-1).type, 'oskiewar:workshop-result'); + host.emit('message', reply(first)); + assert.equal(agent.sent.at(-1).content.id, 'same'); + assert.equal(agent.sent.at(-1).content.result.revision, 7); + assert.notEqual(other.sent.at(-1).type, 'oskiewar:workshop-result'); + host.emit('message', reply(second)); + assert.equal(other.sent.at(-1).content.id, 'same'); +}); diff --git a/system/netlify/functions/oskiewar-maps.mjs b/system/netlify/functions/oskiewar-maps.mjs new file mode 100644 index 0000000000..5f1265129e --- /dev/null +++ b/system/netlify/functions/oskiewar-maps.mjs @@ -0,0 +1,61 @@ +// Private immutable drafts and public immutable map versions, owned by AC accounts. +import { createHash } from 'node:crypto'; +import { validateMap } from '../../../xbox/live/oskiewar-map.mjs'; + +const headers = { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' }; +const reply = (statusCode, value) => ({ statusCode, headers, body: JSON.stringify(value) }); +const idPattern = /^[a-f0-9]{64}$/; + +export function createMapsHandler({ authorize, connect }) { + return async event => { + if (!['GET', 'POST'].includes(event.httpMethod)) return reply(405, { message: 'GET or POST only' }); + let database; + try { + const query = event.queryStringParameters || {}; + const user = await authorize(event.headers || {}); + const owner = user?.sub; + let map; + let body; + if (event.httpMethod === 'POST') { + if (!owner) return reply(401, { message: 'Sign in to save maps' }); + if (typeof event.body !== 'string' || Buffer.byteLength(event.body) > 16384) + return reply(413, { message: 'Map exceeds 16 KiB' }); + try { body = JSON.parse(event.body); map = validateMap(body.map); } + catch (error) { return reply(400, { message: error.message }); } + } else { + if (query.id !== undefined && !idPattern.test(query.id)) return reply(400, { message: 'Invalid map id' }); + if (query.mine === '1' && !owner) return reply(401, { message: 'Sign in to list drafts' }); + } + database = await connect(); + const collection = database.db.collection('oskiewar-maps'); + if (event.httpMethod === 'POST') { + const published = body.publish === true; + const id = createHash('sha256').update(JSON.stringify([owner, published, map])).digest('hex'); + await collection.updateOne({ _id: id }, { $setOnInsert: { + owner, published, map, createdAt: new Date().toISOString(), + } }, { upsert: true }); + return reply(201, { id, published, map, + ...(published ? { url: 'https://oskiewar.com/?workshop=' + id } : {}) }); + } + if (query.id) { + const row = await collection.findOne({ _id: query.id, + $or: [{ published: true }, ...(owner ? [{ owner }] : [])] }); + if (!row) return reply(404, { message: 'Map not found' }); + return reply(200, { id: row._id, published: row.published, map: row.map }); + } + const rows = await collection.find(query.mine === '1' ? { owner } : { published: true }, + { projection: { _id: 1, 'map.name': 1, published: 1, createdAt: 1 } }) + .sort({ createdAt: -1 }).limit(50).toArray(); + return reply(200, { maps: rows.map(row => ({ id: row._id, name: row.map.name, + published: row.published, createdAt: row.createdAt })) }); + } catch { + return reply(503, { message: 'Map storage unavailable' }); + } finally { if (database) await database.disconnect(); } + }; +} +export async function handler(event) { + const [{ authorize }, { connect }] = await Promise.all([ + import('../../backend/authorization.mjs'), import('../../backend/database.mjs'), + ]); + return createMapsHandler({ authorize, connect })(event); +} diff --git a/xbox/live/coach.html b/xbox/live/coach.html index 6f5362ac00..4170beef53 100644 --- a/xbox/live/coach.html +++ b/xbox/live/coach.html @@ -135,6 +135,22 @@ claude mcp add coach -- node ./coach.mjs +

Build a map while playing

+

Open the map workshop, enable + Coach editing, and connect your coach to the room shown + on the title. coach_workshop can reshape terrain and platforms, + move and highlight spawns, drop items, undo edits, reset the round, or + restart the level. The edited map stays in place between rounds.

+

Ask: Raise the middle platform, put a rocket launcher on the left, + highlight the spawns, and restart so I can try it. Then: + Save this as Moon Yard. Publish it and give me the play link.

+

Sign in to save private drafts or publish. Each save keeps a separate + version; published versions have a permanent play link. The coach can + list and load them later. Editing currently works in local practice in + the browser. Edited sessions do not upload competitive replays; reload + before returning to recorded matches. Turning Coach editing off prevents + further commands; an already submitted save may still finish.

+

Things to say to it