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>