clone

minesweeper as an mcp server: agents start games, play moves in batches and watch the board

main
mcpsweeper / src / repository.ts
repository.ts 5.4K
1import {2 countSubmittedTiles,3 decodeBits,4 encodeBits,5 type BatchAction,6 type GameState,7 type MoveEvent,8} from "./game.ts";910type GameRow = {11 id: string;12 seed: string;13 width: number;14 height: number;15 mine_count: number;16 mines: string;17 revealed: string;18 initial_revealed: string;19 flags: string;20 status: "playing" | "won" | "lost";21 revision: number;22 exploded_index: number | null;23 certified_no_guess: number;24 created_at: string;25 updated_at: string;26 slug: string | null;27};2829type MoveRow = {30 action_json: string;31 event_json: string;32};3334function fromMoveRow(row: MoveRow): { action: BatchAction; event: MoveEvent } {35 const action = JSON.parse(row.action_json) as BatchAction;36 const storedEvent = JSON.parse(row.event_json) as MoveEvent;37 const event = Number.isInteger(storedEvent.submittedCount)38 ? storedEvent39 : { ...storedEvent, submittedCount: countSubmittedTiles(action) };40 return { action, event };41}4243function fromRow(row: GameRow): GameState {44 const revealed = decodeBits(row.revealed);45 const storedInitial = decodeBits(row.initial_revealed);46 return {47 id: row.id,48 seed: row.seed,49 width: row.width,50 height: row.height,51 mines: decodeBits(row.mines),52 revealed,53 initialRevealed: storedInitial.length === revealed.length ? storedInitial : revealed.slice(),54 flags: decodeBits(row.flags),55 status: row.status,56 revision: row.revision,57 explodedIndex: row.exploded_index,58 certifiedNoGuess: row.certified_no_guess === 1,59 createdAt: row.created_at,60 updatedAt: row.updated_at,61 ...(row.slug === null ? {} : { slug: row.slug }),62 };63}6465export async function insertGame(db: D1Database, state: GameState, mineCount: number): Promise<void> {66 await db67 .prepare(68 `INSERT INTO games (69 id, seed, width, height, mine_count, mines, revealed, initial_revealed, flags, status,70 revision, exploded_index, certified_no_guess, created_at, updated_at, slug71 ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16)`,72 )73 .bind(74 state.id,75 state.seed,76 state.width,77 state.height,78 mineCount,79 encodeBits(state.mines),80 encodeBits(state.revealed),81 encodeBits(state.initialRevealed),82 encodeBits(state.flags),83 state.status,84 state.revision,85 state.explodedIndex,86 state.certifiedNoGuess ? 1 : 0,87 state.createdAt,88 state.updatedAt,89 state.slug ?? null,90 )91 .run();92}9394export async function findGameBySlug(db: D1Database, slug: string): Promise<GameState | null> {95 const row = await db.prepare("SELECT * FROM games WHERE slug = ?1").bind(slug).first<GameRow>();96 return row === null ? null : fromRow(row);97}9899export async function listAllEvents(db: D1Database, gameId: string): Promise<MoveEvent[]> {100 const events: MoveEvent[] = [];101 let sinceRevision = 0;102 while (true) {103 const page = await listEvents(db, gameId, sinceRevision);104 events.push(...page);105 const last = page.at(-1);106 if (last === undefined || page.length < 200) return events;107 sinceRevision = last.revision;108 }109}110111export async function getGame(db: D1Database, gameId: string): Promise<GameState | null> {112 const row = await db.prepare("SELECT * FROM games WHERE id = ?1").bind(gameId).first<GameRow>();113 return row === null ? null : fromRow(row);114}115116export async function saveMove(117 db: D1Database,118 previousRevision: number,119 state: GameState,120 action: BatchAction,121 event: MoveEvent,122): Promise<void> {123 const results = await db.batch([124 db125 .prepare(126 `UPDATE games SET127 revealed = ?1, flags = ?2, status = ?3, revision = ?4,128 exploded_index = ?5, updated_at = ?6129 WHERE id = ?7 AND revision = ?8`,130 )131 .bind(132 encodeBits(state.revealed),133 encodeBits(state.flags),134 state.status,135 state.revision,136 state.explodedIndex,137 state.updatedAt,138 state.id,139 previousRevision,140 ),141 db142 .prepare(143 `INSERT INTO moves (game_id, revision, intent, action_json, event_json, created_at)144 VALUES (?1, ?2, ?3, ?4, ?5, ?6)`,145 )146 .bind(state.id, state.revision, action.intent, JSON.stringify(action), JSON.stringify(event), event.createdAt),147 ]);148 const update = results[0];149 if (update === undefined || update.meta.changes !== 1) {150 throw new Error("Revision conflict: observe the game again before retrying.");151 }152}153154export async function findMove(155 db: D1Database,156 gameId: string,157 revision: number,158): Promise<{ action: BatchAction; event: MoveEvent } | null> {159 const row = await db160 .prepare("SELECT action_json, event_json FROM moves WHERE game_id = ?1 AND revision = ?2")161 .bind(gameId, revision)162 .first<MoveRow>();163 if (row === null) return null;164 return fromMoveRow(row);165}166167export async function listEvents(db: D1Database, gameId: string, sinceRevision: number): Promise<MoveEvent[]> {168 const result = await db169 .prepare(170 "SELECT action_json, event_json FROM moves WHERE game_id = ?1 AND revision > ?2 ORDER BY revision ASC LIMIT 200",171 )172 .bind(gameId, sinceRevision)173 .all<MoveRow>();174 return result.results.map((row) => fromMoveRow(row).event);175}176177export async function deleteGamesInactiveBefore(db: D1Database, cutoff: string): Promise<number> {178 const result = await db179 .prepare("DELETE FROM games WHERE updated_at < ?1 RETURNING id")180 .bind(cutoff)181 .all<{ id: string }>();182 return result.results.length;183}