import { Server } from '@modelcontextprotocol/sdk/server/index.js';
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
import {
CallToolRequestSchema,
ListToolsRequestSchema,
ListResourcesRequestSchema,
ReadResourceRequestSchema,
ListPromptsRequestSchema,
GetPromptRequestSchema,
} from '@modelcontextprotocol/sdk/types.js';
import { draftStore } from './store/draft-store.js';
import { solve, slideWithHazards } from './core/solver.js';
import { renderLevel, formatSolverResult } from './core/ascii-renderer.js';
import { DIRECTION_VECTORS, PLAYER_MAX_HEALTH, HOT_COALS_DAMAGE } from './core/constants.js';
import {
buildUnsolvableDiagnostics,
computeDirectionBalance,
exploreReachability,
suggestStopPoints,
traceMovePositions,
} from './core/design-assist.js';
import { generateSeedLayout, suggestSkeletonLayouts, type SeedDifficulty, type SeedPattern } from './core/layout-seeds.js';
import {
validatePosition,
validateNotOnStartOrGoal,
validatePuzzleData,
validateStartGoalDifferent,
validateGoalPosition,
} from './core/validator.js';
import { Direction, ObstacleType, DraftState, Position } from './core/types.js';
import { evaluateQualityGate, formatQualityGateReport } from './core/quality-gate.js';
import type { QualityGateReport } from './core/quality-gate.js';
import {
saveDraft,
loadDraft,
listFirestoreDrafts,
deleteFirestoreDraft,
publishLevel,
previewLevel,
listMyPublishedLevels,
getMyPublishedLevel,
unpublishLevel,
restorePublishedLevel,
} from './tools/marketplace.js';
import { authenticate, getAuthStatusMessage } from './auth/token-manager.js';
import {
CAMPAIGN_LEVEL_EXAMPLES,
getCampaignExample,
getCampaignExamplesByDifficulty,
type ExampleDifficulty,
} from './data/campaign-examples.js';
export function createServer(): Server {
const server = new Server(
{
name: 'ice-puzzle-mcp',
version: '1.0.0',
},
{
capabilities: {
tools: {},
resources: {},
prompts: {},
},
}
);
// Helper functions
function requireDraft(): DraftState {
const draft = draftStore.getCurrentDraft();
if (!draft) throw new Error('No active draft. Use create_level first.');
return draft;
}
function autoSolveAndVisualize(showSolution = false): string {
const puzzleData = draftStore.exportPuzzleData();
if (puzzleData) {
const result = solve(puzzleData);
draftStore.updateDraft({ lastSolverResult: result, isDirty: false }, { trackHistory: false });
if (result.solvable) {
draftStore.markCurrentAsLastSolvable();
}
}
const current = draftStore.getCurrentDraft()!;
const viz = renderLevel(current, { showCoords: true, showSolution, solution: current.lastSolverResult?.solution });
return viz;
}
function formatDirectionBalance(solution: Direction[] | null): string {
const balance = computeDirectionBalance(solution);
const totalMoves = (solution || []).length;
return `Direction balance (total ${totalMoves}): Up ${balance.up}, Down ${balance.down}, Left ${balance.left}, Right ${balance.right}`;
}
function formatHistoryStatusLine(): string {
const status = draftStore.getHistoryStatus();
return `History: undo=${status.undoDepth}, redo=${status.redoDepth}`;
}
function detectMechanicFamilies(puzzleData: ReturnType<typeof draftStore.exportPuzzleData>): string[] {
if (!puzzleData) return [];
const mechanics = new Set<string>();
if (puzzleData.obstacles.some((o) => o.type === 'lava')) mechanics.add('lava');
if (puzzleData.obstacles.some((o) => o.type === 'hot_coals' || o.type === 'spike')) mechanics.add('hot_coals');
if (puzzleData.thinIceTiles?.length) mechanics.add('thin_ice');
if (puzzleData.warps?.length) mechanics.add('warp');
if (puzzleData.pushableRocks?.length) mechanics.add('pushable_rock');
if (puzzleData.pressurePlate) mechanics.add('pressure_plate');
if (puzzleData.barrier) mechanics.add('barrier');
return Array.from(mechanics).sort();
}
function computeLayoutFingerprint(puzzleData: ReturnType<typeof draftStore.exportPuzzleData>): string | null {
if (!puzzleData) return null;
const normalizePositions = (positions: Position[] | undefined): string[] => {
if (!positions || positions.length === 0) return [];
return positions
.map((position) => `${position.x},${position.y}`)
.sort();
};
const normalized = {
width: puzzleData.width,
height: puzzleData.height,
start: `${puzzleData.start.x},${puzzleData.start.y}`,
goal: `${puzzleData.goal.x},${puzzleData.goal.y}`,
obstacles: puzzleData.obstacles
.map((obstacle) => `${obstacle.x},${obstacle.y},${obstacle.type}`)
.sort(),
warps: (puzzleData.warps || [])
.map((warp) => `${warp.id}:${warp.x},${warp.y}`)
.sort(),
thinIce: normalizePositions(puzzleData.thinIceTiles),
pushableRocks: normalizePositions(puzzleData.pushableRocks),
pressurePlate: puzzleData.pressurePlate ? `${puzzleData.pressurePlate.x},${puzzleData.pressurePlate.y}` : null,
barrier: puzzleData.barrier ? `${puzzleData.barrier.x},${puzzleData.barrier.y}` : null,
};
return JSON.stringify(normalized);
}
function getChangedMechanicFamilies(baseline: string[], current: string[]): string[] {
const changed = new Set<string>();
const baselineSet = new Set(baseline);
const currentSet = new Set(current);
for (const mechanic of baselineSet) {
if (!currentSet.has(mechanic)) changed.add(mechanic);
}
for (const mechanic of currentSet) {
if (!baselineSet.has(mechanic)) changed.add(mechanic);
}
return Array.from(changed).sort();
}
function classifySolveComplexity(visitedCount: number): string {
if (visitedCount >= 10000) return 'extreme';
if (visitedCount >= 4000) return 'very high';
if (visitedCount >= 1500) return 'high';
if (visitedCount >= 500) return 'moderate';
return 'low';
}
function formatDraftSummary(draft: DraftState): string {
const solverInfo = draft.lastSolverResult ? formatSolverResult(draft.lastSolverResult) : 'Not yet solved';
return [
`Name: ${draft.name}`,
`Grid: ${draft.gridWidth}x${draft.gridHeight}`,
`Par: ${draft.par > 0 ? draft.par : 'not set'}`,
`Start: (${draft.startPosition.x}, ${draft.startPosition.y})`,
`Goal: (${draft.goalPosition.x}, ${draft.goalPosition.y})`,
`Obstacles: ${draft.obstacles.length}`,
`Warps: ${draft.warpPairs.length} pairs`,
`Thin Ice: ${draft.thinIceTiles.length}`,
`Pushable Rocks: ${draft.pushableRocks.length}`,
draft.pressurePlate ? `Pressure Plate: (${draft.pressurePlate.x}, ${draft.pressurePlate.y})` : '',
draft.barrier ? `Barrier: (${draft.barrier.x}, ${draft.barrier.y})` : '',
`Solver: ${solverInfo}`,
].filter(Boolean).join('\n');
}
function formatCampaignExampleLine(
example: (typeof CAMPAIGN_LEVEL_EXAMPLES)[number],
includeSolutions: boolean
): string {
const solutionText = includeSolutions
? `\n solution: ${example.solution.join(' → ')}`
: '';
const mechanics = example.mechanics.join(', ') || 'none';
const drivers = example.hardnessDrivers.join('; ') || 'N/A';
return `L${example.levelNumber}: ${example.name} [${example.difficulty}]` +
`\n par=${example.par} shortest=${example.shortestMoves} mechanics=${mechanics}` +
`${solutionText}` +
`\n hardness: ${drivers}` +
`\n takeaway: ${example.designTakeaway}`;
}
function runQualityGate(requirePar: boolean): { report: QualityGateReport; text: string } {
const puzzleData = draftStore.exportPuzzleData();
if (!puzzleData) {
const fallback: QualityGateReport = {
solvable: false,
shortest: null,
par: 0,
parIsSet: false,
parEqualsShortest: false,
shorterThanParExists: false,
longerThanParConfigured: false,
tiedOptimalPathsCount: null,
timeoutRuleMatchesRuntime: true,
warpParityPass: true,
hotCoalsDiagnostics: {
hotCoalsTileCount: 0,
hotCoalsHitsOnShortest: 0,
shortestIfHotCoalsWereRocks: null,
shortestPathUsesHotCoalsShortcut: false,
},
failures: ['Could not export level data.'],
warnings: [],
pass: false,
};
return { report: fallback, text: formatQualityGateReport(fallback) };
}
const result = solve(puzzleData);
const report = evaluateQualityGate(puzzleData, result, { requirePar });
draftStore.updateDraft({ lastSolverResult: result, isDirty: false }, { trackHistory: false });
if (result.solvable) {
draftStore.markCurrentAsLastSolvable();
}
return { report, text: formatQualityGateReport(report) };
}
// ==================== TOOL HANDLERS ====================
server.setRequestHandler(ListToolsRequestSchema, async () => ({
tools: [
{
name: 'create_level',
description: 'Create a new empty ice puzzle level',
inputSchema: {
type: 'object',
properties: {
name: { type: 'string', description: 'Level name' },
width: { type: 'number', description: 'Grid width (5-25, default 10)', minimum: 5, maximum: 25 },
height: { type: 'number', description: 'Grid height (5-25, default 10)', minimum: 5, maximum: 25 },
},
required: ['name'],
},
},
{
name: 'get_level',
description: 'Get the current working draft level with details and visualization',
inputSchema: { type: 'object', properties: {} },
},
{
name: 'list_drafts',
description: 'List all locally saved draft snapshots',
inputSchema: { type: 'object', properties: {} },
},
{
name: 'save_local_draft',
description: 'Save a snapshot of the current draft in local MCP memory',
inputSchema: { type: 'object', properties: {} },
},
{
name: 'load_local_draft',
description: 'Load a locally saved draft snapshot by ID',
inputSchema: {
type: 'object',
properties: { draftId: { type: 'string', description: 'Local draft ID to load' } },
required: ['draftId'],
},
},
{
name: 'delete_draft',
description: 'Delete a locally saved draft snapshot',
inputSchema: {
type: 'object',
properties: { draftId: { type: 'string', description: 'Draft ID to delete' } },
required: ['draftId'],
},
},
{
name: 'import_level',
description: 'Import a level from PuzzleData JSON',
inputSchema: {
type: 'object',
properties: { puzzleData: { type: 'object', description: 'PuzzleData JSON object' } },
required: ['puzzleData'],
},
},
{
name: 'export_level',
description: 'Export the current working draft as PuzzleData JSON',
inputSchema: { type: 'object', properties: {} },
},
{
name: 'rename_level',
description: 'Rename the current working draft without recreating it',
inputSchema: {
type: 'object',
properties: {
name: { type: 'string', description: 'New level name' },
},
required: ['name'],
},
},
{
name: 'undo',
description: 'Undo the last edit operation',
inputSchema: { type: 'object', properties: {} },
},
{
name: 'redo',
description: 'Redo the most recently undone operation',
inputSchema: { type: 'object', properties: {} },
},
{
name: 'revert_to_last_solvable',
description: 'Restore the latest known solvable state',
inputSchema: { type: 'object', properties: {} },
},
{
name: 'place_tile',
description: 'Place a tile (rock, lava, hot_coals, or spike) at position. Auto-solves.',
inputSchema: {
type: 'object',
properties: {
x: { type: 'number', description: 'X coordinate' },
y: { type: 'number', description: 'Y coordinate' },
type: { type: 'string', enum: ['rock', 'lava', 'hot_coals', 'spike'], description: 'Tile type' },
},
required: ['x', 'y', 'type'],
},
},
{
name: 'remove_tile',
description: 'Remove any tile at position. Auto-solves.',
inputSchema: {
type: 'object',
properties: {
x: { type: 'number', description: 'X coordinate' },
y: { type: 'number', description: 'Y coordinate' },
},
required: ['x', 'y'],
},
},
{
name: 'move_tile',
description: 'Move one tile/special element from one coordinate to another. Auto-solves.',
inputSchema: {
type: 'object',
properties: {
fromX: { type: 'number', description: 'Source X coordinate' },
fromY: { type: 'number', description: 'Source Y coordinate' },
toX: { type: 'number', description: 'Destination X coordinate' },
toY: { type: 'number', description: 'Destination Y coordinate' },
},
required: ['fromX', 'fromY', 'toX', 'toY'],
},
},
{
name: 'place_tiles_batch',
description: 'Place multiple tiles at once. Auto-solves.',
inputSchema: {
type: 'object',
properties: {
tiles: {
type: 'array',
items: {
type: 'object',
properties: {
x: { type: 'number' },
y: { type: 'number' },
type: { type: 'string', enum: ['rock', 'lava', 'hot_coals', 'spike'] },
},
required: ['x', 'y', 'type'],
},
description: 'Array of tiles to place',
},
},
required: ['tiles'],
},
},
{
name: 'fill_region',
description: 'Fill a rectangular region with a tile type. Auto-solves.',
inputSchema: {
type: 'object',
properties: {
x1: { type: 'number', description: 'Top-left X' },
y1: { type: 'number', description: 'Top-left Y' },
x2: { type: 'number', description: 'Bottom-right X' },
y2: { type: 'number', description: 'Bottom-right Y' },
type: { type: 'string', enum: ['rock', 'lava', 'hot_coals', 'spike'], description: 'Tile type' },
},
required: ['x1', 'y1', 'x2', 'y2', 'type'],
},
},
{
name: 'clear_region',
description: 'Clear all tiles in a rectangular region. Auto-solves.',
inputSchema: {
type: 'object',
properties: {
x1: { type: 'number', description: 'Top-left X' },
y1: { type: 'number', description: 'Top-left Y' },
x2: { type: 'number', description: 'Bottom-right X' },
y2: { type: 'number', description: 'Bottom-right Y' },
},
required: ['x1', 'y1', 'x2', 'y2'],
},
},
{
name: 'set_grid_size',
description: 'Resize the level grid. Elements outside new bounds are removed.',
inputSchema: {
type: 'object',
properties: {
width: { type: 'number', minimum: 5, maximum: 25, description: 'New grid width' },
height: { type: 'number', minimum: 5, maximum: 25, description: 'New grid height' },
},
required: ['width', 'height'],
},
},
{
name: 'set_start',
description: 'Set the player start position',
inputSchema: {
type: 'object',
properties: {
x: { type: 'number', description: 'X coordinate' },
y: { type: 'number', description: 'Y coordinate' },
},
required: ['x', 'y'],
},
},
{
name: 'set_goal',
description: 'Set the goal position (can be on edge/wall)',
inputSchema: {
type: 'object',
properties: {
x: { type: 'number', description: 'X coordinate' },
y: { type: 'number', description: 'Y coordinate' },
},
required: ['x', 'y'],
},
},
{
name: 'set_par',
description: 'Set explicit par (target shortest optimal move count) for the current draft',
inputSchema: {
type: 'object',
properties: {
par: { type: 'number', minimum: 1, description: 'Par move count (must be positive integer)' },
},
required: ['par'],
},
},
{
name: 'set_par_to_shortest',
description: 'Solve and set par to the current solver shortest path length',
inputSchema: { type: 'object', properties: {} },
},
{
name: 'clear_level',
description: 'Clear all elements from the level, keeping grid size',
inputSchema: { type: 'object', properties: {} },
},
{
name: 'add_warp_pair',
description: 'Add a warp portal pair connecting two positions',
inputSchema: {
type: 'object',
properties: {
x1: { type: 'number', description: 'First warp X' },
y1: { type: 'number', description: 'First warp Y' },
x2: { type: 'number', description: 'Second warp X' },
y2: { type: 'number', description: 'Second warp Y' },
},
required: ['x1', 'y1', 'x2', 'y2'],
},
},
{
name: 'remove_warp',
description: 'Remove a warp pair by ID',
inputSchema: {
type: 'object',
properties: { warpId: { type: 'string', description: 'Warp pair ID (e.g., "warp_1")' } },
required: ['warpId'],
},
},
{
name: 'add_thin_ice',
description: 'Add thin ice tiles (break after crossing)',
inputSchema: {
type: 'object',
properties: {
positions: {
type: 'array',
items: {
type: 'object',
properties: { x: { type: 'number' }, y: { type: 'number' } },
required: ['x', 'y'],
},
description: 'Positions for thin ice',
},
},
required: ['positions'],
},
},
{
name: 'remove_thin_ice',
description: 'Remove thin ice tiles',
inputSchema: {
type: 'object',
properties: {
positions: {
type: 'array',
items: {
type: 'object',
properties: { x: { type: 'number' }, y: { type: 'number' } },
required: ['x', 'y'],
},
description: 'Positions to remove',
},
},
required: ['positions'],
},
},
{
name: 'add_pushable_rock',
description: 'Add pushable rocks',
inputSchema: {
type: 'object',
properties: {
positions: {
type: 'array',
items: {
type: 'object',
properties: { x: { type: 'number' }, y: { type: 'number' } },
required: ['x', 'y'],
},
description: 'Positions for pushable rocks',
},
},
required: ['positions'],
},
},
{
name: 'remove_pushable_rock',
description: 'Remove pushable rocks',
inputSchema: {
type: 'object',
properties: {
positions: {
type: 'array',
items: {
type: 'object',
properties: { x: { type: 'number' }, y: { type: 'number' } },
required: ['x', 'y'],
},
description: 'Positions to remove',
},
},
required: ['positions'],
},
},
{
name: 'set_pressure_plate',
description: 'Set pressure plate position (deactivates barrier)',
inputSchema: {
type: 'object',
properties: {
x: { type: 'number', description: 'X coordinate' },
y: { type: 'number', description: 'Y coordinate' },
},
required: ['x', 'y'],
},
},
{
name: 'remove_pressure_plate',
description: 'Remove the pressure plate',
inputSchema: { type: 'object', properties: {} },
},
{
name: 'set_barrier',
description: 'Set barrier position (kills if pressure plate not activated)',
inputSchema: {
type: 'object',
properties: {
x: { type: 'number', description: 'X coordinate' },
y: { type: 'number', description: 'Y coordinate' },
},
required: ['x', 'y'],
},
},
{
name: 'remove_barrier',
description: 'Remove the barrier',
inputSchema: { type: 'object', properties: {} },
},
{
name: 'solve_level',
description: 'Run BFS solver on current level with direction balance and unsolvable diagnostics',
inputSchema: { type: 'object', properties: {} },
},
{
name: 'test_placement',
description: 'Dry-run placing one tile and solve without modifying the draft',
inputSchema: {
type: 'object',
properties: {
x: { type: 'number', description: 'X coordinate' },
y: { type: 'number', description: 'Y coordinate' },
type: { type: 'string', enum: ['rock', 'lava', 'hot_coals', 'spike'], description: 'Tile type' },
},
required: ['x', 'y', 'type'],
},
},
{
name: 'reachable_from',
description: 'Analyze all reachable landing positions from a starting coordinate',
inputSchema: {
type: 'object',
properties: {
x: { type: 'number', description: 'Start X coordinate' },
y: { type: 'number', description: 'Start Y coordinate' },
},
required: ['x', 'y'],
},
},
{
name: 'suggest_stop_points',
description: 'Suggest blocker placements to stop a slide at intermediate points',
inputSchema: {
type: 'object',
properties: {
fromX: { type: 'number', description: 'Start X coordinate' },
fromY: { type: 'number', description: 'Start Y coordinate' },
direction: { type: 'string', enum: ['up', 'down', 'left', 'right'], description: 'Slide direction' },
},
required: ['fromX', 'fromY', 'direction'],
},
},
{
name: 'seed_layout_pattern',
description: 'Apply a starter rock skeleton pattern to bootstrap solvable layout design',
inputSchema: {
type: 'object',
properties: {
pattern: {
type: 'string',
enum: ['columns_with_gaps', 'switchback_lanes'],
description: 'Starter pattern to place',
},
difficulty: {
type: 'string',
enum: ['easy', 'medium', 'hard'],
description: 'Difficulty-oriented pattern density (default: medium)',
},
clearExisting: {
type: 'boolean',
description: 'Clear current mechanics first (default: false)',
},
},
required: ['pattern'],
},
},
{
name: 'suggest_skeleton_layout',
description: 'Suggest starter rock skeleton plans without modifying the current draft',
inputSchema: {
type: 'object',
properties: {
difficulty: {
type: 'string',
enum: ['easy', 'medium', 'hard'],
description: 'Difficulty-oriented plan density (default: medium)',
},
},
},
},
{
name: 'simulate_move',
description: 'Simulate a single slide move from a position',
inputSchema: {
type: 'object',
properties: {
direction: { type: 'string', enum: ['up', 'down', 'left', 'right'], description: 'Slide direction' },
fromX: { type: 'number', description: 'Starting X (defaults to start position)' },
fromY: { type: 'number', description: 'Starting Y (defaults to start position)' },
},
required: ['direction'],
},
},
{
name: 'simulate_playthrough',
description: 'Simulate a full sequence of moves',
inputSchema: {
type: 'object',
properties: {
moves: {
type: 'array',
items: { type: 'string', enum: ['up', 'down', 'left', 'right'] },
description: 'Sequence of moves',
},
},
required: ['moves'],
},
},
{
name: 'analyze_difficulty',
description: 'Analyze current level difficulty and characteristics',
inputSchema: { type: 'object', properties: {} },
},
{
name: 'validate_quality_gate',
description: 'Run canonical parity/quality checks (par-shortest, timeout rule, warp parity, hot coals shortcut diagnostics)',
inputSchema: {
type: 'object',
properties: {
requirePar: { type: 'boolean', description: 'Fail if par is not explicitly set (default false)' },
},
},
},
{
name: 'check_publish_readiness',
description: 'Check publish blockers: auth status + strict quality gate',
inputSchema: { type: 'object', properties: {} },
},
{
name: 'visualize_level',
description: 'Show ASCII art visualization of current level',
inputSchema: {
type: 'object',
properties: {
showSolution: { type: 'boolean', description: 'Show solution path overlay' },
showCoords: { type: 'boolean', description: 'Show coordinate numbers (default: true)' },
showStepNumbers: { type: 'boolean', description: 'Overlay step markers for solved path stops' },
},
},
},
{
name: 'get_game_rules',
description: 'Get comprehensive ice puzzle game rules and design guide',
inputSchema: { type: 'object', properties: {} },
},
{
name: 'get_tile_types',
description: 'Get all tile types with behavior descriptions',
inputSchema: { type: 'object', properties: {} },
},
{
name: 'get_level_requirements',
description: 'Get requirements for publishable levels',
inputSchema: { type: 'object', properties: {} },
},
{
name: 'interaction_faq',
description: 'Get quick-reference interaction outcomes for adjacent mechanic combinations',
inputSchema: { type: 'object', properties: {} },
},
{
name: 'list_campaign_examples',
description: 'List campaign level examples by difficulty tier (easy/medium/hard) with solver solutions',
inputSchema: {
type: 'object',
properties: {
difficulty: {
type: 'string',
enum: ['easy', 'medium', 'hard'],
description: 'Optional difficulty filter',
},
includeSolutions: {
type: 'boolean',
description: 'Include move-by-move shortest solution in the response (default true)',
},
includePuzzleData: {
type: 'boolean',
description: 'Include full PuzzleData JSON for each level (default false)',
},
},
},
},
{
name: 'get_campaign_example',
description: 'Get one campaign level example (by level number or ID) with solution and optional PuzzleData',
inputSchema: {
type: 'object',
properties: {
levelNumber: { type: 'number', description: 'Campaign level number (1-based)' },
levelId: { type: 'string', description: 'Campaign level id (e.g., "level_9")' },
includePuzzleData: {
type: 'boolean',
description: 'Include full PuzzleData JSON in the response (default true)',
},
},
},
},
{
name: 'save_draft',
description: 'Save the current draft to Firebase (requires authentication)',
inputSchema: { type: 'object', properties: {} },
},
{
name: 'load_draft',
description: 'Load a draft from Firebase by ID',
inputSchema: {
type: 'object',
properties: { draftId: { type: 'string', description: 'Draft ID to load' } },
required: ['draftId'],
},
},
{
name: 'delete_remote_draft',
description: 'Delete a remote draft from Firebase by ID',
inputSchema: {
type: 'object',
properties: { draftId: { type: 'string', description: 'Draft ID to delete' } },
required: ['draftId'],
},
},
{
name: 'list_remote_drafts',
description: 'List all remote drafts from Firebase',
inputSchema: { type: 'object', properties: {} },
},
{
name: 'list_my_published_levels',
description: 'List your marketplace levels with status filter, sorting, and pagination',
inputSchema: {
type: 'object',
properties: {
statusFilter: {
type: 'string',
enum: ['active', 'unpublished', 'all'],
description: 'Filter by publication status (default: all)',
},
sortBy: {
type: 'string',
enum: ['published_desc', 'published_asc', 'updated_desc', 'updated_asc', 'name_asc', 'name_desc'],
description: 'Sort order (default: published_desc)',
},
limit: {
type: 'number',
minimum: 1,
maximum: 100,
description: 'Page size (default: 20)',
},
cursor: {
type: 'string',
description: 'Cursor from previous list_my_published_levels response',
},
},
},
},
{
name: 'get_my_published_level',
description: 'Get one of your published marketplace levels by ID',
inputSchema: {
type: 'object',
properties: {
levelId: { type: 'string', description: 'Marketplace level ID' },
},
required: ['levelId'],
},
},
{
name: 'unpublish_level',
description: 'Unpublish (soft-delete) one of your marketplace levels by ID',
inputSchema: {
type: 'object',
properties: {
levelId: { type: 'string', description: 'Marketplace level ID' },
reason: { type: 'string', description: 'Optional unpublish reason (max 500 chars)' },
},
required: ['levelId'],
},
},
{
name: 'restore_published_level',
description: 'Restore an unpublished marketplace level back to ACTIVE status',
inputSchema: {
type: 'object',
properties: {
levelId: { type: 'string', description: 'Marketplace level ID' },
},
required: ['levelId'],
},
},
{
name: 'publish_level',
description: 'Publish the current draft as a marketplace level',
inputSchema: {
type: 'object',
properties: {
name: { type: 'string', description: 'Level name (optional)' },
description: { type: 'string', description: 'Level description (optional)' },
},
},
},
{
name: 'preview_level',
description: 'Create or update a shareable preview link without publishing to marketplace',
inputSchema: {
type: 'object',
properties: {
name: { type: 'string', description: 'Preview name override (optional)' },
description: { type: 'string', description: 'Preview description override (optional)' },
},
},
},
{
name: 'auth_status',
description: 'Check Firebase authentication status',
inputSchema: { type: 'object', properties: {} },
},
],
}));
server.setRequestHandler(CallToolRequestSchema, async (request) => {
const { name, arguments: args } = request.params;
try {
switch (name) {
case 'create_level': {
const draft = draftStore.createDraft(
(args as any).name,
(args as any).width,
(args as any).height
);
const viz = renderLevel(draft, { showCoords: true });
return {
content: [
{ type: 'text', text: `Level "${(args as any).name}" created!\n\n${formatDraftSummary(draft)}\n\n${viz}` },
],
};
}
case 'get_level': {
const draft = requireDraft();
if (draft.isDirty || !draft.lastSolverResult) {
const puzzleData = draftStore.exportPuzzleData();
if (puzzleData) {
const result = solve(puzzleData);
draftStore.updateDraft({ lastSolverResult: result, isDirty: false }, { trackHistory: false });
if (result.solvable) {
draftStore.markCurrentAsLastSolvable();
}
}
}
const current = draftStore.getCurrentDraft()!;
const viz = renderLevel(current, {
showCoords: true,
showSolution: true,
solution: current.lastSolverResult?.solution,
});
return { content: [{ type: 'text', text: `${formatDraftSummary(current)}\n\n${viz}` }] };
}
case 'list_drafts': {
const drafts = draftStore.listDrafts();
if (drafts.length === 0) {
return {
content: [{
type: 'text',
text: 'No local draft snapshots yet. Use save_local_draft to store one.',
}],
};
}
const list = drafts
.map((d, i) => `${i + 1}. ${d.name} (${d.id}) - ${d.description || 'No description'}`)
.join('\n');
return { content: [{ type: 'text', text: `Local draft snapshots:\n${list}` }] };
}
case 'save_local_draft': {
const draft = requireDraft();
const id = draftStore.saveDraft();
return {
content: [{
type: 'text',
text: `Saved local snapshot "${draft.name}" as ${id}.`,
}],
};
}
case 'load_local_draft': {
const loaded = draftStore.loadDraft((args as any).draftId);
if (!loaded) {
return {
content: [{
type: 'text',
text: `Local draft ${(args as any).draftId} not found.`,
}],
};
}
const viz = renderLevel(loaded, { showCoords: true });
return {
content: [{
type: 'text',
text: `Loaded local draft "${loaded.name}" (${loaded.id}).\n\n${formatDraftSummary(loaded)}\n\n${viz}`,
}],
};
}
case 'delete_draft': {
const success = draftStore.deleteDraft((args as any).draftId);
return {
content: [
{
type: 'text',
text: success
? `Draft ${(args as any).draftId} deleted.`
: `Draft ${(args as any).draftId} not found.`,
},
],
};
}
case 'import_level': {
const validation = validatePuzzleData((args as any).puzzleData);
if (!validation.valid)
return { content: [{ type: 'text', text: `Invalid puzzle data: ${validation.error}` }] };
const draft = draftStore.importPuzzleData(validation.data!);
const viz = renderLevel(draft, { showCoords: true });
return { content: [{ type: 'text', text: `Level imported!\n\n${formatDraftSummary(draft)}\n\n${viz}` }] };
}
case 'export_level': {
const puzzleData = draftStore.exportPuzzleData();
if (!puzzleData) return { content: [{ type: 'text', text: 'No active draft.' }] };
return { content: [{ type: 'text', text: JSON.stringify(puzzleData, null, 2) }] };
}
case 'rename_level': {
requireDraft();
const { name: newName } = args as any;
const renamed = draftStore.renameCurrentDraft(String(newName));
return {
content: [{
type: 'text',
text: `Renamed level to "${renamed.name}".\n${formatHistoryStatusLine()}`,
}],
};
}
case 'undo': {
requireDraft();
const reverted = draftStore.undo();
if (!reverted) {
return { content: [{ type: 'text', text: 'Nothing to undo.' }] };
}
const viz = autoSolveAndVisualize();
return { content: [{ type: 'text', text: `Undo applied.\n${formatHistoryStatusLine()}\n\n${viz}` }] };
}
case 'redo': {
requireDraft();
const replayed = draftStore.redo();
if (!replayed) {
return { content: [{ type: 'text', text: 'Nothing to redo.' }] };
}
const viz = autoSolveAndVisualize();
return { content: [{ type: 'text', text: `Redo applied.\n${formatHistoryStatusLine()}\n\n${viz}` }] };
}
case 'revert_to_last_solvable': {
requireDraft();
const restored = draftStore.revertToLastSolvable();
if (!restored) {
return {
content: [{
type: 'text',
text: 'No stored solvable snapshot yet. Solve a solvable draft first, then try again.',
}],
};
}
const viz = autoSolveAndVisualize();
return {
content: [{
type: 'text',
text: `Reverted to last solvable state.\n${formatHistoryStatusLine()}\n\n${viz}`,
}],
};
}
case 'place_tile': {
const draft = requireDraft();
const { x, y, type } = args as any;
const posCheck = validatePosition(x, y, draft.gridWidth, draft.gridHeight);
if (!posCheck.valid) return { content: [{ type: 'text', text: posCheck.error! }] };
const sgCheck = validateNotOnStartOrGoal(x, y, draft);
if (!sgCheck.valid) return { content: [{ type: 'text', text: sgCheck.error! }] };
draftStore.placeElement(x, y, type as ObstacleType);
return { content: [{ type: 'text', text: `Placed ${type} at (${x}, ${y})\n\n${autoSolveAndVisualize()}` }] };
}
case 'remove_tile': {
requireDraft();
const { x, y } = args as any;
draftStore.clearPosition(x, y, { trackHistory: true });
return { content: [{ type: 'text', text: `Cleared all elements at (${x}, ${y})\n${formatHistoryStatusLine()}\n\n${autoSolveAndVisualize()}` }] };
}
case 'move_tile': {
const draft = requireDraft();
const { fromX, fromY, toX, toY } = args as any;
const fromCheck = validatePosition(fromX, fromY, draft.gridWidth, draft.gridHeight);
if (!fromCheck.valid) return { content: [{ type: 'text', text: fromCheck.error! }] };
const toCheck = validatePosition(toX, toY, draft.gridWidth, draft.gridHeight);
if (!toCheck.valid) return { content: [{ type: 'text', text: toCheck.error! }] };
const moved = draftStore.moveElement({ x: fromX, y: fromY }, { x: toX, y: toY });
return {
content: [{
type: 'text',
text: `Moved ${moved.movedType} from (${fromX}, ${fromY}) to (${toX}, ${toY}).\n${formatHistoryStatusLine()}\n\n${autoSolveAndVisualize()}`,
}],
};
}
case 'place_tiles_batch': {
const draft = requireDraft();
const { tiles } = args as any;
const errors: string[] = [];
let placed = 0;
draftStore.withSingleHistoryEntry(() => {
for (const tile of tiles) {
const posCheck = validatePosition(tile.x, tile.y, draft.gridWidth, draft.gridHeight);
if (!posCheck.valid) {
errors.push(`(${tile.x},${tile.y}): ${posCheck.error}`);
continue;
}
const sgCheck = validateNotOnStartOrGoal(tile.x, tile.y, draft);
if (!sgCheck.valid) {
errors.push(`(${tile.x},${tile.y}): ${sgCheck.error}`);
continue;
}
draftStore.placeElement(tile.x, tile.y, tile.type as ObstacleType);
placed++;
}
});
const result = `Placed ${placed}/${tiles.length} tiles.${errors.length ? '\nErrors:\n' + errors.join('\n') : ''}`;
const fillRegionTip = placed >= 8 ? '\nTip: fill_region is faster for long columns/rectangles.' : '';
return { content: [{ type: 'text', text: `${result}${fillRegionTip}\n${formatHistoryStatusLine()}\n\n${autoSolveAndVisualize()}` }] };
}
case 'fill_region': {
const draft = requireDraft();
const { x1, y1, x2, y2, type } = args as any;
let placed = 0;
const minX = Math.max(1, Math.min(x1, x2));
const maxX = Math.min(draft.gridWidth - 2, Math.max(x1, x2));
const minY = Math.max(1, Math.min(y1, y2));
const maxY = Math.min(draft.gridHeight - 2, Math.max(y1, y2));
draftStore.withSingleHistoryEntry(() => {
for (let y = minY; y <= maxY; y++) {
for (let x = minX; x <= maxX; x++) {
const sg = validateNotOnStartOrGoal(x, y, draft);
if (!sg.valid) continue;
draftStore.placeElement(x, y, type as ObstacleType);
placed++;
}
}
});
return { content: [{ type: 'text', text: `Filled ${placed} tiles with ${type}\n${formatHistoryStatusLine()}\n\n${autoSolveAndVisualize()}` }] };
}
case 'clear_region': {
const draft = requireDraft();
const { x1, y1, x2, y2 } = args as any;
let cleared = 0;
const minX = Math.max(1, Math.min(x1, x2));
const maxX = Math.min(draft.gridWidth - 2, Math.max(x1, x2));
const minY = Math.max(1, Math.min(y1, y2));
const maxY = Math.min(draft.gridHeight - 2, Math.max(y1, y2));
draftStore.withSingleHistoryEntry(() => {
for (let y = minY; y <= maxY; y++) {
for (let x = minX; x <= maxX; x++) {
draftStore.clearPosition(x, y);
cleared++;
}
}
});
return { content: [{ type: 'text', text: `Cleared all elements from ${cleared} positions\n${formatHistoryStatusLine()}\n\n${autoSolveAndVisualize()}` }] };
}
case 'set_grid_size': {
requireDraft();
const { width, height } = args as any;
const resized = draftStore.resizeGrid(width, height);
return {
content: [{
type: 'text',
text: `Grid resized to ${resized.gridWidth}x${resized.gridHeight}\n\n${autoSolveAndVisualize()}`,
}],
};
}
case 'set_start': {
const draft = requireDraft();
const { x, y } = args as any;
if (typeof x !== 'number' || typeof y !== 'number') {
return { content: [{ type: 'text', text: 'x and y must be numbers.' }] };
}
const check = validatePosition(x, y, draft.gridWidth, draft.gridHeight);
if (!check.valid) return { content: [{ type: 'text', text: check.error! }] };
const sgCheck = validateStartGoalDifferent(x, y, draft.goalPosition.x, draft.goalPosition.y);
if (!sgCheck.valid) return { content: [{ type: 'text', text: sgCheck.error! }] };
draftStore.setStartPosition(x, y);
return { content: [{ type: 'text', text: `Start set to (${x}, ${y})\n\n${autoSolveAndVisualize()}` }] };
}
case 'set_goal': {
const draft = requireDraft();
const { x, y } = args as any;
if (typeof x !== 'number' || typeof y !== 'number') {
return { content: [{ type: 'text', text: 'x and y must be numbers.' }] };
}
const goalCheck = validateGoalPosition(x, y, draft.gridWidth, draft.gridHeight);
if (!goalCheck.valid) return { content: [{ type: 'text', text: goalCheck.error! }] };
const sgCheck = validateStartGoalDifferent(draft.startPosition.x, draft.startPosition.y, x, y);
if (!sgCheck.valid) return { content: [{ type: 'text', text: sgCheck.error! }] };
const edgeNote = goalCheck.isEdge ? ' (edge goal - replaces wall tile)' : '';
draftStore.setGoalPosition(x, y);
return { content: [{ type: 'text', text: `Goal set to (${x}, ${y})${edgeNote}\n\n${autoSolveAndVisualize()}` }] };
}
case 'set_par': {
const draft = requireDraft();
const { par } = args as any;
const normalizedPar = Number.isFinite(par) ? Math.floor(par) : NaN;
if (!Number.isFinite(normalizedPar) || normalizedPar < 1) {
return { content: [{ type: 'text', text: 'par must be a positive integer.' }] };
}
draftStore.updateDraft({ par: normalizedPar, isDirty: true });
const shortest = draft.lastSolverResult?.moves;
const parityNote = shortest !== null && shortest !== undefined
? `\nCurrent solver shortest: ${shortest} (${shortest === normalizedPar ? 'matches' : 'does not match'} par)`
: '\nSolver shortest not available yet. Run solve_level to verify par.';
return {
content: [{ type: 'text', text: `Par set to ${normalizedPar}.${parityNote}` }],
};
}
case 'set_par_to_shortest': {
requireDraft();
const puzzleData = draftStore.exportPuzzleData();
if (!puzzleData) return { content: [{ type: 'text', text: 'Could not export level data.' }] };
const result = solve(puzzleData);
if (!result.solvable || result.moves === null) {
draftStore.updateDraft({ lastSolverResult: result, isDirty: false }, { trackHistory: false });
if (result.solvable) {
draftStore.markCurrentAsLastSolvable();
}
return {
content: [{
type: 'text',
text: 'Level is unsolvable, so par was not changed. Fix the level and try again.',
}],
};
}
const mechanicBaseline = detectMechanicFamilies(puzzleData);
const manualSolveFingerprint = computeLayoutFingerprint(puzzleData);
draftStore.updateDraft({
par: result.moves,
lastSolverResult: result,
isDirty: false,
manualSolveMechanicsBaseline: mechanicBaseline,
manualSolveFingerprint,
});
draftStore.markCurrentAsLastSolvable();
return {
content: [{
type: 'text',
text: `Par synced to solver shortest path: ${result.moves}`,
}],
};
}
case 'clear_level': {
requireDraft();
draftStore.updateDraft({
obstacles: [],
warpPairs: [],
thinIceTiles: [],
pushableRocks: [],
pressurePlate: null,
barrier: null,
par: 0,
isDirty: true,
lastSolverResult: null,
manualSolveMechanicsBaseline: null,
manualSolveFingerprint: null,
});
const current = draftStore.getCurrentDraft()!;
const viz = renderLevel(current, { showCoords: true });
return { content: [{ type: 'text', text: `Level cleared.\n\n${viz}` }] };
}
case 'add_warp_pair': {
const draft = requireDraft();
const { x1, y1, x2, y2 } = args as any;
for (const [x, y] of [
[x1, y1],
[x2, y2],
]) {
const check = validatePosition(x, y, draft.gridWidth, draft.gridHeight);
if (!check.valid) return { content: [{ type: 'text', text: check.error! }] };
}
const sgCheck1 = validateNotOnStartOrGoal(x1, y1, draft);
if (!sgCheck1.valid) return { content: [{ type: 'text', text: `(${x1},${y1}): ${sgCheck1.error}` }] };
const sgCheck2 = validateNotOnStartOrGoal(x2, y2, draft);
if (!sgCheck2.valid) return { content: [{ type: 'text', text: `(${x2},${y2}): ${sgCheck2.error}` }] };
if (x1 === x2 && y1 === y2)
return { content: [{ type: 'text', text: 'Warp positions must be different.' }] };
draftStore.addWarpPair(x1, y1, x2, y2);
return {
content: [
{ type: 'text', text: `Added warp pair: (${x1},${y1}) <-> (${x2},${y2})\n\n${autoSolveAndVisualize()}` },
],
};
}
case 'remove_warp': {
requireDraft();
draftStore.removeWarp((args as any).warpId);
return { content: [{ type: 'text', text: `Removed warp ${(args as any).warpId}\n\n${autoSolveAndVisualize()}` }] };
}
case 'add_thin_ice': {
const draft = requireDraft();
const { positions } = args as any;
for (const pos of positions) {
const check = validatePosition(pos.x, pos.y, draft.gridWidth, draft.gridHeight);
if (!check.valid) return { content: [{ type: 'text', text: `(${pos.x},${pos.y}): ${check.error}` }] };
const sgCheck = validateNotOnStartOrGoal(pos.x, pos.y, draft);
if (!sgCheck.valid) return { content: [{ type: 'text', text: `(${pos.x},${pos.y}): ${sgCheck.error}` }] };
}
draftStore.addThinIce(positions);
return { content: [{ type: 'text', text: `Added ${positions.length} thin ice tiles\n\n${autoSolveAndVisualize()}` }] };
}
case 'remove_thin_ice': {
requireDraft();
draftStore.removeThinIce((args as any).positions);
return { content: [{ type: 'text', text: `Removed thin ice\n\n${autoSolveAndVisualize()}` }] };
}
case 'add_pushable_rock': {
const draft = requireDraft();
const { positions } = args as any;
for (const pos of positions) {
const check = validatePosition(pos.x, pos.y, draft.gridWidth, draft.gridHeight);
if (!check.valid) return { content: [{ type: 'text', text: `(${pos.x},${pos.y}): ${check.error}` }] };
const sgCheck = validateNotOnStartOrGoal(pos.x, pos.y, draft);
if (!sgCheck.valid) return { content: [{ type: 'text', text: `(${pos.x},${pos.y}): ${sgCheck.error}` }] };
}
draftStore.addPushableRock(positions);
return { content: [{ type: 'text', text: `Added ${positions.length} pushable rocks\n\n${autoSolveAndVisualize()}` }] };
}
case 'remove_pushable_rock': {
requireDraft();
draftStore.removePushableRock((args as any).positions);
return { content: [{ type: 'text', text: `Removed pushable rocks\n\n${autoSolveAndVisualize()}` }] };
}
case 'set_pressure_plate': {
const draft = requireDraft();
const { x, y } = args as any;
const check = validatePosition(x, y, draft.gridWidth, draft.gridHeight);
if (!check.valid) return { content: [{ type: 'text', text: check.error! }] };
const sgCheck = validateNotOnStartOrGoal(x, y, draft);
if (!sgCheck.valid) return { content: [{ type: 'text', text: sgCheck.error! }] };
draftStore.setPressurePlate(x, y);
return { content: [{ type: 'text', text: `Pressure plate set at (${x}, ${y})\n\n${autoSolveAndVisualize()}` }] };
}
case 'remove_pressure_plate': {
requireDraft();
draftStore.removePressurePlate();
return { content: [{ type: 'text', text: `Pressure plate removed\n\n${autoSolveAndVisualize()}` }] };
}
case 'set_barrier': {
const draft = requireDraft();
const { x, y } = args as any;
const check = validatePosition(x, y, draft.gridWidth, draft.gridHeight);
if (!check.valid) return { content: [{ type: 'text', text: check.error! }] };
const sgCheck = validateNotOnStartOrGoal(x, y, draft);
if (!sgCheck.valid) return { content: [{ type: 'text', text: sgCheck.error! }] };
draftStore.setBarrier(x, y);
return { content: [{ type: 'text', text: `Barrier set at (${x}, ${y})\n\n${autoSolveAndVisualize()}` }] };
}
case 'remove_barrier': {
requireDraft();
draftStore.removeBarrier();
return { content: [{ type: 'text', text: `Barrier removed\n\n${autoSolveAndVisualize()}` }] };
}
case 'solve_level': {
requireDraft();
const puzzleData = draftStore.exportPuzzleData();
if (!puzzleData) return { content: [{ type: 'text', text: 'Could not export level data.' }] };
const solveStartedAt = Date.now();
const result = solve(puzzleData);
const solveElapsedMs = Date.now() - solveStartedAt;
const mechanicBaseline = detectMechanicFamilies(puzzleData);
const manualSolveFingerprint = computeLayoutFingerprint(puzzleData);
draftStore.updateDraft({
lastSolverResult: result,
isDirty: false,
manualSolveMechanicsBaseline: mechanicBaseline,
manualSolveFingerprint,
}, { trackHistory: false });
if (result.solvable) {
draftStore.markCurrentAsLastSolvable();
}
const current = draftStore.getCurrentDraft()!;
const tracedPositions = result.solution ? traceMovePositions(puzzleData, result.solution).positions : [];
const viz = renderLevel(current, {
showCoords: true,
showSolution: true,
solution: result.solution,
showStepNumbers: result.solvable,
stepPositions: tracedPositions,
});
const lines: string[] = [
viz,
'',
formatDirectionBalance(result.solution),
`Solver iterations: ${result.iterations}`,
`States visited: ${result.visitedCount}`,
`Solve runtime: ${solveElapsedMs} ms`,
`Avg branch factor: ${(result.iterations > 0 ? (result.visitedCount / result.iterations) : 0).toFixed(2)}`,
`Complexity tier: ${classifySolveComplexity(result.visitedCount)}`,
];
if (!result.solvable) {
const diagnostics = buildUnsolvableDiagnostics(puzzleData, result);
lines.push('');
lines.push('Unsolvable Diagnostics:');
for (const reason of diagnostics.reasons) {
lines.push(`- ${reason}`);
}
lines.push(`- Reachable landing tiles: ${diagnostics.reachability.reachableTileCount}`);
lines.push(`- Reachable on goal row/column: ${diagnostics.reachability.goalLineReachableCount}`);
lines.push('');
lines.push('Suggested Next Checks:');
for (const suggestion of diagnostics.suggestions) {
lines.push(`- ${suggestion}`);
}
lines.push('- Tip: simulate_move + reachable_from is usually faster than manual tracing.');
}
return { content: [{ type: 'text', text: lines.join('\n') }] };
}
case 'test_placement': {
const draft = requireDraft();
const { x, y, type } = args as any;
const posCheck = validatePosition(x, y, draft.gridWidth, draft.gridHeight);
if (!posCheck.valid) return { content: [{ type: 'text', text: posCheck.error! }] };
const sgCheck = validateNotOnStartOrGoal(x, y, draft);
if (!sgCheck.valid) return { content: [{ type: 'text', text: sgCheck.error! }] };
const baselinePuzzle = draftStore.exportPuzzleData();
if (!baselinePuzzle) return { content: [{ type: 'text', text: 'Could not export level data.' }] };
const candidateWarps = baselinePuzzle.warps
? baselinePuzzle.warps.filter((warp) => !(warp.x === x && warp.y === y))
: undefined;
const warpCounts = new Map<string, number>();
for (const warp of candidateWarps || []) {
warpCounts.set(warp.id, (warpCounts.get(warp.id) || 0) + 1);
}
const normalizedWarps = candidateWarps?.filter((warp) => warpCounts.get(warp.id) === 2);
const candidatePuzzle = {
...baselinePuzzle,
obstacles: [
...baselinePuzzle.obstacles.filter((obs) => !(obs.x === x && obs.y === y)),
{ x, y, type: type as ObstacleType },
],
thinIceTiles: baselinePuzzle.thinIceTiles?.filter((tile) => !(tile.x === x && tile.y === y)),
pushableRocks: baselinePuzzle.pushableRocks?.filter((rock) => !(rock.x === x && rock.y === y)),
pressurePlate: baselinePuzzle.pressurePlate
&& baselinePuzzle.pressurePlate.x === x
&& baselinePuzzle.pressurePlate.y === y
? undefined
: baselinePuzzle.pressurePlate,
barrier: baselinePuzzle.barrier
&& baselinePuzzle.barrier.x === x
&& baselinePuzzle.barrier.y === y
? undefined
: baselinePuzzle.barrier,
warps: normalizedWarps,
};
const baselineResult = solve(baselinePuzzle);
const candidateResult = solve(candidatePuzzle);
const lines: string[] = [
`Dry-run placement ${type} at (${x}, ${y}) (draft unchanged).`,
'',
`Current level: ${baselineResult.solvable ? `SOLVABLE in ${baselineResult.moves} moves` : 'UNSOLVABLE'}`,
`Test candidate: ${candidateResult.solvable ? `SOLVABLE in ${candidateResult.moves} moves` : 'UNSOLVABLE'}`,
formatDirectionBalance(candidateResult.solution),
];
if (!candidateResult.solvable) {
const diagnostics = buildUnsolvableDiagnostics(candidatePuzzle, candidateResult);
lines.push('');
lines.push('Candidate Diagnostics:');
for (const reason of diagnostics.reasons) {
lines.push(`- ${reason}`);
}
}
return { content: [{ type: 'text', text: lines.join('\n') }] };
}
case 'reachable_from': {
const draft = requireDraft();
const { x, y } = args as any;
const posCheck = validatePosition(x, y, draft.gridWidth, draft.gridHeight);
if (!posCheck.valid) return { content: [{ type: 'text', text: posCheck.error! }] };
const puzzleData = draftStore.exportPuzzleData();
if (!puzzleData) return { content: [{ type: 'text', text: 'Could not export level data.' }] };
const report = exploreReachability(puzzleData, { x, y });
const lines: string[] = [
`Reachable landing analysis from (${x}, ${y})`,
`Reachable positions: ${report.positions.length}`,
`Goal reachable: ${report.goalReachable ? 'Yes' : 'No'}`,
`Pressure plate reachable: ${report.pressurePlateReachable ? 'Yes' : 'No'}`,
`Barrier hit count: ${report.barrierHitCount}`,
`Solver states explored: ${report.visitedStates} (iterations ${report.iterations}${report.exhausted ? ', exhausted' : ''})`,
'',
'Reachable landing tiles (min moves):',
];
const maxRows = 80;
const rows = report.positions.slice(0, maxRows);
for (const row of rows) {
const plateFlag = row.reachedWithPlateActive ? ' [plate-active]' : '';
lines.push(`- (${row.position.x}, ${row.position.y}) in ${row.minMoves}${plateFlag}`);
}
if (report.positions.length > maxRows) {
lines.push(`... ${report.positions.length - maxRows} more omitted`);
}
if (!report.goalReachable) {
lines.push('');
lines.push('Tip: use suggest_stop_points near the furthest reachable tiles to create stop anchors.');
}
return { content: [{ type: 'text', text: lines.join('\n') }] };
}
case 'suggest_stop_points': {
const draft = requireDraft();
const { fromX, fromY, direction } = args as any;
const posCheck = validatePosition(fromX, fromY, draft.gridWidth, draft.gridHeight);
if (!posCheck.valid) return { content: [{ type: 'text', text: posCheck.error! }] };
const puzzleData = draftStore.exportPuzzleData();
if (!puzzleData) return { content: [{ type: 'text', text: 'Could not export level data.' }] };
const report = suggestStopPoints(puzzleData, { x: fromX, y: fromY }, direction as Direction);
const lines: string[] = [
`Stop-point suggestions from (${fromX}, ${fromY}) sliding ${direction}`,
`Current landing: (${report.currentLanding.x}, ${report.currentLanding.y})`,
`Traversed tiles before stop: ${report.traversedPath.map((p) => `(${p.x},${p.y})`).join(' -> ') || 'none'}`,
'',
'Suggested blockers:',
];
if (report.suggestions.length === 0) {
lines.push('- No clean blocker placements found on this ray.');
} else {
for (const suggestion of report.suggestions.slice(0, 20)) {
lines.push(`- Place rock at (${suggestion.blocker.x}, ${suggestion.blocker.y}) to stop at (${suggestion.landAt.x}, ${suggestion.landAt.y}) [distance ${suggestion.distanceFromStart}]`);
}
}
if (report.blockedCandidates.length > 0) {
lines.push('');
lines.push('Blocked candidate positions:');
for (const blocked of report.blockedCandidates.slice(0, 10)) {
lines.push(`- (${blocked.blocker.x}, ${blocked.blocker.y}): ${blocked.reason}`);
}
}
lines.push('');
lines.push('Tip: validate any candidate with test_placement before committing.');
return { content: [{ type: 'text', text: lines.join('\n') }] };
}
case 'seed_layout_pattern': {
const draft = requireDraft();
const patternRaw = (args as any)?.pattern;
const pattern = patternRaw === 'columns_with_gaps' || patternRaw === 'switchback_lanes'
? patternRaw as SeedPattern
: null;
if (!pattern) {
return { content: [{ type: 'text', text: 'pattern must be columns_with_gaps or switchback_lanes.' }] };
}
const difficultyRaw = (args as any)?.difficulty;
const difficulty = difficultyRaw === 'easy' || difficultyRaw === 'hard' ? difficultyRaw as SeedDifficulty : 'medium';
const clearExisting = Boolean((args as any)?.clearExisting);
const plan = generateSeedLayout(pattern, draft.gridWidth, draft.gridHeight, difficulty);
const startKey = `${draft.startPosition.x},${draft.startPosition.y}`;
const goalKey = `${draft.goalPosition.x},${draft.goalPosition.y}`;
let placed = 0;
const skipped: Position[] = [];
draftStore.withSingleHistoryEntry(() => {
if (clearExisting) {
draftStore.updateDraft({
obstacles: [],
warpPairs: [],
thinIceTiles: [],
pushableRocks: [],
pressurePlate: null,
barrier: null,
}, { trackHistory: false });
}
for (const rock of plan.rocks) {
const key = `${rock.x},${rock.y}`;
if (key === startKey || key === goalKey) {
skipped.push(rock);
continue;
}
draftStore.placeElement(rock.x, rock.y, 'rock');
placed++;
}
});
const viz = autoSolveAndVisualize();
const lines = [
`Applied seed pattern: ${plan.pattern} (${plan.difficulty})`,
`Placed rocks: ${placed}${skipped.length ? ` (skipped ${skipped.length} on start/goal)` : ''}`,
`Estimated optimal move band: ${plan.estimatedMoveBand[0]}-${plan.estimatedMoveBand[1]}`,
formatHistoryStatusLine(),
'',
'Pattern notes:',
...plan.notes.map((note) => `- ${note}`),
'',
viz,
];
return { content: [{ type: 'text', text: lines.join('\n') }] };
}
case 'suggest_skeleton_layout': {
const draft = requireDraft();
const difficultyRaw = (args as any)?.difficulty;
const difficulty = difficultyRaw === 'easy' || difficultyRaw === 'hard' ? difficultyRaw as SeedDifficulty : 'medium';
const plans = suggestSkeletonLayouts(draft.gridWidth, draft.gridHeight, difficulty);
const lines: string[] = [
`Skeleton layout suggestions (${difficulty}) for ${draft.gridWidth}x${draft.gridHeight}:`,
'',
];
for (const plan of plans) {
const sampleTiles = plan.rocks.slice(0, 40).map((rock) => ({ x: rock.x, y: rock.y, type: 'rock' }));
lines.push(`Pattern: ${plan.pattern}`);
lines.push(`- Rocks: ${plan.rocks.length}`);
lines.push(`- Estimated move band: ${plan.estimatedMoveBand[0]}-${plan.estimatedMoveBand[1]}`);
for (const note of plan.notes) {
lines.push(`- ${note}`);
}
lines.push('- Sample place_tiles_batch payload (truncated to 40 rocks):');
lines.push(JSON.stringify({ tiles: sampleTiles }, null, 2));
lines.push('');
}
lines.push('Apply one pattern with seed_layout_pattern, then add one mechanic at a time between solve_level runs.');
return { content: [{ type: 'text', text: lines.join('\n') }] };
}
case 'simulate_move': {
const draft = requireDraft();
const puzzleData = draftStore.exportPuzzleData();
if (!puzzleData) return { content: [{ type: 'text', text: 'Could not export level data.' }] };
const { direction, fromX, fromY } = args as any;
const fromPos =
fromX !== undefined && fromY !== undefined ? { x: fromX, y: fromY } : puzzleData.start;
const result = slideWithHazards(fromPos, direction as Direction, puzzleData);
const lines = [
`Slide ${direction} from (${fromPos.x}, ${fromPos.y})`,
`Landed at: (${result.position.x}, ${result.position.y})`,
result.hitHotCoals ? `Hit hot coals! (-${HOT_COALS_DAMAGE} HP on landing)` : '',
result.hitLava ? 'Hit lava! DEAD' : '',
result.fellInHole ? 'Fell in hole! DEAD' : '',
result.hitBarrier ? 'Hit active barrier! DEAD' : '',
result.crossedPressurePlate ? 'Crossed pressure plate!' : '',
result.warpDestination
? `Warped to (${result.warpDestination.x}, ${result.warpDestination.y})`
: '',
result.pushedRock
? `Pushed rock from (${result.pushedRock.from.x},${result.pushedRock.from.y}) to (${result.pushedRock.to.x},${result.pushedRock.to.y})`
: '',
]
.filter(Boolean)
.join('\n');
return { content: [{ type: 'text', text: lines }] };
}
case 'simulate_playthrough': {
const draft = requireDraft();
const puzzleData = draftStore.exportPuzzleData();
if (!puzzleData) return { content: [{ type: 'text', text: 'Could not export level data.' }] };
const { moves } = args as any;
let pos = { ...puzzleData.start };
let health = PLAYER_MAX_HEALTH;
const brokenIce: { x: number; y: number }[] = [];
let pushableRocks = puzzleData.pushableRocks ? [...puzzleData.pushableRocks] : [];
let plateActivated = false;
const parLimit = puzzleData.par > 0 ? puzzleData.par : null;
const steps: string[] = [`Start: (${pos.x}, ${pos.y}) HP: ${health}`];
for (let i = 0; i < moves.length; i++) {
const dir = moves[i] as Direction;
const result = slideWithHazards(pos, dir, puzzleData, brokenIce, pushableRocks, plateActivated);
if (result.hitHotCoals) health -= HOT_COALS_DAMAGE;
if (result.crossedPressurePlate) plateActivated = true;
// Track broken thin ice along path
if (puzzleData.thinIceTiles) {
const delta = DIRECTION_VECTORS[dir];
// Pre-warp path
const target = result.warpEntrance || result.position;
let cx = pos.x,
cy = pos.y;
for (let s = 0; s < 50 && (cx !== target.x || cy !== target.y); s++) {
cx += delta.x;
cy += delta.y;
const ti = puzzleData.thinIceTiles.find((t) => t.x === cx && t.y === cy);
if (ti && !brokenIce.some((b) => b.x === cx && b.y === cy)) {
brokenIce.push({ x: cx, y: cy });
}
}
}
if (result.pushedRock) {
pushableRocks = pushableRocks.filter(
(r) => r.x !== result.pushedRock!.from.x || r.y !== result.pushedRock!.from.y
);
const intoLava = puzzleData.obstacles.some(
(o) =>
o.x === result.pushedRock!.to.x && o.y === result.pushedRock!.to.y && o.type === 'lava'
);
if (!intoLava) pushableRocks.push(result.pushedRock.to);
}
let stepInfo = `${i + 1}. ${dir}: (${pos.x},${pos.y}) -> (${result.position.x},${result.position.y})`;
if (result.hitHotCoals) stepInfo += ` [HOT_COALS -${HOT_COALS_DAMAGE}HP]`;
if (result.hitLava) stepInfo += ' [LAVA - DEAD]';
if (result.fellInHole) stepInfo += ' [HOLE - DEAD]';
if (result.hitBarrier) stepInfo += ' [BARRIER - DEAD]';
if (result.warpDestination)
stepInfo += ` [WARP -> (${result.warpDestination.x},${result.warpDestination.y})]`;
if (result.pushedRock) stepInfo += ' [PUSHED ROCK]';
if (result.crossedPressurePlate) stepInfo += ' [PLATE ACTIVATED]';
stepInfo += ` HP: ${health}`;
steps.push(stepInfo);
pos = result.position;
const reachedGoal = pos.x === puzzleData.goal.x && pos.y === puzzleData.goal.y;
if (parLimit !== null && (i + 1) >= parLimit && !reachedGoal) {
steps.push(`WASTED - Melt timeout at move ${i + 1} (par ${parLimit}).`);
break;
}
if (result.hitLava || result.fellInHole || result.hitBarrier || health <= 0) {
steps.push('DEAD - Simulation stopped.');
break;
}
if (reachedGoal) {
steps.push(`GOAL REACHED in ${i + 1} moves!`);
break;
}
}
return { content: [{ type: 'text', text: steps.join('\n') }] };
}
case 'analyze_difficulty': {
const draft = requireDraft();
const puzzleData = draftStore.exportPuzzleData();
if (!puzzleData) return { content: [{ type: 'text', text: 'Could not export level data.' }] };
const result = solve(puzzleData);
draftStore.updateDraft({ lastSolverResult: result, isDirty: false }, { trackHistory: false });
if (result.solvable) {
draftStore.markCurrentAsLastSolvable();
}
// Detect mechanics
const mechanics: string[] = [];
if (puzzleData.obstacles.some((o) => o.type === 'rock')) mechanics.push('rock');
if (puzzleData.obstacles.some((o) => o.type === 'lava')) mechanics.push('lava');
if (puzzleData.obstacles.some((o) => o.type === 'hot_coals' || o.type === 'spike')) mechanics.push('hot_coals');
if (puzzleData.thinIceTiles?.length) mechanics.push('thin_ice');
if (puzzleData.warps?.length) mechanics.push('warp');
if (puzzleData.pushableRocks?.length) mechanics.push('pushable_rock');
if (puzzleData.pressurePlate) mechanics.push('pressure_plate');
if (puzzleData.barrier) mechanics.push('barrier');
// Direction balance
const dirBalance: Record<string, number> = { up: 0, down: 0, left: 0, right: 0 };
if (result.solution) {
for (const move of result.solution) {
dirBalance[move]++;
}
}
// Quadrant analysis
const midX = puzzleData.width / 2;
const midY = puzzleData.height / 2;
const quadrantsVisited = new Set<number>();
if (result.solution) {
let pos = { ...puzzleData.start };
const addQuadrant = (p: { x: number; y: number }) => {
if (p.x < midX && p.y < midY) quadrantsVisited.add(2);
else if (p.x >= midX && p.y < midY) quadrantsVisited.add(1);
else if (p.x < midX && p.y >= midY) quadrantsVisited.add(3);
else quadrantsVisited.add(4);
};
addQuadrant(pos);
const trackBrokenIce: Position[] = [];
let trackPushableRocks = puzzleData.pushableRocks ? [...puzzleData.pushableRocks] : [];
let trackPlateActivated = false;
for (const dir of result.solution) {
const slideResult = slideWithHazards(pos, dir, puzzleData, trackBrokenIce, trackPushableRocks, trackPlateActivated);
// Track thin ice
if (puzzleData.thinIceTiles) {
const delta = DIRECTION_VECTORS[dir];
// Pre-warp path
const target = slideResult.warpEntrance || slideResult.position;
let cx = pos.x, cy = pos.y;
for (let s = 0; s < 50 && (cx !== target.x || cy !== target.y); s++) {
cx += delta.x;
cy += delta.y;
if (puzzleData.thinIceTiles.some(t => t.x === cx && t.y === cy) &&
!trackBrokenIce.some(b => b.x === cx && b.y === cy)) {
trackBrokenIce.push({ x: cx, y: cy });
}
}
// Post-warp path
if (slideResult.warpDestination) {
cx = slideResult.warpDestination.x;
cy = slideResult.warpDestination.y;
for (let s = 0; s < 50 && (cx !== slideResult.position.x || cy !== slideResult.position.y); s++) {
cx += delta.x;
cy += delta.y;
if (puzzleData.thinIceTiles.some(t => t.x === cx && t.y === cy) &&
!trackBrokenIce.some(b => b.x === cx && b.y === cy)) {
trackBrokenIce.push({ x: cx, y: cy });
}
}
}
}
// Track pushed rocks
if (slideResult.pushedRock) {
trackPushableRocks = trackPushableRocks.filter(
r => r.x !== slideResult.pushedRock!.from.x || r.y !== slideResult.pushedRock!.from.y
);
const intoLava = puzzleData.obstacles.some(
o => o.x === slideResult.pushedRock!.to.x && o.y === slideResult.pushedRock!.to.y && o.type === 'lava'
);
if (!intoLava) trackPushableRocks.push(slideResult.pushedRock.to);
}
if (slideResult.crossedPressurePlate) trackPlateActivated = true;
pos = slideResult.position;
addQuadrant(pos);
}
}
// Difficulty tier
const moves = result.moves ?? 0;
let tier = 'tutorial';
if (moves >= 18) tier = 'expert';
else if (moves >= 14) tier = 'hard';
else if (moves >= 10) tier = 'medium';
else if (moves >= 6) tier = 'easy';
const lines = [
`=== Difficulty Analysis ===`,
``,
`Solvable: ${result.solvable ? 'Yes' : 'No'}`,
`Optimal moves: ${result.moves ?? 'N/A'}`,
`Solver iterations: ${result.iterations}`,
``,
`Mechanics: ${mechanics.join(', ') || 'none'}`,
`Difficulty tier: ${tier}`,
``,
`Direction balance:`,
` Up: ${dirBalance.up} Down: ${dirBalance.down} Left: ${dirBalance.left} Right: ${dirBalance.right}`,
``,
`Quadrants visited: ${Array.from(quadrantsVisited)
.sort()
.map((q) => 'Q' + q)
.join(', ') || 'none'}`,
quadrantsVisited.size < 4
? `WARNING: Only ${quadrantsVisited.size}/4 quadrants visited. Good levels visit all 4.`
: 'All 4 quadrants visited.',
``,
`Grid: ${puzzleData.width}x${puzzleData.height}`,
`Total obstacles: ${puzzleData.obstacles.length}`,
];
return { content: [{ type: 'text', text: lines.join('\n') }] };
}
case 'validate_quality_gate': {
requireDraft();
const requirePar = Boolean((args as any)?.requirePar);
const quality = runQualityGate(requirePar);
return { content: [{ type: 'text', text: quality.text }] };
}
case 'check_publish_readiness': {
const draft = requireDraft();
const quality = runQualityGate(true);
const authResult = await authenticate();
const blockers: string[] = [];
const warnings: string[] = [];
if (!authResult.success) {
blockers.push(`Authentication required: ${authResult.error}`);
}
if (!quality.report.pass) {
blockers.push('Quality gate is not passing yet.');
}
const currentPuzzle = draftStore.exportPuzzleData();
const currentMechanics = detectMechanicFamilies(currentPuzzle);
const baselineMechanics = draft.manualSolveMechanicsBaseline || [];
const currentFingerprint = computeLayoutFingerprint(currentPuzzle);
const baselineFingerprint = draft.manualSolveFingerprint || null;
if (!draft.manualSolveMechanicsBaseline || !baselineFingerprint) {
warnings.push('Run solve_level at least once after your latest mechanic edits before publishing.');
} else {
const changedMechanics = getChangedMechanicFamilies(baselineMechanics, currentMechanics);
const layoutChangedSinceSolve = baselineFingerprint !== currentFingerprint;
if (layoutChangedSinceSolve) {
if (changedMechanics.length > 1) {
blockers.push(`Multiple mechanic families changed since last solve_level: ${changedMechanics.join(', ')}.`);
} else if (changedMechanics.length === 1) {
blockers.push(`Layout changed since last solve_level (${changedMechanics[0]} changed). Re-run solve_level before publish.`);
} else {
blockers.push('Layout changed since last solve_level (same mechanic families). Re-run solve_level before publish.');
}
}
}
const lines = [
'=== Publish Readiness ===',
`Auth status: ${authResult.success ? `ready (${authResult.userId})` : getAuthStatusMessage()}`,
`Ready to publish: ${blockers.length === 0 ? 'Yes' : 'No'}`,
'Publish note: use preview_level for staging links, and list_my_published_levels/unpublish_level/restore_published_level for lifecycle management.',
`One-mechanic discipline baseline: ${baselineMechanics.length ? baselineMechanics.join(', ') : 'not set'}`,
`Manual solve snapshot: ${baselineFingerprint ? 'set' : 'not set'}`,
`Current mechanic families: ${currentMechanics.length ? currentMechanics.join(', ') : 'none'}`,
];
if (blockers.length > 0) {
lines.push('');
lines.push('Blockers:');
for (const blocker of blockers) {
lines.push(`- ${blocker}`);
}
}
if (warnings.length > 0) {
lines.push('');
lines.push('Warnings:');
for (const warning of warnings) {
lines.push(`- ${warning}`);
}
}
lines.push('');
lines.push(quality.text);
return { content: [{ type: 'text', text: lines.join('\n') }] };
}
case 'visualize_level': {
const draft = requireDraft();
const { showSolution, showCoords, showStepNumbers } = args as any;
const _showCoords = showCoords !== false;
let solution = draft.lastSolverResult?.solution ?? null;
const puzzleData = draftStore.exportPuzzleData();
if (showSolution && !solution) {
if (puzzleData) {
const result = solve(puzzleData);
draftStore.updateDraft({ lastSolverResult: result, isDirty: false }, { trackHistory: false });
if (result.solvable) {
draftStore.markCurrentAsLastSolvable();
}
solution = result.solution;
}
}
const current = draftStore.getCurrentDraft()!;
const tracedPositions = showStepNumbers && solution && puzzleData
? traceMovePositions(puzzleData, solution).positions
: [];
const viz = renderLevel(current, {
showCoords: _showCoords,
showSolution,
solution,
showStepNumbers: Boolean(showStepNumbers && solution),
stepPositions: tracedPositions,
});
return { content: [{ type: 'text', text: viz }] };
}
case 'get_game_rules': {
return { content: [{ type: 'text', text: GAME_RULES_TEXT }] };
}
case 'get_tile_types': {
return { content: [{ type: 'text', text: TILE_TYPES_TEXT }] };
}
case 'get_level_requirements': {
return { content: [{ type: 'text', text: LEVEL_REQUIREMENTS_TEXT }] };
}
case 'interaction_faq': {
return { content: [{ type: 'text', text: INTERACTION_FAQ_TEXT }] };
}
case 'list_campaign_examples': {
const requestedDifficulty = (args as any)?.difficulty as ExampleDifficulty | undefined;
const includeSolutions = (args as any)?.includeSolutions !== false;
const includePuzzleData = Boolean((args as any)?.includePuzzleData);
const examples = getCampaignExamplesByDifficulty(requestedDifficulty);
if (examples.length === 0) {
return {
content: [{ type: 'text', text: `No campaign examples found for difficulty "${requestedDifficulty}".` }],
};
}
const header = requestedDifficulty
? `Campaign examples (${requestedDifficulty})`
: 'Campaign examples (all tiers)';
const lines: string[] = [header, `Count: ${examples.length}`, ''];
for (const example of examples) {
lines.push(formatCampaignExampleLine(example, includeSolutions));
if (includePuzzleData) {
lines.push(JSON.stringify(example.puzzleData, null, 2));
}
lines.push('');
}
return { content: [{ type: 'text', text: lines.join('\n').trim() }] };
}
case 'get_campaign_example': {
const levelNumberRaw = (args as any)?.levelNumber;
const levelId = (args as any)?.levelId as string | undefined;
const includePuzzleData = (args as any)?.includePuzzleData !== false;
const levelNumber = Number.isFinite(levelNumberRaw) ? Number(levelNumberRaw) : undefined;
if (levelNumber === undefined && !levelId) {
return {
content: [{
type: 'text',
text: 'Provide either levelNumber or levelId to fetch a campaign example.',
}],
};
}
const example = getCampaignExample(levelNumber, levelId);
if (!example) {
const queryLabel = levelNumber !== undefined ? `levelNumber=${levelNumber}` : `levelId=${levelId}`;
return { content: [{ type: 'text', text: `No campaign example found for ${queryLabel}.` }] };
}
const lines: string[] = [formatCampaignExampleLine(example, true)];
if (includePuzzleData) {
lines.push('');
lines.push(JSON.stringify(example.puzzleData, null, 2));
}
return { content: [{ type: 'text', text: lines.join('\n') }] };
}
case 'save_draft': {
try {
const result = await saveDraft();
return { content: [{ type: 'text', text: result }] };
} catch (error) {
return { content: [{ type: 'text', text: `Error: ${error instanceof Error ? error.message : String(error)}` }] };
}
}
case 'load_draft': {
try {
const result = await loadDraft((args as any).draftId);
return { content: [{ type: 'text', text: result }] };
} catch (error) {
return { content: [{ type: 'text', text: `Error: ${error instanceof Error ? error.message : String(error)}` }] };
}
}
case 'delete_remote_draft': {
try {
const result = await deleteFirestoreDraft((args as any).draftId);
return { content: [{ type: 'text', text: result }] };
} catch (error) {
return { content: [{ type: 'text', text: `Error: ${error instanceof Error ? error.message : String(error)}` }] };
}
}
case 'list_remote_drafts': {
try {
const result = await listFirestoreDrafts();
return { content: [{ type: 'text', text: result }] };
} catch (error) {
return { content: [{ type: 'text', text: `Error: ${error instanceof Error ? error.message : String(error)}` }] };
}
}
case 'list_my_published_levels': {
try {
const statusFilterRaw = (args as any)?.statusFilter;
const statusFilter = statusFilterRaw === 'active' || statusFilterRaw === 'unpublished' || statusFilterRaw === 'all'
? statusFilterRaw
: 'all';
const sortByRaw = (args as any)?.sortBy;
const sortBy = sortByRaw === 'published_desc'
|| sortByRaw === 'published_asc'
|| sortByRaw === 'updated_desc'
|| sortByRaw === 'updated_asc'
|| sortByRaw === 'name_asc'
|| sortByRaw === 'name_desc'
? sortByRaw
: 'published_desc';
const limitRaw = (args as any)?.limit;
const limit = Number.isFinite(limitRaw) ? Number(limitRaw) : undefined;
const cursorRaw = (args as any)?.cursor;
const cursor = typeof cursorRaw === 'string' ? cursorRaw : undefined;
const result = await listMyPublishedLevels({
statusFilter,
sortBy,
...(limit !== undefined ? { limit } : {}),
...(cursor ? { cursor } : {}),
});
return { content: [{ type: 'text', text: result }] };
} catch (error) {
return { content: [{ type: 'text', text: `Error: ${error instanceof Error ? error.message : String(error)}` }] };
}
}
case 'get_my_published_level': {
try {
const result = await getMyPublishedLevel((args as any).levelId);
return { content: [{ type: 'text', text: result }] };
} catch (error) {
return { content: [{ type: 'text', text: `Error: ${error instanceof Error ? error.message : String(error)}` }] };
}
}
case 'unpublish_level': {
try {
const result = await unpublishLevel((args as any).levelId, (args as any).reason);
return { content: [{ type: 'text', text: result }] };
} catch (error) {
return { content: [{ type: 'text', text: `Error: ${error instanceof Error ? error.message : String(error)}` }] };
}
}
case 'restore_published_level': {
try {
const result = await restorePublishedLevel((args as any).levelId);
return { content: [{ type: 'text', text: result }] };
} catch (error) {
return { content: [{ type: 'text', text: `Error: ${error instanceof Error ? error.message : String(error)}` }] };
}
}
case 'preview_level': {
try {
const result = await previewLevel((args as any).name, (args as any).description);
return { content: [{ type: 'text', text: result }] };
} catch (error) {
return { content: [{ type: 'text', text: `Error: ${error instanceof Error ? error.message : String(error)}` }] };
}
}
case 'publish_level': {
try {
const result = await publishLevel((args as any).name, (args as any).description);
const note = 'Tip: run preview_level first for a staging share link. Manage published visibility with list_my_published_levels, unpublish_level, and restore_published_level.';
return { content: [{ type: 'text', text: `${result}\n\n${note}` }] };
} catch (error) {
return { content: [{ type: 'text', text: `Error: ${error instanceof Error ? error.message : String(error)}` }] };
}
}
case 'auth_status': {
try {
const result = await authenticate();
if (result.success) {
return { content: [{ type: 'text', text: `Authenticated as user: ${result.userId}` }] };
} else {
return { content: [{ type: 'text', text: `Not authenticated: ${result.error}` }] };
}
} catch (error) {
return { content: [{ type: 'text', text: `Auth error: ${error instanceof Error ? error.message : String(error)}` }] };
}
}
default:
throw new Error(`Unknown tool: ${name}`);
}
} catch (error: any) {
return {
content: [{ type: 'text', text: `Error: ${error.message}` }],
isError: true,
};
}
});
// ==================== RESOURCES ====================
server.setRequestHandler(ListResourcesRequestSchema, async () => ({
resources: [
{
uri: 'ice-puzzle://rules',
name: 'Game Rules',
description: 'Comprehensive ice puzzle game rules and design guide',
mimeType: 'text/plain',
},
{
uri: 'ice-puzzle://tile-types',
name: 'Tile Types',
description: 'All tile types with behavior descriptions',
mimeType: 'text/plain',
},
{
uri: 'ice-puzzle://current-level',
name: 'Current Level',
description: 'Current working draft level data and visualization',
mimeType: 'text/plain',
},
{
uri: 'ice-puzzle://campaign-examples',
name: 'Campaign Examples',
description: 'All campaign level examples with difficulty tiers and shortest solutions',
mimeType: 'application/json',
},
{
uri: 'ice-puzzle://campaign-examples/easy',
name: 'Campaign Examples (Easy)',
description: 'Easy campaign level examples with shortest solutions',
mimeType: 'application/json',
},
{
uri: 'ice-puzzle://campaign-examples/medium',
name: 'Campaign Examples (Medium)',
description: 'Medium campaign level examples with shortest solutions',
mimeType: 'application/json',
},
{
uri: 'ice-puzzle://campaign-examples/hard',
name: 'Campaign Examples (Hard)',
description: 'Hard campaign level examples with shortest solutions',
mimeType: 'application/json',
},
],
}));
server.setRequestHandler(ReadResourceRequestSchema, async (request) => {
const uri = request.params.uri;
if (uri === 'ice-puzzle://rules') {
return {
contents: [{ uri, mimeType: 'text/plain', text: GAME_RULES_TEXT }],
};
} else if (uri === 'ice-puzzle://tile-types') {
return {
contents: [{ uri, mimeType: 'text/plain', text: TILE_TYPES_TEXT }],
};
} else if (uri === 'ice-puzzle://current-level') {
const draft = draftStore.getCurrentDraft();
if (!draft) {
return {
contents: [{ uri, mimeType: 'text/plain', text: 'No active draft.' }],
};
}
const viz = renderLevel(draft, { showCoords: true });
return {
contents: [
{
uri,
mimeType: 'text/plain',
text: `${JSON.stringify(draftStore.exportPuzzleData(), null, 2)}\n\n${viz}`,
},
],
};
} else if (uri === 'ice-puzzle://campaign-examples') {
return {
contents: [{
uri,
mimeType: 'application/json',
text: JSON.stringify(CAMPAIGN_LEVEL_EXAMPLES, null, 2),
}],
};
} else if (uri === 'ice-puzzle://campaign-examples/easy') {
return {
contents: [{
uri,
mimeType: 'application/json',
text: JSON.stringify(getCampaignExamplesByDifficulty('easy'), null, 2),
}],
};
} else if (uri === 'ice-puzzle://campaign-examples/medium') {
return {
contents: [{
uri,
mimeType: 'application/json',
text: JSON.stringify(getCampaignExamplesByDifficulty('medium'), null, 2),
}],
};
} else if (uri === 'ice-puzzle://campaign-examples/hard') {
return {
contents: [{
uri,
mimeType: 'application/json',
text: JSON.stringify(getCampaignExamplesByDifficulty('hard'), null, 2),
}],
};
}
throw new Error(`Unknown resource: ${uri}`);
});
// ==================== PROMPTS ====================
server.setRequestHandler(ListPromptsRequestSchema, async () => ({
prompts: [
{
name: 'build-level',
description: 'Build a solvable ice puzzle level',
arguments: [
{
name: 'mechanics',
description: 'Mechanics to use (e.g., "rocks, lava, thin_ice")',
required: false,
},
{
name: 'targetMoves',
description: 'Target number of moves for solution',
required: false,
},
{
name: 'gridSize',
description: 'Grid size like "12x10"',
required: false,
},
],
},
{
name: 'improve-level',
description: 'Analyze and improve the current level',
},
{
name: 'challenge-level',
description: 'Build a challenging level using all mechanics',
},
],
}));
server.setRequestHandler(GetPromptRequestSchema, async (request) => {
const { name, arguments: args } = request.params;
if (name === 'build-level') {
const mechanics = (args?.mechanics as string) || '';
const targetMoves = args?.targetMoves ? `${args.targetMoves}` : '';
const gridSize = (args?.gridSize as string) || '';
return {
messages: [
{
role: 'user',
content: {
type: 'text',
text: `Build me an ice puzzle level with these requirements:
${mechanics ? `- Mechanics: ${mechanics}` : '- Use interesting mechanics appropriate for the difficulty'}
${targetMoves ? `- Target solution length: ${targetMoves} moves` : '- Make it challenging but solvable'}
${gridSize ? `- Grid size: ${gridSize}` : '- Choose an appropriate grid size'}
Hard constraints:
- Non-tutorial levels may have tied optimal paths, but shortest optimal length must equal par.
- Reject any candidate where shortest optimal length is below par.
- Over-par behavior is strict timeout death: if moves >= par and player is not on goal, run fails (WASTED).
- Warp semantics are warp-stop: stepping on a warp teleports to its pair and ENDS the move.
- Hot Coals semantics: -5 HP on landing, plus -5 HP/sec while standing.
- Thin ice: crossing marks broken; entering broken thin ice is fatal.
Follow this workflow:
1. Pull reference levels first with list_campaign_examples/get_campaign_example for the intended tier.
2. Create the level and set start/goal in different quadrants.
3. PHASE 1 (structure only): build rock skeleton and stop points, then solve_level.
4. PHASE 2 (one mechanic at a time): add in order barrier -> pressure plate -> warp -> pushable rock -> thin ice; solve_level after EACH addition.
5. PHASE 3 (hazards last): add lava/hot coals to punish wrong paths without changing shortest move count.
6. PHASE 4 (polish): run set_par_to_shortest, validate_quality_gate(requirePar=true), analyze_difficulty, and simulate_playthrough timeout checks.
7. Never apply more than one structural edit between solve_level runs.
8. If UNSOLVABLE, use reachable_from, suggest_stop_points, and test_placement before making permanent edits.
9. If a change breaks the design, use undo/redo or revert_to_last_solvable.
10. Run check_publish_readiness before publish_level.
11. Run preview_level to generate a staging link and collect feedback before publish.
12. After publishing, use list_my_published_levels/unpublish_level/restore_published_level for lifecycle management.`,
},
},
],
};
} else if (name === 'improve-level') {
return {
messages: [
{
role: 'user',
content: {
type: 'text',
text: `Analyze the current ice puzzle level and suggest improvements:
1. Run get_level and analyze_difficulty.
2. Identify one highest-impact issue only (do not batch changes).
3. Propose one edit, apply it, and run solve_level immediately.
4. If the edit breaks solvability, use undo or revert_to_last_solvable.
5. Use reachable_from/suggest_stop_points for uncertain routing areas.
6. Repeat with one change per solve until quality checks pass.`,
},
},
],
};
} else if (name === 'challenge-level') {
return {
messages: [
{
role: 'user',
content: {
type: 'text',
text: `Build a challenging ice puzzle level using ALL available mechanics:
- Rocks for path definition
- Lava (hidden!) for death traps
- Hot Coals for risk/reward shortcuts
- Thin ice for one-way bridges
- Warps for quadrant-crossing teleportation
- Pushable rocks for puzzle-within-puzzle
- Optional: pressure plate + barrier for key-lock routing
Target: 20+ move solution, all 4 quadrants, 3+ deceptive paths, balanced directions.
Grid: at least 14x13.
Hard constraints still apply: tied optimal paths are allowed, but shortest must equal par; warp is warp-stop; and over-par timeout failure triggers when moves >= par without reaching goal.
Use phased construction:
1. Rock skeleton first.
2. Add mechanics one-by-one, solve after every addition.
3. Add hazards last.
4. Never make multiple structural edits between solve calls.`,
},
},
],
};
}
throw new Error(`Unknown prompt: ${name}`);
});
return server;
}
// ==================== STATIC TEXT ====================
const GAME_RULES_TEXT = `# Ice Puzzle Game Rules
## Core Physics
The player slides on ice in a direction until hitting an obstacle or wall. They cannot stop voluntarily.
## Slide Interactions (priority order)
1. Wall/Rock: Stops BEFORE the tile (lands adjacent)
2. Hot Coals: Stops ON the tile + takes 5 damage on landing (plus 5/sec while standing)
3. Lava: Instant death (stops ON tile, dies)
4. Thin Ice: Passes OVER, tile breaks; falling into hole = death
5. Warp: Teleports to paired warp and ENDS the move
6. Pushable Rock: Pushes rock 1 square if space available; player stops
7. Pressure Plate: Passes OVER, activates plate (deactivates paired barrier)
8. Barrier: If active = death; if deactivated (plate pressed) = pass through
## Canonical Constraints
- Level 1 tutorial may allow multiple solutions.
- Non-tutorial levels may have tied optimal paths, but shortest optimal length must equal par.
- Over-par timeout is strict: if moves >= par and player is not on goal, run fails (WASTED).
## Level Design Principles
1. Visit All 4 Quadrants - Force player through all corners
2. Meaningful Choices - 2-3 viable directions at each stop
3. Hidden Traps - Lava NEVER visible from decision points
4. Deceptive Paths - 2+ wrong paths that look right
5. Goal Variety - Avoid always top-right; use center, edge, bottom positions
6. Direction Balance - No single direction >50% of solution
7. No Corridors - Open areas with strategic obstacles > narrow channels
## Difficulty Tiers
- Tutorial (L1): 2-6 moves, rocks only
- Easy (L2-L4): 6-12 moves, rocks + 1 hazard
- Medium (L5-L9): 10-17 moves, 2-3 mechanics
- Hard (L10-L14): 14-24 moves, 3-4 mechanics
- Expert (L15+): 18-30+ moves, all mechanics`;
const TILE_TYPES_TEXT = `# Tile Types
| Symbol | Type | Behavior |
|--------|------|----------|
| # | Wall | Border tiles. Stops player before. |
| . | Ice | Empty. Player slides through. |
| S | Start | Player starting position. |
| G | Goal | Level objective. Stop here to win. |
| R | Rock | Solid obstacle. Stops player before. |
| L | Lava | Instant death on contact. |
| ^ | Hot Coals | Stops player ON tile. Deals 5 damage on landing (+5/sec while standing). |
| ~ | Thin Ice | Player slides over. Breaks after. Hole = death. |
| W | Warp | Teleports to paired warp and ends movement. |
| P | Pushable Rock | Pushed 1 square on contact. Player stops. |
| ! | Pressure Plate | Player slides over. Deactivates paired barrier. |
| B | Barrier | Active = death. Deactivated by plate = pass through. |`;
const LEVEL_REQUIREMENTS_TEXT = `# Level Requirements for Publishing
## Required
- Must be SOLVABLE (BFS solver confirms optimal path exists)
- Grid size: 5-25 in each dimension
- Must have start and goal positions set
- Start and goal must be different positions
- Non-tutorial: shortest optimal length must equal explicit par (ties at shortest are allowed)
- Reject if shortest optimal path is below par
- Over-par timeout rule: if moves >= par and player is not on goal, run is failed (WASTED)
## Recommended
- Visit all 4 quadrants in the solution path
- 2+ deceptive paths (wrong paths that look right)
- Balanced direction usage (no >50% single direction)
- At least 6 moves for non-tutorial levels
- Lava hidden from decision points (not in direct line of sight)
- Multiple mechanics for medium+ difficulty
## Anti-Patterns to Avoid
- Obvious lava visible from every decision point
- Single path forward at each stop (no choices)
- Repetitive zigzag patterns (R-D-R-D)
- All goals in top-right corner
- Empty corridors with no features
- Narrow lava channels instead of open areas`;
const INTERACTION_FAQ_TEXT = `# Interaction FAQ
Quick outcomes when mechanics meet:
- Rock directly after thin ice: player slides across thin ice and stops before rock; thin ice breaks after crossing.
- Broken thin ice (hole): entering the tile is instant death.
- Warp onto pressure plate: teleport destination is occupied, plate activates, and movement ends.
- Warp onto hot coals: player takes hot coals landing damage immediately at destination.
- Warp semantics: stepping on warp always teleports and ends that move (no continued sliding).
- Pushable rock into lava: rock is destroyed; player stops at the rock's previous position.
- Pushable rock into wall/rock/pushable: push fails; player stops before the rock.
- Active barrier: entering barrier tile is instant death.
- Pressure plate then barrier (same move): once plate is crossed, barrier is considered deactivated for subsequent tiles.
- Goal on edge: edge goal can replace wall and is a valid landing tile.
Fast validation workflow:
1. Use simulate_move for single interaction checks.
2. Use simulate_playthrough for chained interactions.
3. Use solve_level to ensure no unintended shortcuts are introduced.`;