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

@@ -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>