import { randomUUID } from 'node:crypto' import { readFile } from 'node:fs/promises' import { dirname, join, resolve } from 'node:path' import { fileURLToPath } from 'node:url' const root = resolve(dirname(fileURLToPath(import.meta.url)), '..') async function loadRootEnvironment() { const contents = await readFile(join(root, '.env'), 'utf8') for (const line of contents.split(/\r?\n/)) { const match = line.match(/^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)=(.*)$/) if (!match || process.env[match[1]] !== undefined) continue let value = match[2].trim() if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) value = value.slice(1, -1) process.env[match[1]] = value } } function required(name) { const value = process.env[name]?.trim() if (!value) throw new Error(`[smoke] ${name} is required.`) return value } await loadRootEnvironment() const supabaseUrl = required('SUPABASE_URL').replace(/\/$/, '') const anonKey = required('NUXT_PUBLIC_SUPABASE_ANON_KEY') const serviceKey = required('SUPABASE_SERVICE_ROLE_KEY') const appUrl = (process.env.DNG_SMOKE_BASE_URL?.trim() || 'http://127.0.0.1:3001').replace(/\/$/, '') async function jsonRequest(url, options = {}) { const response = await fetch(url, options) const payload = await response.json().catch(() => null) if (!response.ok) { const message = payload?.statusMessage || payload?.message || payload?.msg || JSON.stringify(payload) || response.statusText throw new Error(`[smoke] ${options.method || 'GET'} ${url} -> ${response.status}: ${message}`) } return payload } function serviceHeaders() { return { apikey: serviceKey, ...(serviceKey.split('.').length === 3 ? { Authorization: `Bearer ${serviceKey}` } : {}), 'Content-Type': 'application/json', } } async function createEmailAccount(label) { const email = `smoke-${randomUUID()}@example.com` const password = `Smoke-${randomUUID()}!aA1` const user = await jsonRequest(`${supabaseUrl}/auth/v1/admin/users`, { method: 'POST', headers: serviceHeaders(), body: JSON.stringify({ email, password, email_confirm: true, user_metadata: { display_name: label } }), }) const session = await jsonRequest(`${supabaseUrl}/auth/v1/token?grant_type=password`, { method: 'POST', headers: { apikey: anonKey, 'Content-Type': 'application/json' }, body: JSON.stringify({ email, password }), }) if (!session?.access_token || !session?.user?.id) throw new Error('[smoke] Supabase returned an incomplete email session.') if (session.user.id !== user.id) throw new Error('[smoke] Email login returned the wrong user.') return { token: session.access_token, id: session.user.id, email } } function appRequest(path, token, options = {}) { return jsonRequest(`${appUrl}${path}`, { ...options, headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json', ...options.headers }, }) } async function servicePatch(path, body) { await jsonRequest(`${supabaseUrl}/rest/v1/${path}`, { method: 'PATCH', headers: { ...serviceHeaders(), Prefer: 'return=representation', }, body: JSON.stringify(body), }) } const suffix = Date.now().toString(36).toUpperCase() console.log('[smoke] Creating two confirmed email accounts…') const [owner, player] = await Promise.all([ createEmailAccount(`Smoke Owner ${suffix}`), createEmailAccount(`Smoke Player ${suffix}`), ]) console.log('[smoke] Creating a private world and campaign through the public API…') const sessionResult = await appRequest('/api/v1/coauthor/sessions', owner.token, { method: 'POST', body: JSON.stringify({ message: 'A sealed orbital garden wakes after a century of silence.' }), }) const sessionId = sessionResult?.session?.id if (!sessionId) throw new Error('[smoke] Coauthor session id is missing.') const entity = (kind, name, summary) => ({ id: randomUUID(), kind, name, summary, tags: [], secrets: [] }) const world = { title: `Silent Garden ${suffix}`, genre: 'Science-Fiction Mystery', tone: 'Hopeful, tense, and strange', premise: 'Two salvagers enter a sealed orbital garden whose caretaker has been waiting for people who do not exist yet.', contentBoundaries: ['13+ adventure', 'No explicit sexual content', 'No graphic gore'], startingLocation: entity('location', 'Airlock Orchard', 'A frost-covered orchard grows around the station airlock.'), npcs: [ entity('npc', 'Caretaker Ilex', 'A patient horticultural intelligence with fragmented memories.'), entity('npc', 'Dr. Sable', 'A missing botanist whose messages arrive out of order.'), entity('npc', 'Moth-9', 'A damaged pollination drone that follows warm voices.'), ], factions: [ entity('faction', 'The Reclaimers', 'Salvagers who want the station stripped for parts.'), entity('faction', 'The Seed Vault', 'An automated network protecting the last viable specimens.'), ], companions: [1, 2, 3].map(index => ({ name: `Garden Companion ${index}`, concept: `A universe-specific caretaker hero assigned to garden sector ${index}.`, abilities: { str: 10, dex: 12, con: 11, int: 13, wis: 12, cha: 10 }, hp: 11, maxHp: 11, defense: 12, proficiency: 2, inventory: ['Garden survey kit'], persona: { voice: 'Calm and observant.', motivation: 'Protect the living garden.', flaw: 'Trusts the station too much.', bond: 'Treats the party as new growth.' }, })), hook: 'The airlock opens only after both visitors speak names the station already knows.', hiddenThreat: 'The garden predicts visitors by growing imperfect biological copies of them.', openingScene: 'The inner airlock opens on warm rain and rows of silver trees. A voice welcomes both salvagers by name, then asks why one of them has returned without the other.', } await servicePatch(`coauthor_sessions?id=eq.${sessionId}`, { status: 'ready', generated_world: world }) const { worldId } = await appRequest(`/api/v1/coauthor/sessions/${sessionId}/confirm`, owner.token, { method: 'POST', body: JSON.stringify({ world }), }) const { campaignId } = await appRequest('/api/v1/campaigns', owner.token, { method: 'POST', body: JSON.stringify({ worldId, title: `Smoke Party ${suffix}` }), }) const starterParty = await appRequest(`/api/v1/campaigns/${campaignId}`, owner.token) const ownerCharacter = starterParty.characters.find(character => character.user_id === owner.id && character.controller === 'human') const automaticCompanions = starterParty.characters.filter(character => character.controller === 'ai') if (!ownerCharacter || automaticCompanions.length !== world.companions.length || automaticCompanions.some(character => character.name === 'Echo') || starterParty.round?.status !== 'open') { throw new Error('[smoke] A new campaign did not start with its actionable owner hero, generated world companions, and an open round.') } console.log('[smoke] Joining the second player through an invite…') const { token: inviteToken } = await appRequest(`/api/v1/campaigns/${campaignId}/invites`, owner.token, { method: 'POST', body: JSON.stringify({ maxUses: 2, expiresInHours: 1 }), }) const joined = await appRequest('/api/v1/invites/join', player.token, { method: 'POST', body: JSON.stringify({ token: inviteToken }), }) if (joined.campaignId !== campaignId) throw new Error('[smoke] Invite joined the wrong campaign.') const characterBody = (name, concept) => JSON.stringify({ name, concept, controller: 'human', abilities: { str: 10, dex: 10, con: 10, int: 12, wis: 11, cha: 9 }, hp: 12, maxHp: 12, defense: 12, proficiency: 2, inventory: [], statuses: [], persona: {}, }) const playerCharacter = await appRequest(`/api/v1/campaigns/${campaignId}/characters`, player.token, { method: 'POST', body: characterBody('Rowan Pike', 'A curious xenobotanist.'), }) console.log('[smoke] Verifying AI drafting while keeping the automatic companion…') const suggested = await appRequest(`/api/v1/campaigns/${campaignId}/characters/suggest`, owner.token, { method: 'POST', body: JSON.stringify({ controller: 'ai', concept: 'A damaged garden caretaker who values living things over orders.' }), }) if (!suggested?.character?.name || !suggested.character?.persona?.motivation) { throw new Error('[smoke] Character coauthor returned an incomplete draft.') } const initial = await appRequest(`/api/v1/campaigns/${campaignId}`, owner.token) if (initial.members.length !== 2) throw new Error(`[smoke] Expected 2 members, received ${initial.members.length}.`) if (initial.characters.length !== 5 || initial.round?.status !== 'open') throw new Error('[smoke] Multiplayer lobby did not expose two humans, three generated AI companions, and an open round.') const roundId = initial.round.id console.log('[smoke] Verifying that the round waits for both humans…') await appRequest(`/api/v1/campaigns/${campaignId}/rounds/${roundId}/intent`, owner.token, { method: 'PUT', body: JSON.stringify({ characterId: ownerCharacter.id, action: 'The owner checks the airlock telemetry for a safe path.', ready: true }), }) await new Promise(resolve => setTimeout(resolve, 1_500)) const waiting = await appRequest(`/api/v1/campaigns/${campaignId}`, player.token) if (waiting.round?.status !== 'open' || !waiting.intents.some(intent => intent.ready)) { throw new Error('[smoke] The round did not remain open while waiting for the second player.') } console.log('[smoke] Submitting the second action and waiting for the worker…') await appRequest(`/api/v1/campaigns/${campaignId}/rounds/${roundId}/intent`, player.token, { method: 'PUT', body: JSON.stringify({ characterId: playerCharacter.character.id, action: 'Rowan studies the silver trees for signs of movement.', ready: true }), }) const deadline = Date.now() + 120_000 let completed while (Date.now() < deadline) { await new Promise(resolve => setTimeout(resolve, 2_000)) const next = await appRequest(`/api/v1/campaigns/${campaignId}`, owner.token) const resolved = next.rounds.find(round => round.id === roundId) if (resolved?.status === 'failed') throw new Error(`[smoke] Worker failed the multiplayer round: ${resolved.error || 'unknown error'}`) if (resolved?.status === 'resolved' && next.round?.status === 'open' && next.round.id !== roundId) { completed = next break } } if (!completed) throw new Error('[smoke] Multiplayer round did not resolve within 120 seconds.') if (!completed.rounds.find(round => round.id === roundId)?.narration) throw new Error('[smoke] Resolved round has no narration.') console.log(`[smoke] PASS — 2 players, AI character drafting, persistent companion, shared readiness, worker resolution, and next round all work (campaign ${campaignId}).`)