import { BOUNDS, DEFAULT_WORLD_ID, LIMITS, MATERIAL_KEYS, blockId, cleanText, inBounds, } from '../shared/world-rules.ts' import { num, sql, transaction } from './db.ts' import type { PlayerIdentity } from './identity.ts' export class WorldError extends Error { status: number constructor(message: string, status = 400) { super(message) this.status = status } } const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i export function requireWorldId(value: unknown) { const id = value == null || value === '' ? DEFAULT_WORLD_ID : String(value) if (!UUID.test(id)) throw new WorldError('worldId must be a world id from listWorlds.') return id.toLowerCase() } export function requireUuid(value: unknown, label: string) { const id = String(value ?? '') if (!UUID.test(id)) throw new WorldError(`${label} must be an id returned by Kody World Builder.`) return id.toLowerCase() } const SLUG = /^[a-z0-9]{4,24}$/ const ROOM_ALPHABET = 'abcdefghjkmnpqrstuvwxyz23456789' const ADJECTIVES = ['Sunny', 'Misty', 'Golden', 'Hidden', 'Floating', 'Mossy', 'Crystal', 'Windy', 'Cozy', 'Glowing', 'Ancient', 'Snowy', 'Coral', 'Amber', 'Velvet', 'Starry'] const PLACES = ['Valley', 'Harbor', 'Meadow', 'Island', 'Canyon', 'Grove', 'Summit', 'Lagoon', 'Plateau', 'Village', 'Cove', 'Ridge', 'Garden', 'Orchard', 'Fjord', 'Dunes'] /** 10 characters from a 31-letter alphabet: ~49 bits, unguessable enough for share links. */ export function newRoomSlug() { const bytes = crypto.getRandomValues(new Uint8Array(10)) return Array.from(bytes, (b) => ROOM_ALPHABET[b % ROOM_ALPHABET.length]).join('') } export function newRoomName() { const [a, b] = crypto.getRandomValues(new Uint8Array(2)) return `${ADJECTIVES[a! % ADJECTIVES.length]} ${PLACES[b! % PLACES.length]}` } /** Room slug from a slug or a full room URL (".../r/"). */ export function parseRoomRef(value: unknown) { const raw = String(value ?? '').trim() const m = /\/r\/([a-z0-9]+)/i.exec(raw) const slug = (m ? m[1]! : raw).toLowerCase() return SLUG.test(slug) ? slug : null } export type Room = { id: string; slug: string; name: string; ownerId: string } function toRoom(r: Record): Room { return { id: String(r.id), slug: String(r.slug ?? ''), name: String(r.name), ownerId: String(r.created_by) } } export async function getRoom(slug: string) { const rows = await sql('select id, slug, name, created_by from worlds where slug = $1', [slug]) if (!rows[0]) throw new WorldError('That room does not exist. Check the room link.', 404) return toRoom(rows[0]) } export async function createRoom(ownerId: string) { await rateLimit(`rooms:${ownerId}`, 1, 10) for (let attempt = 0; attempt < 3; attempt++) { const rows = await sql( `insert into worlds (id, name, created_by, slug) values (gen_random_uuid(), $1, $2, $3) on conflict (slug) do nothing returning id, slug, name, created_by`, [newRoomName(), ownerId, newRoomSlug()], ) if (rows[0]) return toRoom(rows[0]) } throw new WorldError('Could not create a room. Try again.', 503) } export async function renameRoom(slug: string, ownerId: string, name: unknown) { const clean = cleanText(name, LIMITS.maxNameLength) if (!clean) throw new WorldError('Give the room a name.') const room = await getRoom(slug) if (room.ownerId !== ownerId) throw new WorldError('Only the room owner can rename it.', 403) await rateLimit(`rename:${ownerId}`, 1, 30) const rows = await sql('update worlds set name = $2 where id = $1 returning id, slug, name, created_by', [room.id, clean]) return toRoom(rows[0]!) } /** World id from `{ room }` (slug or room URL) or `{ worldId }`; defaults to the main world. */ export async function resolveWorld(input: { room?: unknown; worldId?: unknown } | undefined) { if (input?.room != null && input.room !== '') { const slug = parseRoomRef(input.room) if (!slug) throw new WorldError('room must be the room link (".../r/") or its code.') return (await getRoom(slug)).id } return requireWorldId(input?.worldId) } export async function assertWorld(worldId: string) { const rows = await sql('select id, name from worlds where id = $1', [worldId]) if (!rows[0]) throw new WorldError('That world does not exist.', 404) return { id: String(rows[0].id), name: String(rows[0].name) } } /** Fixed one-minute window counter in Postgres. Throws 429 past `limit`. */ export async function rateLimit(key: string, amount: number, limit: number) { const window = Math.floor(Date.now() / 60000) const rows = await sql( `insert into rate_limits (key, window_start, count) values ($1, $2, $3) on conflict (key) do update set count = case when rate_limits.window_start = $2 then rate_limits.count + $3 else $3 end, window_start = $2 returning count`, [key, window, amount], ) const count = num(rows[0]?.count) if (count > limit) { throw new WorldError( `Slow down: limit is ${limit} per minute. Try again in a few seconds.`, 429, ) } } export async function upsertPlayer(player: PlayerIdentity, worldId: string) { await sql( `insert into players (id, world_id, kind, owner_id, display_name, color, last_seen) values ($1, $2, $3, $4, $5, $6, now()) on conflict (id) do update set world_id = excluded.world_id, display_name = excluded.display_name, color = excluded.color, last_seen = now()`, [player.id, worldId, player.kind, player.ownerId, player.displayName, player.color], ) } export type BlockOp = | { kind: 'put'; x: number; y: number; z: number; material: string } | { kind: 'del'; x: number; y: number; z: number } export function parseBlockOp(raw: unknown, kind: 'put' | 'del'): BlockOp | null { if (!raw || typeof raw !== 'object') return null const r = raw as Record const x = Number(r.x) const y = Number(r.y) const z = Number(r.z) if (!inBounds(x, y, z)) return null if (kind === 'del') return { kind, x, y, z } const material = String(r.material ?? '') if (!MATERIAL_KEYS.has(material)) return null return { kind, x, y, z, material } } /** * Validate, rate-limit, and apply block writes for one player in one world. * Last write wins per cell. Returns counts. */ export async function applyBlockOps(input: { player: PlayerIdentity worldId: string ops: Array buildId?: string | null }) { const { player, worldId } = input // Dedupe by cell: last op wins. const byCell = new Map() for (const op of input.ops) byCell.set(blockId(worldId, op.x, op.y, op.z), op) const ops = [...byCell.entries()] if (ops.length === 0) return { placed: 0, removed: 0 } const limit = player.kind === 'agent' ? LIMITS.agentBlocksPerMinute : LIMITS.humanBlocksPerMinute await rateLimit(`blocks:${player.id}`, ops.length, limit) // Also cap each person across all of their agents, so new agent names can't multiply the limit. await rateLimit(`owner-blocks:${player.ownerId}`, ops.length, LIMITS.agentBlocksPerMinute) const puts = ops.filter(([, op]) => op.kind === 'put') as Array< [string, Extract] > const dels = ops.filter(([, op]) => op.kind === 'del').map(([id]) => id) if (puts.length) { const rows = await sql('select count(*)::int as n from blocks where world_id = $1', [worldId]) if (num(rows[0]?.n) + puts.length > LIMITS.maxBlocksPerWorld) { throw new WorldError('This world is full. Remove some blocks first.', 409) } } const queries = [] if (puts.length) { queries.push({ query: `insert into blocks (id, world_id, x, y, z, material, build_id, placed_by) select t.id, $1::uuid, t.x, t.y, t.z, t.m, $2::uuid, $3 from unnest($4::text[], $5::int[], $6::int[], $7::int[], $8::text[]) as t(id, x, y, z, m) on conflict (id) do update set material = excluded.material, build_id = excluded.build_id, placed_by = excluded.placed_by, placed_at = now()`, params: [ worldId, input.buildId ?? null, player.id, puts.map(([id]) => id), puts.map(([, o]) => o.x), puts.map(([, o]) => o.y), puts.map(([, o]) => o.z), puts.map(([, o]) => o.material), ], }) } if (dels.length) { queries.push({ query: 'delete from blocks where world_id = $1 and id = any($2::text[])', params: [worldId, dels], }) } if (input.buildId) { queries.push({ query: `update builds set block_count = block_count + $2, status = case when status = 'planning' then 'building' else status end where id = $1`, params: [input.buildId, puts.length], }) } queries.push({ query: 'update players set last_seen = now(), world_id = $2 where id = $1', params: [player.id, worldId], }) await transaction(queries) return { placed: puts.length, removed: dels.length } } export async function updateCursor( player: PlayerIdentity, worldId: string, cursor: { x: number; y: number; z: number } | null, ) { await rateLimit(`cursor:${player.id}`, 1, LIMITS.cursorUpdatesPerMinute) const ok = cursor && inBounds(cursor.x, cursor.y, cursor.z) await sql( `update players set cursor_x = $2, cursor_y = $3, cursor_z = $4, last_seen = now(), world_id = $5 where id = $1`, [player.id, ok ? cursor!.x : null, ok ? cursor!.y : null, ok ? cursor!.z : null, worldId], ) } /** The main world plus rooms `ownerId` created (rooms are private to their link). */ export async function listWorlds(ownerId: string) { const rows = await sql( `select w.id, w.slug, w.name, w.created_at, (select count(*)::int from blocks b where b.world_id = w.id) as block_count, (select count(*)::int from players p where p.world_id = w.id and p.last_seen > now() - interval '2 minutes') as active_players from worlds w where w.slug = 'main' or w.created_by = $1 order by w.created_at desc limit 50`, [ownerId], ) return rows.map((r) => ({ id: String(r.id), room: r.slug == null ? null : String(r.slug), name: String(r.name), blockCount: num(r.block_count), activePlayers: num(r.active_players), })) } export async function createWorld(caller: PlayerIdentity, name: unknown) { const clean = cleanText(name, LIMITS.maxNameLength) if (!clean) throw new WorldError('Give the world a name.') await rateLimit(`worlds:${caller.ownerId}`, 1, 5) const rows = await sql( `insert into worlds (id, name, created_by, slug) values (gen_random_uuid(), $1, $2, $3) returning id, slug, name`, [clean, caller.ownerId, newRoomSlug()], ) return { id: String(rows[0]!.id), room: String(rows[0]!.slug), name: String(rows[0]!.name) } } export async function describeWorld( worldId: string, region?: { minX: number; maxX: number; minY: number; maxY: number; minZ: number; maxZ: number }, ) { const world = await assertWorld(worldId) const [stats, materials, heights, builds] = await Promise.all([ sql( `select count(*)::int n, min(x) min_x, max(x) max_x, min(y) min_y, max(y) max_y, min(z) min_z, max(z) max_z from blocks where world_id = $1`, [worldId], ), sql( `select material, count(*)::int n from blocks where world_id = $1 group by material order by n desc`, [worldId], ), sql( `select floor((x - $2) / 8.0)::int cx, floor((z - $3) / 8.0)::int cz, max(y)::int h from blocks where world_id = $1 group by 1, 2`, [worldId, BOUNDS.minX, BOUNDS.minZ], ), sql( `select b.id, b.prompt, b.status, b.block_count, p.display_name author from builds b left join players p on p.id = b.author_id where b.world_id = $1 order by b.created_at desc limit 10`, [worldId], ), ]) const s = stats[0] ?? {} const cols = Math.ceil((BOUNDS.maxX - BOUNDS.minX + 1) / 8) const rowsN = Math.ceil((BOUNDS.maxZ - BOUNDS.minZ + 1) / 8) const grid: Array> = Array.from({ length: rowsN }, () => Array(cols).fill(-1)) for (const h of heights) { const cz = num(h.cz) const cx = num(h.cx) if (grid[cz] && cx >= 0 && cx < cols) grid[cz]![cx] = num(h.h) } let blocks: Array<{ x: number; y: number; z: number; material: string }> | undefined if (region) { const r = region const rows = await sql( `select x, y, z, material from blocks where world_id = $1 and x between $2 and $3 and y between $4 and $5 and z between $6 and $7 order by y, z, x limit 2000`, [worldId, r.minX, r.maxX, r.minY, r.maxY, r.minZ, r.maxZ], ) blocks = rows.map((b) => ({ x: num(b.x), y: num(b.y), z: num(b.z), material: String(b.material) })) } return { world, rules: { bounds: BOUNDS, materials: [...MATERIAL_KEYS], yIsUp: true, groundLevel: 0, maxBlocksPerCall: LIMITS.maxAgentBlocksPerCall, }, blockCount: num(s.n), occupiedBounds: num(s.n) > 0 ? { minX: num(s.min_x), maxX: num(s.max_x), minY: num(s.min_y), maxY: num(s.max_y), minZ: num(s.min_z), maxZ: num(s.max_z), } : null, materials: materials.map((m) => ({ material: String(m.material), count: num(m.n) })), heightMap: { cellSize: 8, origin: { x: BOUNDS.minX, z: BOUNDS.minZ }, note: 'rows are z cells, columns are x cells; value is the tallest block y in that 8x8 cell, -1 when empty', cells: grid, }, recentBuilds: builds.map((b) => ({ id: String(b.id), prompt: b.prompt == null ? null : String(b.prompt), status: String(b.status), blockCount: num(b.block_count), author: b.author == null ? null : String(b.author), })), ...(blocks ? { blocks } : {}), } } export async function startBuild(player: PlayerIdentity, worldId: string, prompt: unknown) { await assertWorld(worldId) await rateLimit(`builds:${player.id}`, 1, 20) await upsertPlayer(player, worldId) const rows = await sql( `insert into builds (id, world_id, author_id, prompt, status) values (gen_random_uuid(), $1, $2, $3, 'planning') returning id`, [worldId, player.id, cleanText(prompt, LIMITS.maxPromptLength) || null], ) return String(rows[0]!.id) } /** The build, checked to belong to an agent owned by `ownerId`. */ export async function ownedBuild(buildId: string, ownerId: string) { const rows = await sql( `select b.id, b.world_id, b.status, b.author_id, p.owner_id, p.kind, p.display_name, p.color from builds b join players p on p.id = b.author_id where b.id = $1`, [buildId], ) const row = rows[0] if (!row) throw new WorldError('That build does not exist.', 404) if (String(row.owner_id) !== ownerId) throw new WorldError('That build belongs to someone else.', 403) return { id: String(row.id), worldId: String(row.world_id), status: String(row.status), player: { id: String(row.author_id), kind: String(row.kind) as 'agent' | 'human', ownerId: String(row.owner_id), displayName: String(row.display_name), color: String(row.color), } satisfies PlayerIdentity, } } export async function finishBuild(buildId: string, status: 'done' | 'failed') { await sql(`update builds set status = $2 where id = $1`, [buildId, status]) } export const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms))