diff --git a/.github/plugin/marketplace.json b/.github/plugin/marketplace.json index 3715da3ec8..e718ec6c11 100644 --- a/.github/plugin/marketplace.json +++ b/.github/plugin/marketplace.json @@ -1788,6 +1788,12 @@ "sha": "2771046616a3c6bcd62916ed072f71feb5afd222" } }, + { + "name": "keyboard-2048", + "source": "plugins/keyboard-2048", + "description": "Play 2048 in a Copilot canvas with keyboard and touch controls, undo, and session saves.", + "version": "1.0.0" + }, { "name": "kotlin-mcp-development", "source": "plugins/kotlin-mcp-development", diff --git a/docs/README.plugins.md b/docs/README.plugins.md index 128cbbdc20..169af31b6b 100644 --- a/docs/README.plugins.md +++ b/docs/README.plugins.md @@ -84,6 +84,7 @@ See [CONTRIBUTING.md](../CONTRIBUTING.md#adding-plugins) for guidelines on how t | [java-mcp-development](../plugins/java-mcp-development/README.md) | Build Model Context Protocol (MCP) servers in Java with the official MCP Java SDK 2.x. Includes a skill that generates a server project and an expert agent for guidance. | 2 items | java, mcp, model-context-protocol, mcp-java-sdk, server-development, spring-ai | | [java-modernization-studio](../plugins/java-modernization-studio/README.md) | Drive the GitHub Copilot App Modernization for Java workflow from an interactive canvas: environment readiness, repo assessment, prioritized plan and progress, validation gates, and one-click predefined-task runs grounded in the repo's real artifacts. | 1 items | app-modernization, assessment-dashboard, azure-migration, java-modernization, legacy-java, modernization-cockpit, validation-gates | | [jupyter-notebooks](../plugins/jupyter-notebooks/README.md) | Create, edit, run, save, and checkpoint Jupyter notebooks in an interactive Copilot canvas. | 1 items | data-analysis, interactive-notebooks, jupyter, python | +| [keyboard-2048](../plugins/keyboard-2048/README.md) | Play 2048 in a Copilot canvas with keyboard and touch controls, undo, and session saves. | 1 items | 2048, copilot-canvas, game, keyboard, puzzle, undo | | [kotlin-mcp-development](../plugins/kotlin-mcp-development/README.md) | Complete toolkit for building Model Context Protocol (MCP) servers in Kotlin using the official io.modelcontextprotocol:kotlin-sdk library. Includes instructions for best practices, a prompt for generating servers, and an expert chat mode for guidance. | 2 items | kotlin, mcp, model-context-protocol, kotlin-multiplatform, server-development, ktor | | [mcp-m365-copilot](../plugins/mcp-m365-copilot/README.md) | Comprehensive collection for building declarative agents with Model Context Protocol integration for Microsoft 365 Copilot | 4 items | mcp, m365-copilot, declarative-agents, api-plugins, model-context-protocol, adaptive-cards | | [modernization-workflow](../plugins/modernization-workflow/README.md) | Visualize and guide the GitHub Copilot Modernize CLI Assess, Plan, and Execute workflow for .NET, Java, and C++ application repositories. | 1 items | app-modernization, assessment-dashboard, azure-migration, cpp-modernization, dotnet-modernization, java-modernization, modernization-canvas, modernize-cli | diff --git a/extensions/keyboard-2048/app.mjs b/extensions/keyboard-2048/app.mjs new file mode 100644 index 0000000000..fd08bfafc4 --- /dev/null +++ b/extensions/keyboard-2048/app.mjs @@ -0,0 +1,210 @@ +const token = new URLSearchParams(location.search).get("token"); +const endpoint = path => `${path}?token=${encodeURIComponent(token)}`; +const board = document.querySelector("#board"); +const errorBox = document.querySelector("#error"); +const undoButton = document.querySelector("#undo"); +const dialog = document.querySelector("#restart-dialog"); +const number = new Intl.NumberFormat("en-US"); +let state; +let pending = Promise.resolve(); +let connected = false; +let storageError = false; +const reducedMotion = matchMedia("(prefers-reduced-motion: reduce)"); +const animations = new Set(); +const motionLayer = document.createElement("div"); +motionLayer.className = "motion-layer"; +motionLayer.setAttribute("aria-hidden", "true"); +board.parentElement.append(motionLayer); +const cells = Array.from({ length: 16 }, () => { + const cell = document.createElement("div"); + cell.className = "tile"; + cell.setAttribute("role", "img"); + board.append(cell); + return cell; +}); + +function showError(message) { + errorBox.textContent = message; + errorBox.hidden = false; +} + +function clearMotion() { + for (const animation of animations) { + animation.onfinish = null; + animation.cancel(); + } + animations.clear(); + motionLayer.replaceChildren(); + cells.forEach(cell => cell.classList.remove("in-flight")); +} + +function animateMove(motion) { + const merged = new Set(motion.merges); + const moving = motion.movements.filter(tile => tile.from !== tile.to || merged.has(tile.to)); + if (!moving.length) return; + // Layout offsets exclude the spawn/pulse transforms on the final board. + const rects = cells.map(cell => ({ + left: cell.offsetLeft, top: cell.offsetTop, + width: cell.offsetWidth, height: cell.offsetHeight, + })); + const arrivals = new Set(moving.map(tile => tile.to)); + arrivals.forEach(index => cells[index].classList.add("in-flight")); + let remaining = moving.length; + for (const tile of moving) { + const source = rects[tile.from]; + const target = rects[tile.to]; + const ghost = document.createElement("div"); + ghost.className = `tile motion-tile${tile.value >= 1024 ? " large" : ""}`; + ghost.dataset.level = Math.min(11, Math.log2(tile.value)); + ghost.textContent = tile.value; + Object.assign(ghost.style, { + left: `${source.left}px`, top: `${source.top}px`, + width: `${source.width}px`, height: `${source.height}px`, + }); + motionLayer.append(ghost); + const animation = ghost.animate([ + { transform: "translate(0, 0)" }, + { transform: `translate(${target.left - source.left}px, ${target.top - source.top}px)` }, + ], { duration: 120, easing: "ease-out", fill: "forwards" }); + animations.add(animation); + animation.onfinish = () => { + if (--remaining) return; + clearMotion(); + for (const index of merged) { + const pulse = cells[index].animate([ + { transform: "scale(1)" }, { transform: "scale(1.08)" }, { transform: "scale(1)" }, + ], { duration: 100, easing: "ease-out" }); + animations.add(pulse); + pulse.onfinish = () => { animations.delete(pulse); pulse.cancel(); }; + } + }; + } +} + +function render(next) { + // SSE and HTTP can deliver the same update, or an older HTTP reply after newer SSE. + if (state?.update && next.update?.stream === state.update.stream + && next.update.revision <= state.update.revision) return; + const motion = state?.update && next.update?.stream === state.update.stream + && next.update.revision === state.update.revision + 1 + && next.update.motion?.from.every((value, index) => value === state.board[index]) + ? next.update.motion : null; + clearMotion(); + cells.forEach((cell, index) => { + const value = next.board[index]; + const spawned = value && index === next.spawnedIndex; + cell.className = `tile${value >= 1024 ? " large" : ""}`; + if (spawned) cell.classList.add("spawned"); + cell.dataset.level = value ? Math.min(11, Math.log2(value)) : 0; + cell.textContent = value || ""; + cell.setAttribute("aria-label", `Row ${Math.floor(index / 4) + 1}, column ${index % 4 + 1}: ${value || "empty"}${spawned ? " (new tile)" : ""}`); + }); + document.querySelector("#score").textContent = number.format(next.score); + document.querySelector("#best").textContent = number.format(next.best); + document.querySelector("#moves").textContent = `${number.format(next.moves)} ${next.moves === 1 ? "move" : "moves"}`; + document.querySelector("#status").textContent = next.gameOver + ? "No moves left. Undo or start a new game." + : next.won ? "2048 reached! You can keep playing." : "Your move."; + document.querySelector("#game-over").hidden = !next.gameOver; + undoButton.disabled = !next.canUndo; + state = next; + if (motion && !reducedMotion.matches) animateMove(motion); +} + +reducedMotion.addEventListener("change", clearMotion); +window.addEventListener("resize", clearMotion); + +function command(action, direction) { + pending = pending.then(async () => { + const response = await fetch(endpoint("/command"), { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ action, direction }), + }); + const result = await response.json(); + if (!response.ok) throw new Error(result.error || `HTTP error ${response.status}`); + storageError = false; + if (connected) errorBox.hidden = true; + render(result); + }).catch(error => { + storageError = true; + showError(`Move not confirmed: ${error.message}`); + }); + return pending; +} + +function requestRestart() { + if (!dialog.open) { + dialog.returnValue = ""; + dialog.showModal(); + } +} +dialog.addEventListener("close", () => { + if (dialog.returnValue === "restart") void command("new"); + board.focus({ preventScroll: true }); +}); +document.querySelector("#new").addEventListener("click", requestRestart); +undoButton.addEventListener("click", () => { void command("undo"); board.focus({ preventScroll: true }); }); +document.querySelectorAll("[data-direction]").forEach(button => { + button.addEventListener("click", () => { + void command("move", button.dataset.direction); + board.focus({ preventScroll: true }); + }); +}); +const keys = { + ArrowLeft: "left", ArrowRight: "right", ArrowUp: "up", ArrowDown: "down", + a: "left", d: "right", w: "up", s: "down", +}; +board.addEventListener("keydown", event => { + if (dialog.open || event.isComposing || event.altKey + || event.target.closest("input, textarea, select, [contenteditable='true']")) return; + const key = event.key.length === 1 ? event.key.toLowerCase() : event.key; + if ((event.ctrlKey || event.metaKey) && key === "z" && !event.shiftKey) { + event.preventDefault(); + if (!event.repeat) void command("undo"); + return; + } + if (event.ctrlKey || event.metaKey) return; + if (keys[key]) { + event.preventDefault(); + void command("move", keys[key]); + } else if (key === "u") { + event.preventDefault(); + if (!event.repeat) void command("undo"); + } else if (key === "n") { + event.preventDefault(); + if (!event.repeat) requestRestart(); + } +}); +let start; +board.addEventListener("pointerdown", event => { + board.focus({ preventScroll: true }); + start = { x: event.clientX, y: event.clientY, id: event.pointerId }; + board.setPointerCapture(event.pointerId); +}); +board.addEventListener("pointercancel", () => { start = undefined; }); +board.addEventListener("pointerup", event => { + if (!start || start.id !== event.pointerId) return; + const dx = event.clientX - start.x; + const dy = event.clientY - start.y; + start = undefined; + if (Math.max(Math.abs(dx), Math.abs(dy)) < 28) return; + const direction = Math.abs(dx) > Math.abs(dy) ? (dx > 0 ? "right" : "left") : (dy > 0 ? "down" : "up"); + void command("move", direction); +}); + +const events = new EventSource(endpoint("/events")); +events.addEventListener("message", event => { + try { render(JSON.parse(event.data)); } + catch (error) { showError(`Invalid game state: ${error.message}`); } +}); +events.addEventListener("open", () => { + connected = true; + if (!storageError) errorBox.hidden = true; +}); +events.addEventListener("error", () => { + connected = false; + showError("Connection lost. Reconnecting automatically; your game is still saved."); +}); +window.addEventListener("pagehide", () => { clearMotion(); events.close(); }); +board.focus({ preventScroll: true }); diff --git a/extensions/keyboard-2048/assets/preview.png b/extensions/keyboard-2048/assets/preview.png new file mode 100644 index 0000000000..3509aa7f77 Binary files /dev/null and b/extensions/keyboard-2048/assets/preview.png differ diff --git a/extensions/keyboard-2048/extension.mjs b/extensions/keyboard-2048/extension.mjs new file mode 100644 index 0000000000..e8dcc804f5 --- /dev/null +++ b/extensions/keyboard-2048/extension.mjs @@ -0,0 +1,75 @@ +import { join } from "node:path"; +import { createCanvas, CanvasError, joinSession } from "@github/copilot-sdk/extension"; +import { createGameStore, startGameServer } from "./server.mjs"; + +const panels = new Map(); +let storePromise; +let session; +function store() { + if (!session?.workspacePath) { + throw new CanvasError("workspace_unavailable", "Session workspace is unavailable."); + } + if (!storePromise) { + storePromise = createGameStore(join(session.workspacePath, "2048-save.json")) + .catch(error => { storePromise = undefined; throw error; }); + } + return storePromise; +} +const emptyInput = { type: "object", properties: {}, additionalProperties: false }; + +session = await joinSession({ + canvases: [createCanvas({ + id: "keyboard-2048", + displayName: "2048", + description: "Play 2048 with arrow keys or WASD, undo the last move, and resume your saved game.", + inputSchema: emptyInput, + actions: [ + { + name: "get_state", + description: "Read the board, score, and Undo availability.", + inputSchema: emptyInput, + handler: async () => (await store()).snapshot(), + }, + { + name: "move", + description: "Move the tiles in a direction.", + inputSchema: { + type: "object", + properties: { direction: { type: "string", enum: ["left", "right", "up", "down"] } }, + required: ["direction"], + additionalProperties: false, + }, + handler: async ctx => (await store()).execute("move", ctx.input.direction), + }, + { + name: "undo", + description: "Undo only the last valid move, restoring the score and removing the spawned tile.", + inputSchema: emptyInput, + handler: async () => (await store()).execute("undo"), + }, + { + name: "new_game", + description: "Start a new game, keeping the session's best score.", + inputSchema: emptyInput, + handler: async () => (await store()).execute("new"), + }, + ], + open: async ctx => { + let panel = panels.get(ctx.instanceId); + if (!panel) { + panel = Promise.resolve().then(() => store()).then(startGameServer) + .catch(error => { + if (panels.get(ctx.instanceId) === panel) panels.delete(ctx.instanceId); + throw error; + }); + panels.set(ctx.instanceId, panel); + } + return { url: (await panel).url, title: "2048 · Keyboard edition" }; + }, + onClose: async ctx => { + const panel = panels.get(ctx.instanceId); + panels.delete(ctx.instanceId); + if (panel) await (await panel).close(); + }, + })], +}); diff --git a/extensions/keyboard-2048/extension.test.mjs b/extensions/keyboard-2048/extension.test.mjs new file mode 100644 index 0000000000..466f9bc168 --- /dev/null +++ b/extensions/keyboard-2048/extension.test.mjs @@ -0,0 +1,154 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { registerHooks } from "node:module"; +import { startGameServer } from "./server.mjs"; + +let fixtureId = 0; +async function loadCanvas(t, dependencies) { + const key = `__keyboard2048Test${++fixtureId}`; + const fixture = globalThis[key] = { ...dependencies }; + const stub = source => `data:text/javascript,${encodeURIComponent(source)}`; + const sdk = stub(` + export const createCanvas = options => options; + export class CanvasError extends Error {} + export async function joinSession(options) { + globalThis.${key}.canvas = options.canvases[0]; + return { workspacePath: "test-workspace" }; + } + `); + const server = stub(` + export const { createGameStore, startGameServer } = globalThis.${key}; + `); + const entry = new URL("./extension.mjs", import.meta.url); + entry.searchParams.set("test", key); + const hooks = registerHooks({ + resolve(specifier, context, next) { + if (context.parentURL === entry.href) { + if (specifier === "@github/copilot-sdk/extension") return { url: sdk, shortCircuit: true }; + if (specifier === "./server.mjs") return { url: server, shortCircuit: true }; + } + return next(specifier, context); + }, + }); + t.after(() => { hooks.deregister(); delete globalThis[key]; }); + await import(entry.href); + return fixture.canvas; +} + +test("overlapping opens share initialization and one server, which close releases", async t => { + const ready = Promise.withResolvers(); + const initializing = Promise.withResolvers(); + let stores = 0; + let servers = 0; + let subscriptions = 0; + const canvas = await loadCanvas(t, { + createGameStore() { + stores++; + initializing.resolve(); + return ready.promise; + }, + startGameServer(store) { + servers++; + return startGameServer(store); + }, + }); + const ctx = { instanceId: "shared" }; + t.after(() => canvas.onClose(ctx)); + const first = canvas.open(ctx); + const second = canvas.open(ctx); + await initializing.promise; + assert.equal(stores, 1); + assert.equal(servers, 0); + ready.resolve({ + snapshot: () => ({}), + subscribe() { + subscriptions++; + return () => { subscriptions--; }; + }, + }); + const [a, b] = await Promise.all([first, second]); + assert.equal(a.url, b.url); + assert.equal(servers, 1); + assert.equal(subscriptions, 1); + assert.equal((await fetch(a.url)).status, 200); + await canvas.onClose(ctx); + assert.equal(subscriptions, 0); + await assert.rejects(fetch(a.url)); +}); + +test("failed store initialization rejects overlapping opens and permits retry", async t => { + const ready = Promise.withResolvers(); + let stores = 0; + let servers = 0; + let closed = 0; + const canvas = await loadCanvas(t, { + createGameStore() { return ++stores === 1 ? ready.promise : Promise.resolve({}); }, + async startGameServer() { + servers++; + return { url: "http://127.0.0.1/retry", async close() { closed++; } }; + }, + }); + const ctx = { instanceId: "retry" }; + const first = assert.rejects(canvas.open(ctx), /save unavailable/); + const second = assert.rejects(canvas.open(ctx), /save unavailable/); + ready.reject(new Error("save unavailable")); + await Promise.all([first, second]); + const result = await canvas.open(ctx); + assert.equal(result.url, "http://127.0.0.1/retry"); + assert.equal(stores, 2); + assert.equal(servers, 1); + await canvas.onClose(ctx); + assert.equal(closed, 1); +}); + +test("close during initialization releases the pending server", async t => { + const ready = Promise.withResolvers(); + let closed = 0; + const canvas = await loadCanvas(t, { + createGameStore: () => ready.promise, + startGameServer: async () => ({ + url: "http://127.0.0.1/pending", + async close() { closed++; }, + }), + }); + const ctx = { instanceId: "pending" }; + const opening = canvas.open(ctx); + const closing = canvas.onClose(ctx); + assert.equal(closed, 0); + ready.resolve({}); + await Promise.all([opening, closing]); + assert.equal(closed, 1); + await canvas.onClose(ctx); + assert.equal(closed, 1); +}); + +test("a failed closed startup cannot evict a replacement panel", async t => { + const ready = Promise.withResolvers(); + const starting = Promise.withResolvers(); + let servers = 0; + let closed = 0; + const canvas = await loadCanvas(t, { + createGameStore: async () => ({}), + startGameServer() { + if (++servers === 1) { + starting.resolve(); + return ready.promise; + } + return Promise.resolve({ + url: "http://127.0.0.1/replacement", + async close() { closed++; }, + }); + }, + }); + const ctx = { instanceId: "replacement" }; + const opening = assert.rejects(canvas.open(ctx), /listen failed/); + await starting.promise; + const closing = assert.rejects(canvas.onClose(ctx), /listen failed/); + const replacement = await canvas.open(ctx); + ready.reject(new Error("listen failed")); + await Promise.all([opening, closing]); + assert.equal((await canvas.open(ctx)).url, replacement.url); + assert.equal(servers, 2); + await canvas.onClose(ctx); + assert.equal(closed, 1); +}); diff --git a/extensions/keyboard-2048/game.mjs b/extensions/keyboard-2048/game.mjs new file mode 100644 index 0000000000..8ff9071c1a --- /dev/null +++ b/extensions/keyboard-2048/game.mjs @@ -0,0 +1,134 @@ +export const DIRECTIONS = ["left", "right", "up", "down"]; + +function traceLine(line) { + const values = line.flatMap((value, from) => value ? [{ value, from }] : []); + const merged = []; + const movements = []; + const merges = []; + let score = 0; + for (let i = 0; i < values.length; i++) { + const to = merged.length; + movements.push({ from: values[i].from, to, value: values[i].value }); + if (values[i].value === values[i + 1]?.value) { + const value = values[i].value * 2; + movements.push({ from: values[i + 1].from, to, value: values[i + 1].value }); + merges.push(to); + merged.push(value); + score += value; + i++; + } else { + merged.push(values[i].value); + } + } + return { line: merged.concat(Array(4 - merged.length).fill(0)), score, movements, merges }; +} + +export function mergeLine(line) { + const { line: merged, score } = traceLine(line); + return { line: merged, score }; +} + +export function slide(board, direction) { + if (!DIRECTIONS.includes(direction)) throw new Error("Invalid direction."); + const next = [...board]; + const movements = []; + const merges = []; + let score = 0; + for (let row = 0; row < 4; row++) { + const indices = Array.from({ length: 4 }, (_, column) => { + if (direction === "left") return row * 4 + column; + if (direction === "right") return row * 4 + 3 - column; + if (direction === "up") return column * 4 + row; + return (3 - column) * 4 + row; + }); + const result = traceLine(indices.map(index => board[index])); + indices.forEach((index, column) => { next[index] = result.line[column]; }); + movements.push(...result.movements.map(tile => ({ + ...tile, from: indices[tile.from], to: indices[tile.to], + }))); + merges.push(...result.merges.map(column => indices[column])); + score += result.score; + } + return { board: next, score, changed: next.some((value, index) => value !== board[index]), movements, merges }; +} + +function spawn(board, random) { + const empty = board.flatMap((value, index) => value === 0 ? [index] : []); + if (empty.length === 0) return { board: [...board], index: null }; + const next = [...board]; + const index = empty[Math.floor(random() * empty.length)]; + next[index] = random() < 0.9 ? 2 : 4; + return { board: next, index }; +} + +export function newGame(best = 0, random = Math.random) { + const first = spawn(Array(16).fill(0), random); + return { + version: 1, + board: spawn(first.board, random).board, + spawnedIndex: null, + score: 0, + best, + moves: 0, + previous: null, + }; +} + +export function isGameOver(board) { + return !DIRECTIONS.some(direction => slide(board, direction).changed); +} + +export function move(state, direction, random = Math.random) { + const result = slide(state.board, direction); + if (!result.changed) return state; + const score = state.score + result.score; + const spawned = spawn(result.board, random); + return { + ...state, + board: spawned.board, + spawnedIndex: spawned.index, + score, + best: Math.max(state.best, score), + moves: state.moves + 1, + previous: { board: [...state.board], score: state.score, moves: state.moves }, + }; +} + +export function undo(state) { + if (!state.previous) return state; + return { ...state, ...state.previous, spawnedIndex: null, previous: null }; +} + +function validSnapshot(value) { + return value !== null && typeof value === "object" + && Array.isArray(value.board) && value.board.length === 16 + && value.board.every(tile => Number.isSafeInteger(tile) && tile >= 0 + && (tile === 0 || (tile >= 2 && Number.isInteger(Math.log2(tile))))) + && Number.isSafeInteger(value.score) && value.score >= 0 + && Number.isSafeInteger(value.moves) && value.moves >= 0; +} + +export function validateSave(value) { + if (!validSnapshot(value) || value.version !== 1 + || !Number.isSafeInteger(value.best) || value.best < value.score + || !(value.spawnedIndex === undefined || value.spawnedIndex === null + || (Number.isInteger(value.spawnedIndex) && value.spawnedIndex >= 0 + && value.spawnedIndex < 16 && [2, 4].includes(value.board[value.spawnedIndex]))) + || !(value.previous === null || validSnapshot(value.previous))) { + throw new Error("Invalid 2048 save. Back up or rename the file before trying again."); + } + return value; +} + +export function viewState(state) { + return { + board: state.board, + score: state.score, + best: state.best, + moves: state.moves, + spawnedIndex: state.spawnedIndex ?? null, + canUndo: state.previous !== null, + won: state.board.some(value => value >= 2048), + gameOver: isGameOver(state.board), + }; +} diff --git a/extensions/keyboard-2048/game.test.mjs b/extensions/keyboard-2048/game.test.mjs new file mode 100644 index 0000000000..cef6bd24cd --- /dev/null +++ b/extensions/keyboard-2048/game.test.mjs @@ -0,0 +1,224 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { mergeLine, slide, newGame, move, undo, isGameOver, validateSave, viewState, DIRECTIONS } from "./game.mjs"; + +const stateWith = board => ({ version: 1, board, score: 0, best: 0, moves: 0, previous: null }); +const zero = () => 0; + +test("merges pairs only once and skips empty cells", () => { + assert.deepEqual(mergeLine([2, 2, 2, 2]), { line: [4, 4, 0, 0], score: 8 }); + assert.deepEqual(mergeLine([2, 2, 4, 0]), { line: [4, 4, 0, 0], score: 4 }); + assert.deepEqual(mergeLine([2, 0, 2, 2]), { line: [4, 2, 0, 0], score: 4 }); + assert.deepEqual(mergeLine([0, 0, 0, 0]), { line: [0, 0, 0, 0], score: 0 }); +}); + +test("all four directions use the correct merge order", () => { + const board = [2, 2, 4, 4, ...Array(12).fill(0)]; + assert.deepEqual(slide(board, "left").board.slice(0, 4), [4, 8, 0, 0]); + assert.deepEqual(slide(board, "right").board.slice(0, 4), [0, 0, 4, 8]); + const vertical = [2, 0, 0, 0, 2, 0, 0, 0, 4, 0, 0, 0, 4, 0, 0, 0]; + assert.deepEqual(slide(vertical, "up").board.filter((_, i) => i % 4 === 0), [4, 8, 0, 0]); + assert.deepEqual(slide(vertical, "down").board.filter((_, i) => i % 4 === 0), [0, 0, 4, 8]); + assert.throws(() => slide(board, "diagonal"), /Invalid direction/); +}); + +test("slides preserve mass and do not mutate their inputs", () => { + let seed = 41; + const random = () => ((seed = (seed * 1664525 + 1013904223) >>> 0) / 2 ** 32); + for (let sample = 0; sample < 200; sample++) { + const board = Array.from({ length: 16 }, () => [0, 2, 4, 8, 16][Math.floor(random() * 5)]); + const original = [...board]; + for (const direction of DIRECTIONS) { + const result = slide(board, direction); + assert.equal(result.board.reduce((a, b) => a + b), board.reduce((a, b) => a + b)); + assert.deepEqual(board, original); + assert.equal(result.changed, result.board.some((value, i) => value !== board[i])); + assert.deepEqual(result.movements.map(tile => tile.from).sort((a, b) => a - b), + board.flatMap((value, index) => value ? [index] : [])); + const reconstructed = Array(16).fill(0); + for (const tile of result.movements) { + assert.equal(tile.value, board[tile.from]); + reconstructed[tile.to] += tile.value; + if (direction === "left" || direction === "right") { + assert.equal(Math.floor(tile.from / 4), Math.floor(tile.to / 4)); + assert.ok(direction === "left" ? tile.to <= tile.from : tile.to >= tile.from); + } else { + assert.equal(tile.from % 4, tile.to % 4); + assert.ok(direction === "up" ? tile.to <= tile.from : tile.to >= tile.from); + } + } + assert.deepEqual(reconstructed, result.board); + assert.equal(result.score, result.merges.reduce((sum, index) => sum + result.board[index], 0)); + for (let index = 0; index < 16; index++) { + const sources = result.movements.filter(tile => tile.to === index); + assert.ok(sources.length <= 2); + assert.equal(result.merges.includes(index), sources.length === 2); + if (sources.length === 2) assert.equal(sources[0].value, sources[1].value); + } + } + } +}); + +test("movement traces track equal-valued cells and prevent chain merges", () => { + const result = slide([2, 0, 2, 4, ...Array(12).fill(0)], "left"); + assert.deepEqual(result.movements, [ + { from: 0, to: 0, value: 2 }, + { from: 2, to: 0, value: 2 }, + { from: 3, to: 1, value: 4 }, + ]); + assert.deepEqual(result.merges, [0]); + assert.deepEqual(result.board.slice(0, 4), [4, 4, 0, 0]); + const pairs = slide([2, 2, 2, 2, ...Array(12).fill(0)], "right"); + assert.deepEqual(pairs.movements, [ + { from: 3, to: 3, value: 2 }, { from: 2, to: 3, value: 2 }, + { from: 1, to: 2, value: 2 }, { from: 0, to: 2, value: 2 }, + ]); + assert.deepEqual(pairs.merges, [3, 2]); + const unchangedCell = slide([2, 2, ...Array(14).fill(0)], "right"); + assert.equal(unchangedCell.board[0], 0); + assert.deepEqual(unchangedCell.movements.map(tile => tile.to), [3, 3]); + const noOp = slide([2, ...Array(15).fill(0)], "left"); + assert.equal(noOp.changed, false); + assert.deepEqual(noOp.movements, [{ from: 0, to: 0, value: 2 }]); + assert.deepEqual(noOp.merges, []); +}); + +test("new game spawns exactly two tiles, with both spawn values supported", () => { + const game = newGame(100, zero); + assert.equal(game.board.filter(Boolean).length, 2); + assert.equal(game.board.reduce((a, b) => a + b), 4); + assert.equal(game.best, 100); + assert.equal(game.previous, null); + assert.deepEqual(newGame(0, () => .99).board.filter(Boolean), [4, 4]); +}); + +test("Undo removes the spawned tile and restores board, score and moves exactly", () => { + const original = { ...stateWith([2, 2, ...Array(14).fill(0)]), score: 12, best: 12, moves: 3 }; + const next = move(original, "left", zero); + assert.equal(next.score, 16); + assert.equal(next.moves, 4); + assert.equal(next.board.reduce((a, b) => a + b), 6); + const restored = undo(next); + assert.deepEqual(restored.board, original.board); + assert.equal(restored.score, original.score); + assert.equal(restored.moves, original.moves); + assert.equal(restored.best, 16); + assert.equal(restored.previous, null); + assert.equal(undo(restored), restored); + assert.deepEqual(original.board, [2, 2, ...Array(14).fill(0)]); +}); + +test("a no-op move neither spawns nor consumes Undo", () => { + const state = stateWith([2, ...Array(15).fill(0)]); + const next = move(state, "right", zero); + const noOp = move(next, "up", () => { throw new Error("Must not spawn"); }); + assert.equal(noOp, next); + assert.deepEqual(undo(noOp).board, state.board); +}); + +test("only the last valid move can be undone", () => { + const first = move(newGame(0, zero), "right", zero); + const second = move(first, "down", zero); + const restored = undo(second); + assert.deepEqual(restored.board, first.board); + assert.equal(restored.score, first.score); + assert.equal(restored.previous, null); + assert.equal(undo(restored), restored); +}); + +test("full board may still merge and terminal board allows Undo", () => { + const lost = [2, 4, 2, 4, 4, 2, 4, 2, 2, 4, 2, 4, 4, 2, 4, 2]; + assert.equal(isGameOver(lost), true); + const mergeable = [...lost]; + mergeable[0] = 4; + assert.equal(isGameOver(mergeable), false); + const playable = [...lost]; + playable[0] = 0; + assert.equal(isGameOver(playable), false); + const last = move(stateWith(playable), "left", zero); + assert.equal(last.previous !== null, true); + assert.deepEqual(undo(last).board, playable); + const beforeLoss = [4, 2, 4, 0, ...lost.slice(4)]; + const terminal = move(stateWith(beforeLoss), "right", zero); + assert.equal(isGameOver(terminal.board), true); + assert.deepEqual(undo(terminal).board, beforeLoss); +}); + +test("2048 is a win, not a blocking game-over condition", () => { + const result = move(stateWith([1024, 1024, ...Array(14).fill(0)]), "left", zero); + assert.equal(viewState(result).won, true); + assert.equal(viewState(result).gameOver, false); + assert.equal(viewState(result).canUndo, true); + assert.equal(viewState(undo(result)).won, false); +}); + +test("saved state including Undo round-trips and malformed saves are rejected", () => { + const state = move(newGame(0, zero), "right", zero); + assert.deepEqual(validateSave(JSON.parse(JSON.stringify(state))), state); + for (const invalid of [null, {}, { ...state, board: [2] }, { ...state, score: -1 }, + { ...state, best: -1 }, { ...state, previous: {} }, + { ...state, board: Array(16).fill(3) }, { ...state, version: 2 }]) { + assert.throws(() => validateSave(invalid), /Invalid 2048 save/); + } +}); + +test("tracks the spawned tile even when its value matches the previous cell", () => { + const before = stateWith([2, ...Array(15).fill(0)]); + const after = move(before, "right", zero); + assert.equal(after.spawnedIndex, 0); + assert.equal(before.board[0], after.board[0]); + assert.equal(after.board[after.spawnedIndex], 2); + assert.equal(after.board[3], 2); + assert.equal(viewState(after).spawnedIndex, 0); + assert.equal(move(after, "up", zero), after); + assert.equal(undo(after).spawnedIndex, null); + assert.equal(newGame().spawnedIndex, null); +}); + +test("spawn metadata identifies only the random tile, not a merged tile", () => { + const after = move(stateWith([2, 2, ...Array(14).fill(0)]), "left", zero); + assert.equal(after.board[0], 4); + assert.equal(after.spawnedIndex, 1); + assert.equal(after.board[1], 2); + const four = move(stateWith([2, ...Array(15).fill(0)]), "right", () => .99); + assert.equal(four.spawnedIndex, 15); + assert.equal(four.board[15], 4); +}); + +test("spawning uses every empty cell regardless of direction and preserves the 90/10 RNG threshold", () => { + const board = Array(16).fill(0); + board[5] = 2; + for (const direction of DIRECTIONS) { + const state = stateWith(board); + const shifted = slide(board, direction); + const empty = shifted.board.flatMap((value, index) => value === 0 ? [index] : []); + for (let slot = 0; slot < empty.length; slot++) { + for (const [roll, value] of [[0, 2], [0.899999, 2], [0.9, 4], [0.999999, 4]]) { + const rolls = [(slot + 0.5) / empty.length, roll]; + let calls = 0; + const after = move(state, direction, () => { + assert.ok(calls < 2, "a valid move makes exactly two RNG calls"); + return rolls[calls++]; + }); + assert.equal(calls, 2); + assert.equal(after.spawnedIndex, empty[slot]); + assert.equal(after.board[empty[slot]], value); + assert.deepEqual(after.board.filter((_, index) => index !== empty[slot]), + shifted.board.filter((_, index) => index !== empty[slot])); + } + } + } +}); + +test("spawn metadata persists without rejecting saves made before this feature", () => { + const after = move(stateWith([2, ...Array(15).fill(0)]), "right", zero); + const saved = validateSave(JSON.parse(JSON.stringify(after))); + assert.equal(viewState(saved).spawnedIndex, 0); + const legacy = { ...after }; + delete legacy.spawnedIndex; + assert.equal(viewState(validateSave(legacy)).spawnedIndex, null); + assert.equal(viewState(undo(legacy)).spawnedIndex, null); + for (const index of [-1, 16, 1.5, "0", 1]) { + assert.throws(() => validateSave({ ...after, spawnedIndex: index }), /Invalid 2048 save/); + } +}); diff --git a/extensions/keyboard-2048/index.html b/extensions/keyboard-2048/index.html new file mode 100644 index 0000000000..77c00d1e4f --- /dev/null +++ b/extensions/keyboard-2048/index.html @@ -0,0 +1,76 @@ + + + + + + + 2048 · Keyboard edition + + + + +
+
+

KEYBOARD EDITION

2048.

+
+
SCORE0
+
BEST0
+
+
+

Use arrows or WASD to move tiles.
Merge equal tiles to reach 2048.

+
+ + +
+
+
+ +
+
Loading game...0 moves
+ +
+ + + + +
+ + +

Start again?

+

Your current game will be replaced. Your best score will be kept.

+
+ + +
+
+
+ + diff --git a/extensions/keyboard-2048/server.mjs b/extensions/keyboard-2048/server.mjs new file mode 100644 index 0000000000..82b74f1097 --- /dev/null +++ b/extensions/keyboard-2048/server.mjs @@ -0,0 +1,176 @@ +import { createServer } from "node:http"; +import { readFile, mkdir, rename, writeFile, rm } from "node:fs/promises"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { randomUUID } from "node:crypto"; +import { DIRECTIONS, move, newGame, slide, undo, validateSave, viewState } from "./game.mjs"; + +const assets = new Map([ + ["/", ["index.html", "text/html; charset=utf-8"]], + ["/app.mjs", ["app.mjs", "text/javascript; charset=utf-8"]], + ["/style.css", ["style.css", "text/css; charset=utf-8"]], +]); +const root = dirname(fileURLToPath(import.meta.url)); + +export async function createGameStore(savePath) { + let state; + try { + state = validateSave(JSON.parse(await readFile(savePath, "utf8"))); + } catch (error) { + if (error.code !== "ENOENT") throw error; + state = newGame(); + } + let queue = Promise.resolve(); + const stream = randomUUID(); + let update = { stream, revision: 0, motion: null }; + const snapshot = () => ({ ...viewState(state), update }); + const listeners = new Set(); + async function persist(next) { + await mkdir(dirname(savePath), { recursive: true }); + const temporary = `${savePath}.${randomUUID()}.tmp`; + try { + await writeFile(temporary, JSON.stringify(next), { flag: "wx" }); + await rename(temporary, savePath); + } finally { + await rm(temporary, { force: true }); + } + } + await persist(state); + return { + snapshot, + subscribe(listener) { + listeners.add(listener); + return () => listeners.delete(listener); + }, + execute(action, direction) { + const result = queue.then(async () => { + let next; + if (action === "move" && DIRECTIONS.includes(direction)) next = move(state, direction); + else if (action === "undo") next = undo(state); + else if (action === "new") next = newGame(state.best); + else throw new Error("Invalid command."); + if (next !== state) { + const motion = action === "move" ? slide(state.board, direction) : null; + await persist(next); + update = { + stream, + revision: update.revision + 1, + motion: motion ? { + from: state.board, + movements: motion.movements, + merges: motion.merges, + } : null, + }; + state = next; + for (const listener of listeners) listener(snapshot()); + } + return snapshot(); + }); + // A failed write is returned to its caller, without poisoning the command queue. + queue = result.then(() => undefined, () => undefined); + return result; + }, + }; +} + +export async function startGameServer(store) { + const token = randomUUID(); + const clients = new Set(); + const unsubscribe = store.subscribe(state => { + for (const client of clients) client.write(`data: ${JSON.stringify(state)}\n\n`); + }); + let origin; + function json(response, status, value) { + response.writeHead(status, { "Content-Type": "application/json; charset=utf-8" }); + response.end(JSON.stringify(value)); + } + const server = createServer(async (request, response) => { + response.setHeader("Cache-Control", "no-store"); + response.setHeader("X-Content-Type-Options", "nosniff"); + response.setHeader("Referrer-Policy", "no-referrer"); + if (request.headers.host !== new URL(origin).host) { + json(response, 403, { error: "Host not allowed." }); + return; + } + let url; + try { + url = new URL(request.url, origin); + } catch { + json(response, 400, { error: "Invalid request URL." }); + return; + } + if (url.searchParams.get("token") !== token) { + json(response, 403, { error: "Unauthorized access." }); + return; + } + try { + if (request.method === "GET" && assets.has(url.pathname)) { + const [file, type] = assets.get(url.pathname); + const body = await readFile(join(root, file)); + response.writeHead(200, { "Content-Type": type }); + response.end(body); + } else if (request.method === "GET" && url.pathname === "/state") { + json(response, 200, store.snapshot()); + } else if (request.method === "GET" && url.pathname === "/events") { + response.writeHead(200, { + "Content-Type": "text/event-stream", + Connection: "keep-alive", + }); + response.write(`data: ${JSON.stringify(store.snapshot())}\n\n`); + clients.add(response); + request.on("close", () => clients.delete(response)); + } else if (request.method === "POST" && url.pathname === "/command") { + if ((request.headers.origin && request.headers.origin !== origin) + || request.headers["content-type"] !== "application/json") { + json(response, 403, { error: "Origin or content type not allowed." }); + return; + } + let body = ""; + for await (const chunk of request) { + body += chunk; + if (body.length > 1024) { + json(response, 413, { error: "Request too large." }); + return; + } + } + let command; + try { command = JSON.parse(body); } + catch { json(response, 400, { error: "Invalid JSON." }); return; } + if (!command || !["move", "undo", "new"].includes(command.action) + || (command.action === "move" && !DIRECTIONS.includes(command.direction))) { + json(response, 400, { error: "Invalid command." }); + return; + } + json(response, 200, await store.execute(command.action, command.direction)); + } else { + json(response, 404, { error: "Resource not found." }); + } + } catch (error) { + process.stderr.write(`[2048] ${error.stack ?? error}\n`); + if (!response.headersSent) json(response, 500, { error: "Game or save error. Please try again." }); + else response.end(); + } + }); + try { + await new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(0, "127.0.0.1", resolve); + }); + } catch (error) { + unsubscribe(); + throw error; + } + origin = `http://127.0.0.1:${server.address().port}`; + return { + url: `${origin}/?token=${token}`, + async close() { + unsubscribe(); + for (const client of clients) client.end(); + clients.clear(); + const closed = new Promise((resolve, reject) => + server.close(error => error ? reject(error) : resolve())); + server.closeAllConnections(); + await closed; + }, + }; +} diff --git a/extensions/keyboard-2048/server.test.mjs b/extensions/keyboard-2048/server.test.mjs new file mode 100644 index 0000000000..8823eecc5d --- /dev/null +++ b/extensions/keyboard-2048/server.test.mjs @@ -0,0 +1,163 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { mkdtemp, readFile, writeFile, rm, mkdir } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { createGameStore, startGameServer } from "./server.mjs"; +import { newGame, DIRECTIONS, slide } from "./game.mjs"; + +async function fixture(t) { + const directory = await mkdtemp(join(tmpdir(), "copilot-2048-test-")); + t.after(() => rm(directory, { recursive: true, force: true })); + const file = join(directory, "save.json"); + const store = await createGameStore(file); + return { directory, file, store }; +} +const movable = state => DIRECTIONS.find(direction => slide(state.board, direction).changed); + +test("state and one-level Undo survive provider restart", async t => { + const { file, store } = await fixture(t); + const before = store.snapshot(); + await store.execute("move", movable(before)); + const restored = await createGameStore(file); + const { update: restoredUpdate, ...restoredGame } = restored.snapshot(); + const { update: originalUpdate, ...originalGame } = store.snapshot(); + assert.deepEqual(restoredGame, originalGame); + assert.equal(restoredUpdate.motion, null); + assert.notEqual(restoredUpdate.stream, originalUpdate.stream); + const undone = await restored.execute("undo"); + assert.deepEqual(undone.board, before.board); + assert.equal(undone.score, before.score); + assert.equal(undone.moves, before.moves); + assert.equal(undone.canUndo, false); +}); + +test("animation updates are ephemeral, ordered, and identical for all panels and replies", async t => { + const { file } = await fixture(t); + const initial = { ...newGame(), board: [2, 2, 4, ...Array(13).fill(0)] }; + await writeFile(file, JSON.stringify(initial)); + const store = await createGameStore(file); + const firstPanel = []; + const secondPanel = []; + store.subscribe(state => firstPanel.push(state)); + store.subscribe(state => secondPanel.push(state)); + const before = store.snapshot(); + const plan = slide(before.board, "left"); + const moved = await store.execute("move", "left"); + assert.equal(moved.update.stream, before.update.stream); + assert.equal(moved.update.revision, before.update.revision + 1); + assert.deepEqual(moved.update.motion, { + from: before.board, movements: plan.movements, merges: plan.merges, + }); + assert.deepEqual(firstPanel, [moved]); + assert.deepEqual(secondPanel, [moved]); + assert.deepEqual(store.snapshot(), moved); + const saved = JSON.parse(await readFile(file, "utf8")); + assert.equal("update" in saved, false); + assert.equal("motion" in saved, false); + const undone = await store.execute("undo"); + assert.equal(undone.update.revision, moved.update.revision + 1); + assert.equal(undone.update.motion, null); + assert.deepEqual(undone.board, initial.board); + const repeatedUndo = await store.execute("undo"); + assert.deepEqual(repeatedUndo, undone); + const fresh = await store.execute("new"); + assert.equal(fresh.update.revision, undone.update.revision + 1); + assert.equal(fresh.update.motion, null); +}); + +test("new game clears Undo and keeps record", async t => { + const { file } = await fixture(t); + await writeFile(file, JSON.stringify({ + ...newGame(), board: [2, 2, ...Array(14).fill(0)], best: 100, + })); + const store = await createGameStore(file); + await store.execute("move", "left"); + const next = await store.execute("new"); + assert.equal(next.canUndo, false); + assert.equal(next.moves, 0); + assert.equal(next.score, 0); + assert.equal(next.best, 100); + assert.equal(next.board.filter(Boolean).length, 2); +}); + +test("invalid saved data is reported without being overwritten", async t => { + const { file } = await fixture(t); + await writeFile(file, '{"invalid":true}'); + await assert.rejects(createGameStore(file), /Invalid 2048 save/); + assert.equal(await readFile(file, "utf8"), '{"invalid":true}'); +}); + +test("failed persistence leaves live state unchanged and command queue recovers", async t => { + const { file, store } = await fixture(t); + const before = store.snapshot(); + await rm(file); + await mkdir(file); + await assert.rejects(store.execute("move", movable(before))); + assert.deepEqual(store.snapshot(), before); + await rm(file, { recursive: true }); + assert.equal((await store.execute("move", movable(before))).moves, 1); +}); + +test("commands are serialized and invalid commands do not poison the queue", async t => { + const { store } = await fixture(t); + await assert.rejects(store.execute("invalid"), /Invalid command/); + const before = store.snapshot(); + await Promise.all([store.execute("move", movable(before)), store.execute("undo")]); + assert.deepEqual(store.snapshot().board, before.board); + assert.equal(store.snapshot().canUndo, false); +}); + +test("loopback server serves UI and authenticated actions with live panel updates", async t => { + const { store } = await fixture(t); + const server = await startGameServer(store); + t.after(() => server.close()); + const url = new URL(server.url); + const endpoint = path => `${url.origin}${path}${url.search}`; + assert.equal((await fetch(url.origin)).status, 403); + const page = await fetch(server.url); + assert.equal(page.status, 200); + const html = await page.text(); + assert.match(html, //); + assert.match(html, /aria-label="Move up"/); + assert.match(html, /New game/); + assert.match(html, /GAME OVER!/); + assert.match(html, /

Use arrows or WASD to move tiles\.
Merge equal tiles to reach 2048<\/strong>\.<\/p>/); + for (const path of ["/app.mjs", "/style.css"]) { + assert.equal((await fetch(endpoint(path))).status, 200); + } + const script = await (await fetch(endpoint("/app.mjs"))).text(); + assert.match(script, /Intl\.NumberFormat\("en-US"\)/); + const css = await (await fetch(endpoint("/style.css"))).text(); + assert.match(css, /content: "NEW"/); + assert.equal((await fetch(endpoint("/missing"))).status, 404); + const before = await (await fetch(endpoint("/state"))).json(); + assert.deepEqual(before, store.snapshot()); + + const abort = new AbortController(); + t.after(() => abort.abort()); + const events = await fetch(endpoint("/events"), { signal: abort.signal }); + const reader = events.body.getReader(); + const first = new TextDecoder().decode((await reader.read()).value); + assert.match(first, /data:/); + + const send = (body, origin = url.origin) => fetch(endpoint("/command"), { + method: "POST", + headers: { "Content-Type": "application/json", Origin: origin }, + body: JSON.stringify(body), + }); + const invalid = await send({ action: "move", direction: "diagonal" }); + assert.equal(invalid.status, 400); + assert.deepEqual(await invalid.json(), { error: "Invalid command." }); + assert.equal((await send(null)).status, 400); + assert.equal((await send({ action: "new" }, "https://example.invalid")).status, 403); + const moved = await (await send({ action: "move", direction: movable(before) })).json(); + assert.equal(moved.moves, before.moves + 1); + const update = new TextDecoder().decode((await reader.read()).value); + assert.match(update, /"canUndo":true/); + assert.deepEqual(JSON.parse(update.trim().slice("data: ".length)), moved); + const undone = await (await send({ action: "undo" })).json(); + assert.deepEqual(undone.board, before.board); + assert.equal(undone.canUndo, false); + abort.abort(); +}); diff --git a/extensions/keyboard-2048/style.css b/extensions/keyboard-2048/style.css new file mode 100644 index 0000000000..f98435cc2f --- /dev/null +++ b/extensions/keyboard-2048/style.css @@ -0,0 +1,117 @@ +:root { + color-scheme: light; + --cp-bg: #f7f4ef; + --cp-bg-elevated: #fcfbf8; + --cp-surface: #ffffff; + --cp-surface-soft: #f5f5f5; + --cp-border: #dedede; + --cp-border-strong: #919191; + --cp-text: #242424; + --cp-text-muted: #5c5c5c; + --cp-text-soft: #6f6f6f; + --cp-accent: #b11f4b; + --cp-accent-hover: #9a1a41; + --cp-accent-soft: rgba(177, 31, 75, 0.08); + --cp-accent-fg: #ffffff; + --cp-success: #16a34a; + --cp-danger: #dc2626; + --cp-warning: #f59e0b; + --cp-link: #0078d4; + --cp-shadow: 0 18px 48px rgba(0, 0, 0, 0.12); + --cp-overlay: rgba(255, 255, 255, 0.8); + --cp-panel: rgba(255, 255, 255, 0.86); + --cp-panel-strong: rgba(255, 255, 255, 0.96); + --cp-sheen: rgba(255, 255, 255, 0.55); + --cp-highlight: rgba(177, 31, 75, 0.12); +} +html[data-theme="dark"] { + color-scheme: dark; + --cp-bg: #3d3b3a; + --cp-bg-elevated: #343231; + --cp-surface: #292929; + --cp-surface-soft: #2e2e2e; + --cp-border: #474747; + --cp-border-strong: #5f5f5f; + --cp-text: #dedede; + --cp-text-muted: #b0b0b0; + --cp-text-soft: #b0b0b0; + --cp-accent: #fd8ea1; + --cp-accent-hover: #fb7b91; + --cp-accent-soft: rgba(253, 142, 161, 0.14); + --cp-accent-fg: #1a1a1a; + --cp-success: #4ade80; + --cp-danger: #f87171; + --cp-warning: #fbbf24; + --cp-link: #4da6ff; + --cp-shadow: 0 18px 48px rgba(0, 0, 0, 0.32); + --cp-overlay: rgba(41, 41, 41, 0.88); + --cp-panel: rgba(41, 41, 41, 0.72); + --cp-panel-strong: rgba(41, 41, 41, 0.96); + --cp-sheen: rgba(255, 255, 255, 0.04); + --cp-highlight: rgba(253, 142, 161, 0.12); +} +* { box-sizing: border-box; } +body { margin: 0; background: var(--cp-bg); color: var(--cp-text); font: 14px/1.5 "Segoe UI", Aptos, Calibri, -apple-system, BlinkMacSystemFont, sans-serif; } +main { max-width: 480px; margin: auto; padding: 28px 20px; } +header { display: flex; align-items: center; justify-content: space-between; gap: 12px; } +.eyebrow { font-size: 10px; letter-spacing: 2px; margin: 0; color: var(--cp-text-muted); font-weight: 700; } +h1 { margin: 0; font-size: clamp(44px, 12vw, 64px); letter-spacing: -4px; line-height: 1.15; } +h1 span { color: var(--cp-accent); } +.scores { display: flex; gap: 8px; } +.score-card { border: 1px solid var(--cp-border); background: var(--cp-surface); padding: 8px 12px; border-radius: 10px; text-align: center; min-width: 72px; } +.score-card span { display: block; font-size: 10px; font-weight: 700; letter-spacing: 1px; color: var(--cp-text-muted); } +.score-card strong { display: block; font-size: 20px; font-variant-numeric: tabular-nums; } +.intro { color: var(--cp-text-muted); margin: 16px 0 20px; } +.intro strong { color: var(--cp-text); } +.toolbar { display: flex; justify-content: space-between; gap: 8px; margin-bottom: 16px; } +button { font: inherit; font-weight: 600; border: 1px solid var(--cp-border); border-radius: 10px; padding: 10px 12px; background: var(--cp-surface); color: var(--cp-text); cursor: pointer; min-height: 44px; } +button:hover:enabled { border-color: var(--cp-accent); background: var(--cp-accent-soft); } +button:disabled { opacity: .45; cursor: not-allowed; } +button.primary { background: var(--cp-accent); color: var(--cp-accent-fg); border-color: var(--cp-accent); } +button.primary:hover:enabled { background: var(--cp-accent-hover); } +button.primary kbd { color: var(--cp-accent-fg); background: var(--cp-accent-hover); border-color: var(--cp-accent-hover); } +:focus-visible { outline: 3px solid var(--cp-accent); outline-offset: 4px; } +kbd { display: inline-block; font: 11px Consolas, "Courier New", Courier, monospace; padding: 2px 5px; background: var(--cp-surface-soft); color: var(--cp-text-muted); border: 1px solid var(--cp-border); border-radius: 4px; } +.toolbar kbd { margin-left: 4px; } +.board-shell { position: relative; container-type: inline-size; } +.board { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 10px; padding: 10px; border-radius: 16px; background: var(--cp-border); touch-action: none; user-select: none; } +.game-over { position: absolute; inset: 0; z-index: 2; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 20px; padding: 24px; border-radius: 16px; background: var(--cp-overlay); text-align: center; pointer-events: none; } +.game-over[hidden] { display: none; } +.game-over strong { max-width: 100%; font-size: clamp(32px, 18cqw, 80px); font-weight: 900; line-height: .95; letter-spacing: -2px; color: var(--cp-accent); text-wrap: balance; } +.game-over span { color: var(--cp-text); font-weight: 600; } +.tile { aspect-ratio: 1; display: flex; align-items: center; justify-content: center; border-radius: 10px; font-size: clamp(20px, 7vw, 36px); font-weight: 750; background: var(--cp-surface-soft); font-variant-numeric: tabular-nums; } +.tile[data-level="1"] { background: var(--cp-bg-elevated); } +.tile[data-level="2"] { background: var(--cp-surface); } +.tile[data-level="3"], .tile[data-level="4"] { background: var(--cp-highlight); color: var(--cp-text); box-shadow: inset 0 0 0 2px var(--cp-accent); } +.tile[data-level="5"], .tile[data-level="6"], .tile[data-level="7"] { background: var(--cp-accent); color: var(--cp-accent-fg); } +.tile[data-level="8"], .tile[data-level="9"], .tile[data-level="10"] { background: var(--cp-accent-hover); color: var(--cp-accent-fg); box-shadow: inset 0 0 0 3px var(--cp-accent); } +.tile[data-level="11"] { background: var(--cp-text); color: var(--cp-bg); box-shadow: inset 0 0 0 3px var(--cp-accent); } +.tile.large { font-size: clamp(14px, 5vw, 26px); } +.motion-layer { position: absolute; inset: 0; z-index: 1; pointer-events: none; } +.motion-tile { position: absolute; margin: 0; } +.tile.in-flight { color: transparent; background: var(--cp-surface-soft); box-shadow: none; outline: none; } +.tile.in-flight::after { display: none; } +.tile.spawned { position: relative; background: var(--cp-highlight); color: var(--cp-text); outline: 3px solid var(--cp-accent); outline-offset: -3px; animation: spawn-pop 360ms ease-out; } +.tile.spawned::after { content: "NEW"; position: absolute; top: 4px; right: 4px; padding: 2px 3px; border-radius: 4px; background: var(--cp-accent); color: var(--cp-accent-fg); font-size: clamp(7px, 2.4cqw, 10px); font-weight: 700; line-height: 1; pointer-events: none; } +@keyframes spawn-pop { from { transform: scale(.8); } to { transform: scale(1); } } +.game-info { display: flex; justify-content: space-between; align-items: baseline; gap: 12px; margin: 16px 0; min-height: 24px; } +#status { font-weight: 600; } +#moves { color: var(--cp-text-muted); white-space: nowrap; font-size: 12px; } +#error { padding: 12px; border: 1px solid var(--cp-danger); border-radius: 10px; color: var(--cp-danger); background: var(--cp-surface); } +.dpad { display: flex; justify-content: center; gap: 8px; margin: 20px 0; } +.dpad button { width: 48px; font-size: 20px; } +footer { color: var(--cp-text-muted); font-size: 12px; border-top: 1px solid var(--cp-border); padding-top: 12px; } +footer p { margin: 8px 0; } +.hint { color: var(--cp-text-soft); font-size: 11px; } +dialog { max-width: min(400px, calc(100vw - 32px)); padding: 24px; background: var(--cp-surface); color: var(--cp-text); border: 1px solid var(--cp-border); border-radius: 16px; } +dialog::backdrop { background: var(--cp-overlay); } +dialog h2 { margin-top: 0; } +dialog form { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 20px; } +@media (max-width: 340px) { + main { padding: 20px 12px; } + .board { gap: 6px; padding: 6px; } + .score-card { min-width: 56px; padding: 8px; } + .score-card strong { font-size: 16px; } + .toolbar kbd { display: none; } +} +@media (prefers-reduced-motion: reduce) { .tile.spawned { animation: none; } } diff --git a/plugins/keyboard-2048/README.md b/plugins/keyboard-2048/README.md new file mode 100644 index 0000000000..6ea7ec7130 --- /dev/null +++ b/plugins/keyboard-2048/README.md @@ -0,0 +1,26 @@ +# Keyboard 2048 Canvas + +Play 2048 in a Copilot canvas with keyboard and touch controls, undo, and session saves. + +## Installation + +```bash +copilot plugin install keyboard-2048@awesome-copilot +``` + +## Development + +From `extensions/keyboard-2048`, run the lifecycle, game, and server tests with Node.js 22.15 or later: + +```bash +node --test extension.test.mjs game.test.mjs server.test.mjs +``` + +## Source + +Created by [geniodelmale](https://github.com/geniodelmale). The source project is +[2048 Keyboard Canvas](https://github.com/geniodelmale/2048-keyboard-canvas). + +## License + +MIT diff --git a/plugins/keyboard-2048/plugin.json b/plugins/keyboard-2048/plugin.json new file mode 100644 index 0000000000..d9e6c1ddf5 --- /dev/null +++ b/plugins/keyboard-2048/plugin.json @@ -0,0 +1,30 @@ +{ + "$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json", + "name": "keyboard-2048", + "description": "Play 2048 in a Copilot canvas with keyboard and touch controls, undo, and session saves.", + "version": "1.0.0", + "author": { + "name": "geniodelmale", + "url": "https://github.com/geniodelmale" + }, + "repository": "https://github.com/geniodelmale/2048-keyboard-canvas", + "license": "MIT", + "keywords": [ + "2048", + "copilot-canvas", + "game", + "keyboard", + "puzzle", + "undo" + ], + "extensions": { + "com.github.copilot": { + "logo": "assets/preview.png" + }, + "com.github.awesome-copilot": { + "extensions": [ + "./extensions/keyboard-2048" + ] + } + } +}