diff --git a/README.md b/README.md
index 18476ea..60ce249 100644
--- a/README.md
+++ b/README.md
@@ -14,7 +14,7 @@ pnpm dev:all
Open `http://localhost:3000`. `pnpm dev:all` starts the Nuxt site and the AI worker in one terminal. To run them separately, use `pnpm dev` in the first terminal and `pnpm dev:worker` in the second.
-To verify the complete hosted-Supabase multiplayer path (two temporary guests, invite, shared readiness, AI resolution, and the next round), keep `dev:all` running and execute `pnpm smoke:multiplayer` in another terminal.
+To verify the complete hosted-Supabase multiplayer path (two temporary guests, invite, AI character draft, persistent AI companion, shared readiness, AI resolution, and the next round), keep `dev:all` running and execute `pnpm smoke:multiplayer` in another terminal.
Before starting either process, the root commands check the Supabase schema. If it is missing and `SUPABASE_DB_URL` is configured, they transactionally create all D&G tables, functions, triggers, indexes, and policies from `supabase/bootstrap.sql`. Copy the **Session pooler** URI from the Supabase Dashboard **Connect** panel into the root `.env` and replace the password placeholder with the URL-encoded database password. The database URL is server-only and must never use a `NUXT_PUBLIC_` prefix.
@@ -22,6 +22,8 @@ The web and worker commands both load this root `.env` file explicitly. Environm
The UI can be built without credentials. A running Nitro server and every worker process require `SUPABASE_URL`, `SUPABASE_SERVICE_ROLE_KEY`, `NUXT_PUBLIC_SUPABASE_URL`, and `NUXT_PUBLIC_SUPABASE_ANON_KEY`; startup fails with a clear error when required server credentials are missing. Redis is optional: when `REDIS_URL` is absent, the worker safely claims jobs from the Supabase `ai_jobs` outbox using short database leases and retries failed jobs up to three times.
+Both legacy JWT service-role keys and current `sb_secret_…` Supabase keys are supported. Keep either form server-only; never expose it through a `NUXT_PUBLIC_` variable.
+
Select OpenRouter or the official DeepSeek API with `AI_PROVIDER=openrouter|deepseek` and provide the matching API key. OpenRouter retains JSON Schema structured output; DeepSeek uses its official JSON mode, followed by the same Zod validation.
Use Node.js 18.20.5 or newer. The lockfile pins the web toolchain to the Node 18-compatible Nuxt 3.15, Nitro 2.10, and Vite 6 line; CI verifies the project on Node 18.20.8.
@@ -51,11 +53,12 @@ Use Node.js 18.20.5 or newer. The lockfile pins the web toolchain to the Node 18
If the worker reports `POST /rest/v1/rpc/claim_ai_job 404` and `/rest/v1/profiles` also returns 404, the base schema was never installed. Run the generated `supabase/bootstrap.sql` in the SQL Editor and restart `pnpm dev:all`. Do not run only `0002`: it depends on the tables created by `0001`.
-## Stage two flow
+## Weeks 3–4 flow
1. Click **Enter the Alpha** to create a persistent guest session without email. Do not sign out or clear site storage unless you are prepared to lose that guest account. An allowlisted email magic link is still available as an alternative.
-2. Create a world in the coauthor chat, review the editable result, and confirm it.
-3. Create human characters and optional AI companions in the campaign lobby.
-4. The owner creates an expiring private invite link. Guest or allowlisted-email users join through `/join/:token`.
-5. Players save actions and mark them ready. The final ready action queues the round automatically; the owner can also continue without waiting.
-6. The worker resolves server-owned rolls and state, publishes narration, and opens the next round. Visible campaign tabs synchronize every two seconds and immediately when the tab regains focus.
+2. Create a world in the dynamic coauthor chat. Unfinished conversations and generated drafts appear on the dashboard and resume after a reload.
+3. Review every starting-world field, including the owner-only hidden threat, then confirm it.
+4. Create a human character manually or ask the coauthor for an editable draft. Owners can add persistent AI companions the same way.
+5. The owner creates an expiring private invite link. Guest or allowlisted-email users join through `/join/:token`.
+6. Players save actions and mark them ready. The final ready action queues the round automatically; the owner can also continue without waiting.
+7. The worker resolves server-owned rolls and state, publishes narration, and opens the next round. Visible campaign tabs synchronize every two seconds and immediately when the tab regains focus.
diff --git a/apps/web/components/CoauthorWorldPreview.vue b/apps/web/components/CoauthorWorldPreview.vue
index 7c8a8ef..b454f47 100644
--- a/apps/web/components/CoauthorWorldPreview.vue
+++ b/apps/web/components/CoauthorWorldPreview.vue
@@ -18,6 +18,7 @@ defineEmits<{ revise: []; confirm: [] }>()
+ PRIVATE GM TRUTH This hidden threat or goal is visible only to the owner and the Groundkeeper.
@@ -29,4 +30,5 @@ defineEmits<{ revise: []; confirm: [] }>()
diff --git a/apps/web/pages/campaign/[id].vue b/apps/web/pages/campaign/[id].vue
index 864258a..5cd6453 100644
--- a/apps/web/pages/campaign/[id].vue
+++ b/apps/web/pages/campaign/[id].vue
@@ -1,4 +1,6 @@
@@ -164,7 +237,7 @@ async function confirm() {
01 Seed ideaSay what cannot exist yet.
- 02 Shape the signal{{ answerCount }} / up to 5 answers captured.
+ 02 Shape the signal{{ answerCount }} / 4 answers captured.
03 Generate structureA playable starting kit.
04 Review & launchYou remain the final author.
diff --git a/apps/web/server/api/v1/campaigns/[id]/characters.post.ts b/apps/web/server/api/v1/campaigns/[id]/characters.post.ts
index d0221e3..1f7b8bf 100644
--- a/apps/web/server/api/v1/campaigns/[id]/characters.post.ts
+++ b/apps/web/server/api/v1/campaigns/[id]/characters.post.ts
@@ -1,6 +1,6 @@
import { AbilityScoresSchema } from '@dng/shared'
import { z } from 'zod'
-import { requireCampaignAccess, requireStageTwoSafeText, requireStageTwoUser, stageTwoApiError, stageTwoDatabase, stageTwoUuid } from '~/server/utils/stage-two-supabase'
+import { requireCampaignAccess, requireStageTwoSafeText, requireStageTwoUser, stageTwoApiError, stageTwoDatabase, stageTwoRpc, stageTwoUuid } from '~/server/utils/stage-two-supabase'
const BodySchema = z.object({
name: z.string().trim().min(1).max(80),
@@ -26,25 +26,24 @@ export default defineEventHandler(async (event) => {
throw createError({ statusCode: 403, statusMessage: 'Only the campaign owner can add AI heroes.' })
}
requireStageTwoSafeText([body.name, body.concept, ...body.inventory, ...body.statuses, JSON.stringify(body.persona)].join('\n'))
- const rows = await stageTwoDatabase>>('characters', {
- method: 'POST',
- prefer: 'return=representation',
- body: JSON.stringify({
- campaign_id: campaignId,
- user_id: body.controller === 'human' ? user.id : null,
- name: body.name,
- concept: body.concept,
- controller: body.controller,
- abilities: body.abilities,
- hp: body.hp,
- max_hp: body.maxHp,
- defense: body.defense,
- proficiency: body.proficiency,
- inventory: body.inventory,
- statuses: body.statuses,
- persona: body.persona,
- }),
+ const characterId = await stageTwoRpc('stage_four_create_character', {
+ p_campaign_id: campaignId,
+ p_actor_id: user.id,
+ p_controller: body.controller,
+ p_name: body.name,
+ p_concept: body.concept,
+ p_abilities: body.abilities,
+ p_hp: body.hp,
+ p_max_hp: body.maxHp,
+ p_defense: body.defense,
+ p_proficiency: body.proficiency,
+ p_inventory: body.inventory,
+ p_statuses: body.statuses,
+ p_persona: body.persona,
})
+ const rows = await stageTwoDatabase>>(
+ `characters?select=*&id=eq.${characterId}&campaign_id=eq.${campaignId}&limit=1`,
+ )
setResponseStatus(event, 201)
return { character: rows[0] }
} catch (error) {
diff --git a/apps/web/server/api/v1/campaigns/[id]/characters/suggest.post.ts b/apps/web/server/api/v1/campaigns/[id]/characters/suggest.post.ts
new file mode 100644
index 0000000..5069f13
--- /dev/null
+++ b/apps/web/server/api/v1/campaigns/[id]/characters/suggest.post.ts
@@ -0,0 +1,77 @@
+import { CharacterDraftSchema } from '@dng/shared'
+import { z } from 'zod'
+import { buildJsonCompletion, parseJsonCompletion } from '~/server/utils/ai-provider'
+import {
+ requireCampaignAccess,
+ requireStageTwoSafeText,
+ requireStageTwoUser,
+ stageTwoApiError,
+ stageTwoDatabase,
+ stageTwoUuid,
+} from '~/server/utils/stage-two-supabase'
+
+const BodySchema = z.object({
+ concept: z.string().trim().max(600).default(''),
+ controller: z.enum(['human', 'ai']).default('human'),
+}).strict()
+
+export default defineEventHandler(async (event) => {
+ try {
+ const user = await requireStageTwoUser(event)
+ const campaignId = stageTwoUuid(getRouterParam(event, 'id'), 'campaign id')
+ const access = await requireCampaignAccess(campaignId, user.id)
+ const body = BodySchema.parse((await readBody(event)) ?? {})
+ if (body.controller === 'ai' && !access.owner) {
+ throw createError({ statusCode: 403, statusMessage: 'Only the campaign owner can add AI heroes.' })
+ }
+ if (body.concept) requireStageTwoSafeText(body.concept)
+
+ const worlds = await stageTwoDatabase>>(
+ `worlds?select=title,genre,tone,premise,content_boundaries&` +
+ `id=eq.${String(access.campaign.world_id)}&limit=1`,
+ )
+ const world = worlds[0]
+ if (!world) throw createError({ statusCode: 404, statusMessage: 'Campaign world not found.' })
+
+ const completion = buildJsonCompletion(useRuntimeConfig(), {
+ system: `You create an original, editable Dungeons & Ground character draft for a private 13+ TTRPG campaign. Adapt archetype, equipment and voice to the supplied genre, but always use STR, DEX, CON, INT, WIS and CHA. Keep an alpha hero grounded: ability scores 7–16, max HP 8–20, Defense 10–16, proficiency 2. Give 2–5 useful starting items and a distinctive persona. Never use protected franchise characters. Return only the requested JSON.`,
+ messages: [{
+ role: 'user',
+ content: JSON.stringify({
+ world,
+ currentScene: access.campaign.current_scene,
+ heroKind: body.controller === 'ai' ? 'persistent AI companion' : 'human player character',
+ requestedConcept: body.concept || 'Surprise me with a character who creates interesting choices for this party.',
+ }),
+ }],
+ schemaName: 'character_draft',
+ jsonSchema: CharacterDraftSchema.toJSONSchema(),
+ })
+
+ const controller = new AbortController()
+ const timeout = setTimeout(() => controller.abort(), 30_000)
+ let response: Response
+ try {
+ response = await fetch(completion.endpoint, {
+ method: 'POST',
+ headers: completion.headers,
+ body: JSON.stringify(completion.body),
+ signal: controller.signal,
+ })
+ } finally {
+ clearTimeout(timeout)
+ }
+ if (!response.ok) throw createError({ statusCode: 502, statusMessage: 'The character coauthor is temporarily unavailable.' })
+
+ let character
+ try {
+ character = CharacterDraftSchema.parse(parseJsonCompletion(await response.json()))
+ } catch {
+ throw createError({ statusCode: 502, statusMessage: 'The coauthor returned an invalid character draft.' })
+ }
+ requireStageTwoSafeText(JSON.stringify(character))
+ return { character }
+ } catch (error) {
+ stageTwoApiError(error)
+ }
+})
diff --git a/apps/web/server/api/v1/coauthor/sessions/[id].get.ts b/apps/web/server/api/v1/coauthor/sessions/[id].get.ts
index 0f23946..6a77878 100644
--- a/apps/web/server/api/v1/coauthor/sessions/[id].get.ts
+++ b/apps/web/server/api/v1/coauthor/sessions/[id].get.ts
@@ -5,7 +5,7 @@ export default defineEventHandler(async (event) => {
const user = await requireStageTwoUser(event)
const id = stageTwoUuid(getRouterParam(event, 'id'))
const sessions = await stageTwoDatabase>>(
- `coauthor_sessions?select=id,status,messages,generated_world,confirmed_world_id,created_at,updated_at&id=eq.${id}&owner_id=eq.${user.id}&limit=1`,
+ `coauthor_sessions?select=id,status,messages,current_question,generated_world,confirmed_world_id,created_at,updated_at&id=eq.${id}&owner_id=eq.${user.id}&limit=1`,
)
if (!sessions[0]) throw createError({ statusCode: 404, statusMessage: 'Coauthor session not found.' })
return { session: { ...sessions[0], generatedWorld: sessions[0].generated_world ?? null } }
diff --git a/apps/web/server/api/v1/coauthor/sessions/[id]/respond.post.ts b/apps/web/server/api/v1/coauthor/sessions/[id]/respond.post.ts
index a232963..a0e5538 100644
--- a/apps/web/server/api/v1/coauthor/sessions/[id]/respond.post.ts
+++ b/apps/web/server/api/v1/coauthor/sessions/[id]/respond.post.ts
@@ -7,13 +7,18 @@ const QuestionSchema = z.object({
question: z.string().trim().min(5).max(300),
options: z.array(z.string().trim().min(1).max(100)).length(3),
})
+const StoredQuestionSchema = z.object({
+ id: z.string().trim().min(1).max(80),
+ label: z.string().trim().min(5).max(300),
+ options: z.array(z.string().trim().min(1).max(100)).length(3),
+})
export default defineEventHandler(async (event) => {
try {
const user = await requireStageTwoUser(event)
const sessionId = stageTwoUuid(getRouterParam(event, 'id'), 'session id')
- const sessions = await stageTwoDatabase>(
- `coauthor_sessions?select=status,messages&id=eq.${sessionId}&owner_id=eq.${user.id}&limit=1`,
+ const sessions = await stageTwoDatabase>(
+ `coauthor_sessions?select=status,messages,current_question&id=eq.${sessionId}&owner_id=eq.${user.id}&limit=1`,
)
const session = sessions[0]
if (!session) throw createError({ statusCode: 404, statusMessage: 'Coauthor session not found.' })
@@ -21,6 +26,9 @@ export default defineEventHandler(async (event) => {
const messages = z.array(MessageSchema).min(1).max(12).parse(session.messages)
const answerCount = Math.max(0, messages.filter(message => message.role === 'user').length - 1)
if (answerCount >= 4) return { readyToGenerate: true }
+ if (session.current_question) {
+ return { readyToGenerate: false, question: StoredQuestionSchema.parse(session.current_question) }
+ }
const completion = buildJsonCompletion(useRuntimeConfig(), {
system: `You are the Dungeons & Ground coauthor. Based only on this conversation, ask one focused question that makes the original TTRPG world more playable. This is clarification ${answerCount + 1} of 4. Do not repeat a topic already answered. Adapt to the requested genre. Provide exactly three concise, mutually distinct answer options. Keep everything suitable for ages 13+.`,
@@ -41,12 +49,17 @@ export default defineEventHandler(async (event) => {
if (!response.ok) throw createError({ statusCode: 502, statusMessage: 'The coauthor is temporarily unavailable.' })
const question = QuestionSchema.parse(parseJsonCompletion(await response.json()))
requireStageTwoSafeText([question.question, ...question.options].join('\n'))
- await stageTwoRpc('stage_two_append_coauthor_assistant_message', {
- p_session_id: sessionId, p_owner_id: user.id, p_content: question.question,
+ const storedQuestion = StoredQuestionSchema.parse({
+ id: `question-${answerCount + 1}`,
+ label: question.question,
+ options: question.options,
+ })
+ await stageTwoRpc('stage_four_set_coauthor_question', {
+ p_session_id: sessionId, p_owner_id: user.id, p_question: storedQuestion,
})
return {
readyToGenerate: false,
- question: { id: `question-${messages.length}`, label: question.question, options: question.options },
+ question: storedQuestion,
}
} catch (error) {
stageTwoApiError(error)
diff --git a/apps/web/server/api/v1/coauthor/sessions/index.get.ts b/apps/web/server/api/v1/coauthor/sessions/index.get.ts
index bf77daa..d132bec 100644
--- a/apps/web/server/api/v1/coauthor/sessions/index.get.ts
+++ b/apps/web/server/api/v1/coauthor/sessions/index.get.ts
@@ -4,7 +4,7 @@ export default defineEventHandler(async (event) => {
try {
const user = await requireStageTwoUser(event)
const sessions = await stageTwoDatabase>>(
- `coauthor_sessions?select=id,status,messages,generated_world,confirmed_world_id,created_at,updated_at&owner_id=eq.${user.id}&order=updated_at.desc`,
+ `coauthor_sessions?select=id,status,messages,current_question,generated_world,confirmed_world_id,created_at,updated_at&owner_id=eq.${user.id}&status=in.(collecting,generating,ready,failed)&order=updated_at.desc`,
)
return { sessions }
} catch (error) {
diff --git a/apps/web/server/api/worlds/generate.post.ts b/apps/web/server/api/worlds/generate.post.ts
index 44bbcd4..aae441f 100644
--- a/apps/web/server/api/worlds/generate.post.ts
+++ b/apps/web/server/api/worlds/generate.post.ts
@@ -1,7 +1,9 @@
import { CreateWorldRequestSchema, WorldStarterSchema, moderate13Plus } from '@dng/shared'
import { buildJsonCompletion, parseJsonCompletion } from '../../utils/ai-provider'
+import { requireStageTwoUser } from '../../utils/stage-two-supabase'
export default defineEventHandler(async event => {
+ await requireStageTwoUser(event)
const raw = await readBody(event)
const prompt = typeof raw?.prompt === 'string' ? raw.prompt : ''
const answers = raw?.answers && typeof raw.answers === 'object' ? raw.answers : {}
@@ -28,9 +30,14 @@ export default defineEventHandler(async event => {
body: JSON.stringify(completion.body),
})
if (!response.ok) throw createError({ statusCode: 502, statusMessage: 'The coauthor is temporarily unavailable.' })
+ let world
try {
- return WorldStarterSchema.parse(parseJsonCompletion(await response.json()))
+ world = WorldStarterSchema.parse(parseJsonCompletion(await response.json()))
} catch {
throw createError({ statusCode: 502, statusMessage: 'The coauthor returned an invalid world structure.' })
}
+ if (!moderate13Plus(JSON.stringify(world)).allowed) {
+ throw createError({ statusCode: 422, statusMessage: 'The generated world falls outside the alpha’s 13+ content boundary.' })
+ }
+ return world
})
diff --git a/apps/web/server/utils/stage-two-supabase.test.ts b/apps/web/server/utils/stage-two-supabase.test.ts
index 0dff1d4..ae11c88 100644
--- a/apps/web/server/utils/stage-two-supabase.test.ts
+++ b/apps/web/server/utils/stage-two-supabase.test.ts
@@ -18,4 +18,34 @@ describe('stage-two Supabase client', () => {
body: '{}',
})).resolves.toBeUndefined()
})
+
+ it('supports new Supabase secret keys without using them as bearer tokens', async () => {
+ vi.stubGlobal('useRuntimeConfig', () => ({
+ supabaseUrl: 'https://example.supabase.co',
+ supabaseServiceRoleKey: 'sb_secret_example',
+ public: { supabaseAnonKey: 'sb_publishable_example' },
+ }))
+ const fetchMock = vi.fn(async (_url: URL, init?: RequestInit) => new Response('[]', { status: 200 }))
+ vi.stubGlobal('fetch', fetchMock)
+
+ await stageTwoDatabase('profiles?select=id&limit=1')
+ expect(fetchMock).toHaveBeenCalledOnce()
+ expect(fetchMock.mock.calls[0]?.[1]?.headers).toMatchObject({ apikey: 'sb_secret_example' })
+ expect(fetchMock.mock.calls[0]?.[1]?.headers).not.toHaveProperty('Authorization')
+ })
+
+ it('keeps legacy service-role JWT authorization support', async () => {
+ vi.stubGlobal('useRuntimeConfig', () => ({
+ supabaseUrl: 'https://example.supabase.co',
+ supabaseServiceRoleKey: 'header.payload.signature',
+ public: { supabaseAnonKey: 'anon-key' },
+ }))
+ const fetchMock = vi.fn(async (_url: URL, init?: RequestInit) => new Response('[]', { status: 200 }))
+ vi.stubGlobal('fetch', fetchMock)
+
+ await stageTwoDatabase('profiles?select=id&limit=1')
+ expect(fetchMock.mock.calls[0]?.[1]?.headers).toMatchObject({
+ Authorization: 'Bearer header.payload.signature',
+ })
+ })
})
diff --git a/apps/web/server/utils/stage-two-supabase.ts b/apps/web/server/utils/stage-two-supabase.ts
index 604ccb2..724a7cb 100644
--- a/apps/web/server/utils/stage-two-supabase.ts
+++ b/apps/web/server/utils/stage-two-supabase.ts
@@ -13,6 +13,15 @@ interface RequestOptions extends RequestInit {
prefer?: string
}
+function serviceAuthenticationHeaders(serviceKey: string): Record {
+ // Legacy service-role keys are JWTs and may be used as the PostgREST bearer.
+ // New Supabase `sb_secret_…` keys are gateway API keys and must not be sent
+ // as an Authorization token.
+ return serviceKey.split('.').length === 3
+ ? { Authorization: `Bearer ${serviceKey}` }
+ : {}
+}
+
export interface StageTwoUser {
id: string
email?: string
@@ -51,7 +60,7 @@ export async function stageTwoDatabase(path: string, options: RequestOptions
...options,
headers: {
apikey: serviceKey,
- Authorization: `Bearer ${serviceKey}`,
+ ...serviceAuthenticationHeaders(serviceKey),
'Content-Type': 'application/json',
...(options.prefer ? { Prefer: options.prefer } : {}),
...options.headers,
diff --git a/apps/worker/src/ai.test.ts b/apps/worker/src/ai.test.ts
index 26dde64..191962e 100644
--- a/apps/worker/src/ai.test.ts
+++ b/apps/worker/src/ai.test.ts
@@ -2,6 +2,17 @@ import { afterEach, describe, expect, it, vi } from 'vitest'
const originalEnv = { ...process.env }
+const worldFixture = () => ({
+ title: 'Test Reach', genre: 'Science fiction', tone: 'Tense', premise: 'A sufficiently long premise for a generated private campaign world.',
+ contentBoundaries: ['13+'],
+ startingLocation: { id: 'location', kind: 'location' as const, name: 'Gate', summary: 'A station beyond known space.', tags: [] as string[], secrets: [] as string[] },
+ npcs: [1, 2, 3].map(index => ({ id: `npc-${index}`, kind: 'npc' as const, name: `NPC ${index}`, summary: 'A useful person with their own agenda.', tags: [], secrets: [] })),
+ factions: [1, 2].map(index => ({ id: `faction-${index}`, kind: 'faction' as const, name: `Faction ${index}`, summary: 'An organization pursuing a hidden objective.', tags: [], secrets: [] })),
+ hook: 'A sufficiently long hook that immediately gives the party something to investigate.',
+ hiddenThreat: 'A hidden threat waits beyond the gate.',
+ openingScene: 'The gate opens without warning, and an impossible signal calls every hero by name.',
+})
+
afterEach(() => {
process.env = { ...originalEnv }
vi.unstubAllGlobals()
@@ -12,16 +23,7 @@ describe('worker AI providers', () => {
process.env.AI_PROVIDER = 'deepseek'
process.env.DEEPSEEK_API_KEY = 'secret'
process.env.DEEPSEEK_MODEL = 'deepseek-v4-flash'
- const world = {
- title: 'Test Reach', genre: 'Science fiction', tone: 'Tense', premise: 'A sufficiently long premise for a generated private campaign world.',
- contentBoundaries: ['13+'],
- startingLocation: { id: 'location', kind: 'location', name: 'Gate', summary: 'A station beyond known space.', tags: [], secrets: [] },
- npcs: [1, 2, 3].map(index => ({ id: `npc-${index}`, kind: 'npc', name: `NPC ${index}`, summary: 'A useful person with their own agenda.', tags: [], secrets: [] })),
- factions: [1, 2].map(index => ({ id: `faction-${index}`, kind: 'faction', name: `Faction ${index}`, summary: 'An organization pursuing a hidden objective.', tags: [], secrets: [] })),
- hook: 'A sufficiently long hook that immediately gives the party something to investigate.',
- hiddenThreat: 'A hidden threat waits beyond the gate.',
- openingScene: 'The gate opens without warning, and an impossible signal calls every hero by name.',
- }
+ const world = worldFixture()
const fetchMock = vi.fn(async (_url: string, init?: RequestInit) => {
const body = JSON.parse(String(init?.body))
expect(body.response_format).toEqual({ type: 'json_object' })
@@ -35,4 +37,17 @@ describe('worker AI providers', () => {
await expect(generateWorld([{ role: 'user', content: 'Create a science fiction frontier.' }])).resolves.toMatchObject({ title: 'Test Reach' })
expect(fetchMock).toHaveBeenCalledWith('https://api.deepseek.com/chat/completions', expect.any(Object))
})
+
+ it('rejects explicit content hidden inside generated entity fields', async () => {
+ process.env.AI_PROVIDER = 'deepseek'
+ process.env.DEEPSEEK_API_KEY = 'secret'
+ const world = worldFixture()
+ world.startingLocation.secrets = ['The requested reward is explicit sex.']
+ vi.stubGlobal('fetch', vi.fn(async () => new Response(JSON.stringify({
+ choices: [{ message: { content: JSON.stringify(world) } }],
+ }), { status: 200 })))
+ const { generateWorld } = await import('./ai')
+ await expect(generateWorld([{ role: 'user', content: 'Create a science fiction frontier.' }]))
+ .rejects.toThrow('Content policy rejected')
+ })
})
diff --git a/apps/worker/src/ai.ts b/apps/worker/src/ai.ts
index f11a3f9..d970806 100644
--- a/apps/worker/src/ai.ts
+++ b/apps/worker/src/ai.ts
@@ -92,15 +92,19 @@ export async function generateWorld(messages: Array<{ role: 'user' | 'assistant'
{ role: 'system', content: 'You are the Dungeons & Ground world coauthor. Create an original 13+ private TTRPG setting in any requested genre. Never use protected settings or characters. Return exactly three NPCs and two factions. Make the opening immediately playable.' },
...messages,
], value => WorldStarterSchema.parse(value))
- assert13Plus(world.title, world.premise, world.hook, world.hiddenThreat, world.openingScene)
+ // Moderate the entire typed object, including entity summaries, secrets and
+ // boundaries—not just the headline fields shown in the first preview.
+ assert13Plus(JSON.stringify(world))
return world
}
export async function planRound(input: RoundContext): Promise {
- return structuredRequest('round_plan', RoundPlanSchema.toJSONSchema(), [
+ const plan = await structuredRequest('round_plan', RoundPlanSchema.toJSONSchema(), [
{ role: 'system', content: 'You plan one asynchronous TTRPG round. Human intents are authoritative and happen first. Then provide at most one action for each supplied aiCharacter, in the supplied order; never create AI actions for human-controlled characters. You may request checks and propose story events. Never invent dice results and never directly mutate mechanical state. The server owns rules, HP, inventory, statuses and randomness.' },
{ role: 'user', content: JSON.stringify(input) },
], value => RoundPlanSchema.parse(value))
+ assert13Plus(JSON.stringify(plan.aiActions), JSON.stringify(plan.proposedEvents))
+ return plan
}
export async function narrateRound(input: { context: RoundContext; actionSequence: Array<{ phase: 'human' | 'ai'; characterId: string; action: string }>; rolls: DiceRoll[]; permittedEvents: RoundPlan['proposedEvents'] }): Promise {
@@ -108,7 +112,7 @@ export async function narrateRound(input: { context: RoundContext; actionSequenc
{ role: 'system', content: 'Narrate the resolved TTRPG round in the exact supplied actionSequence order: all human actions first, then AI/delegated hero actions. Use the authoritative dice results without changing them. Keep player agency, maintain a 13+ rating, and end with a clear prompt for the next round.' },
{ role: 'user', content: JSON.stringify(input) },
], value => RoundResolutionSchema.parse(value))
- assert13Plus(resolution.narration, resolution.nextPrompt, resolution.memory?.summary ?? '')
+ assert13Plus(JSON.stringify(resolution))
return resolution
}
diff --git a/apps/worker/src/index.ts b/apps/worker/src/index.ts
index 78b17f2..1c900c2 100644
--- a/apps/worker/src/index.ts
+++ b/apps/worker/src/index.ts
@@ -45,14 +45,14 @@ if (!supabaseUrl || !serviceKey) {
}
async function databaseRequest(path: string, init: RequestInit = {}): Promise {
+ const headers = new Headers(init.headers)
+ headers.set('apikey', databaseServiceKey)
+ headers.set('Content-Type', 'application/json')
+ if (databaseServiceKey.split('.').length === 3) headers.set('Authorization', `Bearer ${databaseServiceKey}`)
+ else headers.delete('Authorization')
const response = await fetch(new URL(`/rest/v1/${path}`, databaseUrl), {
...init,
- headers: {
- apikey: databaseServiceKey,
- Authorization: `Bearer ${databaseServiceKey}`,
- 'Content-Type': 'application/json',
- ...init.headers,
- },
+ headers,
})
if (!response.ok) {
const detail = await response.text()
diff --git a/packages/shared/src/character.test.ts b/packages/shared/src/character.test.ts
new file mode 100644
index 0000000..e6c46e3
--- /dev/null
+++ b/packages/shared/src/character.test.ts
@@ -0,0 +1,31 @@
+import { describe, expect, it } from 'vitest'
+import { CharacterDraftSchema } from './index'
+
+const draft = {
+ name: 'Iris Vale',
+ concept: 'A patient relay mechanic who hears impossible harmonics.',
+ abilities: { str: 9, dex: 13, con: 12, int: 16, wis: 14, cha: 10 },
+ hp: 12,
+ maxHp: 12,
+ defense: 13,
+ proficiency: 2,
+ inventory: ['Signal lens', 'Field toolkit'],
+ persona: {
+ voice: 'Precise, with dry humor.',
+ motivation: 'Prove the relay is speaking from the future.',
+ flaw: 'Treats every warning as a puzzle.',
+ bond: 'Will not abandon another crew member.',
+ },
+}
+
+describe('CharacterDraftSchema', () => {
+ it('accepts a complete editable 13+ character draft', () => {
+ expect(CharacterDraftSchema.parse(draft)).toEqual(draft)
+ expect(CharacterDraftSchema.toJSONSchema()).toMatchObject({ type: 'object' })
+ })
+
+ it('rejects invalid server-facing mechanics', () => {
+ expect(() => CharacterDraftSchema.parse({ ...draft, hp: 20, maxHp: 10 })).toThrow()
+ expect(() => CharacterDraftSchema.parse({ ...draft, defense: 99 })).toThrow()
+ })
+})
diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts
index 9945a01..8f3c3a9 100644
--- a/packages/shared/src/index.ts
+++ b/packages/shared/src/index.ts
@@ -45,6 +45,30 @@ export const WorldStarterSchema = z.object({
})
export type WorldStarter = z.infer
+export const CharacterPersonaSchema = z.object({
+ voice: z.string().trim().min(1).max(240),
+ motivation: z.string().trim().min(1).max(300),
+ flaw: z.string().trim().min(1).max(300),
+ bond: z.string().trim().min(1).max(300),
+})
+export type CharacterPersona = z.infer
+
+export const CharacterDraftSchema = z.object({
+ name: z.string().trim().min(1).max(80),
+ concept: z.string().trim().min(3).max(600),
+ abilities: AbilityScoresSchema,
+ hp: z.number().int().min(1).max(100),
+ maxHp: z.number().int().min(1).max(100),
+ defense: z.number().int().min(1).max(40),
+ proficiency: z.number().int().min(1).max(10),
+ inventory: z.array(z.string().trim().min(1).max(120)).max(12),
+ persona: CharacterPersonaSchema,
+}).strict().refine(value => value.hp <= value.maxHp, {
+ message: 'hp must not exceed maxHp',
+ path: ['hp'],
+})
+export type CharacterDraft = z.infer
+
export const CharacterSchema = z.object({
id: z.string().min(1),
name: z.string().min(1).max(80),
diff --git a/scripts/build-supabase-bootstrap.mjs b/scripts/build-supabase-bootstrap.mjs
index ef35f54..51f10db 100644
--- a/scripts/build-supabase-bootstrap.mjs
+++ b/scripts/build-supabase-bootstrap.mjs
@@ -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;
$$;
diff --git a/scripts/ensure-supabase-schema.mjs b/scripts/ensure-supabase-schema.mjs
index 149809f..1882740 100644
--- a/scripts/ensure-supabase-schema.mjs
+++ b/scripts/ensure-supabase-schema.mjs
@@ -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 })
}
diff --git a/scripts/smoke-multiplayer.mjs b/scripts/smoke-multiplayer.mjs
index 9c0f81e..5a3f147 100644
--- a/scripts/smoke-multiplayer.mjs
+++ b/scripts/smoke-multiplayer.mjs
@@ -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}).`)
diff --git a/supabase/bootstrap.sql b/supabase/bootstrap.sql
index 7adbde9..14cd163 100644
--- a/supabase/bootstrap.sql
+++ b/supabase/bootstrap.sql
@@ -2030,6 +2030,151 @@ grant execute on function public.dng_schema_version() to service_role;
notify pgrst, 'reload schema';
+-- ============================================================================
+-- 0007_stage_three_four_completion.sql
+-- ============================================================================
+
+-- Complete the universe/character alpha slice: resumable coauthor questions and
+-- an atomic, server-only character creation path.
+
+alter table public.coauthor_sessions
+ add column if not exists current_question jsonb;
+
+alter table public.coauthor_sessions
+ drop constraint if exists coauthor_sessions_current_question_object;
+alter table public.coauthor_sessions
+ add constraint coauthor_sessions_current_question_object check (
+ current_question is null or jsonb_typeof(current_question) = 'object'
+ );
+
+create or replace function public.stage_two_append_coauthor_message(
+ p_session_id uuid,
+ p_owner_id uuid,
+ p_content text
+) returns jsonb language plpgsql security definer set search_path = '' as $$
+declare
+ v_session public.coauthor_sessions%rowtype;
+ v_result jsonb;
+begin
+ if p_content is null or char_length(btrim(p_content)) not between 1 and 5000 then
+ raise exception 'message must be between 1 and 5000 characters';
+ end if;
+ select * into v_session from public.coauthor_sessions
+ where id = p_session_id and owner_id = p_owner_id for update;
+ if not found then raise exception 'coauthor session not found'; end if;
+ if v_session.status in ('generating', 'confirmed') then
+ raise exception 'coauthor session cannot be edited in status %', v_session.status;
+ end if;
+ update public.coauthor_sessions
+ set messages = messages || jsonb_build_array(jsonb_build_object('role', 'user', 'content', btrim(p_content))),
+ generated_world = null,
+ current_question = null,
+ status = 'collecting',
+ updated_at = now()
+ where id = p_session_id
+ returning jsonb_build_object('id', id, 'status', status, 'messages', messages, 'updatedAt', updated_at)
+ into v_result;
+ return v_result;
+end;
+$$;
+
+create or replace function public.stage_four_set_coauthor_question(
+ p_session_id uuid,
+ p_owner_id uuid,
+ p_question jsonb
+) returns jsonb language plpgsql security definer set search_path = '' as $$
+declare
+ v_result jsonb;
+begin
+ if p_question is null
+ or jsonb_typeof(p_question) <> 'object'
+ or nullif(btrim(p_question->>'id'), '') is null
+ or char_length(btrim(p_question->>'label')) not between 5 and 300
+ or jsonb_typeof(p_question->'options') <> 'array'
+ or jsonb_array_length(p_question->'options') <> 3 then
+ raise exception 'invalid coauthor question';
+ end if;
+
+ update public.coauthor_sessions
+ set messages = messages || jsonb_build_array(jsonb_build_object(
+ 'role', 'assistant', 'content', btrim(p_question->>'label')
+ )),
+ current_question = p_question,
+ updated_at = now()
+ where id = p_session_id and owner_id = p_owner_id and status = 'collecting'
+ returning current_question into v_result;
+ if not found then raise exception 'collecting coauthor session not found'; end if;
+ return v_result;
+end;
+$$;
+
+create or replace function public.stage_four_create_character(
+ p_campaign_id uuid,
+ p_actor_id uuid,
+ p_controller text,
+ p_name text,
+ p_concept text,
+ p_abilities jsonb,
+ p_hp integer,
+ p_max_hp integer,
+ p_defense integer,
+ p_proficiency integer,
+ p_inventory jsonb,
+ p_statuses jsonb,
+ p_persona jsonb
+) returns uuid language plpgsql security definer set search_path = '' as $$
+declare
+ v_character_id uuid;
+ v_user_id uuid;
+begin
+ if p_controller not in ('human', 'ai') then raise exception 'invalid character controller'; end if;
+ if not exists (
+ select 1 from public.campaign_members
+ where campaign_id = p_campaign_id and user_id = p_actor_id and active
+ ) then raise exception 'active campaign membership is required'; end if;
+
+ if p_controller = 'ai' then
+ if not exists (
+ select 1 from public.campaigns where id = p_campaign_id and owner_id = p_actor_id
+ ) then raise exception 'only the campaign owner can add AI heroes'; end if;
+ v_user_id := null;
+ else
+ v_user_id := p_actor_id;
+ perform pg_catalog.pg_advisory_xact_lock(
+ pg_catalog.hashtextextended(p_campaign_id::text || ':' || p_actor_id::text, 0)
+ );
+ if exists (
+ select 1 from public.characters
+ where campaign_id = p_campaign_id and user_id = p_actor_id
+ ) then raise exception 'this player already has a character'; end if;
+ end if;
+
+ insert into public.characters(
+ campaign_id, user_id, name, concept, controller, abilities, hp, max_hp,
+ defense, proficiency, inventory, statuses, persona
+ ) values (
+ p_campaign_id, v_user_id, btrim(p_name), btrim(p_concept),
+ p_controller::public.character_controller, p_abilities, p_hp, p_max_hp,
+ p_defense, p_proficiency, p_inventory, p_statuses, p_persona
+ ) returning id into v_character_id;
+ return v_character_id;
+end;
+$$;
+
+create or replace function public.dng_schema_version()
+returns integer language sql stable security definer set search_path = '' as $$
+ select 7;
+$$;
+
+revoke all on function public.stage_four_set_coauthor_question(uuid, uuid, jsonb) from public, anon, authenticated;
+revoke all on function public.stage_four_create_character(uuid, uuid, text, text, text, jsonb, integer, integer, integer, integer, jsonb, jsonb, jsonb) from public, anon, authenticated;
+revoke all on function public.dng_schema_version() from public, anon, authenticated;
+grant execute on function public.stage_four_set_coauthor_question(uuid, uuid, jsonb) to service_role;
+grant execute on function public.stage_four_create_character(uuid, uuid, text, text, text, jsonb, integer, integer, integer, integer, jsonb, jsonb, jsonb) to service_role;
+grant execute on function public.dng_schema_version() to service_role;
+
+notify pgrst, 'reload schema';
+
do $$
begin
@@ -2051,6 +2196,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() <> 7 then
+ raise exception 'D&G bootstrap verification failed: unexpected schema version';
+ end if;
end;
$$;
diff --git a/supabase/migrations/0007_stage_three_four_completion.sql b/supabase/migrations/0007_stage_three_four_completion.sql
new file mode 100644
index 0000000..3016e28
--- /dev/null
+++ b/supabase/migrations/0007_stage_three_four_completion.sql
@@ -0,0 +1,140 @@
+-- Complete the universe/character alpha slice: resumable coauthor questions and
+-- an atomic, server-only character creation path.
+
+alter table public.coauthor_sessions
+ add column if not exists current_question jsonb;
+
+alter table public.coauthor_sessions
+ drop constraint if exists coauthor_sessions_current_question_object;
+alter table public.coauthor_sessions
+ add constraint coauthor_sessions_current_question_object check (
+ current_question is null or jsonb_typeof(current_question) = 'object'
+ );
+
+create or replace function public.stage_two_append_coauthor_message(
+ p_session_id uuid,
+ p_owner_id uuid,
+ p_content text
+) returns jsonb language plpgsql security definer set search_path = '' as $$
+declare
+ v_session public.coauthor_sessions%rowtype;
+ v_result jsonb;
+begin
+ if p_content is null or char_length(btrim(p_content)) not between 1 and 5000 then
+ raise exception 'message must be between 1 and 5000 characters';
+ end if;
+ select * into v_session from public.coauthor_sessions
+ where id = p_session_id and owner_id = p_owner_id for update;
+ if not found then raise exception 'coauthor session not found'; end if;
+ if v_session.status in ('generating', 'confirmed') then
+ raise exception 'coauthor session cannot be edited in status %', v_session.status;
+ end if;
+ update public.coauthor_sessions
+ set messages = messages || jsonb_build_array(jsonb_build_object('role', 'user', 'content', btrim(p_content))),
+ generated_world = null,
+ current_question = null,
+ status = 'collecting',
+ updated_at = now()
+ where id = p_session_id
+ returning jsonb_build_object('id', id, 'status', status, 'messages', messages, 'updatedAt', updated_at)
+ into v_result;
+ return v_result;
+end;
+$$;
+
+create or replace function public.stage_four_set_coauthor_question(
+ p_session_id uuid,
+ p_owner_id uuid,
+ p_question jsonb
+) returns jsonb language plpgsql security definer set search_path = '' as $$
+declare
+ v_result jsonb;
+begin
+ if p_question is null
+ or jsonb_typeof(p_question) <> 'object'
+ or nullif(btrim(p_question->>'id'), '') is null
+ or char_length(btrim(p_question->>'label')) not between 5 and 300
+ or jsonb_typeof(p_question->'options') <> 'array'
+ or jsonb_array_length(p_question->'options') <> 3 then
+ raise exception 'invalid coauthor question';
+ end if;
+
+ update public.coauthor_sessions
+ set messages = messages || jsonb_build_array(jsonb_build_object(
+ 'role', 'assistant', 'content', btrim(p_question->>'label')
+ )),
+ current_question = p_question,
+ updated_at = now()
+ where id = p_session_id and owner_id = p_owner_id and status = 'collecting'
+ returning current_question into v_result;
+ if not found then raise exception 'collecting coauthor session not found'; end if;
+ return v_result;
+end;
+$$;
+
+create or replace function public.stage_four_create_character(
+ p_campaign_id uuid,
+ p_actor_id uuid,
+ p_controller text,
+ p_name text,
+ p_concept text,
+ p_abilities jsonb,
+ p_hp integer,
+ p_max_hp integer,
+ p_defense integer,
+ p_proficiency integer,
+ p_inventory jsonb,
+ p_statuses jsonb,
+ p_persona jsonb
+) returns uuid language plpgsql security definer set search_path = '' as $$
+declare
+ v_character_id uuid;
+ v_user_id uuid;
+begin
+ if p_controller not in ('human', 'ai') then raise exception 'invalid character controller'; end if;
+ if not exists (
+ select 1 from public.campaign_members
+ where campaign_id = p_campaign_id and user_id = p_actor_id and active
+ ) then raise exception 'active campaign membership is required'; end if;
+
+ if p_controller = 'ai' then
+ if not exists (
+ select 1 from public.campaigns where id = p_campaign_id and owner_id = p_actor_id
+ ) then raise exception 'only the campaign owner can add AI heroes'; end if;
+ v_user_id := null;
+ else
+ v_user_id := p_actor_id;
+ perform pg_catalog.pg_advisory_xact_lock(
+ pg_catalog.hashtextextended(p_campaign_id::text || ':' || p_actor_id::text, 0)
+ );
+ if exists (
+ select 1 from public.characters
+ where campaign_id = p_campaign_id and user_id = p_actor_id
+ ) then raise exception 'this player already has a character'; end if;
+ end if;
+
+ insert into public.characters(
+ campaign_id, user_id, name, concept, controller, abilities, hp, max_hp,
+ defense, proficiency, inventory, statuses, persona
+ ) values (
+ p_campaign_id, v_user_id, btrim(p_name), btrim(p_concept),
+ p_controller::public.character_controller, p_abilities, p_hp, p_max_hp,
+ p_defense, p_proficiency, p_inventory, p_statuses, p_persona
+ ) returning id into v_character_id;
+ return v_character_id;
+end;
+$$;
+
+create or replace function public.dng_schema_version()
+returns integer language sql stable security definer set search_path = '' as $$
+ select 7;
+$$;
+
+revoke all on function public.stage_four_set_coauthor_question(uuid, uuid, jsonb) from public, anon, authenticated;
+revoke all on function public.stage_four_create_character(uuid, uuid, text, text, text, jsonb, integer, integer, integer, integer, jsonb, jsonb, jsonb) from public, anon, authenticated;
+revoke all on function public.dng_schema_version() from public, anon, authenticated;
+grant execute on function public.stage_four_set_coauthor_question(uuid, uuid, jsonb) to service_role;
+grant execute on function public.stage_four_create_character(uuid, uuid, text, text, text, jsonb, integer, integer, integer, integer, jsonb, jsonb, jsonb) to service_role;
+grant execute on function public.dng_schema_version() to service_role;
+
+notify pgrst, 'reload schema';