// Thin Ice: the lake world and the tools Claude can call. // Everything here is plain, deterministic JavaScript. The model never sees // this file; it only sees the JSON text each tool returns. export const COLS = 9; // A..I, west to east export const ROWS = 7; // 1..7, south to north export const SAFE_INCHES = 4; // game rule: 4 inches of ice or more to walk on export const FISH_GOAL = 4; // fish marks needed to set up the shanty const LETTERS = "ABCDEFGHI"; export const DIRS = { north: [0, 1], south: [0, -1], east: [1, 0], west: [-1, 0], }; export const cellName = (x, y) => `${LETTERS[x]}${y + 1}`; export const inBounds = (x, y) => x >= 0 && y >= 0 && x < COLS && y < ROWS; // Small seeded random number generator (mulberry32) so a seed always // builds the same lake. export function rng(seed) { let a = seed >>> 0; return () => { a = (a + 0x6d2b79f5) >>> 0; let t = a; t = Math.imul(t ^ (t >>> 15), t | 1); t ^= t + Math.imul(t ^ (t >>> 7), t | 61); return ((t ^ (t >>> 14)) >>> 0) / 4294967296; }; } function buildLake(seed) { const r = rng(seed); const cells = []; for (let y = 0; y < ROWS; y++) { for (let x = 0; x < COLS; x++) { cells.push({ x, y, inches: 5 + Math.round(r() * 7) / 2, surface: "", fish: 0, drilled: false }); } } const at = (x, y) => cells[y * COLS + x]; // Fish hold near a drop-off in the northern half of the lake. const hx = 4 + Math.floor(r() * 4); // E..H const hy = 4 + Math.floor(r() * 3); // 5..7 for (const c of cells) { const d = Math.abs(c.x - hx) + Math.abs(c.y - hy); c.fish = Math.max(0, Math.round(8 - 1.5 * d)); c.signal = 20 - d; // the fish finder's faint reading, which grows toward the drop-off } // A creek inlet on the west shore keeps the ice thin there. const iy = 2 + Math.floor(r() * 3); for (let y = iy - 1; y <= iy + 1; y++) { for (let x = 0; x <= 1; x++) { if (!inBounds(x, y)) continue; const c = at(x, y); c.inches = x === 0 && y === iy ? 0 : 1 + Math.round(r() * 4) / 2; } } // A spring hole in the middle of the lake: open water ringed by thin ice. const sx = 3 + Math.floor(r() * 3); const sy = 2 + Math.floor(r() * 2); at(sx, sy).inches = 0; for (const [dx, dy] of Object.values(DIRS)) { const x = sx + dx, y = sy + dy; if (inBounds(x, y) && !(x === 4 && y === 0) && r() < 0.75) at(x, y).inches = 1.5 + Math.round(r() * 3) / 2; } // A pressure ridge runs part way across the lake, heaved and cracked. const ry = 3 + Math.floor(r() * 2); const rFrom = 2 + Math.floor(r() * 3); for (let x = rFrom; x < Math.min(COLS, rFrom + 4); x++) { const c = at(x, ry); if (c.inches > 0) { c.inches = 2 + Math.round(r() * 2) / 2; c.ridge = true; } } // The start is always good ice, right off the public access. at(4, 0).inches = 7; // What a person standing next to a cell can see. Thin ice is usually // gray and slushy, but sometimes fresh snow hides it. for (const c of cells) { if (c.inches === 0) c.surface = "open water"; else if (c.ridge) c.surface = "pressure ridge, heaved and cracked"; else if (c.inches < SAFE_INCHES) c.surface = r() < 0.65 ? "gray, slushy patch" : "fresh snow"; else c.surface = r() < 0.5 ? "clear black ice" : r() < 0.7 ? "fresh snow" : "wind-packed snow"; } return { cells, hotspot: [hx, hy], seed }; } // There has to be a safe path from the start to a spot with enough fish. function solvable(lake) { const at = (x, y) => lake.cells[y * COLS + x]; const seen = new Set(["4,0"]); const queue = [[4, 0]]; while (queue.length) { const [x, y] = queue.shift(); if (at(x, y).fish >= FISH_GOAL) return true; for (const [dx, dy] of Object.values(DIRS)) { const nx = x + dx, ny = y + dy, k = `${nx},${ny}`; if (inBounds(nx, ny) && !seen.has(k) && at(nx, ny).inches >= SAFE_INCHES) { seen.add(k); queue.push([nx, ny]); } } } return false; } export function createLake(seed = 1) { let s = seed; let lake = buildLake(s); while (!solvable(lake)) lake = buildLake(++s); return { ...lake, sled: [4, 0], shanty: null, holes: 0, tracks: new Set(["4,0"]), // cells the sled has crossed, for the trail // What the agent has learned so far. Drives the "model's eye" view. known: new Map(), // key "x,y" -> { surface?: string, inches?: number } }; } export const cellAt = (lake, x, y) => lake.cells[y * COLS + x]; function learn(lake, x, y, fact) { const k = `${x},${y}`; lake.known.set(k, { ...(lake.known.get(k) || {}), ...fact }); } // --------------------------------------------------------------------------- // Tool definitions, exactly as they are sent to the API. const direction = (withHere) => ({ type: "string", enum: withHere ? ["here", "north", "south", "east", "west"] : ["north", "south", "east", "west"], description: withHere ? "Where to drill: your own spot, or the spot one step away in that direction." : "Which way to step.", }); export const TOOLS = [ { name: "look_around", description: "Look at the ice surface in the eight spots around the sled. Surface is only a hint: fresh snow can hide thin ice.", input_schema: { type: "object", properties: {}, required: [], additionalProperties: false }, strict: true, }, { name: "measure_ice", description: "Drill a hole with the auger and measure the ice thickness, in inches, at your spot or one step away.", input_schema: { type: "object", properties: { direction: direction(true) }, required: ["direction"], additionalProperties: false }, strict: true, }, { name: "sonar", description: "Read the fish finder at your spot. Returns fish marks here (0 to 8), the depth, and which directions read stronger.", input_schema: { type: "object", properties: {}, required: [], additionalProperties: false }, strict: true, }, { name: "move", description: `Pull the sled one step. Refused unless you measured that spot first and it is at least ${SAFE_INCHES} inches thick.`, input_schema: { type: "object", properties: { direction: direction(false) }, required: ["direction"], additionalProperties: false }, strict: true, }, { name: "set_up_shanty", description: `Set up the ice shanty at your spot and start fishing. Only works where sonar reads at least ${FISH_GOAL} fish marks.`, input_schema: { type: "object", properties: {}, required: [], additionalProperties: false }, strict: true, }, ]; export const SYSTEM_PROMPT = `You are guiding an ice-fishing sled across a frozen Michigan lake in January. The lake is a grid. Columns A to I run west to east, rows 1 to 7 run south to north. North is up. You cannot see the lake. You only learn about it through your tools. Rules: - Never step onto ice you have not measured. ${SAFE_INCHES} inches is the minimum. - Open water and pressure ridges are never safe. - Keep your notes to the user short: one sentence before your tool calls is plenty. When the shanty is up, tell the user where you set up and why, in two sentences or fewer.`; export const TASK = `We're at the public access on the south shore, standing on the ice at E1 with the sled. Find a spot where the fish are biting (${FISH_GOAL} or more marks on sonar) and set up the shanty there. Stay safe.`; // --------------------------------------------------------------------------- // Running a tool. Returns the text the model will read, whether it is an // error, and a small event the scene uses for its animation. function neighbor(lake, dirName) { const [dx, dy] = DIRS[dirName]; return [lake.sled[0] + dx, lake.sled[1] + dy]; } export function runTool(lake, name, input = {}) { const [sx, sy] = lake.sled; const here = cellName(sx, sy); const ok = (data, event) => ({ text: JSON.stringify(data), isError: false, event }); const fail = (message, event) => ({ text: JSON.stringify({ error: message }), isError: true, event }); if (lake.shanty && name !== "look_around" && name !== "sonar") { return fail("The shanty is already up. You're done.", { type: "noop" }); } switch (name) { case "look_around": { const around = {}; const cells = []; for (let dy = 1; dy >= -1; dy--) { for (let dx = -1; dx <= 1; dx++) { if (!dx && !dy) continue; const x = sx + dx, y = sy + dy; const label = `${dy > 0 ? "north" : dy < 0 ? "south" : ""}${dx > 0 ? "east" : dx < 0 ? "west" : ""}`; if (!inBounds(x, y)) { around[label] = "shoreline"; continue; } const c = cellAt(lake, x, y); around[label] = `${cellName(x, y)}: ${c.surface}`; learn(lake, x, y, { surface: c.surface }); cells.push([x, y]); } } return ok({ position: here, around }, { type: "look", cells }); } case "measure_ice": { const dir = input.direction; if (dir !== "here" && !DIRS[dir]) return fail(`Unknown direction "${dir}".`, { type: "noop" }); const [x, y] = dir === "here" ? [sx, sy] : neighbor(lake, dir); if (!inBounds(x, y)) return fail(`${dir} of ${here} is shoreline, not ice.`, { type: "noop" }); const c = cellAt(lake, x, y); lake.holes++; c.drilled = true; learn(lake, x, y, { inches: c.inches, surface: c.surface }); if (c.inches === 0) return ok({ spot: cellName(x, y), ice_inches: 0, note: "Open water. The auger never touched ice." }, { type: "drill", cell: [x, y], inches: 0 }); return ok( { spot: cellName(x, y), ice_inches: c.inches, safe_to_walk: c.inches >= SAFE_INCHES, holes_drilled: lake.holes }, { type: "drill", cell: [x, y], inches: c.inches }, ); } case "sonar": { const c = cellAt(lake, sx, sy); const stronger = []; for (const [d, [dx, dy]] of Object.entries(DIRS)) { const x = sx + dx, y = sy + dy; if (inBounds(x, y) && cellAt(lake, x, y).signal > c.signal) stronger.push([d, cellAt(lake, x, y).signal]); } stronger.sort((a, b) => b[1] - a[1]); learn(lake, sx, sy, { fish: c.fish }); const depth = 6 + c.fish * 2 + ((sx * 7 + sy * 3) % 3); return ok( { spot: here, fish_marks: c.fish, depth_feet: depth, stronger_toward: stronger.map(([d]) => d) }, { type: "sonar", cell: [sx, sy], fish: c.fish }, ); } case "move": { const dir = input.direction; if (!DIRS[dir]) return fail(`Unknown direction "${dir}".`, { type: "noop" }); const [x, y] = neighbor(lake, dir); if (!inBounds(x, y)) return fail(`Can't go ${dir} from ${here}: that's the shoreline.`, { type: "blocked", cell: [sx, sy] }); const c = cellAt(lake, x, y); const k = lake.known.get(`${x},${y}`); if (!k || k.inches === undefined) { return fail(`Refused. You haven't measured ${cellName(x, y)} yet. Measure it before you step there.`, { type: "blocked", cell: [x, y] }); } if (c.inches < SAFE_INCHES) { return fail(`Refused. ${cellName(x, y)} is ${c.inches} inches. The minimum is ${SAFE_INCHES}.`, { type: "blocked", cell: [x, y] }); } lake.sled = [x, y]; lake.tracks.add(`${x},${y}`); return ok({ position: cellName(x, y), moved: dir }, { type: "move", from: [sx, sy], to: [x, y] }); } case "set_up_shanty": { const c = cellAt(lake, sx, sy); if (c.fish < FISH_GOAL) { return fail(`Only ${c.fish} fish marks at ${here}. Find a spot with ${FISH_GOAL} or more.`, { type: "blocked", cell: [sx, sy] }); } lake.shanty = [sx, sy]; return ok({ shanty: here, fish_marks: c.fish, status: "Shanty is up. Lines are in the water." }, { type: "shanty", cell: [sx, sy] }); } default: return fail(`There is no tool named "${name}".`, { type: "noop" }); } }