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
Some checks failed
CI / validate (push) Has been cancelled
Reviewed-on: #12
This commit was merged in pull request #12.
This commit is contained in:
@@ -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.
|
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.
|
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.
|
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`.
|
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.
|
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.
|
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.
|
||||||
|
|||||||
@@ -51,7 +51,11 @@ const myIntent = computed(() => payload.value?.intents.find(intent =>
|
|||||||
intent.member_id === currentMember.value?.id || intent.character_id === myCharacter.value?.id,
|
intent.member_id === currentMember.value?.id || intent.character_id === myCharacter.value?.id,
|
||||||
))
|
))
|
||||||
const isReady = computed(() => Boolean(myIntent.value?.ready))
|
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 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 canAct = computed(() => currentRound.value?.status === 'open' && Boolean(myCharacter.value))
|
||||||
const roundBusy = computed(() => ['queued', 'resolving'].includes(String(currentRound.value?.status)))
|
const roundBusy = computed(() => ['queued', 'resolving'].includes(String(currentRound.value?.status)))
|
||||||
|
|||||||
@@ -16,6 +16,8 @@ interface StoredSession {
|
|||||||
confirmed_world_id?: string | null
|
confirmed_world_id?: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const questionLimit = 4
|
||||||
|
|
||||||
const { api } = useDngApi()
|
const { api } = useDngApi()
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const stage = ref<Stage>('seed')
|
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 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) {
|
function addMessage(role: Message['role'], body: string, label?: string) {
|
||||||
messages.value.push({ id: `${Date.now()}-${messages.value.length}`, role, body, label })
|
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) {
|
async function answerQuestion(key: string, value: string) {
|
||||||
if (stage.value !== 'questions' || currentQuestion.value?.id !== key || busy.value || !sessionId.value) return
|
if (stage.value !== 'questions' || currentQuestion.value?.id !== key || busy.value || !sessionId.value) return
|
||||||
|
const answeredQuestion = currentQuestion.value
|
||||||
busy.value = true
|
busy.value = true
|
||||||
pendingAnswer.value = { key, value }
|
pendingAnswer.value = { key, value }
|
||||||
try {
|
try {
|
||||||
@@ -124,9 +134,10 @@ async function answerQuestion(key: string, value: string) {
|
|||||||
method: 'POST', body: { content: value },
|
method: 'POST', body: { content: value },
|
||||||
})
|
})
|
||||||
answers[key] = value
|
answers[key] = value
|
||||||
|
addMessage('coauthor', answeredQuestion.label, `COAUTHOR · QUESTION ${Math.min(answerCount.value + 1, questionLimit)} OF ${questionLimit}`)
|
||||||
addMessage('player', value)
|
addMessage('player', value)
|
||||||
currentQuestion.value = null
|
currentQuestion.value = null
|
||||||
answerCount.value = Math.min(answerCount.value + 1, 5)
|
answerCount.value = Math.min(answerCount.value + 1, questionLimit)
|
||||||
pendingAnswer.value = null
|
pendingAnswer.value = null
|
||||||
retryAction.value = 'respond'
|
retryAction.value = 'respond'
|
||||||
await requestNextQuestion()
|
await requestNextQuestion()
|
||||||
@@ -140,14 +151,13 @@ async function answerQuestion(key: string, value: string) {
|
|||||||
async function requestNextQuestion() {
|
async function requestNextQuestion() {
|
||||||
if (!sessionId.value) return
|
if (!sessionId.value) return
|
||||||
const result = await api<RespondResult>(`/api/v1/coauthor/sessions/${sessionId.value}/respond`, { method: 'POST' })
|
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
|
currentQuestion.value = null
|
||||||
await generate()
|
await generate()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (!result.question) throw new Error('The coauthor did not return its next question.')
|
if (!result.question) throw new Error('The coauthor did not return its next question.')
|
||||||
currentQuestion.value = result.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) {
|
function errorText(error: unknown) {
|
||||||
@@ -247,7 +257,7 @@ onMounted(() => {
|
|||||||
<main v-if="stage !== 'preview' && stage !== 'confirming'" class="coauthor">
|
<main v-if="stage !== 'preview' && stage !== 'confirming'" class="coauthor">
|
||||||
<p class="kicker">COAUTHOR / SESSION 01</p>
|
<p class="kicker">COAUTHOR / SESSION 01</p>
|
||||||
<h1>BUILD THE<br>IMPOSSIBLE<span>.</span></h1>
|
<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">
|
<form v-if="stage === 'seed'" class="seed" @submit.prevent="begin">
|
||||||
<textarea v-model="seed" aria-label="World idea" rows="3" maxlength="1200" autofocus />
|
<textarea v-model="seed" aria-label="World idea" rows="3" maxlength="1200" autofocus />
|
||||||
<button :disabled="!seed.trim() || busy">{{ busy ? 'SAVING…' : 'BEGIN' }} <span>→</span></button>
|
<button :disabled="!seed.trim() || busy">{{ busy ? 'SAVING…' : 'BEGIN' }} <span>→</span></button>
|
||||||
|
|||||||
@@ -1,17 +1,9 @@
|
|||||||
import { z } from 'zod'
|
import { z } from 'zod'
|
||||||
import { buildJsonCompletion, parseJsonCompletion } from '~/server/utils/ai-provider'
|
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'
|
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 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) => {
|
export default defineEventHandler(async (event) => {
|
||||||
try {
|
try {
|
||||||
@@ -27,29 +19,42 @@ export default defineEventHandler(async (event) => {
|
|||||||
const answerCount = Math.max(0, messages.filter(message => message.role === 'user').length - 1)
|
const answerCount = Math.max(0, messages.filter(message => message.role === 'user').length - 1)
|
||||||
if (answerCount >= 4) return { readyToGenerate: true }
|
if (answerCount >= 4) return { readyToGenerate: true }
|
||||||
if (session.current_question) {
|
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(), {
|
let question: z.infer<typeof CoauthorQuestionSchema> | undefined
|
||||||
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+.`,
|
for (let attempt = 0; attempt < 2 && !question; attempt += 1) {
|
||||||
messages,
|
const correction = attempt
|
||||||
schemaName: 'coauthor_question',
|
? ' The previous response was malformed. Ensure every option is a natural-language answer, not punctuation, a JSON key, or a copy of the question.'
|
||||||
jsonSchema: QuestionSchema.toJSONSchema(),
|
: ''
|
||||||
})
|
const completion = buildJsonCompletion(useRuntimeConfig(), {
|
||||||
const controller = new AbortController()
|
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}`,
|
||||||
const timeout = setTimeout(() => controller.abort(), 30_000)
|
messages,
|
||||||
let response: Response
|
schemaName: 'coauthor_question',
|
||||||
try {
|
jsonSchema: CoauthorQuestionSchema.toJSONSchema(),
|
||||||
response = await fetch(completion.endpoint, {
|
|
||||||
method: 'POST', headers: completion.headers, body: JSON.stringify(completion.body), signal: controller.signal,
|
|
||||||
})
|
})
|
||||||
} finally {
|
const controller = new AbortController()
|
||||||
clearTimeout(timeout)
|
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.' })
|
if (!question) throw createError({ statusCode: 502, statusMessage: 'The coauthor returned invalid answer options. Try the question again.' })
|
||||||
const question = QuestionSchema.parse(parseJsonCompletion(await response.json()))
|
|
||||||
requireStageTwoSafeText([question.question, ...question.options].join('\n'))
|
requireStageTwoSafeText([question.question, ...question.options].join('\n'))
|
||||||
const storedQuestion = StoredQuestionSchema.parse({
|
const storedQuestion = StoredCoauthorQuestionSchema.parse({
|
||||||
id: `question-${answerCount + 1}`,
|
id: `question-${answerCount + 1}`,
|
||||||
label: question.question,
|
label: question.question,
|
||||||
options: question.options,
|
options: question.options,
|
||||||
|
|||||||
30
apps/web/server/utils/coauthor-question.test.ts
Normal file
30
apps/web/server/utils/coauthor-question.test.ts
Normal 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()
|
||||||
|
})
|
||||||
|
})
|
||||||
63
apps/web/server/utils/coauthor-question.ts
Normal file
63
apps/web/server/utils/coauthor-question.ts
Normal 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,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -123,6 +123,12 @@ const { worldId } = await appRequest(`/api/v1/coauthor/sessions/${sessionId}/con
|
|||||||
const { campaignId } = await appRequest('/api/v1/campaigns', owner.token, {
|
const { campaignId } = await appRequest('/api/v1/campaigns', owner.token, {
|
||||||
method: 'POST', body: JSON.stringify({ worldId, title: `Smoke Party ${suffix}` }),
|
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…')
|
console.log('[smoke] Joining the second player through an invite…')
|
||||||
const { token: inviteToken } = await appRequest(`/api/v1/campaigns/${campaignId}/invites`, owner.token, {
|
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 },
|
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: {},
|
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, {
|
const playerCharacter = await appRequest(`/api/v1/campaigns/${campaignId}/characters`, player.token, {
|
||||||
method: 'POST', body: characterBody('Rowan Pike', 'A curious xenobotanist.'),
|
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, {
|
const suggested = await appRequest(`/api/v1/campaigns/${campaignId}/characters/suggest`, owner.token, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify({ controller: 'ai', concept: 'A damaged garden caretaker who values living things over orders.' }),
|
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) {
|
if (!suggested?.character?.name || !suggested.character?.persona?.motivation) {
|
||||||
throw new Error('[smoke] Character coauthor returned an incomplete draft.')
|
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)
|
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.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…')
|
console.log('[smoke] Verifying that the round waits for both humans…')
|
||||||
await appRequest(`/api/v1/campaigns/${campaignId}/rounds/${roundId}/intent`, owner.token, {
|
await appRequest(`/api/v1/campaigns/${campaignId}/rounds/${roundId}/intent`, owner.token, {
|
||||||
method: 'PUT',
|
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))
|
await new Promise(resolve => setTimeout(resolve, 1_500))
|
||||||
const waiting = await appRequest(`/api/v1/campaigns/${campaignId}`, player.token)
|
const waiting = await appRequest(`/api/v1/campaigns/${campaignId}`, player.token)
|
||||||
|
|||||||
@@ -2324,6 +2324,226 @@ grant execute on function public.dng_schema_version() to service_role;
|
|||||||
|
|
||||||
notify pgrst, 'reload schema';
|
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 $$
|
do $$
|
||||||
begin
|
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
|
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';
|
raise exception 'D&G bootstrap verification failed: character creation RPC is missing';
|
||||||
end if;
|
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';
|
raise exception 'D&G bootstrap verification failed: unexpected schema version';
|
||||||
end if;
|
end if;
|
||||||
end;
|
end;
|
||||||
|
|||||||
215
supabase/migrations/0010_playable_starter_parties.sql
Normal file
215
supabase/migrations/0010_playable_starter_parties.sql
Normal 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';
|
||||||
Reference in New Issue
Block a user