Important Fixes, New mechanics, and many more
Some checks failed
CI / validate (push) Failing after 9m21s

This commit is contained in:
2026-08-15 17:37:57 +05:00
parent 1774496cf9
commit 438e5af0ad
56 changed files with 5090 additions and 119 deletions

View File

@@ -1,48 +1,158 @@
<script setup lang="ts">
import type { WorldStarter } from '@dng/shared'
const { worlds, activeWorld } = useDemo()
const step = ref<'chat' | 'generating' | 'preview'>('chat')
const input = ref('A science-fiction mystery about a dead relay sending messages from the future.')
const answers = reactive({ tone: '', conflict: '', role: '' })
const questionIndex = ref(0)
const questions = [
{ key: 'tone', label: 'What should the story feel like?', options: ['Tense & uncanny', 'Bold & adventurous', 'Melancholy & intimate'] },
{ key: 'conflict', label: 'What pressure drives the opening?', options: ['A ticking clock', 'A fragile alliance', 'A dangerous discovery'] },
{ key: 'role', label: 'Who are the players in this world?', options: ['A freelance crew', 'Reluctant investigators', 'Agents of a fading power'] },
]
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.' },
])
function answerFor(key: string) {
return answers[key as keyof typeof answers]
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 })
}
function begin() {
if (!input.value.trim()) return
questionIndex.value = 1
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
}
}
function choose(key: string, value: string) {
;(answers as Record<string, string>)[key] = value
if (questionIndex.value < questions.length) questionIndex.value += 1
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() {
step.value = 'generating'
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 {
draft.value = await $fetch<WorldStarter>('/api/worlds/generate', { method: 'POST', body: { prompt: input.value, answers } })
} catch {
draft.value = structuredClone(activeWorld.value)
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'
}
await new Promise(resolve => setTimeout(resolve, 700))
step.value = 'preview'
}
function confirm() {
if (!draft.value) return
activeWorld.value = draft.value
if (!worlds.value.some(world => world.title === draft.value!.title)) worlds.value.unshift(draft.value)
navigateTo('/campaign/demo')
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>
@@ -51,50 +161,32 @@ function confirm() {
<div class="forge noise">
<aside>
<p class="step-label">CREATION PROTOCOL</p>
<div class="progress"><i :style="{ width: `${progress}%` }" /></div>
<ol>
<li :class="{ active: step==='chat' }"><b>01</b><span>Seed idea<small>Say what cannot exist yet.</small></span></li>
<li :class="{ active: questionIndex>0 && step==='chat' }"><b>02</b><span>Shape the signal<small>Tone, pressure, player role.</small></span></li>
<li :class="{ active: step==='generating' }"><b>03</b><span>Generate structure<small>A playable starting kit.</small></span></li>
<li :class="{ active: step==='preview' }"><b>04</b><span>Review & launch<small>You remain the final author.</small></span></li>
<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="step==='chat'" class="coauthor">
<main v-if="stage !== 'preview' && stage !== 'confirming'" class="coauthor">
<p class="kicker">COAUTHOR / SESSION 01</p>
<h1>WHAT SHOULD<br>WE BUILD<span>?</span></h1>
<div class="chat-line ai"><small>COAUTHOR</small><p>Give me the first impossible sentence. Ill help turn it into a world your party can enter tonight.</p></div>
<form class="seed" @submit.prevent="begin"><textarea v-model="input" aria-label="World idea" rows="3"/><button>BEGIN <span></span></button></form>
<div v-for="(question, index) in questions" v-show="questionIndex > index" :key="question.key" class="question-block">
<div class="chat-line ai"><small>COAUTHOR · {{ String(index+2).padStart(2,'0') }}</small><p>{{ question.label }}</p></div>
<div class="choices">
<button v-for="option in question.options" :key="option" :class="{selected:answerFor(question.key)===option}" @click="choose(question.key, option)">{{ option }}</button>
</div>
</div>
<button v-if="Object.values(answers).every(Boolean)" class="generate" @click="generate">GENERATE STARTING WORLD <span></span></button>
<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>
<main v-else-if="step==='generating'" class="generating">
<div class="scanner"><i/><i/><i/><span>D&G</span></div><p>ASSEMBLING A PLAYABLE WORLD</p><small>Premise · Location · 3 NPCs · 2 factions · Hidden pressure</small>
</main>
<main v-else-if="draft" class="preview">
<div class="preview-top"><div><p class="kicker">GENERATED STARTING KIT</p><input v-model="draft.title" aria-label="World title"></div><button @click="step='chat'"> REVISE INPUT</button></div>
<div class="preview-grid">
<section class="premise"><label>PREMISE</label><textarea v-model="draft.premise" rows="6"/><div><span>{{ draft.genre }}</span><span>{{ draft.tone }}</span></div></section>
<section><label>STARTING LOCATION</label><h2>{{ draft.startingLocation.name }}</h2><p>{{ draft.startingLocation.summary }}</p></section>
<section class="wide"><label>KEY PEOPLE</label><div class="entity-row"><article v-for="npc in draft.npcs" :key="npc.id"><small>NPC</small><h3>{{ npc.name }}</h3><p>{{ npc.summary }}</p></article></div></section>
<section class="wide"><label>FACTIONS</label><div class="entity-row factions"><article v-for="faction in draft.factions" :key="faction.id"><small>FACTION</small><h3>{{ faction.name }}</h3><p>{{ faction.summary }}</p></article></div></section>
<section><label>OPENING HOOK</label><p>{{ draft.hook }}</p></section>
<section><label>CONTENT BOUNDARIES</label><ul><li v-for="boundary in draft.contentBoundaries" :key="boundary">{{ boundary }}</li></ul></section>
</div>
<div class="confirm-bar"><p><b>PRIVATE WORLD</b><span>Only invited campaign members can see it.</span></p><button @click="confirm">CONFIRM & ENTER WORLD <span></span></button></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)}ol{list-style:none;padding:28px 0;margin:0}li{display:flex;gap:18px;padding:20px 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{color:var(--acid)}li.active small{color:var(--muted)}.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,.preview{padding:clamp(44px,6vw,82px);max-width:1100px;width:100%}.coauthor h1{font:600 clamp(42px,5vw,72px)/.95 var(--display);letter-spacing:-.06em;margin:16px 0 54px}.coauthor h1 span{color:var(--acid)}.chat-line{max-width:680px;border-left:2px solid var(--acid);padding:3px 20px;margin:26px 0 18px}.chat-line small{font:500 8px var(--mono);letter-spacing:.15em;color:var(--acid)}.chat-line p{font-size:15px;line-height:1.65;color:#d0d0ca}.seed{display:flex;align-items:stretch;max-width:760px}.seed textarea,.preview textarea,.preview input{width:100%;resize:none;background:#10100f;border:1px solid var(--line);color:var(--ink);padding:18px;font:500 14px/1.6 var(--body)}.seed button,.generate,.confirm-bar button{border:0;background:var(--acid);color:#080808;padding:0 24px;font:600 9px var(--mono);letter-spacing:.12em}.choices{display:flex;gap:9px;flex-wrap:wrap;margin-left:20px}.choices button{padding:12px 14px;border:1px solid var(--line);background:#10100f;color:var(--muted);font:500 9px var(--mono)}.choices button.selected{border-color:var(--acid);color:var(--acid)}.generate{margin-top:42px;min-height:52px}.generating{display:grid;place-content:center;text-align:center}.scanner{position:relative;width:250px;height:250px;border:1px solid var(--line);border-radius:50%;display:grid;place-items:center;margin:0 auto 34px;animation:rotate 8s linear infinite}.scanner:after{content:"";position:absolute;inset:35px;border:1px dashed var(--acid-dim);border-radius:50%}.scanner i{position:absolute;width:7px;height:7px;background:var(--acid);border-radius:50%}.scanner i:nth-child(1){top:10px}.scanner i:nth-child(2){left:20px;bottom:55px}.scanner i:nth-child(3){right:5px;top:90px}.scanner span{font:600 26px var(--display);color:var(--acid)}.generating p{font:600 11px var(--mono);letter-spacing:.16em}.generating small{color:var(--muted)}@keyframes rotate{to{transform:rotate(360deg)}}.preview{max-width:1300px}.preview-top{display:flex;justify-content:space-between;align-items:end;gap:20px;margin-bottom:34px}.preview-top input{border:0;border-bottom:1px solid var(--line);font:600 clamp(30px,4vw,54px) var(--display);letter-spacing:-.05em;padding:12px 0;background:transparent}.preview-top button{background:transparent;color:var(--muted);border:0;font:500 8px var(--mono)}.preview-grid{display:grid;grid-template-columns:1fr 1fr;border-top:1px solid var(--line);border-left:1px solid var(--line)}.preview-grid>section{padding:28px;border-right:1px solid var(--line);border-bottom:1px solid var(--line)}.preview-grid .wide{grid-column:1/-1}.preview-grid label{font:600 8px var(--mono);letter-spacing:.15em;color:var(--acid)}.preview-grid h2{font:600 22px var(--display);margin:20px 0 12px}.preview-grid p{font-size:12px;line-height:1.7;color:var(--muted)}.premise div{display:flex;gap:8px;margin-top:14px}.premise div span{padding:7px 9px;border:1px solid var(--line);font:500 8px var(--mono);color:var(--muted)}.entity-row{display:grid;grid-template-columns:repeat(3,1fr);gap:1px;background:var(--line);margin-top:22px}.entity-row article{background:#0d0d0c;padding:20px}.entity-row small{font:500 7px var(--mono);color:var(--acid)}.entity-row h3{font:600 13px var(--display)}.factions{grid-template-columns:1fr 1fr}.preview-grid ul{padding-left:18px;color:var(--muted);font-size:12px}.confirm-bar{position:sticky;bottom:0;display:flex;justify-content:space-between;align-items:center;padding:18px 22px;background:#11110f;border:1px solid var(--line);margin-top:24px}.confirm-bar p{margin:0;display:flex;flex-direction:column;font:600 8px var(--mono);color:var(--acid)}.confirm-bar p span{margin-top:5px;color:var(--muted);font-weight:400}.confirm-bar button{min-height:48px}@media(max-width:850px){.forge{grid-template-columns:1fr}.forge>aside{display:none}.coauthor,.preview{padding:40px 20px}.preview-grid{grid-template-columns:1fr}.preview-grid .wide{grid-column:auto}.entity-row,.factions{grid-template-columns:1fr}.confirm-bar{align-items:stretch;flex-direction:column;gap:14px}}
.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>