Files
pavel444-byte 031d094867
Some checks failed
CI / validate (push) Has been cancelled
CI / validate (pull_request) Has been cancelled
feat(memory): implement stability stage
Co-authored-by: multica-agent <github@multica.ai>
2026-09-02 15:04:18 +05:00

409 lines
35 KiB
Vue
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import { skillKeys, type CharacterDraft, type SkillKey } from '@dng/shared'
type AbilityKey = 'str' | 'dex' | 'con' | 'int' | 'wis' | 'cha'
type Controller = 'human' | 'ai' | 'delegated'
interface CampaignPayload {
campaign: Record<string, any>
world: Record<string, any>
members: Array<Record<string, any> & { profile?: { id?: string; display_name?: string; avatar_url?: string | null } | null }>
characters: Array<Record<string, any>>
round: Record<string, any> | null
rounds: Array<Record<string, any>>
intents: Array<Record<string, any>>
events: Array<Record<string, any>>
diceRolls: Array<Record<string, any>>
sceneState: Record<string, any> | null
activeGoals: Array<Record<string, any>>
relationships: Array<Record<string, any>>
memories: Array<Record<string, any>>
storySummary: Record<string, any> | null
auditEntries: Array<Record<string, any>>
}
const route = useRoute()
const { api } = useDngApi()
const auth = useDngAuth()
const campaignId = computed(() => String(route.params.id ?? ''))
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('')
const actionHydratedFor = ref('')
const showCharacterForm = ref(false)
const inviteLink = ref('')
const inviteCopied = ref(false)
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>,
skillProficiencies: [] as SkillKey[], skillExpertise: [] as SkillKey[],
savingThrowProficiencies: [] as AbilityKey[],
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,
))
const isReady = computed(() => Boolean(myIntent.value?.ready))
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 canAct = computed(() => currentRound.value?.status === 'open' && Boolean(myCharacter.value))
const roundBusy = computed(() => ['queued', 'resolving'].includes(String(currentRound.value?.status)))
const timeline = computed(() => {
if (!payload.value) return []
const characterNames = new Map(payload.value.characters.map(character => [character.id, character.name]))
const items = [
...payload.value.rounds.filter(round => round.narration).map(round => ({
id: `narration-${round.id}`, type: 'narration', at: round.resolved_at ?? round.created_at ?? '',
label: 'GROUNDKEEPER', meta: `ROUND ${round.number} · RESOLVED`, body: round.narration,
})),
...payload.value.intents.map(intent => ({
id: `intent-${intent.id}`, type: 'intent', at: intent.updated_at ?? intent.created_at ?? '',
label: characterNames.get(intent.character_id) ?? 'PLAYER', meta: intent.ready ? 'READY' : 'DRAFT', body: intent.action,
})),
...payload.value.diceRolls.map(roll => ({
id: `roll-${roll.id}`, type: 'roll', at: roll.created_at ?? '', label: 'SERVER ROLL',
meta: rollMeta(roll),
body: `${roll.formula} · [${(roll.rolls ?? []).join(', ')}] ${signed(Number(roll.modifier ?? 0))} = ${roll.total}`,
})),
...payload.value.events.map(event => ({
id: `event-${event.id}`, type: 'event', at: event.created_at ?? '', label: String(event.event_type ?? 'EVENT').toUpperCase(),
meta: 'WORLD STATE', body: event.payload?.description ?? event.description ?? JSON.stringify(event.payload ?? event),
})),
]
return items.sort((left, right) => left.at.localeCompare(right.at))
})
function signed(value: number) {
return value >= 0 ? `+ ${value}` : ` ${Math.abs(value)}`
}
function abilityModifier(score: number) {
return Math.floor((Number(score) - 10) / 2)
}
function abilityLabel(score: number) {
const modifier = abilityModifier(score)
return modifier >= 0 ? `+${modifier}` : String(modifier)
}
function rollMeta(roll: Record<string, any>) {
if (roll.success === null || roll.success === undefined) return String(roll.check_kind ?? 'ROLL').toUpperCase()
const kept = Number(roll.kept?.[0])
const outcome = roll.check_kind === 'attack' && kept === 20
? 'CRITICAL HIT'
: roll.check_kind === 'attack' && kept === 1
? 'CRITICAL MISS'
: roll.success ? 'SUCCESS' : 'FAILED'
const target = roll.difficulty === null || roll.difficulty === undefined
? ''
: ` · ${roll.check_kind === 'attack' ? 'AC' : 'DC'} ${roll.difficulty}`
return `${outcome}${target}`
}
function initials(name: string) {
return name.split(/\s+/).map(part => part[0]).join('').slice(0, 2).toUpperCase()
}
function messageFrom(error: unknown) {
const candidate = error as { data?: { statusMessage?: string; message?: string }; message?: string }
return candidate.data?.statusMessage ?? candidate.data?.message ?? candidate.message ?? 'The campaign could not be updated.'
}
async function loadCampaign(initial = false) {
if (refreshing.value || !campaignId.value) return
refreshing.value = true
if (initial) loading.value = true
try {
const next = await api<CampaignPayload>(`/api/v1/campaigns/${campaignId.value}`)
payload.value = next
errorMessage.value = ''
const intentKey = `${next.round?.id ?? 'none'}:${next.intents.find((intent: Record<string, any>) => intent.member_id === next.members.find((member: Record<string, any>) => member.user_id === auth.session.value?.user.id)?.id)?.id ?? 'none'}`
if (intentKey !== actionHydratedFor.value) {
const ownMember = next.members.find((member: Record<string, any>) => member.user_id === auth.session.value?.user.id)
actionText.value = next.intents.find((intent: Record<string, any>) => intent.member_id === ownMember?.id)?.action ?? ''
actionHydratedFor.value = intentKey
}
} catch (error) {
errorMessage.value = messageFrom(error)
} finally {
refreshing.value = false
loading.value = false
}
}
async function runMutation(task: () => Promise<unknown>) {
if (mutating.value) return false
mutating.value = true
mutationError.value = ''
try {
await task()
await loadCampaign()
return true
} catch (error) {
mutationError.value = messageFrom(error)
return false
} finally {
mutating.value = false
}
}
async function saveIntent(markReady = false) {
if (!currentRound.value || !myCharacter.value || !actionText.value.trim()) return
const roundId = String(currentRound.value.id)
const characterId = String(myCharacter.value.id)
await runMutation(async () => {
await api(`/api/v1/campaigns/${campaignId.value}/rounds/${roundId}/intent`, {
method: 'PUT', body: { characterId, action: actionText.value.trim(), ready: false },
})
if (markReady) await api(`/api/v1/campaigns/${campaignId.value}/rounds/${roundId}/ready`, { method: 'POST', body: { ready: true } })
})
}
async function editIntent() {
if (!currentRound.value) return
const roundId = String(currentRound.value.id)
await runMutation(() => api(`/api/v1/campaigns/${campaignId.value}/rounds/${roundId}/ready`, { method: 'POST', body: { ready: false } }))
}
async function forceRound() {
if (!currentRound.value || !confirm('Continue without waiting for every player? Missing eligible characters may be delegated to AI.')) return
const roundId = String(currentRound.value.id)
await runMutation(() => api(`/api/v1/campaigns/${campaignId.value}/rounds/${roundId}/force`, { method: 'POST', body: {} }))
}
async function retryFailedRound() {
if (!currentRound.value) return
const roundId = String(currentRound.value.id)
await runMutation(() => api(`/api/v1/campaigns/${campaignId.value}/rounds/${roundId}/retry`, { method: 'POST', body: {} }))
}
async function createInvite() {
await runMutation(async () => {
const result = await api<{ token: string }>(`/api/v1/campaigns/${campaignId.value}/invites`, {
method: 'POST', body: { maxUses: 8, expiresInHours: 72 },
})
inviteLink.value = `${window.location.origin}/join/${result.token}`
})
}
async function copyInvite() {
if (!inviteLink.value) return
await navigator.clipboard.writeText(inviteLink.value)
inviteCopied.value = true
setTimeout(() => { inviteCopied.value = false }, 1_500)
}
async function toggleTakeover(member: Record<string, any>) {
await runMutation(() => api(`/api/v1/campaigns/${campaignId.value}/members/${member.id}`, {
method: 'PATCH', body: { aiTakeoverAllowed: !member.ai_takeover_allowed },
}))
}
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.skillProficiencies = [...suggestion.skillProficiencies]
characterForm.skillExpertise = [...suggestion.skillExpertise]
characterForm.savingThrowProficiencies = [...suggestion.savingThrowProficiencies]
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`, {
method: 'POST',
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: inventoryText.value.split(/\n|,/).map(item => item.trim()).filter(Boolean).slice(0, 50),
statuses: [],
skillProficiencies: characterForm.skillProficiencies,
skillExpertise: characterForm.skillExpertise.filter(skill => characterForm.skillProficiencies.includes(skill)),
savingThrowProficiencies: characterForm.savingThrowProficiencies,
persona: characterForm.persona,
},
}))
if (success) {
showCharacterForm.value = false
characterForm.name = ''
characterForm.concept = ''
characterForm.skillProficiencies = []
characterForm.skillExpertise = []
characterForm.savingThrowProficiencies = []
inventoryText.value = ''
characterForm.persona = { voice: '', motivation: '', flaw: '', bond: '' }
}
}
function visibilitySync() {
if (!document.hidden) void loadCampaign()
}
onMounted(async () => {
await auth.restore()
await loadCampaign(true)
pollTimer = setInterval(() => { if (!document.hidden) void loadCampaign() }, 2_000)
document.addEventListener('visibilitychange', visibilitySync)
})
onBeforeUnmount(() => {
if (pollTimer) clearInterval(pollTimer)
document.removeEventListener('visibilitychange', visibilitySync)
})
</script>
<template>
<AppShell :section="campaign?.title?.toUpperCase() ?? 'CAMPAIGN'">
<div v-if="loading" class="state-page noise"><i class="spinner" /><p>SYNCING PRIVATE CAMPAIGN</p></div>
<div v-else-if="!payload" class="state-page noise">
<p class="eyebrow">CAMPAIGN LINK UNAVAILABLE</p><h1>THE TRAIL GOES COLD.</h1><p>{{ errorMessage || 'This campaign does not exist or is private.' }}</p>
<div><button class="acid-button" @click="loadCampaign(true)">TRY AGAIN</button><NuxtLink to="/dashboard">BACK TO DASHBOARD</NuxtLink></div>
</div>
<div v-else class="campaign-layout">
<aside class="party-panel">
<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'}" :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>AC <input v-model.number="characterForm.defense" type="number" min="1" max="40"></label></div>
<details class="proficiency-editor"><summary>SRD PROFICIENCIES</summary><small>SKILLS</small><div><label v-for="skill in skillKeys" :key="skill"><input v-model="characterForm.skillProficiencies" type="checkbox" :value="skill">{{ skill }}</label></div><small>SAVING THROWS</small><div><label v-for="ability in (['str','dex','con','int','wis','cha'] as AbilityKey[])" :key="ability"><input v-model="characterForm.savingThrowProficiencies" type="checkbox" :value="ability">{{ ability }}</label></div><small>EXPERTISE</small><div><label v-for="skill in characterForm.skillProficiencies" :key="skill"><input v-model="characterForm.skillExpertise" type="checkbox" :value="skill">{{ skill }}</label></div></details>
<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>
<article v-for="character in payload.characters" :key="character.id" class="party-member" :class="character.controller">
<div class="portrait">{{ initials(character.name) }}</div><div><h3>{{ character.name }}</h3><p>{{ character.controller === 'human' ? (character.user_id === auth.session.value?.user.id ? 'YOU' : 'PLAYER') : character.controller === 'delegated' ? 'AI STAND-IN' : 'AI COMPANION' }}</p></div><i />
</article>
<section v-if="myCharacter" class="sheet">
<small>YOUR CHARACTER</small><h2>{{ myCharacter.name }}</h2><p>{{ myCharacter.concept }}</p>
<div class="vitals"><span><b>{{ myCharacter.hp }}</b>/{{ myCharacter.max_hp }} HP<span v-if="myCharacter.rules_state?.temporaryHp"> + {{ myCharacter.rules_state.temporaryHp }} TEMP</span></span><span><b>{{ myCharacter.defense }}</b> AC</span><span v-if="myCharacter.rules_state"><b>{{ myCharacter.rules_state.level }}</b> LEVEL</span></div>
<div class="abilities"><span v-for="(score,key) in myCharacter.abilities" :key="key"><small>{{ key }} {{ abilityLabel(Number(score)) }}</small><b>{{ score }}</b></span></div>
</section>
</aside>
<main class="chronicle noise">
<header class="scene-head"><div><p>{{ payload.world?.genre ?? 'PRIVATE WORLD' }}</p><h1>{{ campaign?.title }}</h1><small>{{ payload.world?.title ?? 'Unknown world' }}</small></div><span v-if="currentRound">ROUND {{ String(currentRound.number).padStart(2,'0') }} · {{ currentRound.status.toUpperCase() }}</span><span v-else>LOBBY</span></header>
<div v-if="errorMessage || mutationError" class="sync-error" role="alert"><span>{{ mutationError || errorMessage }}</span><button v-if="errorMessage" @click="loadCampaign()">RETRY SYNC</button></div>
<section class="timeline" aria-live="polite">
<article v-if="currentRound?.status === 'failed'" class="round-failure">
<div><small>ROUND INTERRUPTED</small><span>NO STATE WAS CHANGED</span></div>
<p>The Groundkeeper could not finish this round. The owner can retry it safely; no partial narration, rolls, or character changes were saved.</p>
</article>
<article v-if="!timeline.length" class="empty-timeline"><small>THE CHRONICLE IS QUIET</small><p>{{ currentRound ? 'Submit the first action of this round.' : 'The owner has not opened the first round yet.' }}</p></article>
<article v-for="item in timeline" :key="item.id" :class="item.type"><div><small>{{ item.label }}</small><span>{{ item.meta }}</span></div><p>{{ item.body }}</p></article>
</section>
<section class="action-dock">
<div class="action-state"><span><i :class="{ready:isReady,busy:roundBusy}" />{{ roundBusy ? 'GROUNDKEEPER IS RESOLVING' : isReady ? 'YOUR ACTION IS READY' : canAct ? 'WAITING FOR YOUR ACTION' : 'SPECTATING' }}</span><span v-if="refreshing">SYNCING</span></div>
<template v-if="canAct">
<textarea v-model="actionText" maxlength="2000" :disabled="isReady || mutating" :placeholder="`What does ${myCharacter?.name ?? 'your hero'} do?`" @keydown.meta.enter.prevent="saveIntent(true)" />
<div class="dock-actions"><small>{{ actionText.length }} / 2000 · ENTER TO READY</small><div><button v-if="!isReady" class="ghost-button" :disabled="mutating || !actionText.trim()" @click="saveIntent(false)">SAVE DRAFT</button><button v-if="!isReady" class="acid-button" :disabled="mutating || !actionText.trim()" @click="saveIntent(true)">MARK READY →</button><button v-else class="ghost-button" :disabled="mutating" @click="editIntent">EDIT ACTION</button></div></div>
</template>
<p v-else class="spectator-note">{{ !myCharacter ? 'Create a human character to submit actions.' : roundBusy ? 'Actions are locked while the round resolves.' : 'Waiting for the next open round.' }}</p>
</section>
</main>
<aside class="control-panel">
<header class="panel-title"><span>ROUND CONTROL</span><i :class="{live:!refreshing}" /></header>
<section class="readiness"><p><b>{{ readyCount }} / {{ activeHumanMembers.length }}</b> HUMANS READY</p><div><i :style="{width:`${activeHumanMembers.length ? readyCount/activeHumanMembers.length*100 : 0}%`} " /></div></section>
<section class="members"><small>PLAYERS</small><article v-for="member in payload.members" :key="member.id"><i :class="{ready:payload.intents.some(intent=>intent.member_id===member.id && intent.ready)}" /><NuxtLink class="member-profile" :to="`/profile/${member.user_id}?campaign=${campaignId}`"><span class="member-avatar"><b>{{ initials(member.profile?.display_name || 'Adventurer') }}</b><img v-if="member.profile?.avatar_url" :src="member.profile.avatar_url" alt=""></span><span><b>{{ member.profile?.display_name || 'Adventurer' }}</b><em>{{ member.role }} · {{ payload.intents.some(intent=>intent.member_id===member.id && intent.ready) ? 'ready' : 'waiting' }}</em></span></NuxtLink><button v-if="isOwner" class="takeover-toggle" :class="{enabled:member.ai_takeover_allowed}" :disabled="mutating" @click="toggleTakeover(member)">AI TAKEOVER {{ member.ai_takeover_allowed ? 'ON' : 'OFF' }}</button></article></section>
<section v-if="isOwner" class="invite-box"><small>INVITE PLAYERS</small><p>Private link · up to 8 joins · expires in 72 hours.</p><button v-if="!inviteLink" class="ghost-button" :disabled="mutating" @click="createInvite">CREATE INVITE LINK</button><template v-else><input :value="inviteLink" readonly aria-label="Campaign invite link"><button class="acid-button" @click="copyInvite">{{ inviteCopied ? 'COPIED' : 'COPY LINK' }}</button></template></section>
<section class="ai-order"><small>AI TURN ORDER</small><p v-if="!payload.characters.some(character=>character.controller!=='human')">No AI heroes in this party.</p><div v-for="(character,index) in payload.characters.filter(character=>character.controller!=='human')" :key="character.id"><b>{{ String(index+1).padStart(2,'0') }}</b><span>{{ character.name }}<small>{{ character.controller === 'delegated' ? 'Temporary stand-in' : 'Acts after all humans' }}</small></span></div></section>
<section class="memory-vault">
<small>CAMPAIGN MEMORY</small>
<p v-if="payload.storySummary"><b>THROUGH ROUND {{ payload.storySummary.through_round }}</b>{{ payload.storySummary.summary }}</p>
<p v-else>No three-round summary has been created yet.</p>
<div v-for="memory in payload.memories.slice(0,3)" :key="memory.id"><i>{{ memory.importance }}</i><span>{{ memory.summary }}<small>{{ (memory.tags || []).join(' · ') || 'UNTAGGED' }}</small></span></div>
<div v-for="goal in payload.activeGoals" :key="goal.quest_entity_id" class="goal"><i></i><span>{{ goal.summary }}<small>ACTIVE GOAL</small></span></div>
</section>
<button v-if="isOwner && currentRound?.status==='open'" class="force-button" :disabled="mutating" @click="forceRound">CONTINUE WITHOUT WAITING <span></span></button>
<button v-if="isOwner && currentRound?.status==='failed'" class="force-button" :disabled="mutating" @click="retryFailedRound">RETRY FAILED ROUND <span></span></button>
<div class="mechanics-note"><i /> SRD 5.2.1 RULES V{{ campaign?.ruleset_version ?? 2 }} · D20 tests, proficiency, conditions, damage and mechanical state are resolved by the server and recorded in the campaign log.</div>
</aside>
</div>
</AppShell>
</template>
<style scoped>
.campaign-layout{height:calc(100vh - 76px);display:grid;grid-template-columns:270px minmax(420px,1fr) 300px;overflow:hidden}.party-panel,.control-panel{background:#0c0c0b;overflow-y:auto}.party-panel{border-right:1px solid var(--line)}.control-panel{border-left:1px solid var(--line)}.panel-title{height:52px;padding:0 18px;display:flex;align-items:center;justify-content:space-between;border-bottom:1px solid var(--line);font:600 8px var(--mono);letter-spacing:.14em;color:var(--muted)}.panel-title button{border:0;background:none;color:var(--ink);font-size:20px}.panel-title>i{width:6px;height:6px;border-radius:50%;background:#5b5b55}.panel-title>i.live{background:var(--acid);box-shadow:0 0 8px var(--acid)}.party-member{display:grid;grid-template-columns:38px 1fr 8px;gap:12px;align-items:center;padding:17px 18px;border-bottom:1px solid var(--line)}.portrait{display:grid;place-items:center;width:38px;height:38px;background:#191917;border:1px solid #32322e;font:600 9px var(--mono)}.party-member h3{margin:0 0 5px;font:600 10px var(--display)}.party-member p{margin:0;font:500 7px var(--mono);color:var(--muted)}.party-member>i{width:6px;height:6px;border-radius:50%;background:#6c6c65}.party-member.human>i{background:var(--acid)}.party-member.delegated>i{background:#f3a85e}.sheet{padding:26px 18px}.sheet>small,.members>small,.ai-order>small{font:500 7px var(--mono);color:var(--acid);letter-spacing:.14em}.sheet h2{margin:14px 0 8px;font:600 19px var(--display)}.sheet>p{font-size:11px;line-height:1.55;color:var(--muted)}.vitals{display:flex;gap:8px;margin:20px 0}.vitals span{flex:1;padding:11px 8px;background:#121210;border:1px solid var(--line);font:500 7px var(--mono);color:var(--muted)}.vitals b{font-size:13px;color:var(--ink)}.abilities,.ability-editor{display:grid;grid-template-columns:repeat(3,1fr);gap:1px;background:var(--line);border:1px solid var(--line)}.abilities span{padding:9px;background:#10100f;text-align:center}.abilities small{display:block;text-transform:uppercase;font:500 7px var(--mono);color:var(--muted)}.abilities b{font:600 12px var(--mono)}.character-create{padding:16px;border-bottom:1px solid var(--line);display:grid;gap:10px;background:#10100f}.character-create input,.character-create textarea{width:100%;border:1px solid #383833;background:#0b0b0a;color:var(--ink);padding:10px;font-size:10px}.character-create textarea{height:64px;resize:vertical}.kind-switch{display:grid;grid-template-columns:1fr 1fr}.kind-switch button{padding:8px;border:1px solid var(--line);background:none;color:var(--muted);font:600 7px var(--mono)}.kind-switch button.active{background:var(--acid);color:#090909}.ability-editor label{padding:5px;background:#111;display:grid;grid-template-columns:1fr 34px;align-items:center;font:600 7px var(--mono);text-transform:uppercase}.ability-editor input{padding:4px;text-align:center}.stat-editor{display:grid;grid-template-columns:repeat(3,1fr);gap:5px}.stat-editor label{font:600 7px var(--mono);color:var(--muted)}.empty-card{padding:28px 18px;border-bottom:1px solid var(--line)}.empty-card b{font:600 9px var(--mono)}.empty-card p{font-size:10px;color:var(--muted)}.chronicle{position:relative;overflow-y:auto;padding-bottom:200px}.scene-head{position:sticky;top:0;z-index:5;padding:28px clamp(26px,5vw,72px) 18px;display:flex;justify-content:space-between;align-items:end;background:linear-gradient(#0a0a0a 76%,transparent)}.scene-head p{margin:0;font:500 8px var(--mono);letter-spacing:.16em;color:var(--acid);text-transform:uppercase}.scene-head h1{margin:8px 0 5px;font:600 clamp(23px,3vw,38px) var(--display);letter-spacing:-.05em}.scene-head small,.scene-head>span{font:500 8px var(--mono);color:var(--muted);text-transform:uppercase}.timeline{max-width:780px;margin:12px auto;padding:0 34px}.timeline article{padding:24px 0;border-bottom:1px solid var(--line)}.timeline article>div{display:flex;justify-content:space-between}.timeline article small{font:600 8px var(--mono);letter-spacing:.12em;color:var(--acid)}.timeline article span{font:500 7px var(--mono);color:var(--muted)}.timeline article p{font:400 14px/1.75 var(--body);color:#d1d1ca}.timeline .intent{margin-left:40px;padding-left:18px;border-left:2px solid #4b4b45}.timeline .intent small{color:var(--ink)}.timeline .roll{margin:14px 0;padding:14px 17px;background:#121210;border:1px solid var(--line)}.timeline .roll p{font:500 11px var(--mono)}.timeline .event p{font-size:11px;color:var(--muted)}.empty-timeline{text-align:center;margin-top:12vh;border:1px dashed var(--line)!important;padding:36px!important}.action-dock{position:fixed;bottom:0;left:270px;right:300px;z-index:10;padding:13px 22px 18px;background:rgba(12,12,11,.96);backdrop-filter:blur(15px);border-top:1px solid var(--line)}.action-state{display:flex;justify-content:space-between;margin-bottom:9px;font:500 7px var(--mono);letter-spacing:.11em;color:var(--muted)}.action-state i{display:inline-block;width:6px;height:6px;border-radius:50%;margin-right:7px;background:#555}.action-state i.ready{background:var(--acid)}.action-state i.busy{background:#f3a85e}.action-dock textarea{width:100%;height:70px;resize:none;padding:14px;background:#11110f;border:1px solid #383833;color:var(--ink);font:13px/1.5 var(--body)}.dock-actions{display:flex;justify-content:space-between;align-items:center;margin-top:8px}.dock-actions small{font:500 7px var(--mono);color:var(--muted)}.dock-actions>div{display:flex;gap:8px}.acid-button,.ghost-button,.force-button{min-height:38px;padding:0 15px;border:0;background:var(--acid);color:#090909;font:600 8px var(--mono);letter-spacing:.08em}.ghost-button{border:1px solid var(--line);background:transparent;color:var(--ink)}button:disabled{opacity:.38}.spectator-note{margin:12px 0;color:var(--muted);font-size:11px}.readiness,.members,.ai-order,.mechanics-note{padding:22px;border-bottom:1px solid var(--line)}.readiness p{font:500 8px var(--mono);color:var(--muted)}.readiness b{font-size:18px;color:var(--ink)}.readiness>div{height:5px;background:#252522}.readiness>div i{display:block;height:100%;background:var(--acid);transition:width .25s}.members article{display:flex;gap:10px;align-items:center;margin-top:16px}.members article>i{width:6px;height:6px;border-radius:50%;background:#555}.members article>i.ready{background:var(--acid)}.members span{display:flex;flex-direction:column}.members b{font:600 9px var(--display)}.members em{font:500 7px var(--mono);color:var(--muted);text-transform:uppercase;font-style:normal;margin-top:4px}.ai-order>p{font-size:10px;color:var(--muted)}.ai-order>div{display:flex;gap:12px;margin-top:17px}.ai-order>div>b{font:500 8px var(--mono);color:var(--acid)}.ai-order>div>span{font:600 10px var(--display)}.ai-order span small{display:block;margin-top:4px;color:var(--muted);font:400 8px var(--body)}.force-button{width:calc(100% - 44px);height:48px;margin:22px}.mechanics-note{font-size:9px;line-height:1.5;color:var(--muted)}.mechanics-note i{display:inline-block;width:6px;height:6px;border-radius:50%;background:var(--acid);margin-right:7px}.sync-error{display:flex;justify-content:space-between;gap:20px;margin:0 clamp(26px,5vw,72px);padding:12px 14px;border:1px solid #6a3a31;background:#251613;color:#ffb29f;font:500 9px var(--mono)}.sync-error button{border:0;background:none;color:inherit;font:inherit}.state-page{min-height:calc(100vh - 76px);display:grid;place-content:center;justify-items:center;text-align:center;padding:30px}.state-page h1{font:600 clamp(34px,7vw,76px) var(--display);margin:10px}.state-page>p{max-width:520px;color:var(--muted)}.state-page .eyebrow{color:var(--acid);font:500 9px var(--mono)}.state-page>div{display:flex;align-items:center;gap:18px;margin-top:20px}.state-page a{color:var(--muted);font:500 9px var(--mono)}.spinner{width:24px;height:24px;border:2px solid var(--line);border-top-color:var(--acid);border-radius:50%;animation:spin .8s linear infinite}@keyframes spin{to{transform:rotate(360deg)}}@media(max-width:1080px){.campaign-layout{grid-template-columns:230px 1fr}.control-panel{display:none}.action-dock{left:230px;right:0}}@media(max-width:720px){.campaign-layout{display:block;height:auto}.party-panel{max-height:240px;border-right:0;border-bottom:1px solid var(--line)}.sheet,.party-member:not(:first-of-type){display:none}.chronicle{min-height:calc(100vh - 76px)}.action-dock{left:0}.timeline{padding:0 18px}.scene-head{padding-left:18px;padding-right:18px}.scene-head>span{display:none}.dock-actions{align-items:flex-start;gap:8px}.dock-actions>small{display:none}}
</style>
<style scoped>
.memory-vault{padding:22px;border-bottom:1px solid var(--line)}
.memory-vault>small{font:500 7px var(--mono);color:var(--acid);letter-spacing:.14em}
.memory-vault>p{display:grid;gap:7px;font-size:9px;line-height:1.5;color:var(--muted)}
.memory-vault>p b{font:600 7px var(--mono);color:var(--ink)}
.memory-vault>div{display:flex;gap:10px;padding-top:12px;margin-top:12px;border-top:1px solid var(--line)}
.memory-vault>div>i{display:grid;place-items:center;flex:0 0 18px;width:18px;height:18px;border:1px solid var(--acid-dim);color:var(--acid);font:600 7px var(--mono);font-style:normal}
.memory-vault>div>span{font-size:9px;line-height:1.4;color:#c7c7c0}
.memory-vault span small{display:block;margin-top:4px;color:var(--muted);font:500 6px var(--mono)}
.memory-vault .goal>i{border:0}
.invite-box{padding:22px;border-bottom:1px solid var(--line)}
.invite-box>small{font:500 7px var(--mono);color:var(--acid);letter-spacing:.14em}
.invite-box p{font-size:9px;line-height:1.5;color:var(--muted)}
.invite-box input{width:100%;margin:8px 0;padding:9px;border:1px solid var(--line);background:#090909;color:var(--muted);font:7px var(--mono)}
.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}
.proficiency-editor{border:1px solid var(--line);padding:9px}.proficiency-editor summary,.proficiency-editor>small{color:var(--muted);font:600 7px var(--mono);letter-spacing:.1em}.proficiency-editor>small{display:block;margin:10px 0 5px;color:var(--acid)}.proficiency-editor>div{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:4px}.proficiency-editor label{display:flex;gap:5px;align-items:center;overflow-wrap:anywhere;color:var(--muted);font:500 6px var(--mono);letter-spacing:0}.proficiency-editor input{width:auto;margin:0;padding:0}
.members article{display:grid;grid-template-columns:6px minmax(0,1fr);align-items:center}
.member-profile{display:grid;grid-template-columns:30px minmax(0,1fr);gap:9px;align-items:center;color:var(--ink);text-decoration:none}
.member-profile>span:last-child{display:flex;min-width:0;flex-direction:column}
.member-avatar{position:relative;overflow:hidden;display:grid!important;place-items:center;width:30px;height:30px;border:1px solid var(--line);background:#171714;color:var(--acid)}
.member-avatar>b{font:600 7px var(--mono)!important}
.member-avatar img{position:absolute;inset:0;width:100%;height:100%;object-fit:cover}
.members .member-profile b{overflow-wrap:anywhere}
.members .takeover-toggle{grid-column:2;margin-left:39px}
</style>