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 assetsRoot = path.join(__dirname, "assets"); const indexPath = path.join(gameRoot, "index.html"); const gameJsPath = path.join(gameRoot, "game.js"); const alienOnslaughtJsPath = path.join(gameRoot, "scenes", "AlienOnslaught.js"); const galaxyBlasterJsPath = path.join(gameRoot, "scenes", "GalaxyBlaster.js"); const games = [ { key: "cosmic-rocks", label: "Cosmic Rocks", icon: "☄️" }, { key: "alien-onslaught", label: "Alien Onslaught", icon: "👾" }, { key: "galaxy-blaster", label: "Galaxy Blaster", icon: "🚀" }, { key: "ninja-runner", label: "Ninja Runner", icon: "🥷" }, { key: "defender", label: "Planet Guardian", icon: "🛡️" }, ]; const gameKeys = new Set(games.map((game) => game.key)); const defaultGame = "ninja-runner"; const canvasBackgroundGames = ["cosmic-rocks", "alien-onslaught", "galaxy-blaster", "defender"]; const servers = new Map(); function normalizeGameKey(value) { return typeof value === "string" && gameKeys.has(value) ? value : defaultGame; } 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 ".png": return "image/png"; case ".webp": return "image/webp"; case ".xml": return "application/xml; charset=utf-8"; case ".mp3": return "audio/mpeg"; case ".ogg": return "audio/ogg"; case ".m4a": return "audio/mp4"; case ".wav": return "audio/wav"; default: return "application/octet-stream"; } } 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 arcade 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); } } async function renderIndex(entry) { const html = await readFile(indexPath, "utf8"); const bootstrap = ``; return html.replace('', `${bootstrap}\n `); } async function renderGameJs() { const js = await readFile(gameJsPath, "utf8"); return js .replaceAll("newW > 800 && newH > 400", "newW > 320 && newH > 220") .replaceAll("game && newH > 400", "game && newH > 220") .replaceAll("window.innerWidth > 800 && window.innerHeight > 400", "window.innerWidth > 320 && window.innerHeight > 220"); } async function renderAlienOnslaughtJs() { const js = await readFile(alienOnslaughtJsPath, "utf8"); const layoutH = "Math.min(H, W * 3 / 4)"; const layoutY = `((H - ${layoutH}) / 2)`; return js .replace("this.playerY = H * 0.92;", `this.playerY = ${layoutY} + ${layoutH} * 0.95;`) .replace("this.alienGridY = Math.max(H * 0.20, 120);", `this.alienGridY = Math.max(${layoutY} + ${layoutH} * 0.10, 80);`) .replace("const targetShieldH = H * 0.055;", `const targetShieldH = ${layoutH} * 0.065;`) .replace("SCALE = Math.min(W / 1920, H / 1080);", "SCALE = Math.max(1.25, Math.min(W / 1920, H / 1080));") .replace("this.alienCellW = Math.round(W * 0.055);", "this.alienCellW = Math.round(W * 0.068);"); } async function renderGalaxyBlasterJs() { const js = await readFile(galaxyBlasterJsPath, "utf8"); return js .replaceAll("SCALE = Math.min(CONV_X, CONV_Y);", "SCALE = Math.max(1.7, Math.min(CONV_X, CONV_Y));") .replaceAll("OPPONENT_SIZE = Math.min(32 * SCALE, W / 35);", "OPPONENT_SIZE = Math.max(54, Math.min(32 * SCALE, W / 24));"); } 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 handleSelectGame(entry, req, res) { let body = ""; req.setEncoding("utf8"); req.on("data", (chunk) => { body += chunk; }); req.on("end", () => { let input; try { input = JSON.parse(body || "{}"); } catch { res.writeHead(400, { "content-type": "text/plain; charset=utf-8" }); res.end("Invalid JSON request body"); return; } entry.selectedGame = normalizeGameKey(input.gameKey); broadcast(entry, "selectGame", { gameKey: entry.selectedGame }); sendJson(res, { selectedGame: entry.selectedGame }); }); } 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); sendSse(res, "selectGame", { gameKey: entry.selectedGame }); req.on("close", () => entry.clients.delete(res)); return; } if (url.pathname === "/state") { sendJson(res, { games, selectedGame: entry.selectedGame }); return; } if (url.pathname === "/favicon.ico") { await streamFile(res, path.join(assetsRoot, "icon.png")); return; } if (url.pathname === "/select-game" && req.method === "POST") { await handleSelectGame(entry, req, res); return; } try { if (url.pathname === "/" || url.pathname === "/index.html" || url.pathname === "/game" || url.pathname === "/game/") { res.writeHead(200, { "content-type": "text/html; charset=utf-8", "cache-control": "no-cache", }); res.end(await renderIndex(entry)); return; } if (url.pathname === "/game.js" || url.pathname === "/game/game.js") { res.writeHead(200, { "content-type": "text/javascript; charset=utf-8", "cache-control": "no-cache", }); res.end(await renderGameJs()); return; } if (url.pathname === "/scenes/AlienOnslaught.js" || url.pathname === "/game/scenes/AlienOnslaught.js") { res.writeHead(200, { "content-type": "text/javascript; charset=utf-8", "cache-control": "no-cache", }); res.end(await renderAlienOnslaughtJs()); return; } if (url.pathname === "/scenes/GalaxyBlaster.js" || url.pathname === "/game/scenes/GalaxyBlaster.js") { res.writeHead(200, { "content-type": "text/javascript; charset=utf-8", "cache-control": "no-cache", }); res.end(await renderGalaxyBlasterJs()); return; } const staticPath = url.pathname.startsWith("/assets/") ? resolveUnder(assetsRoot, url.pathname.slice("/assets".length)) : resolveUnder(gameRoot, url.pathname.startsWith("/game/") ? url.pathname.slice("/game".length) : 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, selectedGame) { const entry = { clients: new Set(), selectedGame, 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 : "Arcade 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("arcade_not_open", "Open the Arcade canvas before invoking this action."); } return entry; } await joinSession({ canvases: [ createCanvas({ id: "arcade-canvas", displayName: "Agent Arcade", description: "A retro arcade canvas with five mini-games for waiting while agents work.", inputSchema: { type: "object", properties: { defaultGame: { type: "string", enum: games.map((game) => game.key), description: "Game to show first.", }, }, additionalProperties: false, }, actions: [ { name: "list_games", description: "List the mini-games available in the arcade canvas.", handler: (ctx) => { const entry = servers.get(ctx.instanceId); return { games, selectedGame: entry?.selectedGame ?? defaultGame, }; }, }, { name: "select_game", description: "Switch the open arcade canvas to a specific mini-game.", inputSchema: { type: "object", properties: { gameKey: { type: "string", enum: games.map((game) => game.key), }, }, required: ["gameKey"], additionalProperties: false, }, handler: (ctx) => { const entry = getOpenEntry(ctx.instanceId); entry.selectedGame = normalizeGameKey(ctx.input?.gameKey); broadcast(entry, "selectGame", { gameKey: entry.selectedGame }); return { selectedGame: entry.selectedGame, }; }, }, { name: "restart_game", description: "Reload the open arcade canvas to restart the selected game.", handler: (ctx) => { const entry = getOpenEntry(ctx.instanceId); broadcast(entry, "reload", {}); return { selectedGame: entry.selectedGame, }; }, }, ], open: async (ctx) => { let entry = servers.get(ctx.instanceId); if (!entry) { entry = await startServer(ctx.instanceId, normalizeGameKey(ctx.input?.defaultGame)); } else if (ctx.input?.defaultGame) { entry.selectedGame = normalizeGameKey(ctx.input.defaultGame); } return { title: "Agent Arcade", status: games.find((game) => game.key === entry.selectedGame)?.label ?? "Ready", 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())); }, }), ], });