Add AI-assisted world and character drafting flows
All checks were successful
CI / validate (push) Successful in 14m3s
All checks were successful
CI / validate (push) Successful in 14m3s
This commit is contained in:
@@ -8,6 +8,7 @@ const outputPath = join(root, 'supabase', 'bootstrap.sql')
|
||||
const migrationNames = (await readdir(migrationsDirectory))
|
||||
.filter(name => /^\d+_.+\.sql$/.test(name))
|
||||
.sort((left, right) => left.localeCompare(right))
|
||||
const latestSchemaVersion = Math.max(...migrationNames.map(name => Number(name.slice(0, 4))))
|
||||
|
||||
if (migrationNames.length === 0) throw new Error('No Supabase migrations were found.')
|
||||
|
||||
@@ -62,6 +63,12 @@ begin
|
||||
if to_regprocedure('public.dng_schema_version()') is null then
|
||||
raise exception 'D&G bootstrap verification failed: schema version RPC is missing';
|
||||
end if;
|
||||
if to_regprocedure('public.stage_four_create_character(uuid,uuid,text,text,text,jsonb,integer,integer,integer,integer,jsonb,jsonb,jsonb)') is null then
|
||||
raise exception 'D&G bootstrap verification failed: character creation RPC is missing';
|
||||
end if;
|
||||
if public.dng_schema_version() <> ${latestSchemaVersion} then
|
||||
raise exception 'D&G bootstrap verification failed: unexpected schema version';
|
||||
end if;
|
||||
end;
|
||||
$$;
|
||||
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import { readFile, readdir } 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)), '..')
|
||||
const migrationDirectory = join(root, 'supabase', 'migrations')
|
||||
const migrationNames = (await readdir(migrationDirectory))
|
||||
.filter(name => /^\d+_.+\.sql$/.test(name))
|
||||
.sort((left, right) => left.localeCompare(right))
|
||||
const latestSchemaVersion = Math.max(...migrationNames.map(name => Number(name.slice(0, 4))))
|
||||
|
||||
async function loadRootEnvironment() {
|
||||
let contents
|
||||
@@ -55,9 +60,13 @@ function assertMatchingProject(databaseUrl, supabaseUrl) {
|
||||
}
|
||||
}
|
||||
|
||||
async function dataApiHasSchema(supabaseUrl, serviceKey) {
|
||||
async function dataApiSchemaState(supabaseUrl, serviceKey) {
|
||||
const base = supabaseUrl.replace(/\/$/, '')
|
||||
const headers = { apikey: serviceKey, Authorization: `Bearer ${serviceKey}`, 'Content-Type': 'application/json' }
|
||||
const headers = {
|
||||
apikey: serviceKey,
|
||||
...(serviceKey.split('.').length === 3 ? { 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`, {
|
||||
@@ -78,13 +87,17 @@ async function dataApiHasSchema(supabaseUrl, serviceKey) {
|
||||
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, workerRpc, retryRpc, versionRpc].some(response => response.status === 404)) {
|
||||
return { installed: false, version: 0 }
|
||||
}
|
||||
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
|
||||
const version = Number(await versionRpc.json())
|
||||
if (!Number.isInteger(version) || version < 1) throw new Error('Supabase returned an invalid D&G schema version.')
|
||||
return { installed: true, version }
|
||||
}
|
||||
|
||||
async function ensureSchema() {
|
||||
@@ -93,10 +106,15 @@ async function ensureSchema() {
|
||||
const serviceKey = required('SUPABASE_SERVICE_ROLE_KEY')
|
||||
|
||||
try {
|
||||
if (await dataApiHasSchema(supabaseUrl, serviceKey)) {
|
||||
console.log('[database] Supabase schema is ready.')
|
||||
const state = await dataApiSchemaState(supabaseUrl, serviceKey)
|
||||
if (state.installed && state.version === latestSchemaVersion) {
|
||||
console.log(`[database] Supabase schema v${state.version} is ready.`)
|
||||
return
|
||||
}
|
||||
if (state.version > latestSchemaVersion) {
|
||||
throw new Error(`Database schema v${state.version} is newer than this checkout (v${latestSchemaVersion}).`)
|
||||
}
|
||||
if (state.installed) console.log(`[database] Supabase schema v${state.version} needs upgrade to v${latestSchemaVersion}.`)
|
||||
} catch (error) {
|
||||
console.warn(`[database] Data API preflight failed: ${error instanceof Error ? error.message : String(error)}`)
|
||||
}
|
||||
@@ -104,7 +122,7 @@ async function ensureSchema() {
|
||||
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.',
|
||||
`[database] The Supabase schema is missing or out of date (target v${latestSchemaVersion}). Add SUPABASE_DB_URL from Dashboard → Connect → Session pooler to the root .env; the next start will create or upgrade it automatically.`,
|
||||
)
|
||||
}
|
||||
assertMatchingProject(databaseUrl, supabaseUrl)
|
||||
@@ -129,25 +147,43 @@ async function ensureSchema() {
|
||||
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')
|
||||
if (baseEntries.every(([, value]) => Boolean(value))) {
|
||||
let currentVersion = 4
|
||||
if (state.version_rpc) {
|
||||
const [row] = await sql.unsafe('select public.dng_schema_version() as version')
|
||||
currentVersion = Number(row.version)
|
||||
} else if (state.retry_rpc) {
|
||||
currentVersion = 5
|
||||
}
|
||||
if (!Number.isInteger(currentVersion) || currentVersion < 1) throw new Error('[database] Invalid installed schema version.')
|
||||
if (currentVersion > latestSchemaVersion) {
|
||||
throw new Error(`[database] Schema v${currentVersion} is newer than this checkout (v${latestSchemaVersion}).`)
|
||||
}
|
||||
const pending = migrationNames.filter(name => Number(name.slice(0, 4)) > currentVersion)
|
||||
if (!pending.length) {
|
||||
await sql.unsafe("notify pgrst, 'reload schema'")
|
||||
console.log(`[database] Supabase schema v${currentVersion} is ready; PostgREST cache reload requested.`)
|
||||
return
|
||||
}
|
||||
console.log(`[database] Applying ${pending.length} migration(s): ${pending.join(', ')}…`)
|
||||
await sql.begin(async transaction => {
|
||||
await transaction.unsafe(migration)
|
||||
for (const name of pending) {
|
||||
const migration = await readFile(join(migrationDirectory, name), 'utf8')
|
||||
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
|
||||
to_regprocedure('public.dng_schema_version()') is not null as version_rpc,
|
||||
to_regprocedure('public.stage_four_create_character(uuid,uuid,text,text,text,jsonb,integer,integer,integer,integer,jsonb,jsonb,jsonb)') is not null as character_rpc,
|
||||
public.dng_schema_version() as version
|
||||
`)
|
||||
if (!verified.retry_rpc || !verified.version_rpc) throw new Error('[database] Failed-round recovery upgrade verification failed.')
|
||||
if (!verified.retry_rpc || !verified.version_rpc || !verified.character_rpc || Number(verified.version) !== latestSchemaVersion) {
|
||||
throw new Error('[database] Schema upgrade verification failed.')
|
||||
}
|
||||
await sql.unsafe("notify pgrst, 'reload schema'")
|
||||
console.log('[database] Supabase schema upgraded successfully.')
|
||||
console.log(`[database] Supabase schema upgraded successfully to v${latestSchemaVersion}.`)
|
||||
return
|
||||
}
|
||||
if (baseEntries.some(([, value]) => Boolean(value))) {
|
||||
@@ -166,12 +202,16 @@ async function ensureSchema() {
|
||||
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
|
||||
to_regprocedure('public.stage_four_create_character(uuid,uuid,text,text,text,jsonb,integer,integer,integer,integer,jsonb,jsonb,jsonb)') is not null as character_rpc,
|
||||
to_regprocedure('public.dng_schema_version()') is not null as version_rpc,
|
||||
public.dng_schema_version() as version
|
||||
`)
|
||||
if (!Object.values(verified).every(Boolean)) {
|
||||
if (!verified.profiles || !verified.ai_jobs || !verified.worker_rpc || !verified.campaign_rpc
|
||||
|| !verified.retry_rpc || !verified.character_rpc || !verified.version_rpc
|
||||
|| Number(verified.version) !== latestSchemaVersion) {
|
||||
throw new Error('[database] Bootstrap completed but schema verification failed.')
|
||||
}
|
||||
console.log('[database] Dungeons & Ground schema created successfully.')
|
||||
console.log(`[database] Dungeons & Ground schema v${latestSchemaVersion} created successfully.`)
|
||||
} finally {
|
||||
await sql.end({ timeout: 5 })
|
||||
}
|
||||
|
||||
@@ -56,11 +56,14 @@ function appRequest(path, token, options = {}) {
|
||||
}
|
||||
|
||||
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,
|
||||
Authorization: `Bearer ${serviceKey}`,
|
||||
...serviceAuthorization,
|
||||
'Content-Type': 'application/json',
|
||||
Prefer: 'return=representation',
|
||||
},
|
||||
@@ -130,9 +133,22 @@ const playerCharacter = await appRequest(`/api/v1/campaigns/${campaignId}/charac
|
||||
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 !== 2 || initial.round?.status !== 'open') throw new Error('[smoke] Multiplayer lobby did not expose two characters and an open round.')
|
||||
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…')
|
||||
@@ -167,4 +183,4 @@ while (Date.now() < deadline) {
|
||||
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}).`)
|
||||
console.log(`[smoke] PASS — 2 players, AI character drafting, persistent companion, shared readiness, worker resolution, and next round all work (campaign ${campaignId}).`)
|
||||
|
||||
Reference in New Issue
Block a user