Files
awesome-copilot/extensions/backrooms-canvas/extension.mjs
T
John Haugabook ae8ada62bd Extension backrooms (#2348)
* new extension backrooms-canvas

* new extension backrooms-canvas

* Apply suggestions from code review

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>

* update according to code review

update according to code review

* Apply suggestions from code review

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>

* apply code review, change backviews to backrooms

* Potential fix for pull request finding

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>

---------

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-07-23 10:51:06 -07:00

473 lines
18 KiB
JavaScript

/**
* Copilot canvas entry point for BackRooms.
*
* The game itself is the prebuilt browser bundle at game/webview.js (an
* esbuild IIFE that already carries the WebGL renderer, the procedural maze
* engine, and the whole first-person world). This file is only the host: a
* tiny local HTTP server that serves that bundle plus its photo materials,
* and a canvas registration that lets an agent drive the game through actions.
*
* The bundle was written for a VSCode webview and talks to its host through
* `acquireVsCodeApi()` + `window.postMessage`. game/index.html shims that API
* against this server's Server-Sent Events stream, so the same bundle runs
* unchanged inside the canvas. The message shapes below match the bundle's
* expectations exactly (see the original src/shared/settings.ts):
*
* host -> game : { type: 'config', settings }
* { type: 'jobStatus', job } // CopilotJob
* { type: 'chatSession', session } // ChatSessionSnapshot
* { type: 'relocate' }
* game -> host : { type: 'ready' } // handled in the shim
* { type: 'updateSetting', key, value } // handled in the shim
*/
import { createReadStream } from "node:fs";
import { readFile, stat } from "node:fs/promises";
import { createServer } from "node:http";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { CanvasError, createCanvas, joinSession } from "@github/copilot-sdk/extension";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const gameRoot = path.join(__dirname, "game");
const materialsRoot = path.join(__dirname, "materials");
const assetsRoot = path.join(__dirname, "assets");
const indexPath = path.join(gameRoot, "index.html");
/**
* Default settings, mirrored from the bundle's own DEFAULT_SETTINGS. The shim
* merges any per-open overrides and the player's saved menu choices on top of
* these before handing the game its first `config` message.
*/
const DEFAULT_SETTINGS = {
seed: 0,
moveSpeed: 2.2,
renderDistance: 14,
cameraShake: true,
filmGrain: true,
vhsHud: true,
furniture: true,
wallpaperShifts: false,
mouseLook: true,
invertTurn: false,
invertStrafe: false,
invertForward: false,
materialPreset: "classic",
materialHueShift: 0,
materialBrightness: 1,
monsterEnabled: true,
monsterSpeed: 2.6,
monsterSpawnMin: 1,
monsterSpawnMax: 5,
monsterForm: "random",
copilotGhostWriter: true,
};
const MATERIAL_PRESETS = ["classic", "office", "pool", "concrete", "panel"];
const MONSTER_FORMS = ["spider", "humanoid", "cloud", "random"];
/** Idle Copilot job; the walls stay quiet and the HUD counter fades out. */
const IDLE_JOB = { working: false, status: "", tokens: 0 };
const servers = new Map();
function contentType(filePath) {
switch (path.extname(filePath).toLowerCase()) {
case ".html":
return "text/html; charset=utf-8";
case ".js":
return "text/javascript; charset=utf-8";
case ".css":
return "text/css; charset=utf-8";
case ".json":
return "application/json; charset=utf-8";
case ".map":
return "application/json; charset=utf-8";
case ".png":
return "image/png";
case ".jpg":
case ".jpeg":
return "image/jpeg";
case ".svg":
return "image/svg+xml";
case ".webp":
return "image/webp";
default:
return "application/octet-stream";
}
}
/** Resolves a request path under a root, refusing anything that escapes it. */
function resolveUnder(root, requestPath) {
const resolved = path.resolve(root, `.${requestPath}`);
if (resolved !== root && !resolved.startsWith(`${root}${path.sep}`)) {
throw new CanvasError("invalid_path", "Requested path is outside the backrooms assets.");
}
return resolved;
}
function sendJson(res, value) {
res.writeHead(200, {
"content-type": "application/json; charset=utf-8",
"cache-control": "no-store",
});
res.end(JSON.stringify(value));
}
function sendNotFound(res) {
res.writeHead(404, { "content-type": "text/plain; charset=utf-8" });
res.end("Not found");
}
function sendSse(res, event, data) {
res.write(`event: ${event}\n`);
res.write(`data: ${JSON.stringify(data)}\n\n`);
}
function broadcast(entry, event, data) {
for (const client of entry.clients) {
sendSse(client, event, data);
}
}
function randomSeed() {
return Math.floor(Math.random() * 999_999) + 1;
}
/** Coerces one enum-ish value, falling back to the default when unknown. */
function pickEnum(value, allowed, fallback) {
return typeof value === "string" && allowed.includes(value) ? value : fallback;
}
/** Coerces a finite number into [min, max], or returns the fallback. */
function clampNumber(value, min, max, fallback) {
if (typeof value !== "number" || !Number.isFinite(value)) {
return fallback;
}
return Math.min(max, Math.max(min, value));
}
/**
* Turns arbitrary open-input into a partial settings override the shim can
* merge over the defaults. Only the settings that make sense to preset from an
* agent are honored; everything else stays on its default or saved value.
*/
function normalizeOverrides(input) {
const overrides = {};
if (!input || typeof input !== "object") {
return overrides;
}
if (input.seed !== undefined) {
const seed = Math.trunc(clampNumber(input.seed, 0, Number.MAX_SAFE_INTEGER, 0));
overrides.seed = seed === 0 ? randomSeed() : seed;
}
if (input.materialPreset !== undefined) {
overrides.materialPreset = pickEnum(input.materialPreset, MATERIAL_PRESETS, "classic");
}
if (input.monsterEnabled !== undefined) {
overrides.monsterEnabled = input.monsterEnabled === true;
}
if (input.monsterForm !== undefined) {
overrides.monsterForm = pickEnum(input.monsterForm, MONSTER_FORMS, "random");
}
if (input.copilotGhostWriter !== undefined) {
overrides.copilotGhostWriter = input.copilotGhostWriter !== false;
}
return overrides;
}
/** Coerces report_job input into a CopilotJob the game understands. */
function normalizeJob(input) {
if (!input || typeof input !== "object") {
return { ...IDLE_JOB };
}
const done = input.done === true;
return {
working: !done,
status: typeof input.status === "string" ? input.status.slice(0, 120) : "",
tokens:
typeof input.tokens === "number" && Number.isFinite(input.tokens)
? Math.max(0, Math.floor(input.tokens))
: 0,
};
}
/**
* Coerces ghost_write input into a ChatSessionSnapshot. The game ghost-writes
* `current` onto the wall ahead as it grows, and the token count drives the
* HUD odometer. `done` settles the writing in place and stops the counter.
*/
function normalizeSession(input) {
const text = typeof input?.text === "string" ? input.text : "";
const done = input?.done === true;
const tokens =
typeof input?.tokens === "number" && Number.isFinite(input.tokens)
? Math.max(0, Math.floor(input.tokens))
: Math.ceil(text.length / 4);
return { working: !done && text.length > 0, history: [], current: text, tokens };
}
async function renderIndex(entry) {
const html = await readFile(indexPath, "utf8");
// Handed to the shim so it can build the game's first `config` message and
// replay any job that is already running when the panel opens.
const init = {
defaults: DEFAULT_SETTINGS,
overrides: entry.overrides,
materials: {
wallpaper: "/materials/wallpaper.jpg",
ceiling: "/materials/ceiling.jpg",
carpet: "/materials/carpet.jpg",
},
job: entry.job,
session: entry.session,
};
return html.replace("__BACKROOMS_INIT__", JSON.stringify(init).replace(/</g, "\\u003c"));
}
async function streamFile(res, filePath) {
const fileStat = await stat(filePath).catch(() => undefined);
if (!fileStat?.isFile()) {
sendNotFound(res);
return;
}
res.writeHead(200, {
"content-type": contentType(filePath),
"cache-control": "no-cache",
});
const stream = createReadStream(filePath);
stream.on("error", () => {
if (!res.headersSent) {
sendNotFound(res);
} else {
res.destroy();
}
});
stream.pipe(res);
}
async function handleRequest(entry, req, res) {
const url = new URL(req.url ?? "/", entry.url);
if (url.pathname === "/events") {
res.writeHead(200, {
"content-type": "text/event-stream; charset=utf-8",
"cache-control": "no-cache",
connection: "keep-alive",
});
entry.clients.add(res);
// Catch a fresh (or reconnecting) client up to the live state.
sendSse(res, "jobStatus", { job: entry.job });
if (entry.session) {
sendSse(res, "chatSession", { session: entry.session });
}
req.on("close", () => entry.clients.delete(res));
return;
}
// The shim reports explicit setting changes (menu edits, relocate): the
// per-open override for that key stops applying, so a reload keeps the
// player's choice instead of replaying the stale override.
if (req.method === "DELETE" && url.pathname.startsWith("/override/")) {
delete entry.overrides[decodeURIComponent(url.pathname.slice("/override/".length))];
res.writeHead(204);
res.end();
return;
}
if (url.pathname === "/favicon.ico") {
await streamFile(res, path.join(assetsRoot, "icon.png"));
return;
}
try {
if (url.pathname === "/" || url.pathname === "/index.html") {
res.writeHead(200, {
"content-type": "text/html; charset=utf-8",
"cache-control": "no-cache",
});
res.end(await renderIndex(entry));
return;
}
const staticPath = url.pathname.startsWith("/materials/")
? resolveUnder(materialsRoot, url.pathname.slice("/materials".length))
: url.pathname.startsWith("/assets/")
? resolveUnder(assetsRoot, url.pathname.slice("/assets".length))
: resolveUnder(gameRoot, url.pathname);
await streamFile(res, staticPath);
} catch (error) {
if (error instanceof CanvasError) {
res.writeHead(400, { "content-type": "text/plain; charset=utf-8" });
res.end(error.message);
return;
}
throw error;
}
}
async function startServer(instanceId, overrides) {
const entry = {
clients: new Set(),
overrides,
job: { ...IDLE_JOB },
session: null,
server: undefined,
url: undefined,
};
const server = createServer((req, res) => {
handleRequest(entry, req, res).catch((error) => {
res.writeHead(500, { "content-type": "text/plain; charset=utf-8" });
res.end(error instanceof Error ? error.message : "Backrooms canvas server error");
});
});
entry.server = server;
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
const address = server.address();
const port = typeof address === "object" && address ? address.port : 0;
entry.url = `http://127.0.0.1:${port}/`;
servers.set(instanceId, entry);
return entry;
}
function getOpenEntry(instanceId) {
const entry = servers.get(instanceId);
if (!entry) {
throw new CanvasError("backrooms_not_open", "Open the Backrooms canvas before invoking this action.");
}
return entry;
}
await joinSession({
canvases: [
createCanvas({
id: "backrooms-canvas",
displayName: "BackRooms",
description:
"An endless first-person backrooms to wander while agents work. The agent's status ghost-writes on the walls.",
inputSchema: {
type: "object",
properties: {
seed: {
type: "number",
description: "Maze seed. The same seed always rebuilds the same halls. 0 rolls a random seed.",
},
materialPreset: {
type: "string",
enum: MATERIAL_PRESETS,
description: "Wall material set to start with.",
},
monsterEnabled: {
type: "boolean",
description: "Whether something else walks the halls.",
},
},
additionalProperties: false,
},
actions: [
{
name: "report_job",
description:
"Report your current job status to the backrooms. Call it when you start a chat request, again on each new step, and once more with done=true when finished. The status text is scrawled on the walls and the token count drives a camcorder-style HUD counter.",
inputSchema: {
type: "object",
properties: {
status: {
type: "string",
description:
"Short status text to scrawl on the walls (e.g. 'reading the codebase', 'rewriting the parser').",
},
tokens: {
type: "number",
description: "Approximate tokens consumed by the current job so far.",
},
done: {
type: "boolean",
description: "Set true when the job is finished; the walls stop updating and the counter fades out.",
},
},
required: ["status"],
additionalProperties: false,
},
handler: (ctx) => {
const entry = getOpenEntry(ctx.instanceId);
entry.job = normalizeJob(ctx.input);
broadcast(entry, "jobStatus", { job: entry.job });
return { job: entry.job };
},
},
{
name: "ghost_write",
description:
"Ghost-write a block of text onto the walls ahead, character by character as it grows, like a streaming chat response. Call repeatedly with the full text so far, then once with done=true to settle it in place.",
inputSchema: {
type: "object",
properties: {
text: {
type: "string",
description: "The full response text so far. Send the growing text on each call; the walls reveal the new characters.",
},
tokens: {
type: "number",
description: "Approximate tokens of the response so far. Defaults to text length / 4.",
},
done: {
type: "boolean",
description: "Set true when the response is complete; the writing settles onto the wall and the counter stops.",
},
},
required: ["text"],
additionalProperties: false,
},
handler: (ctx) => {
const entry = getOpenEntry(ctx.instanceId);
entry.session = normalizeSession(ctx.input);
broadcast(entry, "chatSession", { session: entry.session });
return { working: entry.session.working, tokens: entry.session.tokens };
},
},
{
name: "relocate",
description: "Drop the wanderer into a fresh random seed, wiping any writing already on the walls.",
handler: (ctx) => {
const entry = getOpenEntry(ctx.instanceId);
const seed = randomSeed();
// The walls are wiped on relocate, so drop the job and
// session snapshots too or a reload would replay them
// onto the fresh maze.
entry.job = { ...IDLE_JOB };
entry.session = null;
broadcast(entry, "jobStatus", { job: entry.job });
broadcast(entry, "relocate", { seed });
return { seed };
},
},
],
open: async (ctx) => {
const overrides = normalizeOverrides(ctx.input);
let entry = servers.get(ctx.instanceId);
if (!entry) {
entry = await startServer(ctx.instanceId, overrides);
} else {
entry.overrides = { ...entry.overrides, ...overrides };
}
return {
title: "BackRooms",
status: entry.job.working && entry.job.status ? entry.job.status : "Wandering",
url: entry.url,
};
},
onClose: async (ctx) => {
const entry = servers.get(ctx.instanceId);
if (!entry) return;
servers.delete(ctx.instanceId);
for (const client of entry.clients) {
client.end();
}
await new Promise((resolve) => entry.server.close(() => resolve()));
},
}),
],
});