← Public packages
@kentcdodds/world-builder
Build a shared 3D voxel world with friends and your AI agents, live.
src/lib/world-ops.ts
401 lines · 14.6 KB · TypeScriptimport {
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/<slug>"). */
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<string, unknown>): 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/<code>") 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<string, unknown>
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<BlockOp>
buildId?: string | null
}) {
const { player, worldId } = input
// Dedupe by cell: last op wins.
const byCell = new Map<string, BlockOp>()
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<BlockOp, { kind: 'put' }>]
>
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<number>> = 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))