KEYBOARD EDITION
2048.
Use arrows or WASD to move tiles.
Merge equal tiles to reach 2048.
or N to start again. +
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 @@ + + +
+ + + +KEYBOARD EDITION
Use arrows or WASD to move tiles.
Merge equal tiles to reach 2048.
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"
+ ]
+ }
+ }
+}