Important Fixes, New mechanics, and many more
Some checks failed
CI / validate (push) Failing after 9m21s
Some checks failed
CI / validate (push) Failing after 9m21s
This commit is contained in:
89
scripts/build-supabase-bootstrap.mjs
Normal file
89
scripts/build-supabase-bootstrap.mjs
Normal file
@@ -0,0 +1,89 @@
|
||||
import { readdir, readFile, writeFile } from 'node:fs/promises'
|
||||
import { dirname, join, resolve } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
const root = resolve(dirname(fileURLToPath(import.meta.url)), '..')
|
||||
const migrationsDirectory = join(root, 'supabase', 'migrations')
|
||||
const outputPath = join(root, 'supabase', 'bootstrap.sql')
|
||||
const migrationNames = (await readdir(migrationsDirectory))
|
||||
.filter(name => /^\d+_.+\.sql$/.test(name))
|
||||
.sort((left, right) => left.localeCompare(right))
|
||||
|
||||
if (migrationNames.length === 0) throw new Error('No Supabase migrations were found.')
|
||||
|
||||
const sections = await Promise.all(migrationNames.map(async (name) => {
|
||||
const sql = await readFile(join(migrationsDirectory, name), 'utf8')
|
||||
return `\n-- ============================================================================\n-- ${name}\n-- ============================================================================\n\n${sql.trim()}\n`
|
||||
}))
|
||||
|
||||
const preflight = `
|
||||
do $$
|
||||
begin
|
||||
if to_regclass('auth.users') is null then
|
||||
raise exception 'D&G bootstrap preflight failed: auth.users is missing';
|
||||
end if;
|
||||
if not exists (
|
||||
select 1
|
||||
from information_schema.columns
|
||||
where table_schema = 'auth'
|
||||
and table_name = 'users'
|
||||
and column_name = 'is_anonymous'
|
||||
) then
|
||||
raise exception 'D&G bootstrap preflight failed: auth.users.is_anonymous is missing';
|
||||
end if;
|
||||
if to_regclass('public.profiles') is not null
|
||||
or to_regclass('public.ai_jobs') is not null
|
||||
or to_regtype('public.member_role') is not null
|
||||
or to_regprocedure('public.claim_ai_job(text)') is not null then
|
||||
raise exception 'D&G bootstrap preflight failed: a partial/existing D&G schema was found. Do not run the fresh bootstrap over it.';
|
||||
end if;
|
||||
end;
|
||||
$$;
|
||||
`
|
||||
|
||||
const verification = `
|
||||
do $$
|
||||
begin
|
||||
if to_regclass('public.profiles') is null then
|
||||
raise exception 'D&G bootstrap verification failed: public.profiles is missing';
|
||||
end if;
|
||||
if to_regclass('public.ai_jobs') is null then
|
||||
raise exception 'D&G bootstrap verification failed: public.ai_jobs is missing';
|
||||
end if;
|
||||
if to_regprocedure('public.claim_ai_job(text)') is null then
|
||||
raise exception 'D&G bootstrap verification failed: public.claim_ai_job(text) is missing';
|
||||
end if;
|
||||
if to_regprocedure('public.stage_two_create_campaign(uuid,uuid,text)') is null then
|
||||
raise exception 'D&G bootstrap verification failed: stage-two RPCs are missing';
|
||||
end if;
|
||||
if to_regprocedure('public.stage_two_retry_failed_round(uuid,uuid)') is null then
|
||||
raise exception 'D&G bootstrap verification failed: failed-round recovery RPC is missing';
|
||||
end if;
|
||||
if to_regprocedure('public.dng_schema_version()') is null then
|
||||
raise exception 'D&G bootstrap verification failed: schema version RPC is missing';
|
||||
end if;
|
||||
end;
|
||||
$$;
|
||||
|
||||
notify pgrst, 'reload schema';
|
||||
commit;
|
||||
|
||||
select
|
||||
'Dungeons & Ground database is ready' as result,
|
||||
to_regclass('public.profiles') as profiles,
|
||||
to_regclass('public.ai_jobs') as ai_jobs,
|
||||
to_regprocedure('public.claim_ai_job(text)') as worker_rpc;
|
||||
`
|
||||
|
||||
const output = `-- GENERATED FILE. Rebuild with: pnpm db:bundle
|
||||
-- Paste this entire file into a new Supabase SQL Editor query and click Run.
|
||||
-- It is intended for a fresh Dungeons & Ground project. The explicit
|
||||
-- transaction ensures a failure cannot leave a half-created schema.
|
||||
|
||||
begin;
|
||||
${preflight}
|
||||
${sections.join('')}
|
||||
${verification}`
|
||||
|
||||
await writeFile(outputPath, output)
|
||||
console.log(`Wrote ${outputPath} from ${migrationNames.length} migrations.`)
|
||||
183
scripts/ensure-supabase-schema.mjs
Normal file
183
scripts/ensure-supabase-schema.mjs
Normal file
@@ -0,0 +1,183 @@
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import { dirname, join, resolve } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import postgres from 'postgres'
|
||||
|
||||
const root = resolve(dirname(fileURLToPath(import.meta.url)), '..')
|
||||
|
||||
async function loadRootEnvironment() {
|
||||
let contents
|
||||
try {
|
||||
contents = await readFile(join(root, '.env'), 'utf8')
|
||||
} catch (error) {
|
||||
if (error && typeof error === 'object' && error.code === 'ENOENT') return
|
||||
throw error
|
||||
}
|
||||
|
||||
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(`[database] ${name} is required.`)
|
||||
return value
|
||||
}
|
||||
|
||||
function assertMatchingProject(databaseUrl, supabaseUrl) {
|
||||
let database
|
||||
let api
|
||||
try {
|
||||
database = new URL(databaseUrl)
|
||||
api = new URL(supabaseUrl)
|
||||
} catch {
|
||||
throw new Error('[database] SUPABASE_DB_URL and SUPABASE_URL must be valid URLs.')
|
||||
}
|
||||
if (!['postgres:', 'postgresql:'].includes(database.protocol)) {
|
||||
throw new Error('[database] SUPABASE_DB_URL must use the postgres:// or postgresql:// protocol.')
|
||||
}
|
||||
if (/\[(?:your-?)?password\]/i.test(databaseUrl) || /YOUR_PASSWORD/i.test(databaseUrl)) {
|
||||
throw new Error('[database] Replace the password placeholder in SUPABASE_DB_URL with your database password.')
|
||||
}
|
||||
|
||||
const projectRef = api.hostname.split('.')[0]
|
||||
const directMatch = database.hostname === `db.${projectRef}.supabase.co`
|
||||
const poolerMatch = database.username === `postgres.${projectRef}`
|
||||
if (!directMatch && !poolerMatch) {
|
||||
throw new Error('[database] SUPABASE_DB_URL does not belong to the project configured by SUPABASE_URL.')
|
||||
}
|
||||
}
|
||||
|
||||
async function dataApiHasSchema(supabaseUrl, serviceKey) {
|
||||
const base = supabaseUrl.replace(/\/$/, '')
|
||||
const headers = { apikey: serviceKey, Authorization: `Bearer ${serviceKey}`, 'Content-Type': 'application/json' }
|
||||
const [profiles, workerRpc, retryRpc, versionRpc] = await Promise.all([
|
||||
fetch(`${base}/rest/v1/profiles?select=id&limit=1`, { headers }),
|
||||
fetch(`${base}/rest/v1/rpc/claim_ai_job`, {
|
||||
method: 'POST', headers, body: JSON.stringify({ p_worker_id: '' }),
|
||||
}),
|
||||
fetch(`${base}/rest/v1/rpc/stage_two_retry_failed_round`, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify({
|
||||
p_round_id: '00000000-0000-0000-0000-000000000000',
|
||||
p_owner_id: '00000000-0000-0000-0000-000000000000',
|
||||
}),
|
||||
}),
|
||||
fetch(`${base}/rest/v1/rpc/dng_schema_version`, {
|
||||
method: 'POST', headers, body: '{}',
|
||||
}),
|
||||
])
|
||||
if ([profiles, workerRpc, retryRpc, versionRpc].some(response => response.status === 401 || response.status === 403)) {
|
||||
throw new Error('SUPABASE_SERVICE_ROLE_KEY was rejected by the configured project.')
|
||||
}
|
||||
if ([profiles, workerRpc, retryRpc, versionRpc].some(response => response.status === 404)) return false
|
||||
if (profiles.status !== 200 || workerRpc.status !== 400 || retryRpc.status !== 400 || versionRpc.status !== 200) {
|
||||
throw new Error(
|
||||
`Unexpected Supabase preflight response (profiles ${profiles.status}, claim_ai_job ${workerRpc.status}, retry_round ${retryRpc.status}, schema_version ${versionRpc.status}).`,
|
||||
)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
async function ensureSchema() {
|
||||
await loadRootEnvironment()
|
||||
const supabaseUrl = required('SUPABASE_URL')
|
||||
const serviceKey = required('SUPABASE_SERVICE_ROLE_KEY')
|
||||
|
||||
try {
|
||||
if (await dataApiHasSchema(supabaseUrl, serviceKey)) {
|
||||
console.log('[database] Supabase schema is ready.')
|
||||
return
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn(`[database] Data API preflight failed: ${error instanceof Error ? error.message : String(error)}`)
|
||||
}
|
||||
|
||||
const databaseUrl = process.env.SUPABASE_DB_URL?.trim()
|
||||
if (!databaseUrl) {
|
||||
throw new Error(
|
||||
'[database] The Supabase schema is missing. Add SUPABASE_DB_URL from Dashboard → Connect → Session pooler to the root .env; the next start will create it automatically.',
|
||||
)
|
||||
}
|
||||
assertMatchingProject(databaseUrl, supabaseUrl)
|
||||
|
||||
const sql = postgres(databaseUrl, {
|
||||
max: 1,
|
||||
prepare: false,
|
||||
ssl: 'require',
|
||||
connect_timeout: 15,
|
||||
idle_timeout: 5,
|
||||
})
|
||||
|
||||
try {
|
||||
const [state] = await sql.unsafe(`
|
||||
select
|
||||
to_regclass('public.profiles')::text as profiles,
|
||||
to_regclass('public.ai_jobs')::text as ai_jobs,
|
||||
to_regtype('public.member_role')::text as member_role,
|
||||
to_regprocedure('public.claim_ai_job(text)')::text as worker_rpc,
|
||||
to_regprocedure('public.stage_two_create_campaign(uuid,uuid,text)')::text as campaign_rpc,
|
||||
to_regprocedure('public.stage_two_retry_failed_round(uuid,uuid)')::text as retry_rpc,
|
||||
to_regprocedure('public.dng_schema_version()')::text as version_rpc
|
||||
`)
|
||||
const baseEntries = Object.entries(state).filter(([name]) => !['retry_rpc', 'version_rpc'].includes(name))
|
||||
if (baseEntries.every(([, value]) => Boolean(value)) && state.retry_rpc && state.version_rpc) {
|
||||
await sql.unsafe("notify pgrst, 'reload schema'")
|
||||
console.log('[database] Supabase schema is ready; PostgREST cache reload requested.')
|
||||
return
|
||||
}
|
||||
if (baseEntries.every(([, value]) => Boolean(value)) && (!state.retry_rpc || !state.version_rpc)) {
|
||||
console.log('[database] Applying the pending failed-round recovery upgrade…')
|
||||
const migration = await readFile(join(root, 'supabase', 'migrations', '0006_retry_job_compatibility.sql'), 'utf8')
|
||||
await sql.begin(async transaction => {
|
||||
await transaction.unsafe(migration)
|
||||
})
|
||||
const [verified] = await sql.unsafe(`
|
||||
select
|
||||
to_regprocedure('public.stage_two_retry_failed_round(uuid,uuid)') is not null as retry_rpc,
|
||||
to_regprocedure('public.dng_schema_version()') is not null as version_rpc
|
||||
`)
|
||||
if (!verified.retry_rpc || !verified.version_rpc) throw new Error('[database] Failed-round recovery upgrade verification failed.')
|
||||
await sql.unsafe("notify pgrst, 'reload schema'")
|
||||
console.log('[database] Supabase schema upgraded successfully.')
|
||||
return
|
||||
}
|
||||
if (baseEntries.some(([, value]) => Boolean(value))) {
|
||||
const missing = baseEntries.filter(([, value]) => !value).map(([name]) => name).join(', ')
|
||||
throw new Error(`[database] A partial D&G schema exists; automatic bootstrap stopped without changing data. Missing: ${missing}.`)
|
||||
}
|
||||
|
||||
console.log('[database] No D&G schema found. Applying the transactional bootstrap…')
|
||||
const bootstrap = await readFile(join(root, 'supabase', 'bootstrap.sql'), 'utf8')
|
||||
await sql.unsafe(bootstrap)
|
||||
|
||||
const [verified] = await sql.unsafe(`
|
||||
select
|
||||
to_regclass('public.profiles') is not null as profiles,
|
||||
to_regclass('public.ai_jobs') is not null as ai_jobs,
|
||||
to_regprocedure('public.claim_ai_job(text)') is not null as worker_rpc,
|
||||
to_regprocedure('public.stage_two_create_campaign(uuid,uuid,text)') is not null as campaign_rpc,
|
||||
to_regprocedure('public.stage_two_retry_failed_round(uuid,uuid)') is not null as retry_rpc,
|
||||
to_regprocedure('public.dng_schema_version()') is not null as version_rpc
|
||||
`)
|
||||
if (!Object.values(verified).every(Boolean)) {
|
||||
throw new Error('[database] Bootstrap completed but schema verification failed.')
|
||||
}
|
||||
console.log('[database] Dungeons & Ground schema created successfully.')
|
||||
} finally {
|
||||
await sql.end({ timeout: 5 })
|
||||
}
|
||||
}
|
||||
|
||||
await ensureSchema().catch((error) => {
|
||||
console.error(error instanceof Error ? error.message : error)
|
||||
process.exitCode = 1
|
||||
})
|
||||
170
scripts/smoke-multiplayer.mjs
Normal file
170
scripts/smoke-multiplayer.mjs
Normal file
@@ -0,0 +1,170 @@
|
||||
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) {
|
||||
await jsonRequest(`${supabaseUrl}/rest/v1/${path}`, {
|
||||
method: 'PATCH',
|
||||
headers: {
|
||||
apikey: serviceKey,
|
||||
Authorization: `Bearer ${serviceKey}`,
|
||||
'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.'),
|
||||
})
|
||||
|
||||
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 !== 2 || initial.round?.status !== 'open') throw new Error('[smoke] Multiplayer lobby did not expose two characters 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 joined, shared readiness, worker resolution, and next round all work (campaign ${campaignId}).`)
|
||||
Reference in New Issue
Block a user