Files
Dungeons-Ground/apps/web/pages/campaign/demo.vue
pavel444-byte 4e0c38c925
All checks were successful
CI / validate (push) Successful in 21m53s
CI / validate (pull_request) Successful in 15m15s
feat(gameplay): enforce core SRD d20 rules
Co-authored-by: multica-agent <github@multica.ai>
2026-08-23 18:50:55 +05:00

114 lines
12 KiB
Vue
Raw 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">
const { activeWorld, characters, roundNumber, history, currentAction, ready, scene } = useDemo()
const submitting = ref(false)
const forceOpen = ref(false)
const errorMessage = ref('')
const submittedActionIndex = ref<number | null>(null)
function abilityLabel(score: number) {
const modifier = Math.floor((Number(score) - 10) / 2)
return modifier >= 0 ? `+${modifier}` : String(modifier)
}
async function submitAction() {
if (!currentAction.value.trim() || submitting.value || ready.value) return
submitting.value = true
errorMessage.value = ''
const action = currentAction.value.trim()
try {
submittedActionIndex.value = history.value.push({ type: 'action', author: characters.value[0]?.name.toUpperCase() ?? 'PLAYER', body: action, meta: 'READY' }) - 1
ready.value = true
currentAction.value = ''
await new Promise(resolve => setTimeout(resolve, 650))
} finally {
submitting.value = false
}
}
function editAction() {
const index = submittedActionIndex.value
if (index === null) return
const submitted = history.value[index]
if (submitted?.type === 'action') {
currentAction.value = submitted.body
history.value.splice(index, 1)
}
submittedActionIndex.value = null
ready.value = false
}
async function continueRound() {
if ((!ready.value && !forceOpen.value) || submitting.value) return
submitting.value = true
errorMessage.value = ''
try {
const resolved = await $fetch<{ narration: string; roll: string; outcome: string }>('/api/rounds/demo/resolve', {
method: 'POST', body: { action: history.value.filter(item => item.type === 'action').at(-1)?.body ?? 'I carefully investigate.' },
})
history.value.push({ type: 'roll', author: 'SYSTEM CHECK', body: resolved.roll, meta: resolved.outcome })
history.value.push({ type: 'narration', author: 'GROUNDKEEPER', body: resolved.narration, meta: `ROUND ${String(roundNumber.value).padStart(2, '0')} · RESOLVED` })
scene.value = resolved.narration
roundNumber.value += 1
ready.value = false
forceOpen.value = false
submittedActionIndex.value = null
} catch {
errorMessage.value = 'The Groundkeeper could not resolve this round. Your action is still ready; please try again.'
} finally {
submitting.value = false
}
}
</script>
<template>
<AppShell :section="activeWorld.title.toUpperCase()">
<div class="game">
<aside class="party-panel">
<div class="panel-label"><span>PARTY / 02</span><button></button></div>
<article v-for="character in characters" :key="character.id" :class="['party-member', character.controller]">
<div class="portrait">{{ character.name.split(' ').map(n=>n[0]).join('') }}</div>
<div><h3>{{ character.name }}</h3><p>{{ character.controller === 'human' ? 'YOU · READY' : 'AI COMPANION · WAITING' }}</p></div>
<i :class="character.controller" />
</article>
<div class="character-sheet">
<small>ACTIVE CHARACTER</small><h2>{{ characters[0]?.name }}</h2><p>{{ characters[0]?.concept }}</p>
<div class="vitals"><span><b>{{ characters[0]?.hp }}</b>/{{ characters[0]?.maxHp }} HP</span><span><b>{{ characters[0]?.defense }}</b> AC</span></div>
<div class="abilities"><span v-for="(score,key) in characters[0]?.abilities" :key="key"><small>{{ key }} {{ abilityLabel(score) }}</small><b>{{ score }}</b></span></div>
<details><summary>INVENTORY</summary><ul><li v-for="item in characters[0]?.inventory" :key="item">{{ item }}</li></ul></details>
</div>
</aside>
<main class="chronicle noise">
<header class="scene-head"><div><p>BLACK MERIDIAN RELAY</p><h1>THE ARCHIVE BELOW</h1></div><span>ROUND {{ String(roundNumber).padStart(2,'0') }}</span></header>
<div class="timeline">
<article v-for="(item,index) in history" :key="index" :class="item.type">
<div class="timeline-meta"><small>{{ item.author }}</small><span>{{ item.meta }}</span></div>
<p>{{ item.body }}</p>
</article>
</div>
<div class="action-dock">
<div class="round-state"><span><i :class="{on:ready}" /> {{ ready ? 'YOUR ACTION IS READY' : 'WAITING FOR YOUR ACTION' }}</span><span>ROOK-7 ACTS AFTER HUMANS</span></div>
<textarea v-model="currentAction" :disabled="ready" maxlength="2000" placeholder="What does Mara do?" aria-label="Your action" @keydown.meta.enter="submitAction" />
<p v-if="errorMessage" class="action-error" role="alert">{{ errorMessage }}</p>
<div class="dock-actions"><small>{{ currentAction.length }} / 2000 · ENTER TO READY</small><button v-if="!ready" :disabled="submitting || !currentAction.trim()" @click="submitAction">MARK READY <span>→</span></button><button v-else class="edit" :disabled="submitting" @click="editAction">EDIT ACTION</button></div>
</div>
</main>
<aside class="round-panel">
<div class="panel-label"><span>ROUND CONTROL</span><button></button></div>
<div class="readiness"><p><b>1 / 1</b> HUMANS READY</p><div><i :class="{filled:ready}" /></div></div>
<div class="intent-preview"><small>MARA VALE</small><p>{{ history.filter(i=>i.type==='action').at(-1)?.body || 'No action submitted yet.' }}</p><span>{{ ready ? 'READY' : 'DRAFT' }}</span></div>
<div class="ai-turn"><small>AI TURN ORDER</small><div><b>01</b><span>Rook-7<p>Acts after human intentions</p></span></div></div>
<label class="force"><input v-model="forceOpen" type="checkbox"><span><b>CONTINUE WITHOUT WAITING</b><small>Owner override. Missing human characters may be delegated to AI.</small></span></label>
<button class="resolve" :disabled="submitting || (!ready && !forceOpen)" @click="continueRound">{{ submitting ? 'RESOLVING' : 'RESOLVE ROUND' }} <span></span></button>
<div class="round-note"><i /> All rolls and mechanical changes are resolved by the server and written to the audit log.</div>
</aside>
</div>
</AppShell>
</template>
<style scoped>
.game{height:calc(100vh - 76px);display:grid;grid-template-columns:270px minmax(420px,1fr) 300px;overflow:hidden}.party-panel,.round-panel{background:#0c0c0b;overflow-y:auto}.party-panel{border-right:1px solid var(--line)}.round-panel{border-left:1px solid var(--line)}.panel-label{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-label button{border:0;background:none;color:var(--muted)}.party-member{display:grid;grid-template-columns:38px 1fr 8px;gap:12px;align-items:center;padding: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:var(--muted)}.party-member>i.human{background:var(--acid);box-shadow:0 0 10px var(--acid)}.character-sheet{padding:28px 18px}.character-sheet>small,.ai-turn>small{font:500 7px var(--mono);color:var(--acid);letter-spacing:.14em}.character-sheet h2{font:600 20px var(--display);margin:14px 0 8px}.character-sheet>p{font-size:11px;line-height:1.5;color:var(--muted)}.vitals{display:flex;gap:8px;margin:24px 0}.vitals span{flex:1;padding:12px 8px;background:#121210;border:1px solid var(--line);font:500 7px var(--mono);color:var(--muted)}.vitals b{color:var(--ink);font-size:13px}.abilities{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)}details{margin-top:24px;border-top:1px solid var(--line);padding-top:16px}summary{font:600 8px var(--mono);cursor:pointer}details ul{padding-left:18px;color:var(--muted);font-size:11px}.chronicle{position:relative;overflow-y:auto;padding-bottom:220px}.scene-head{position:sticky;top:0;z-index:5;padding:30px clamp(28px,5vw,76px) 20px;display:flex;justify-content:space-between;align-items:end;background:linear-gradient(#0a0a0a 75%,transparent)}.scene-head p{font:500 8px var(--mono);letter-spacing:.16em;color:var(--acid)}.scene-head h1{font:600 clamp(24px,3vw,39px) var(--display);letter-spacing:-.05em;margin:8px 0}.scene-head>span{font:500 9px var(--mono);color:var(--muted)}.timeline{max-width:780px;margin:20px auto;padding:0 34px}.timeline article{padding:26px 0;border-bottom:1px solid var(--line)}.timeline-meta{display:flex;justify-content:space-between;align-items:center}.timeline-meta small{font:600 8px var(--mono);letter-spacing:.14em;color:var(--acid)}.timeline-meta span{font:500 7px var(--mono);color:var(--muted)}.timeline p{font:400 15px/1.75 var(--body);color:#d1d1ca}.timeline .action{margin-left:50px;border-left:2px solid #494943;padding-left:20px}.timeline .action .timeline-meta small{color:var(--ink)}.timeline .roll{margin:18px 0;padding:14px 17px;background:#121210;border:1px solid var(--line)}.timeline .roll p{font:500 11px var(--mono);margin:8px 0;color:#aaa}.action-dock{position:fixed;bottom:0;left:270px;right:300px;z-index:10;padding:14px 22px 20px;background:rgba(12,12,11,.95);backdrop-filter:blur(15px);border-top:1px solid var(--line)}.round-state{display:flex;justify-content:space-between;margin-bottom:10px;font:500 7px var(--mono);letter-spacing:.12em;color:var(--muted)}.round-state i{display:inline-block;width:6px;height:6px;border-radius:50%;background:#555;margin-right:6px}.round-state i.on{background:var(--acid)}.action-dock textarea{width:100%;height:74px;resize:none;padding:15px;background:#11110f;border:1px solid #383833;color:var(--ink);font:13px/1.5 var(--body);box-sizing:border-box}.dock-actions{display:flex;justify-content:space-between;align-items:center;margin-top:9px}.dock-actions small{font:500 7px var(--mono);color:var(--muted)}.dock-actions button,.resolve{min-height:38px;border:0;background:var(--acid);padding:0 17px;font:600 8px var(--mono);letter-spacing:.1em}.dock-actions .edit{background:transparent;border:1px solid var(--line);color:var(--ink)}button:disabled{opacity:.35}.readiness,.intent-preview,.ai-turn,.force,.round-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;width:100%;height:100%;background:#333}.readiness div i.filled{background:var(--acid)}.intent-preview{position:relative}.intent-preview small{font:600 8px var(--mono)}.intent-preview p{font-size:11px;line-height:1.6;color:var(--muted)}.intent-preview>span{font:600 7px var(--mono);color:var(--acid)}.ai-turn div{display:flex;gap:12px;margin-top:18px}.ai-turn div>b{font:500 8px var(--mono);color:var(--acid)}.ai-turn span{font:600 10px var(--display)}.ai-turn p{font:400 9px var(--body);color:var(--muted)}.force{display:flex;gap:12px;cursor:pointer}.force input{accent-color:var(--acid)}.force span{display:flex;flex-direction:column}.force b{font:600 8px var(--mono)}.force small{margin-top:7px;font:400 9px/1.45 var(--body);color:var(--muted)}.resolve{display:block;width:calc(100% - 44px);height:48px;margin:22px}.round-note{font-size:9px;line-height:1.5;color:var(--muted)}.round-note i{display:inline-block;width:6px;height:6px;border-radius:50%;background:var(--acid);margin-right:7px}@media(max-width:1050px){.game{grid-template-columns:220px 1fr}.round-panel{display:none}.action-dock{left:220px;right:0}}@media(max-width:700px){.game{display:block;height:auto}.party-panel{display:none}.chronicle{min-height:calc(100vh - 76px)}.action-dock{left:0}.timeline{padding:0 20px}.scene-head{padding-left:20px;padding-right:20px}.timeline .action{margin-left:16px}}
.action-error{margin:0 0 9px;color:#ff9d85;font:500 9px/1.4 var(--mono)}
</style>