Files
pavel444-byte f7ca158fe4
Some checks failed
CI / validate (pull_request) Has been cancelled
CI / validate (push) Successful in 32m54s
fix(gameplay): start campaigns in playable state
Co-authored-by: multica-agent <github@multica.ai>
2026-08-21 19:41:50 +05:00

276 lines
15 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 { WorldStarterSchema, 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 }
interface StoredMessage { role: 'user' | 'assistant'; content: string }
interface StoredSession {
id: string
status: 'collecting' | 'generating' | 'ready' | 'failed' | 'confirmed'
messages: StoredMessage[]
current_question?: DynamicQuestion | null
generatedWorld?: unknown
generated_world?: unknown
confirmed_world_id?: string | null
}
const questionLimit = 4
const { api } = useDngApi()
const route = useRoute()
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 conversationMessages = computed(() => {
if (!currentQuestion.value) return messages.value
const currentIndex = messages.value.findLastIndex(message =>
message.role === 'coauthor' && message.body === currentQuestion.value?.label,
)
return currentIndex < 0 ? messages.value : messages.value.filter((_, index) => index !== currentIndex)
})
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, questionLimit) / questionLimit) * 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 resume(id: string) {
busy.value = true
retryAction.value = 'respond'
try {
const result = await api<{ session: StoredSession }>(`/api/v1/coauthor/sessions/${id}`)
const session = result.session
sessionId.value = session.id
const storedMessages = Array.isArray(session.messages) ? session.messages : []
seed.value = storedMessages.find(message => message.role === 'user')?.content ?? seed.value
answerCount.value = Math.max(0, storedMessages.filter(message => message.role === 'user').length - 1)
let questionNumber = 0
messages.value = [messages.value[0]!, ...storedMessages.map((message, index) => {
if (message.role === 'assistant') questionNumber += 1
return {
id: `stored-${index}`,
role: message.role === 'assistant' ? 'coauthor' as const : 'player' as const,
body: message.content,
label: message.role === 'assistant' ? `COAUTHOR · QUESTION ${questionNumber} OF 4` : undefined,
}
})]
if (session.status === 'confirmed') {
await navigateTo('/dashboard')
return
}
if (session.status === 'ready') {
const parsed = WorldStarterSchema.safeParse(session.generatedWorld ?? session.generated_world)
if (!parsed.success) throw new Error('This saved world draft is invalid. Generate it again.')
draft.value = parsed.data
stage.value = 'preview'
addMessage('status', `${parsed.data.title}” was restored.`, 'DRAFT RESTORED')
return
}
if (session.status === 'generating') {
busy.value = false
await generate()
return
}
if (session.status === 'failed') {
stage.value = 'questions'
retryAction.value = 'generate'
addMessage('status', 'The previous generation failed. Your answers are safe.', 'ERROR')
return
}
stage.value = 'questions'
currentQuestion.value = session.current_question ?? null
if (!currentQuestion.value) await requestNextQuestion()
} catch (error) {
stage.value = 'seed'
sessionId.value = null
addMessage('status', errorText(error) || 'This saved coauthor session could not be restored.', '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
const answeredQuestion = currentQuestion.value
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('coauthor', answeredQuestion.label, `COAUTHOR · QUESTION ${Math.min(answerCount.value + 1, questionLimit)} OF ${questionLimit}`)
addMessage('player', value)
currentQuestion.value = null
answerCount.value = Math.min(answerCount.value + 1, questionLimit)
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 >= questionLimit) {
currentQuestion.value = null
await generate()
return
}
if (!result.question) throw new Error('The coauthor did not return its next question.')
currentQuestion.value = result.question
}
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'
}
}
onMounted(() => {
const resumeId = typeof route.query.session === 'string' ? route.query.session : ''
if (resumeId) void resume(resumeId)
})
</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 >= 4 }"><b>02</b><span>Shape the signal<small>{{ answerCount }} / 4 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="conversationMessages" :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>