Compare commits

...

2 Commits

Author SHA1 Message Date
56bddafbe8 Merge pull request 'DUNGEONS-16: make campaigns immediately playable' (#12) from agent/codex-chatgpt/1cffe813 into main
Some checks failed
CI / validate (push) Has been cancelled
Reviewed-on: #12
2026-08-21 14:50:56 +00:00
f7ca158fe4 fix(gameplay): start campaigns in playable state
Some checks failed
CI / validate (pull_request) Has been cancelled
CI / validate (push) Successful in 32m54s
Co-authored-by: multica-agent <github@multica.ai>
2026-08-21 19:41:50 +05:00
9 changed files with 590 additions and 44 deletions

View File

@@ -58,7 +58,7 @@ If the worker reports `POST /rest/v1/rpc/claim_ai_job 404` and `/rest/v1/profile
1. Click **Enter the Alpha** to open registration. Every account requires a display name, email, password, and 13+ confirmation. Existing users sign in with email/password; forgotten passwords use the email recovery flow.
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.
4. A new campaign starts immediately with an owner-controlled hero and a persistent AI companion. Additional players create a human character manually or ask the coauthor for an editable draft; owners can add more AI companions the same way.
5. The owner creates an expiring private invite link. Signed-in 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.

View File

@@ -51,7 +51,11 @@ const myIntent = computed(() => payload.value?.intents.find(intent =>
intent.member_id === currentMember.value?.id || intent.character_id === myCharacter.value?.id,
))
const isReady = computed(() => Boolean(myIntent.value?.ready))
const activeHumanMembers = computed(() => payload.value?.members.filter(member => member.active !== false) ?? [])
const activeHumanMembers = computed(() => payload.value?.members.filter(member =>
member.active !== false && payload.value?.characters.some(character =>
character.user_id === member.user_id && character.controller === 'human',
),
) ?? [])
const readyCount = computed(() => activeHumanMembers.value.filter(member => payload.value?.intents.some(intent => intent.member_id === member.id && intent.ready)).length)
const canAct = computed(() => currentRound.value?.status === 'open' && Boolean(myCharacter.value))
const roundBusy = computed(() => ['queued', 'resolving'].includes(String(currentRound.value?.status)))

View File

@@ -16,6 +16,8 @@ interface StoredSession {
confirmed_world_id?: string | null
}
const questionLimit = 4
const { api } = useDngApi()
const route = useRoute()
const stage = ref<Stage>('seed')
@@ -33,7 +35,14 @@ const messages = ref<Message[]>([
])
const displayedQuestion = computed(() => currentQuestion.value ? { key: currentQuestion.value.id, label: currentQuestion.value.label, options: currentQuestion.value.options } : null)
const progress = computed(() => stage.value === 'preview' || stage.value === 'confirming' ? 100 : stage.value === 'generating' ? 85 : stage.value === 'seed' ? 10 : 20 + Math.round((Math.min(answerCount.value, 5) / 5) * 55))
const conversationMessages = computed(() => {
if (!currentQuestion.value) return messages.value
const currentIndex = messages.value.findLastIndex(message =>
message.role === 'coauthor' && message.body === currentQuestion.value?.label,
)
return currentIndex < 0 ? messages.value : messages.value.filter((_, index) => index !== currentIndex)
})
const progress = computed(() => stage.value === 'preview' || stage.value === 'confirming' ? 100 : stage.value === 'generating' ? 85 : stage.value === 'seed' ? 10 : 20 + Math.round((Math.min(answerCount.value, questionLimit) / questionLimit) * 55))
function addMessage(role: Message['role'], body: string, label?: string) {
messages.value.push({ id: `${Date.now()}-${messages.value.length}`, role, body, label })
@@ -117,6 +126,7 @@ async function resume(id: string) {
async function answerQuestion(key: string, value: string) {
if (stage.value !== 'questions' || currentQuestion.value?.id !== key || busy.value || !sessionId.value) return
const answeredQuestion = currentQuestion.value
busy.value = true
pendingAnswer.value = { key, value }
try {
@@ -124,9 +134,10 @@ async function answerQuestion(key: string, value: string) {
method: 'POST', body: { content: value },
})
answers[key] = value
addMessage('coauthor', answeredQuestion.label, `COAUTHOR · QUESTION ${Math.min(answerCount.value + 1, questionLimit)} OF ${questionLimit}`)
addMessage('player', value)
currentQuestion.value = null
answerCount.value = Math.min(answerCount.value + 1, 5)
answerCount.value = Math.min(answerCount.value + 1, questionLimit)
pendingAnswer.value = null
retryAction.value = 'respond'
await requestNextQuestion()
@@ -140,14 +151,13 @@ async function answerQuestion(key: string, value: string) {
async function requestNextQuestion() {
if (!sessionId.value) return
const result = await api<RespondResult>(`/api/v1/coauthor/sessions/${sessionId.value}/respond`, { method: 'POST' })
if (result.readyToGenerate || answerCount.value >= 5) {
if (result.readyToGenerate || answerCount.value >= questionLimit) {
currentQuestion.value = null
await generate()
return
}
if (!result.question) throw new Error('The coauthor did not return its next question.')
currentQuestion.value = result.question
addMessage('coauthor', result.question.label, `COAUTHOR · QUESTION ${Math.min(answerCount.value + 1, 5)} OF UP TO 5`)
}
function errorText(error: unknown) {
@@ -247,7 +257,7 @@ onMounted(() => {
<main v-if="stage !== 'preview' && stage !== 'confirming'" class="coauthor">
<p class="kicker">COAUTHOR / SESSION 01</p>
<h1>BUILD THE<br>IMPOSSIBLE<span>.</span></h1>
<CoauthorConversation :messages="messages" :question="displayedQuestion" :selected-answer="currentQuestion ? answers[currentQuestion.id] : undefined" :busy="stage === 'generating' || busy" @answer="answerQuestion" @retry="retry" />
<CoauthorConversation :messages="conversationMessages" :question="displayedQuestion" :selected-answer="currentQuestion ? answers[currentQuestion.id] : undefined" :busy="stage === 'generating' || busy" @answer="answerQuestion" @retry="retry" />
<form v-if="stage === 'seed'" class="seed" @submit.prevent="begin">
<textarea v-model="seed" aria-label="World idea" rows="3" maxlength="1200" autofocus />
<button :disabled="!seed.trim() || busy">{{ busy ? 'SAVING…' : 'BEGIN' }} <span></span></button>

View File

@@ -1,17 +1,9 @@
import { z } from 'zod'
import { buildJsonCompletion, parseJsonCompletion } from '~/server/utils/ai-provider'
import { CoauthorQuestionSchema, StoredCoauthorQuestionSchema } from '~/server/utils/coauthor-question'
import { requireStageTwoSafeText, requireStageTwoUser, stageTwoApiError, stageTwoDatabase, stageTwoRpc, stageTwoUuid } from '~/server/utils/stage-two-supabase'
const MessageSchema = z.object({ role: z.enum(['user', 'assistant']), content: z.string().min(1).max(5000) })
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 {
@@ -27,29 +19,42 @@ export default defineEventHandler(async (event) => {
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 storedQuestion = StoredCoauthorQuestionSchema.safeParse(session.current_question)
if (storedQuestion.success) return { readyToGenerate: false, question: storedQuestion.data }
}
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. Use the language of the first user message for the question and every answer option unless the user explicitly requests another language. Provide exactly three concise, mutually distinct answer options. Keep everything suitable for ages 13+.`,
messages,
schemaName: 'coauthor_question',
jsonSchema: QuestionSchema.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,
let question: z.infer<typeof CoauthorQuestionSchema> | undefined
for (let attempt = 0; attempt < 2 && !question; attempt += 1) {
const correction = attempt
? ' The previous response was malformed. Ensure every option is a natural-language answer, not punctuation, a JSON key, or a copy of the 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. Use the language of the first user message for the question and every answer option unless the user explicitly requests another language. Provide exactly three concise, mutually distinct natural-language answers to the question. Never use punctuation or JSON field names as an option, and never repeat the question as an option. Keep everything suitable for ages 13+.${correction}`,
messages,
schemaName: 'coauthor_question',
jsonSchema: CoauthorQuestionSchema.toJSONSchema(),
})
} finally {
clearTimeout(timeout)
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 coauthor is temporarily unavailable.' })
try {
const parsed = CoauthorQuestionSchema.safeParse(parseJsonCompletion(await response.json()))
if (parsed.success) question = parsed.data
} catch {
// Retry once when the provider ignores structured-output requirements.
}
}
if (!response.ok) throw createError({ statusCode: 502, statusMessage: 'The coauthor is temporarily unavailable.' })
const question = QuestionSchema.parse(parseJsonCompletion(await response.json()))
if (!question) throw createError({ statusCode: 502, statusMessage: 'The coauthor returned invalid answer options. Try the question again.' })
requireStageTwoSafeText([question.question, ...question.options].join('\n'))
const storedQuestion = StoredQuestionSchema.parse({
const storedQuestion = StoredCoauthorQuestionSchema.parse({
id: `question-${answerCount + 1}`,
label: question.question,
options: question.options,

View File

@@ -0,0 +1,30 @@
import { describe, expect, it } from 'vitest'
import { CoauthorQuestionSchema, StoredCoauthorQuestionSchema } from './coauthor-question'
describe('coauthor question validation', () => {
it('accepts three useful, distinct answers', () => {
expect(CoauthorQuestionSchema.parse({
question: 'How will the party enter the sealed archive?',
options: ['Negotiate with its keeper', 'Search for a maintenance route', 'Force the main gate'],
})).toMatchObject({ options: ['Negotiate with its keeper', 'Search for a maintenance route', 'Force the main gate'] })
expect(CoauthorQuestionSchema.toJSONSchema()).toMatchObject({
type: 'object',
properties: { options: { type: 'array', minItems: 3, maxItems: 3 } },
})
})
it('rejects malformed JSON fragments and a repeated question', () => {
expect(() => CoauthorQuestionSchema.parse({
question: 'Как персонажи собираются решить проблему отсутствия наличных денег?',
options: [':', 'question', 'Как персонажи собираются решить проблему отсутствия наличных денег?'],
})).toThrow()
})
it('applies the same quality checks to a stored question', () => {
expect(() => StoredCoauthorQuestionSchema.parse({
id: 'question-2',
label: 'What makes the signal dangerous?',
options: ['The signal is alive', 'The signal is alive', 'Nobody knows yet'],
})).toThrow()
})
})

View File

@@ -0,0 +1,63 @@
import { z } from 'zod'
const reservedOptionValues = new Set([
'answer',
'answers',
'label',
'option',
'options',
'question',
])
function normalized(value: string) {
return value.trim().toLocaleLowerCase().replace(/\s+/g, ' ')
}
export const CoauthorQuestionSchema = z.object({
question: z.string().trim().min(5).max(300),
options: z.array(z.string().trim().min(2).max(100)).length(3),
}).superRefine((value, context) => {
const question = normalized(value.question)
const seen = new Set<string>()
value.options.forEach((option, index) => {
const candidate = normalized(option)
if (!/[\p{L}\p{N}]/u.test(candidate) || reservedOptionValues.has(candidate)) {
context.addIssue({
code: 'custom',
message: 'Each option must be a meaningful answer.',
path: ['options', index],
})
}
if (seen.has(candidate)) {
context.addIssue({
code: 'custom',
message: 'Answer options must be distinct.',
path: ['options', index],
})
}
if (candidate === question || (candidate.length >= 20 && question.includes(candidate))) {
context.addIssue({
code: 'custom',
message: 'An answer option must not repeat the question.',
path: ['options', index],
})
}
seen.add(candidate)
})
})
export const StoredCoauthorQuestionSchema = z.object({
id: z.string().trim().min(1).max(80),
label: z.string().trim().min(5).max(300),
options: CoauthorQuestionSchema.shape.options,
}).superRefine((value, context) => {
const result = CoauthorQuestionSchema.safeParse({ question: value.label, options: value.options })
for (const issue of result.error?.issues ?? []) {
context.addIssue({
code: 'custom',
message: issue.message,
path: issue.path[0] === 'question' ? ['label', ...issue.path.slice(1)] : issue.path,
})
}
})

View File

@@ -123,6 +123,12 @@ const { worldId } = await appRequest(`/api/v1/coauthor/sessions/${sessionId}/con
const { campaignId } = await appRequest('/api/v1/campaigns', owner.token, {
method: 'POST', body: JSON.stringify({ worldId, title: `Smoke Party ${suffix}` }),
})
const starterParty = await appRequest(`/api/v1/campaigns/${campaignId}`, owner.token)
const ownerCharacter = starterParty.characters.find(character => character.user_id === owner.id && character.controller === 'human')
const automaticCompanion = starterParty.characters.find(character => character.controller === 'ai')
if (!ownerCharacter || !automaticCompanion || starterParty.round?.status !== 'open') {
throw new Error('[smoke] A new campaign did not start with an actionable owner hero, an AI companion, and an open round.')
}
console.log('[smoke] Joining the second player through an invite…')
const { token: inviteToken } = await appRequest(`/api/v1/campaigns/${campaignId}/invites`, owner.token, {
@@ -137,14 +143,11 @@ 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…')
console.log('[smoke] Verifying AI drafting while keeping the automatic companion…')
const suggested = await appRequest(`/api/v1/campaigns/${campaignId}/characters/suggest`, owner.token, {
method: 'POST',
body: JSON.stringify({ controller: 'ai', concept: 'A damaged garden caretaker who values living things over orders.' }),
@@ -152,10 +155,6 @@ const suggested = await appRequest(`/api/v1/campaigns/${campaignId}/characters/s
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}.`)
@@ -165,7 +164,7 @@ 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 }),
body: JSON.stringify({ characterId: ownerCharacter.id, action: 'The owner checks the airlock telemetry for a safe path.', ready: true }),
})
await new Promise(resolve => setTimeout(resolve, 1_500))
const waiting = await appRequest(`/api/v1/campaigns/${campaignId}`, player.token)

View File

@@ -2324,6 +2324,226 @@ grant execute on function public.dng_schema_version() to service_role;
notify pgrst, 'reload schema';
-- ============================================================================
-- 0010_playable_starter_parties.sql
-- ============================================================================
-- Make a newly launched campaign playable immediately: the owner receives a
-- human-controlled starter and the party receives one persistent AI companion.
create or replace function public.stage_two_create_campaign(
p_world_id uuid,
p_owner_id uuid,
p_title text
) returns uuid language plpgsql security definer set search_path = '' as $$
declare
v_world public.worlds%rowtype;
v_campaign_id uuid;
v_owner_name text;
begin
if p_title is null or char_length(btrim(p_title)) not between 3 and 100 then
raise exception 'campaign title must be between 3 and 100 characters';
end if;
select * into v_world from public.worlds
where id = p_world_id and owner_id = p_owner_id and status = 'confirmed' for share;
if not found then raise exception 'confirmed world not found'; end if;
select nullif(btrim(display_name), '') into v_owner_name
from public.profiles where id = p_owner_id;
v_owner_name := left(coalesce(v_owner_name, 'Wayfinder'), 80);
insert into public.campaigns(world_id, owner_id, title, current_scene, next_prompt, status)
values (p_world_id, p_owner_id, btrim(p_title), coalesce(v_world.opening_scene, v_world.premise), 'What do you do?', 'active')
returning id into v_campaign_id;
insert into public.campaign_members(campaign_id, user_id, role)
values (v_campaign_id, p_owner_id, 'owner');
insert into public.characters(
campaign_id, user_id, name, concept, controller, abilities, hp, max_hp,
defense, proficiency, inventory, statuses, persona
) values (
v_campaign_id,
p_owner_id,
v_owner_name,
left('An adaptable protagonist ready to confront the opening mystery of ' || v_world.title || '.', 600),
'human'::public.character_controller,
'{"str":10,"dex":12,"con":12,"int":11,"wis":13,"cha":10}'::jsonb,
12,
12,
12,
2,
'["Field kit","Personal keepsake"]'::jsonb,
'[]'::jsonb,
jsonb_build_object(
'voice', 'Defined by the player.',
'motivation', 'Discover what the opening scene is hiding.',
'flaw', 'Still learning what this world demands.',
'bond', 'Protect the party through the first danger.'
)
);
insert into public.characters(
campaign_id, user_id, name, concept, controller, abilities, hp, max_hp,
defense, proficiency, inventory, statuses, persona
) values (
v_campaign_id,
null,
'Echo',
left('A persistent AI companion shaped by the ' || v_world.genre || ' world of ' || v_world.title || ', ready to support the player without taking over their choices.', 600),
'ai'::public.character_controller,
'{"str":9,"dex":13,"con":11,"int":13,"wis":12,"cha":10}'::jsonb,
11,
11,
13,
2,
'["Survey kit","Emergency supplies"]'::jsonb,
'[]'::jsonb,
jsonb_build_object(
'voice', 'Observant, concise, and quietly curious.',
'motivation', 'Help the party understand this unfamiliar world.',
'flaw', 'Sometimes values patterns more than instinct.',
'bond', 'Stays beside the player when the path turns dangerous.'
)
);
insert into public.rounds(campaign_id, number) values (v_campaign_id, 1);
return v_campaign_id;
end;
$$;
-- Members who have not created a character cannot submit an intent and must not
-- hold the round open. Only active members with a human-controlled character
-- participate in the readiness barrier.
create or replace function public.stage_two_submit_intent(
p_round_id uuid,
p_user_id uuid,
p_character_id uuid,
p_action text,
p_ready boolean default false
) returns jsonb language plpgsql security definer set search_path = '' as $$
declare
v_round public.rounds%rowtype;
v_member public.campaign_members%rowtype;
v_intent_id uuid;
v_job_id uuid;
begin
if p_action is null or char_length(btrim(p_action)) not between 1 and 2000 then
raise exception 'action must be between 1 and 2000 characters';
end if;
select * into v_round from public.rounds where id = p_round_id for update;
if not found or v_round.status <> 'open' then raise exception 'round is not open'; end if;
select * into v_member from public.campaign_members
where campaign_id = v_round.campaign_id and user_id = p_user_id and active;
if not found then raise exception 'active campaign membership not found'; end if;
if not exists (
select 1 from public.characters
where id = p_character_id and campaign_id = v_round.campaign_id
and user_id = p_user_id and controller = 'human'
) then raise exception 'controlled character not found'; end if;
insert into public.player_intents(round_id, member_id, character_id, action, ready)
values (p_round_id, v_member.id, p_character_id, btrim(p_action), coalesce(p_ready, false))
on conflict (round_id, member_id) do update
set character_id = excluded.character_id, action = excluded.action,
ready = excluded.ready, updated_at = now()
returning id into v_intent_id;
if coalesce(p_ready, false) and not exists (
select 1
from public.campaign_members member
join public.characters character
on character.campaign_id = member.campaign_id
and character.user_id = member.user_id
and character.controller = 'human'::public.character_controller
where member.campaign_id = v_round.campaign_id
and member.active
and not exists (
select 1 from public.player_intents intent
where intent.round_id = p_round_id and intent.member_id = member.id and intent.ready
)
) then
v_job_id := public.enqueue_round_resolution(p_round_id, null);
end if;
return jsonb_build_object('intentId', v_intent_id, 'jobId', v_job_id);
end;
$$;
-- Repair campaigns created before starter parties were automatic.
insert into public.characters(
campaign_id, user_id, name, concept, controller, abilities, hp, max_hp,
defense, proficiency, inventory, statuses, persona
)
select
campaign.id,
campaign.owner_id,
left(coalesce(nullif(btrim(profile.display_name), ''), 'Wayfinder'), 80),
left('An adaptable protagonist ready to confront the opening mystery of ' || world.title || '.', 600),
'human'::public.character_controller,
'{"str":10,"dex":12,"con":12,"int":11,"wis":13,"cha":10}'::jsonb,
12,
12,
12,
2,
'["Field kit","Personal keepsake"]'::jsonb,
'[]'::jsonb,
jsonb_build_object(
'voice', 'Defined by the player.',
'motivation', 'Discover what the opening scene is hiding.',
'flaw', 'Still learning what this world demands.',
'bond', 'Protect the party through the first danger.'
)
from public.campaigns campaign
join public.worlds world on world.id = campaign.world_id
left join public.profiles profile on profile.id = campaign.owner_id
where campaign.status = 'active'
and not exists (
select 1 from public.characters character
where character.campaign_id = campaign.id
and character.user_id = campaign.owner_id
);
insert into public.characters(
campaign_id, user_id, name, concept, controller, abilities, hp, max_hp,
defense, proficiency, inventory, statuses, persona
)
select
campaign.id,
null,
'Echo',
left('A persistent AI companion shaped by the ' || world.genre || ' world of ' || world.title || ', ready to support the player without taking over their choices.', 600),
'ai'::public.character_controller,
'{"str":9,"dex":13,"con":11,"int":13,"wis":12,"cha":10}'::jsonb,
11,
11,
13,
2,
'["Survey kit","Emergency supplies"]'::jsonb,
'[]'::jsonb,
jsonb_build_object(
'voice', 'Observant, concise, and quietly curious.',
'motivation', 'Help the party understand this unfamiliar world.',
'flaw', 'Sometimes values patterns more than instinct.',
'bond', 'Stays beside the player when the path turns dangerous.'
)
from public.campaigns campaign
join public.worlds world on world.id = campaign.world_id
where campaign.status = 'active'
and not exists (
select 1 from public.characters character
where character.campaign_id = campaign.id
and character.controller = 'ai'::public.character_controller
);
create or replace function public.dng_schema_version()
returns integer language sql stable security definer set search_path = '' as $$
select 10;
$$;
revoke all on function public.dng_schema_version() from public, anon, authenticated;
grant execute on function public.dng_schema_version() to service_role;
notify pgrst, 'reload schema';
do $$
begin
@@ -2348,7 +2568,7 @@ begin
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() <> 9 then
if public.dng_schema_version() <> 10 then
raise exception 'D&G bootstrap verification failed: unexpected schema version';
end if;
end;

View File

@@ -0,0 +1,215 @@
-- Make a newly launched campaign playable immediately: the owner receives a
-- human-controlled starter and the party receives one persistent AI companion.
create or replace function public.stage_two_create_campaign(
p_world_id uuid,
p_owner_id uuid,
p_title text
) returns uuid language plpgsql security definer set search_path = '' as $$
declare
v_world public.worlds%rowtype;
v_campaign_id uuid;
v_owner_name text;
begin
if p_title is null or char_length(btrim(p_title)) not between 3 and 100 then
raise exception 'campaign title must be between 3 and 100 characters';
end if;
select * into v_world from public.worlds
where id = p_world_id and owner_id = p_owner_id and status = 'confirmed' for share;
if not found then raise exception 'confirmed world not found'; end if;
select nullif(btrim(display_name), '') into v_owner_name
from public.profiles where id = p_owner_id;
v_owner_name := left(coalesce(v_owner_name, 'Wayfinder'), 80);
insert into public.campaigns(world_id, owner_id, title, current_scene, next_prompt, status)
values (p_world_id, p_owner_id, btrim(p_title), coalesce(v_world.opening_scene, v_world.premise), 'What do you do?', 'active')
returning id into v_campaign_id;
insert into public.campaign_members(campaign_id, user_id, role)
values (v_campaign_id, p_owner_id, 'owner');
insert into public.characters(
campaign_id, user_id, name, concept, controller, abilities, hp, max_hp,
defense, proficiency, inventory, statuses, persona
) values (
v_campaign_id,
p_owner_id,
v_owner_name,
left('An adaptable protagonist ready to confront the opening mystery of ' || v_world.title || '.', 600),
'human'::public.character_controller,
'{"str":10,"dex":12,"con":12,"int":11,"wis":13,"cha":10}'::jsonb,
12,
12,
12,
2,
'["Field kit","Personal keepsake"]'::jsonb,
'[]'::jsonb,
jsonb_build_object(
'voice', 'Defined by the player.',
'motivation', 'Discover what the opening scene is hiding.',
'flaw', 'Still learning what this world demands.',
'bond', 'Protect the party through the first danger.'
)
);
insert into public.characters(
campaign_id, user_id, name, concept, controller, abilities, hp, max_hp,
defense, proficiency, inventory, statuses, persona
) values (
v_campaign_id,
null,
'Echo',
left('A persistent AI companion shaped by the ' || v_world.genre || ' world of ' || v_world.title || ', ready to support the player without taking over their choices.', 600),
'ai'::public.character_controller,
'{"str":9,"dex":13,"con":11,"int":13,"wis":12,"cha":10}'::jsonb,
11,
11,
13,
2,
'["Survey kit","Emergency supplies"]'::jsonb,
'[]'::jsonb,
jsonb_build_object(
'voice', 'Observant, concise, and quietly curious.',
'motivation', 'Help the party understand this unfamiliar world.',
'flaw', 'Sometimes values patterns more than instinct.',
'bond', 'Stays beside the player when the path turns dangerous.'
)
);
insert into public.rounds(campaign_id, number) values (v_campaign_id, 1);
return v_campaign_id;
end;
$$;
-- Members who have not created a character cannot submit an intent and must not
-- hold the round open. Only active members with a human-controlled character
-- participate in the readiness barrier.
create or replace function public.stage_two_submit_intent(
p_round_id uuid,
p_user_id uuid,
p_character_id uuid,
p_action text,
p_ready boolean default false
) returns jsonb language plpgsql security definer set search_path = '' as $$
declare
v_round public.rounds%rowtype;
v_member public.campaign_members%rowtype;
v_intent_id uuid;
v_job_id uuid;
begin
if p_action is null or char_length(btrim(p_action)) not between 1 and 2000 then
raise exception 'action must be between 1 and 2000 characters';
end if;
select * into v_round from public.rounds where id = p_round_id for update;
if not found or v_round.status <> 'open' then raise exception 'round is not open'; end if;
select * into v_member from public.campaign_members
where campaign_id = v_round.campaign_id and user_id = p_user_id and active;
if not found then raise exception 'active campaign membership not found'; end if;
if not exists (
select 1 from public.characters
where id = p_character_id and campaign_id = v_round.campaign_id
and user_id = p_user_id and controller = 'human'
) then raise exception 'controlled character not found'; end if;
insert into public.player_intents(round_id, member_id, character_id, action, ready)
values (p_round_id, v_member.id, p_character_id, btrim(p_action), coalesce(p_ready, false))
on conflict (round_id, member_id) do update
set character_id = excluded.character_id, action = excluded.action,
ready = excluded.ready, updated_at = now()
returning id into v_intent_id;
if coalesce(p_ready, false) and not exists (
select 1
from public.campaign_members member
join public.characters character
on character.campaign_id = member.campaign_id
and character.user_id = member.user_id
and character.controller = 'human'::public.character_controller
where member.campaign_id = v_round.campaign_id
and member.active
and not exists (
select 1 from public.player_intents intent
where intent.round_id = p_round_id and intent.member_id = member.id and intent.ready
)
) then
v_job_id := public.enqueue_round_resolution(p_round_id, null);
end if;
return jsonb_build_object('intentId', v_intent_id, 'jobId', v_job_id);
end;
$$;
-- Repair campaigns created before starter parties were automatic.
insert into public.characters(
campaign_id, user_id, name, concept, controller, abilities, hp, max_hp,
defense, proficiency, inventory, statuses, persona
)
select
campaign.id,
campaign.owner_id,
left(coalesce(nullif(btrim(profile.display_name), ''), 'Wayfinder'), 80),
left('An adaptable protagonist ready to confront the opening mystery of ' || world.title || '.', 600),
'human'::public.character_controller,
'{"str":10,"dex":12,"con":12,"int":11,"wis":13,"cha":10}'::jsonb,
12,
12,
12,
2,
'["Field kit","Personal keepsake"]'::jsonb,
'[]'::jsonb,
jsonb_build_object(
'voice', 'Defined by the player.',
'motivation', 'Discover what the opening scene is hiding.',
'flaw', 'Still learning what this world demands.',
'bond', 'Protect the party through the first danger.'
)
from public.campaigns campaign
join public.worlds world on world.id = campaign.world_id
left join public.profiles profile on profile.id = campaign.owner_id
where campaign.status = 'active'
and not exists (
select 1 from public.characters character
where character.campaign_id = campaign.id
and character.user_id = campaign.owner_id
);
insert into public.characters(
campaign_id, user_id, name, concept, controller, abilities, hp, max_hp,
defense, proficiency, inventory, statuses, persona
)
select
campaign.id,
null,
'Echo',
left('A persistent AI companion shaped by the ' || world.genre || ' world of ' || world.title || ', ready to support the player without taking over their choices.', 600),
'ai'::public.character_controller,
'{"str":9,"dex":13,"con":11,"int":13,"wis":12,"cha":10}'::jsonb,
11,
11,
13,
2,
'["Survey kit","Emergency supplies"]'::jsonb,
'[]'::jsonb,
jsonb_build_object(
'voice', 'Observant, concise, and quietly curious.',
'motivation', 'Help the party understand this unfamiliar world.',
'flaw', 'Sometimes values patterns more than instinct.',
'bond', 'Stays beside the player when the path turns dangerous.'
)
from public.campaigns campaign
join public.worlds world on world.id = campaign.world_id
where campaign.status = 'active'
and not exists (
select 1 from public.characters character
where character.campaign_id = campaign.id
and character.controller = 'ai'::public.character_controller
);
create or replace function public.dng_schema_version()
returns integer language sql stable security definer set search_path = '' as $$
select 10;
$$;
revoke all on function public.dng_schema_version() from public, anon, authenticated;
grant execute on function public.dng_schema_version() to service_role;
notify pgrst, 'reload schema';