Files
Dungeons-Ground/apps/web/pages/worlds/new.vue
pavel444-byte 438e5af0ad
Some checks failed
CI / validate (push) Failing after 9m21s
Important Fixes, New mechanics, and many more
2026-08-15 17:37:57 +05:00

193 lines
11 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">
import 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 }
const { api } = useDngApi()
const stage = ref<Stage>('seed')
const busy = ref(false)
const sessionId = ref<string | null>(null)
const seed = ref('A science-fiction mystery about a dead relay sending messages from the future.')
const answers = reactive<Record<string, string>>({})
const currentQuestion = ref<DynamicQuestion | null>(null)
const answerCount = ref(0)
const draft = ref<WorldStarter | null>(null)
const retryAction = ref<'begin' | 'respond' | 'generate'>('begin')
const pendingAnswer = ref<{ key: string; value: string } | null>(null)
const messages = ref<Message[]>([
{ id: 'welcome', role: 'coauthor', body: 'Give me the first impossible sentence. Ill ask a few focused questions, then turn it into a private world your party can enter tonight.' },
])
const displayedQuestion = computed(() => currentQuestion.value ? { key: currentQuestion.value.id, label: currentQuestion.value.label, options: currentQuestion.value.options } : null)
const progress = computed(() => stage.value === 'preview' || stage.value === 'confirming' ? 100 : stage.value === 'generating' ? 85 : stage.value === 'seed' ? 10 : 20 + Math.round((Math.min(answerCount.value, 5) / 5) * 55))
function addMessage(role: Message['role'], body: string, label?: string) {
messages.value.push({ id: `${Date.now()}-${messages.value.length}`, role, body, label })
}
async function begin() {
const prompt = seed.value.trim()
if (!prompt || stage.value !== 'seed' || busy.value) return
busy.value = true
retryAction.value = 'begin'
try {
const result = await api<{ session: { id: string } }>('/api/v1/coauthor/sessions', { method: 'POST', body: { message: prompt } })
sessionId.value = result.session.id
addMessage('player', prompt)
stage.value = 'questions'
retryAction.value = 'respond'
await requestNextQuestion()
} catch (error) {
addMessage('status', errorText(error) || 'Could not start a private coauthor session. Sign in and try again.', '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
pendingAnswer.value = { key, value }
try {
await api(`/api/v1/coauthor/sessions/${sessionId.value}/messages`, {
method: 'POST', body: { content: value },
})
answers[key] = value
addMessage('player', value)
currentQuestion.value = null
answerCount.value = Math.min(answerCount.value + 1, 5)
pendingAnswer.value = null
retryAction.value = 'respond'
await requestNextQuestion()
} catch (error) {
addMessage('status', errorText(error) || 'That answer could not be saved. Try again.', 'ERROR')
} finally {
busy.value = false
}
}
async function requestNextQuestion() {
if (!sessionId.value) return
const result = await api<RespondResult>(`/api/v1/coauthor/sessions/${sessionId.value}/respond`, { method: 'POST' })
if (result.readyToGenerate || answerCount.value >= 5) {
currentQuestion.value = null
await generate()
return
}
if (!result.question) throw new Error('The coauthor did not return its next question.')
currentQuestion.value = result.question
addMessage('coauthor', result.question.label, `COAUTHOR · QUESTION ${Math.min(answerCount.value + 1, 5)} OF UP TO 5`)
}
function errorText(error: unknown) {
if (error && typeof error === 'object') {
const value = error as { data?: { statusMessage?: string; message?: string }; statusMessage?: string; message?: string }
return value.data?.statusMessage || value.data?.message || value.statusMessage || value.message
}
return undefined
}
async function generate() {
if (!sessionId.value) return
stage.value = 'generating'
retryAction.value = 'generate'
addMessage('status', 'Building premise, location, key people, factions, hidden pressure, and an opening scene…', 'GENERATING')
try {
await api(`/api/v1/coauthor/sessions/${sessionId.value}/generate`, { method: 'POST' })
const deadline = Date.now() + 90_000
while (Date.now() < deadline) {
const result = await api<{ session: { status: string; generatedWorld?: unknown; generated_world?: unknown } }>(`/api/v1/coauthor/sessions/${sessionId.value}`)
if (result.session.status === 'ready') {
draft.value = (result.session.generatedWorld ?? result.session.generated_world) as WorldStarter
break
}
if (result.session.status === 'failed') throw new Error('The coauthor job failed. Try generation again.')
await new Promise(resolve => setTimeout(resolve, 1_000))
}
if (!draft.value) throw new Error('World generation is taking too long. Your session is saved; try again shortly.')
messages.value.pop()
addMessage('status', `${draft.value.title}” is ready for your review. Every visible field remains editable.`, 'READY')
stage.value = 'preview'
} catch (error) {
messages.value.pop()
addMessage('status', errorText(error) || 'The coauthor could not generate this world. Your answers are safe—try again.', 'ERROR')
stage.value = 'questions'
}
}
function retry() {
const last = messages.value.at(-1)
if (last?.role === 'status' && last.label === 'ERROR') messages.value.pop()
if (retryAction.value === 'begin') begin()
else if (retryAction.value === 'generate') generate()
else if (pendingAnswer.value && currentQuestion.value) answerQuestion(pendingAnswer.value.key, pendingAnswer.value.value)
else requestNextQuestion().catch(error => addMessage('status', errorText(error) || 'The coauthor could not continue. Try again.', 'ERROR'))
}
function revise() {
stage.value = 'seed'
sessionId.value = null
currentQuestion.value = null
answerCount.value = 0
pendingAnswer.value = null
for (const key of Object.keys(answers)) delete answers[key]
draft.value = null
addMessage('coauthor', 'Lets reshape it from the opening sentence. Ill adapt the next questions to your new direction.', 'COAUTHOR · REVISION')
}
async function confirm() {
if (!draft.value || !sessionId.value || stage.value === 'confirming') return
stage.value = 'confirming'
try {
const confirmed = await api<{ worldId: string }>(`/api/v1/coauthor/sessions/${sessionId.value}/confirm`, {
method: 'POST', body: { world: draft.value },
})
const created = await api<{ campaignId: string }>('/api/v1/campaigns', {
method: 'POST', body: { worldId: confirmed.worldId, title: draft.value.title },
})
await navigateTo(`/campaign/${created.campaignId}`)
} catch (error) {
addMessage('status', errorText(error) || 'The world could not be confirmed. Your draft is still safe.', 'ERROR')
stage.value = 'preview'
}
}
</script>
<template>
<AppShell section="WORLD FORGE">
<div class="forge noise">
<aside>
<p class="step-label">CREATION PROTOCOL</p>
<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 === '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>
<div class="boundary"><span>13+ BOUNDARY</span><p>Dark themes are welcome. Explicit sexual content and extreme graphic violence are not.</p></div>
</aside>
<main v-if="stage !== 'preview' && stage !== 'confirming'" class="coauthor">
<p class="kicker">COAUTHOR / SESSION 01</p>
<h1>BUILD THE<br>IMPOSSIBLE<span>.</span></h1>
<CoauthorConversation :messages="messages" :question="displayedQuestion" :selected-answer="currentQuestion ? answers[currentQuestion.id] : undefined" :busy="stage === 'generating' || busy" @answer="answerQuestion" @retry="retry" />
<form v-if="stage === 'seed'" class="seed" @submit.prevent="begin">
<textarea v-model="seed" aria-label="World idea" rows="3" maxlength="1200" autofocus />
<button :disabled="!seed.trim() || busy">{{ busy ? 'SAVING…' : 'BEGIN' }} <span></span></button>
</form>
<div v-if="stage === 'generating'" class="scanner" aria-label="Generating world"><i /><i /><i /><span>D&G</span></div>
</main>
<CoauthorWorldPreview v-else-if="draft" v-model="draft" @revise="revise" @confirm="confirm" />
</div>
</AppShell>
</template>
<style scoped>
.forge{min-height:calc(100vh - 76px);display:grid;grid-template-columns:300px 1fr}.forge>aside{padding:50px 34px;border-right:1px solid var(--line);display:flex;flex-direction:column}.step-label,.kicker{font:500 9px var(--mono);letter-spacing:.18em;color:var(--acid)}.progress{height:3px;margin-top:22px;background:#252522}.progress i{display:block;height:100%;background:var(--acid);transition:width .35s ease}ol{list-style:none;padding:20px 0;margin:0}li{display:flex;gap:18px;padding:18px 0;color:#4d4d48}li>b{font:500 9px var(--mono)}li span{font:600 10px var(--display);letter-spacing:.04em}li small{display:block;margin-top:7px;font:400 10px/1.4 var(--body);color:#55554f}li.active{color:var(--ink)}li.active b,li.done b{color:var(--acid)}li.active small{color:var(--muted)}li.done:not(.active){color:#77776f}.boundary{margin-top:auto;padding:18px;border:1px solid var(--line)}.boundary span{font:600 8px var(--mono);color:var(--acid)}.boundary p{font-size:10px;line-height:1.5;color:var(--muted)}.coauthor{padding:clamp(44px,6vw,82px);max-width:1000px;width:100%}.coauthor h1{font:600 clamp(42px,5vw,72px)/.95 var(--display);letter-spacing:-.06em;margin:16px 0 44px}.coauthor h1 span{color:var(--acid)}.seed{display:flex;align-items:stretch;max-width:780px;margin-top:22px}.seed textarea{width:100%;resize:vertical;background:#10100f;border:1px solid var(--line);color:var(--ink);padding:18px;font:500 14px/1.6 var(--body)}.seed button{border:0;background:var(--acid);color:#080808;padding:0 24px;font:600 9px var(--mono);letter-spacing:.12em}.seed button:disabled{opacity:.4}.scanner{position:relative;width:180px;height:180px;border:1px solid var(--line);border-radius:50%;display:grid;place-items:center;margin:40px auto 0;animation:rotate 8s linear infinite}.scanner:after{content:"";position:absolute;inset:28px;border:1px dashed var(--acid-dim);border-radius:50%}.scanner i{position:absolute;width:6px;height:6px;background:var(--acid);border-radius:50%}.scanner i:nth-child(1){top:8px}.scanner i:nth-child(2){left:13px;bottom:40px}.scanner i:nth-child(3){right:3px;top:64px}.scanner span{font:600 20px var(--display);color:var(--acid)}@keyframes rotate{to{transform:rotate(360deg)}}@media(max-width:850px){.forge{grid-template-columns:1fr}.forge>aside{display:none}.coauthor{box-sizing:border-box;padding:40px 20px}}
</style>