mirror of
https://github.com/github/awesome-copilot.git
synced 2026-08-13 20:59:12 +00:00
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>
This commit is contained in:
@@ -0,0 +1,472 @@
|
||||
/**
|
||||
* 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()));
|
||||
},
|
||||
}),
|
||||
],
|
||||
});
|
||||
Reference in New Issue
Block a user