Add AI-assisted world and character drafting flows
All checks were successful
CI / validate (push) Successful in 14m3s
All checks were successful
CI / validate (push) Successful in 14m3s
This commit is contained in:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user