Add AI-assisted world and character drafting flows
All checks were successful
CI / validate (push) Successful in 14m3s

This commit is contained in:
2026-08-17 09:25:37 +05:00
parent 830aa5dc1f
commit cdeea8653c
23 changed files with 811 additions and 92 deletions

View File

@@ -18,6 +18,7 @@ defineEmits<{ revise: []; confirm: [] }>()
<section><label>TONE</label><input v-model="draft.tone"></section>
<section><label>STARTING LOCATION</label><input v-model="draft.startingLocation.name"><textarea v-model="draft.startingLocation.summary" rows="3" /></section>
<section><label>OPENING HOOK</label><textarea v-model="draft.hook" rows="5" /></section>
<section class="wide private-truth"><label>PRIVATE GM TRUTH</label><textarea v-model="draft.hiddenThreat" rows="4" /><small>This hidden threat or goal is visible only to the owner and the Groundkeeper.</small></section>
<section class="wide"><label>KEY PEOPLE</label><div class="entities"><article v-for="npc in draft.npcs" :key="npc.id"><small>NPC</small><input v-model="npc.name"><textarea v-model="npc.summary" rows="3" /></article></div></section>
<section class="wide"><label>FACTIONS</label><div class="entities two"><article v-for="faction in draft.factions" :key="faction.id"><small>FACTION</small><input v-model="faction.name"><textarea v-model="faction.summary" rows="3" /></article></div></section>
<section><label>OPENING SCENE</label><textarea v-model="draft.openingScene" rows="6" /></section>
@@ -29,4 +30,5 @@ defineEmits<{ revise: []; confirm: [] }>()
<style scoped>
.preview{max-width:1300px;width:100%;padding:clamp(40px,5vw,76px)}header{display:flex;justify-content:space-between;align-items:end;gap:20px}.kicker,label{font:600 8px var(--mono);letter-spacing:.15em;color:var(--acid)}header>div{flex:1}header input{border:0;border-bottom:1px solid var(--line);font:600 clamp(30px,4vw,54px) var(--display);letter-spacing:-.05em;padding:12px 0;background:transparent}.revise,.add{background:transparent;color:var(--muted);border:1px solid var(--line);padding:12px;font:500 8px var(--mono)}.edit-note{color:var(--muted);font-size:11px}.grid{display:grid;grid-template-columns:1fr 1fr;border-top:1px solid var(--line);border-left:1px solid var(--line);margin-top:28px}.grid>section{padding:26px;border-right:1px solid var(--line);border-bottom:1px solid var(--line)}.wide{grid-column:1/-1}input,textarea{box-sizing:border-box;width:100%;margin-top:12px;padding:13px;resize:vertical;background:#10100f;border:1px solid var(--line);color:var(--ink);font:12px/1.55 var(--body)}.entities{display:grid;grid-template-columns:repeat(3,1fr);gap:12px;margin-top:18px}.entities.two{grid-template-columns:repeat(2,1fr)}.entities article{padding:16px;border:1px solid var(--line);background:#0c0c0b}.entities small{font:500 7px var(--mono);color:var(--acid)}.boundary{display:flex}.boundary input{margin-top:8px}.boundary button{margin-top:8px;width:42px;border:1px solid var(--line);background:#151513;color:var(--muted)}.add{margin-top:12px}footer{position:sticky;bottom:0;display:flex;justify-content:space-between;align-items:center;padding:18px 22px;background:#11110f;border:1px solid var(--line);margin-top:24px}footer p{margin:0;display:flex;flex-direction:column;font:600 8px var(--mono);color:var(--acid)}footer p span{margin-top:5px;color:var(--muted);font-weight:400}footer>button{min-height:48px;border:0;background:var(--acid);color:#080808;padding:0 24px;font:600 9px var(--mono)}@media(max-width:760px){.preview{padding:36px 18px}header{align-items:stretch;flex-direction:column}.grid{grid-template-columns:1fr}.wide{grid-column:auto}.entities,.entities.two{grid-template-columns:1fr}footer{align-items:stretch;flex-direction:column;gap:14px}}
.private-truth{background:linear-gradient(90deg,rgba(207,255,70,.035),transparent)}.private-truth small{display:block;margin-top:9px;color:var(--muted);font-size:9px}
</style>

View File

@@ -1,4 +1,6 @@
<script setup lang="ts">
import type { CharacterDraft } from '@dng/shared'
type AbilityKey = 'str' | 'dex' | 'con' | 'int' | 'wis' | 'cha'
type Controller = 'human' | 'ai' | 'delegated'
@@ -22,6 +24,7 @@ const payload = ref<CampaignPayload | null>(null)
const loading = ref(true)
const refreshing = ref(false)
const mutating = ref(false)
const suggestingCharacter = ref(false)
const errorMessage = ref('')
const mutationError = ref('')
const actionText = ref('')
@@ -33,13 +36,16 @@ const createKind = ref<'human' | 'ai'>('human')
const characterForm = reactive({
name: '', concept: '', hp: 12, maxHp: 12, defense: 12, proficiency: 2,
abilities: { str: 10, dex: 10, con: 10, int: 10, wis: 10, cha: 10 } as Record<AbilityKey, number>,
persona: { voice: '', motivation: '', flaw: '', bond: '' },
})
const inventoryText = ref('')
let pollTimer: ReturnType<typeof setInterval> | undefined
const campaign = computed(() => payload.value?.campaign)
const currentRound = computed(() => payload.value?.round)
const isOwner = computed(() => Boolean(campaign.value && auth.session.value?.user.id === campaign.value.owner_id))
const currentMember = computed(() => payload.value?.members.find(member => member.user_id === auth.session.value?.user.id))
const ownedCharacter = computed(() => payload.value?.characters.find(character => character.user_id === auth.session.value?.user.id))
const myCharacter = computed(() => payload.value?.characters.find(character => character.user_id === auth.session.value?.user.id && character.controller === 'human'))
const myIntent = computed(() => payload.value?.intents.find(intent =>
intent.member_id === currentMember.value?.id || intent.character_id === myCharacter.value?.id,
@@ -178,6 +184,37 @@ async function toggleTakeover(member: Record<string, any>) {
}))
}
function toggleCharacterForm() {
showCharacterForm.value = !showCharacterForm.value
if (showCharacterForm.value) createKind.value = ownedCharacter.value ? 'ai' : 'human'
}
async function suggestCharacter() {
if (suggestingCharacter.value) return
suggestingCharacter.value = true
mutationError.value = ''
try {
const result = await api<{ character: CharacterDraft }>(`/api/v1/campaigns/${campaignId.value}/characters/suggest`, {
method: 'POST',
body: { concept: characterForm.concept.trim(), controller: createKind.value },
})
const suggestion = result.character
characterForm.name = suggestion.name
characterForm.concept = suggestion.concept
characterForm.hp = suggestion.hp
characterForm.maxHp = suggestion.maxHp
characterForm.defense = suggestion.defense
characterForm.proficiency = suggestion.proficiency
characterForm.abilities = { ...suggestion.abilities }
characterForm.persona = { ...suggestion.persona }
inventoryText.value = suggestion.inventory.join('\n')
} catch (error) {
mutationError.value = messageFrom(error)
} finally {
suggestingCharacter.value = false
}
}
async function createCharacter() {
const controller: Controller = createKind.value === 'ai' ? 'ai' : 'human'
const success = await runMutation(() => api(`/api/v1/campaigns/${campaignId.value}/characters`, {
@@ -185,13 +222,17 @@ async function createCharacter() {
body: {
name: characterForm.name.trim(), concept: characterForm.concept.trim(), controller,
abilities: characterForm.abilities, hp: characterForm.hp, maxHp: characterForm.maxHp,
defense: characterForm.defense, proficiency: characterForm.proficiency, inventory: [], statuses: [], persona: {},
defense: characterForm.defense, proficiency: characterForm.proficiency,
inventory: inventoryText.value.split(/\n|,/).map(item => item.trim()).filter(Boolean).slice(0, 50),
statuses: [], persona: characterForm.persona,
},
}))
if (success) {
showCharacterForm.value = false
characterForm.name = ''
characterForm.concept = ''
inventoryText.value = ''
characterForm.persona = { voice: '', motivation: '', flaw: '', bond: '' }
}
}
@@ -222,13 +263,16 @@ onBeforeUnmount(() => {
<div v-else class="campaign-layout">
<aside class="party-panel">
<header class="panel-title"><span>PARTY / {{ payload.characters.length }}</span><button v-if="!myCharacter || isOwner" aria-label="Create character" @click="showCharacterForm = !showCharacterForm">{{ showCharacterForm ? '×' : '' }}</button></header>
<header class="panel-title"><span>PARTY / {{ payload.characters.length }}</span><button v-if="!ownedCharacter || isOwner" aria-label="Create character" @click="toggleCharacterForm">{{ showCharacterForm ? '×' : '' }}</button></header>
<form v-if="showCharacterForm" class="character-create" @submit.prevent="createCharacter">
<div v-if="isOwner" class="kind-switch"><button type="button" :class="{active:createKind==='human'}" @click="createKind='human'">HUMAN</button><button type="button" :class="{active:createKind==='ai'}" @click="createKind='ai'">AI HERO</button></div>
<div v-if="isOwner" class="kind-switch"><button type="button" :class="{active:createKind==='human'}" :disabled="Boolean(ownedCharacter)" @click="createKind='human'">HUMAN</button><button type="button" :class="{active:createKind==='ai'}" @click="createKind='ai'">AI HERO</button></div>
<input v-model="characterForm.name" required maxlength="80" placeholder="Character name">
<textarea v-model="characterForm.concept" required maxlength="600" placeholder="Concept, role, personality" />
<button type="button" class="ai-draft" :disabled="suggestingCharacter || mutating" @click="suggestCharacter">{{ suggestingCharacter ? 'COAUTHOR IS DRAFTING' : ' DRAFT WITH AI' }}</button>
<div class="ability-editor"><label v-for="key in (['str','dex','con','int','wis','cha'] as AbilityKey[])" :key="key"><span>{{ key }}</span><input v-model.number="characterForm.abilities[key]" type="number" min="1" max="30"></label></div>
<div class="stat-editor"><label>HP <input v-model.number="characterForm.hp" type="number" min="1" :max="characterForm.maxHp"></label><label>MAX <input v-model.number="characterForm.maxHp" type="number" min="1"></label><label>DEF <input v-model.number="characterForm.defense" type="number" min="1" max="40"></label></div>
<textarea v-model="inventoryText" maxlength="800" placeholder="Starting items, one per line" />
<details class="persona-editor"><summary>VOICE & PERSONALITY</summary><input v-model="characterForm.persona.voice" maxlength="240" placeholder="Voice"><input v-model="characterForm.persona.motivation" maxlength="300" placeholder="Motivation"><input v-model="characterForm.persona.flaw" maxlength="300" placeholder="Flaw"><input v-model="characterForm.persona.bond" maxlength="300" placeholder="Bond"></details>
<button class="acid-button" :disabled="mutating">{{ mutating ? 'CREATING…' : 'ADD TO PARTY' }}</button>
</form>
<div v-if="!payload.characters.length" class="empty-card"><b>NO HEROES YET</b><p>Create a character to begin this campaign.</p></div>
@@ -289,4 +333,5 @@ onBeforeUnmount(() => {
.invite-box button{width:100%}
.takeover-toggle{margin-top:7px;padding:4px 6px;border:1px solid var(--line);background:transparent;color:var(--muted);font:500 6px var(--mono);text-align:left}
.takeover-toggle.enabled{border-color:var(--acid-dim);color:var(--acid)}
.ai-draft{min-height:34px;border:1px solid var(--acid-dim);background:rgba(207,255,70,.04);color:var(--acid);font:600 7px var(--mono);letter-spacing:.1em}.persona-editor{border:1px solid var(--line);padding:9px}.persona-editor summary{cursor:pointer;color:var(--muted);font:600 7px var(--mono);letter-spacing:.1em}.persona-editor input{margin-top:7px}
</style>

View File

@@ -6,21 +6,50 @@ interface CampaignRow {
status: string
updated_at: string
}
interface DraftSession {
id: string
status: string
messages: Array<{ role: 'user' | 'assistant'; content: string }>
generated_world?: { title?: string; premise?: string } | null
updated_at: string
}
const { api } = useDngApi()
const auth = useDngAuth()
const filter = ref<'all' | 'active' | 'drafts'>('all')
const campaigns = ref<CampaignRow[]>([])
const drafts = ref<DraftSession[]>([])
const loading = ref(true)
const error = ref('')
const visibleCampaigns = computed(() => filter.value === 'active'
? campaigns.value.filter(campaign => campaign.status === 'active')
: campaigns.value)
: filter.value === 'drafts' ? [] : campaigns.value)
const visibleDrafts = computed(() => filter.value === 'active' ? [] : drafts.value)
const displayName = computed(() => {
const value = auth.session.value?.user.user_metadata?.display_name
return typeof value === 'string' && value.trim() ? value.trim() : 'ADVENTURER'
})
function draftTitle(draft: DraftSession) {
return draft.generated_world?.title || 'UNFINISHED UNIVERSE'
}
function draftSummary(draft: DraftSession) {
return draft.generated_world?.premise
|| draft.messages.find(message => message.role === 'user')?.content
|| 'Continue your conversation with the coauthor.'
}
onMounted(async () => {
try {
const result = await api<{ campaigns: CampaignRow[] }>('/api/v1/campaigns')
campaigns.value = result.campaigns
await auth.restore()
const [campaignResult, draftResult] = await Promise.all([
api<{ campaigns: CampaignRow[] }>('/api/v1/campaigns'),
api<{ sessions: DraftSession[] }>('/api/v1/coauthor/sessions'),
])
campaigns.value = campaignResult.campaigns
drafts.value = draftResult.sessions
} catch (cause) {
const value = cause as { data?: { statusMessage?: string }; message?: string }
error.value = value.data?.statusMessage ?? value.message ?? 'Could not load your campaigns.'
@@ -34,13 +63,13 @@ onMounted(async () => {
<AppShell section="COMMAND DECK">
<div class="dash-wrap noise">
<section class="dash-head">
<div><p class="kicker">WELCOME BACK, MARA</p><h1>YOUR UNIVERSES<span>.</span></h1><p>Every world remembers where you left it.</p></div>
<div><p class="kicker">WELCOME BACK, {{ displayName.toUpperCase() }}</p><h1>YOUR UNIVERSES<span>.</span></h1><p>Every world remembers where you left it.</p></div>
<NuxtLink to="/worlds/new" class="create-button"><span></span> CREATE A UNIVERSE</NuxtLink>
</section>
<div class="filter-row">
<button v-for="item in ['all','active','drafts']" :key="item" :class="{active:filter===item}" @click="filter=item as typeof filter">{{ item }}</button>
<span>{{ campaigns.length }} / 5 ALPHA CAMPAIGNS</span>
<span>{{ campaigns.length }} CAMPAIGNS · {{ drafts.length }} DRAFTS</span>
</div>
<section class="world-grid">
@@ -50,16 +79,20 @@ onMounted(async () => {
<div class="world-art"><div class="eclipse" /><span>{{ campaign.status.toUpperCase() }} CAMPAIGN</span></div>
<div class="world-info"><small>PRIVATE MULTIPLAYER</small><h2>{{ campaign.title }}</h2><p>{{ campaign.current_scene }}</p><div><b>CONTINUE STORY</b><span>{{ new Date(campaign.updated_at).toLocaleDateString() }}</span></div></div>
</NuxtLink>
<NuxtLink v-for="draft in visibleDrafts" :key="draft.id" :to="`/worlds/new?session=${draft.id}`" class="world-card active-world draft-world">
<div class="world-art"><div class="eclipse" /><span>{{ draft.status.toUpperCase() }} DRAFT</span></div>
<div class="world-info"><small>PRIVATE COAUTHOR SESSION</small><h2>{{ draftTitle(draft) }}</h2><p>{{ draftSummary(draft) }}</p><div><b>RESUME CREATION</b><span>{{ new Date(draft.updated_at).toLocaleDateString() }}</span></div></div>
</NuxtLink>
<NuxtLink to="/worlds/new" class="world-card new-card">
<span class="plus"></span><h2>MAKE THE NEXT<br>IMPOSSIBLE PLACE</h2><p>Begin with a sentence. The coauthor will ask the rest.</p>
</NuxtLink>
</section>
<section class="system-strip"><span><i /> OPENROUTER ADAPTER READY</span><span>SERVER DICE <b>ONLINE</b></span><span>PRIVATE BY DEFAULT</span></section>
<section class="system-strip"><span><i /> AI COAUTHOR READY</span><span>SERVER DICE <b>ONLINE</b></span><span>PRIVATE BY DEFAULT</span></section>
</div>
</AppShell>
</template>
<style scoped>
.dash-wrap{min-height:calc(100vh - 76px);padding:clamp(42px,6vw,86px) clamp(20px,6vw,88px)}.dash-head{display:flex;align-items:end;justify-content:space-between;gap:30px}.kicker{font:500 9px var(--mono);letter-spacing:.2em;color:var(--acid)}.dash-head h1{margin:14px 0 10px;font:600 clamp(44px,6vw,82px)/1 var(--display);letter-spacing:-.06em}.dash-head h1 span{color:var(--acid)}.dash-head p{color:var(--muted)}.create-button{display:flex;align-items:center;gap:18px;padding:18px 22px;background:var(--acid);color:#090909;text-decoration:none;font:600 10px var(--mono);letter-spacing:.12em}.create-button span{font-size:20px}.filter-row{display:flex;align-items:center;gap:10px;margin:58px 0 24px;border-bottom:1px solid var(--line)}.filter-row button{padding:0 4px 16px;background:none;border:0;color:var(--muted);font:500 9px var(--mono);text-transform:uppercase;letter-spacing:.14em;margin-right:18px}.filter-row button.active{color:var(--ink);border-bottom:2px solid var(--acid)}.filter-row>span{margin-left:auto;padding-bottom:16px;font:500 8px var(--mono);color:var(--muted)}.world-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:18px}.load-state{grid-column:1/-1;padding:34px;border:1px solid var(--line);font:500 9px var(--mono);color:var(--muted)}.load-state.error{color:#ff9d85}.load-state a{color:var(--acid)}.world-card{min-height:420px;border:1px solid var(--line);color:var(--ink);text-decoration:none;background:#0e0e0d;transition:.25s ease}.world-card:hover{border-color:#65655e;transform:translateY(-3px)}.active-world{display:grid;grid-template-columns:.75fr 1.25fr}.world-art{position:relative;overflow:hidden;display:grid;place-items:center;background:radial-gradient(circle at 50% 60%,#7d8240 0 2%,#303018 4%,#0b0b0a 36%,#030303 72%)}.world-art:before{content:"";position:absolute;width:320px;height:320px;border:1px solid #38382d;border-radius:50%;box-shadow:0 0 0 34px #111,0 0 0 35px #26261d}.eclipse{position:absolute;width:126px;height:126px;border-radius:50%;background:#020202;box-shadow:0 0 50px var(--acid-dim)}.world-art span{position:absolute;left:20px;top:20px;padding:9px 11px;background:var(--acid);color:#0a0a0a;font:600 8px var(--mono);letter-spacing:.12em}.world-info{padding:42px;display:flex;flex-direction:column}.world-info small{font:500 8px var(--mono);letter-spacing:.14em;color:var(--acid);text-transform:uppercase}.world-info h2,.new-card h2{font:600 clamp(25px,3vw,42px)/1.05 var(--display);letter-spacing:-.05em;margin:22px 0}.world-info p,.new-card p{color:var(--muted);font-size:13px;line-height:1.7;display:-webkit-box;-webkit-line-clamp:5;-webkit-box-orient:vertical;overflow:hidden}.world-info div{margin-top:auto;padding-top:26px;border-top:1px solid var(--line);display:flex;justify-content:space-between;font:500 8px var(--mono);color:var(--muted)}.world-info b{color:var(--acid)}.new-card{padding:48px;display:flex;flex-direction:column;justify-content:flex-end;background:linear-gradient(145deg,#121211,#090909)}.new-card .plus{margin-bottom:auto;font:300 42px var(--body);color:var(--acid)}.system-strip{margin-top:32px;padding:18px 0;border-top:1px solid var(--line);display:flex;justify-content:space-between;font:500 8px var(--mono);letter-spacing:.12em;color:var(--muted)}.system-strip i{display:inline-block;width:6px;height:6px;background:var(--acid);border-radius:50%;margin-right:8px}.system-strip b{color:var(--acid)}@media(max-width:1050px){.world-grid{grid-template-columns:1fr}}@media(max-width:850px){.dash-head{align-items:start;flex-direction:column}.active-world{grid-template-columns:1fr}.world-art{min-height:280px}.system-strip{gap:16px;flex-wrap:wrap}}@media(max-width:520px){.filter-row>span{display:none}.world-info,.new-card{padding:28px}.active-world{min-height:580px}}
.draft-world .world-art{background:radial-gradient(circle at 50% 60%,#3f4720 0 2%,#1c2011 9%,#080808 58%)}.draft-world .world-art span{background:transparent;color:var(--acid);border:1px solid var(--acid-dim)}
</style>

View File

@@ -1,12 +1,23 @@
<script setup lang="ts">
import type { WorldStarter } from '@dng/shared'
import { WorldStarterSchema, type WorldStarter } from '@dng/shared'
type Stage = 'seed' | 'questions' | 'generating' | 'preview' | 'confirming'
interface Message { id: string; role: 'coauthor' | 'player' | 'status'; body: string; label?: string }
interface DynamicQuestion { id: string; label: string; options: string[] }
interface RespondResult { readyToGenerate: boolean; question?: DynamicQuestion }
interface StoredMessage { role: 'user' | 'assistant'; content: string }
interface StoredSession {
id: string
status: 'collecting' | 'generating' | 'ready' | 'failed' | 'confirmed'
messages: StoredMessage[]
current_question?: DynamicQuestion | null
generatedWorld?: unknown
generated_world?: unknown
confirmed_world_id?: string | null
}
const { api } = useDngApi()
const route = useRoute()
const stage = ref<Stage>('seed')
const busy = ref(false)
const sessionId = ref<string | null>(null)
@@ -47,6 +58,63 @@ async function begin() {
}
}
async function resume(id: string) {
busy.value = true
retryAction.value = 'respond'
try {
const result = await api<{ session: StoredSession }>(`/api/v1/coauthor/sessions/${id}`)
const session = result.session
sessionId.value = session.id
const storedMessages = Array.isArray(session.messages) ? session.messages : []
seed.value = storedMessages.find(message => message.role === 'user')?.content ?? seed.value
answerCount.value = Math.max(0, storedMessages.filter(message => message.role === 'user').length - 1)
let questionNumber = 0
messages.value = [messages.value[0]!, ...storedMessages.map((message, index) => {
if (message.role === 'assistant') questionNumber += 1
return {
id: `stored-${index}`,
role: message.role === 'assistant' ? 'coauthor' as const : 'player' as const,
body: message.content,
label: message.role === 'assistant' ? `COAUTHOR · QUESTION ${questionNumber} OF 4` : undefined,
}
})]
if (session.status === 'confirmed') {
await navigateTo('/dashboard')
return
}
if (session.status === 'ready') {
const parsed = WorldStarterSchema.safeParse(session.generatedWorld ?? session.generated_world)
if (!parsed.success) throw new Error('This saved world draft is invalid. Generate it again.')
draft.value = parsed.data
stage.value = 'preview'
addMessage('status', `${parsed.data.title}” was restored.`, 'DRAFT RESTORED')
return
}
if (session.status === 'generating') {
busy.value = false
await generate()
return
}
if (session.status === 'failed') {
stage.value = 'questions'
retryAction.value = 'generate'
addMessage('status', 'The previous generation failed. Your answers are safe.', 'ERROR')
return
}
stage.value = 'questions'
currentQuestion.value = session.current_question ?? null
if (!currentQuestion.value) await requestNextQuestion()
} catch (error) {
stage.value = 'seed'
sessionId.value = null
addMessage('status', errorText(error) || 'This saved coauthor session could not be restored.', 'ERROR')
} finally {
busy.value = false
}
}
async function answerQuestion(key: string, value: string) {
if (stage.value !== 'questions' || currentQuestion.value?.id !== key || busy.value || !sessionId.value) return
busy.value = true
@@ -154,6 +222,11 @@ async function confirm() {
stage.value = 'preview'
}
}
onMounted(() => {
const resumeId = typeof route.query.session === 'string' ? route.query.session : ''
if (resumeId) void resume(resumeId)
})
</script>
<template>
@@ -164,7 +237,7 @@ async function confirm() {
<div class="progress"><i :style="{ width: `${progress}%` }" /></div>
<ol>
<li :class="{ active: stage === 'seed', done: stage !== 'seed' }"><b>01</b><span>Seed idea<small>Say what cannot exist yet.</small></span></li>
<li :class="{ active: stage === 'questions', done: answerCount >= 3 }"><b>02</b><span>Shape the signal<small>{{ answerCount }} / up to 5 answers captured.</small></span></li>
<li :class="{ active: stage === 'questions', done: answerCount >= 4 }"><b>02</b><span>Shape the signal<small>{{ answerCount }} / 4 answers captured.</small></span></li>
<li :class="{ active: stage === 'generating', done: stage === 'preview' || stage === 'confirming' }"><b>03</b><span>Generate structure<small>A playable starting kit.</small></span></li>
<li :class="{ active: stage === 'preview' || stage === 'confirming' }"><b>04</b><span>Review & launch<small>You remain the final author.</small></span></li>
</ol>

View File

@@ -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<Array<Record<string, unknown>>>('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<string>('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<Array<Record<string, unknown>>>(
`characters?select=*&id=eq.${characterId}&campaign_id=eq.${campaignId}&limit=1`,
)
setResponseStatus(event, 201)
return { character: rows[0] }
} catch (error) {

View File

@@ -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<Array<Record<string, unknown>>>(
`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 716, max HP 820, Defense 1016, proficiency 2. Give 25 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)
}
})

View File

@@ -5,7 +5,7 @@ export default defineEventHandler(async (event) => {
const user = await requireStageTwoUser(event)
const id = stageTwoUuid(getRouterParam(event, 'id'))
const sessions = await stageTwoDatabase<Array<Record<string, unknown>>>(
`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 } }

View File

@@ -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<Array<{ status: string; messages: unknown }>>(
`coauthor_sessions?select=status,messages&id=eq.${sessionId}&owner_id=eq.${user.id}&limit=1`,
const sessions = await stageTwoDatabase<Array<{ status: string; messages: unknown; current_question: unknown }>>(
`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)

View File

@@ -4,7 +4,7 @@ export default defineEventHandler(async (event) => {
try {
const user = await requireStageTwoUser(event)
const sessions = await stageTwoDatabase<Array<Record<string, unknown>>>(
`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) {

View File

@@ -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 alphas 13+ content boundary.' })
}
return world
})

View File

@@ -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',
})
})
})

View File

@@ -13,6 +13,15 @@ interface RequestOptions extends RequestInit {
prefer?: string
}
function serviceAuthenticationHeaders(serviceKey: string): Record<string, string> {
// 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<T>(path: string, options: RequestOptions
...options,
headers: {
apikey: serviceKey,
Authorization: `Bearer ${serviceKey}`,
...serviceAuthenticationHeaders(serviceKey),
'Content-Type': 'application/json',
...(options.prefer ? { Prefer: options.prefer } : {}),
...options.headers,