Files
Dungeons-Ground/scripts/smoke-multiplayer.mjs
pavel444-byte cdeea8653c
All checks were successful
CI / validate (push) Successful in 14m3s
Add AI-assisted world and character drafting flows
2026-08-17 09:25:37 +05:00

187 lines
9.3 KiB
JavaScript

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
}
async function createGuest(label) {
const session = await jsonRequest(`${supabaseUrl}/auth/v1/signup`, {
method: 'POST',
headers: { apikey: anonKey, 'Content-Type': 'application/json' },
body: JSON.stringify({ data: { display_name: label }, gotrue_meta_security: {} }),
})
if (!session?.access_token || !session?.user?.id) throw new Error('[smoke] Supabase returned an incomplete anonymous session.')
return { token: session.access_token, id: session.user.id }
}
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) {
const serviceAuthorization = serviceKey.split('.').length === 3
? { Authorization: `Bearer ${serviceKey}` }
: {}
await jsonRequest(`${supabaseUrl}/rest/v1/${path}`, {
method: 'PATCH',
headers: {
apikey: serviceKey,
...serviceAuthorization,
'Content-Type': 'application/json',
Prefer: 'return=representation',
},
body: JSON.stringify(body),
})
}
const suffix = Date.now().toString(36).toUpperCase()
console.log('[smoke] Creating two isolated guest sessions…')
const [owner, player] = await Promise.all([
createGuest(`Smoke Owner ${suffix}`),
createGuest(`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.'),
],
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}` }),
})
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 ownerCharacter = await appRequest(`/api/v1/campaigns/${campaignId}/characters`, owner.token, {
method: 'POST', body: characterBody('Iris Vale', 'A methodical station engineer.'),
})
const playerCharacter = await appRequest(`/api/v1/campaigns/${campaignId}/characters`, player.token, {
method: 'POST', body: characterBody('Rowan Pike', 'A curious xenobotanist.'),
})
console.log('[smoke] Drafting and adding a persistent AI 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.')
}
await appRequest(`/api/v1/campaigns/${campaignId}/characters`, owner.token, {
method: 'POST',
body: JSON.stringify({ ...suggested.character, controller: 'ai', statuses: [] }),
})
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 !== 3 || initial.round?.status !== 'open') throw new Error('[smoke] Multiplayer lobby did not expose two humans, one AI companion, 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.character.id, action: 'Iris 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}).`)