mirror of
https://github.com/github/awesome-copilot.git
synced 2026-08-01 23:12:29 +00:00
ae8ada62bd
* 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>
473 lines
18 KiB
JavaScript
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()));
|
|
},
|
|
}),
|
|
],
|
|
});
|