Important Fixes, New mechanics, and many more
Some checks failed
CI / validate (push) Failing after 9m21s
Some checks failed
CI / validate (push) Failing after 9m21s
This commit is contained in:
@@ -1,5 +1,18 @@
|
||||
<script setup lang="ts">
|
||||
defineProps<{ section?: string }>()
|
||||
const { session, restore, signOut } = useDngAuth()
|
||||
onMounted(() => void restore())
|
||||
|
||||
const initials = computed(() => {
|
||||
const email = session.value?.user.email ?? ''
|
||||
return email.slice(0, 2).toUpperCase() || 'G'
|
||||
})
|
||||
|
||||
async function leave() {
|
||||
if (session.value?.user.is_anonymous && !window.confirm('This guest account cannot be recovered after sign out. Leave anyway?')) return
|
||||
await signOut()
|
||||
await navigateTo('/')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -9,7 +22,8 @@ defineProps<{ section?: string }>()
|
||||
<div class="topbar-center"><span class="status-dot" /> PRIVATE ALPHA <b v-if="section">/ {{ section }}</b></div>
|
||||
<div class="topbar-actions">
|
||||
<NuxtLink to="/dashboard" class="icon-link" aria-label="Dashboard">⌂</NuxtLink>
|
||||
<div class="avatar">MV</div>
|
||||
<button v-if="session" class="avatar" :title="session.user.is_anonymous ? 'Guest session — sign out' : `Sign out ${session.user.email ?? ''}`" @click="leave">{{ initials }}</button>
|
||||
<div v-else class="avatar">D&G</div>
|
||||
</div>
|
||||
</header>
|
||||
<main><slot /></main>
|
||||
@@ -17,5 +31,5 @@ defineProps<{ section?: string }>()
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.shell{min-height:100vh}.topbar{position:sticky;top:0;z-index:20;height:76px;padding:0 clamp(18px,4vw,64px);display:grid;grid-template-columns:1fr auto 1fr;align-items:center;border-bottom:1px solid var(--line);background:rgba(10,10,10,.9);backdrop-filter:blur(18px)}.topbar-center{font:500 10px/1 var(--mono);color:var(--muted);letter-spacing:.18em}.topbar-center b{color:var(--ink);font-weight:500}.status-dot{display:inline-block;width:6px;height:6px;margin-right:8px;border-radius:50%;background:var(--acid);box-shadow:0 0 12px var(--acid)}.topbar-actions{justify-self:end;display:flex;align-items:center;gap:14px}.icon-link{display:grid;place-items:center;width:36px;height:36px;border:1px solid var(--line);color:var(--muted);text-decoration:none}.avatar{display:grid;place-items:center;width:36px;height:36px;border-radius:50%;background:var(--acid);color:#0a0a0a;font:700 10px var(--mono)}@media(max-width:700px){.topbar{grid-template-columns:1fr auto}.topbar-center{display:none}}
|
||||
.shell{min-height:100vh}.topbar{position:sticky;top:0;z-index:20;height:76px;padding:0 clamp(18px,4vw,64px);display:grid;grid-template-columns:1fr auto 1fr;align-items:center;border-bottom:1px solid var(--line);background:rgba(10,10,10,.9);backdrop-filter:blur(18px)}.topbar-center{font:500 10px/1 var(--mono);color:var(--muted);letter-spacing:.18em}.topbar-center b{color:var(--ink);font-weight:500}.status-dot{display:inline-block;width:6px;height:6px;margin-right:8px;border-radius:50%;background:var(--acid);box-shadow:0 0 12px var(--acid)}.topbar-actions{justify-self:end;display:flex;align-items:center;gap:14px}.icon-link{display:grid;place-items:center;width:36px;height:36px;border:1px solid var(--line);color:var(--muted);text-decoration:none}.avatar{display:grid;place-items:center;width:36px;height:36px;padding:0;border:0;border-radius:50%;background:var(--acid);color:#0a0a0a;font:700 10px var(--mono)}@media(max-width:700px){.topbar{grid-template-columns:1fr auto}.topbar-center{display:none}}
|
||||
</style>
|
||||
|
||||
52
apps/web/components/CoauthorConversation.vue
Normal file
52
apps/web/components/CoauthorConversation.vue
Normal file
@@ -0,0 +1,52 @@
|
||||
<script setup lang="ts">
|
||||
interface Message { id: string; role: 'coauthor' | 'player' | 'status'; body: string; label?: string }
|
||||
interface Question { key: string; label: string; options: string[] }
|
||||
|
||||
const props = defineProps<{
|
||||
messages: Message[]
|
||||
question: Question | null
|
||||
selectedAnswer?: string
|
||||
busy?: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
answer: [key: string, value: string]
|
||||
retry: []
|
||||
}>()
|
||||
|
||||
const customAnswer = ref('')
|
||||
|
||||
function submitCustom() {
|
||||
if (!props.question || !customAnswer.value.trim()) return
|
||||
emit('answer', props.question.key, customAnswer.value.trim())
|
||||
customAnswer.value = ''
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="conversation" aria-live="polite">
|
||||
<ol class="messages">
|
||||
<li v-for="message in messages" :key="message.id" :class="message.role">
|
||||
<small>{{ message.label || (message.role === 'player' ? 'YOU' : message.role === 'status' ? 'SYSTEM' : 'COAUTHOR') }}</small>
|
||||
<p>{{ message.body }}</p>
|
||||
</li>
|
||||
</ol>
|
||||
|
||||
<div v-if="question" class="question-card">
|
||||
<p>{{ question.label }}</p>
|
||||
<div class="options">
|
||||
<button v-for="option in question.options" :key="option" type="button" :class="{ selected: selectedAnswer === option }" :disabled="busy" @click="emit('answer', question.key, option)">{{ option }}</button>
|
||||
</div>
|
||||
<form class="custom" @submit.prevent="submitCustom">
|
||||
<input v-model="customAnswer" :disabled="busy" maxlength="160" placeholder="Or write your own answer…" :aria-label="`Custom answer: ${question.label}`">
|
||||
<button :disabled="busy || !customAnswer.trim()">SEND →</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<button v-if="messages.at(-1)?.role === 'status' && messages.at(-1)?.label === 'ERROR'" class="retry" type="button" @click="emit('retry')">TRY AGAIN</button>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.conversation{max-width:780px}.messages{list-style:none;padding:0;margin:0}.messages li{max-width:680px;margin:22px 0;padding:5px 20px;border-left:2px solid var(--acid)}.messages li.player{margin-left:auto;border-left:0;border-right:2px solid #5f5f58;text-align:right}.messages li.status{border:1px solid var(--line);background:#10100f}.messages li.status[aria-invalid=true]{border-color:#7b4137}.messages small{font:500 8px var(--mono);letter-spacing:.15em;color:var(--acid)}.messages .player small{color:var(--ink)}.messages .status small{color:var(--muted)}.messages p{margin:9px 0 0;font-size:14px;line-height:1.65;color:#d0d0ca;white-space:pre-wrap}.question-card{margin:28px 0 0 20px;padding:24px;border:1px solid var(--line);background:#0d0d0c}.question-card>p{margin:0 0 18px;font:600 14px/1.5 var(--display)}.options{display:flex;gap:9px;flex-wrap:wrap}.options button,.retry{padding:12px 14px;border:1px solid var(--line);background:#10100f;color:var(--muted);font:500 9px var(--mono)}.options button:hover,.options button.selected{border-color:var(--acid);color:var(--acid)}button:disabled{opacity:.45;cursor:not-allowed}.custom{display:flex;margin-top:12px}.custom input{min-width:0;flex:1;padding:13px 14px;border:1px solid var(--line);background:#080808;color:var(--ink);font:12px var(--body)}.custom button{border:0;background:var(--acid);color:#080808;padding:0 17px;font:600 8px var(--mono)}.retry{margin:18px 0 0 20px;color:#ff9d85;border-color:#7b4137}@media(max-width:600px){.messages li.player{margin-left:28px}.question-card{margin-left:0}.custom{flex-direction:column}.custom button{min-height:42px}}
|
||||
</style>
|
||||
32
apps/web/components/CoauthorWorldPreview.vue
Normal file
32
apps/web/components/CoauthorWorldPreview.vue
Normal file
@@ -0,0 +1,32 @@
|
||||
<script setup lang="ts">
|
||||
import type { WorldStarter } from '@dng/shared'
|
||||
|
||||
const draft = defineModel<WorldStarter>({ required: true })
|
||||
defineEmits<{ revise: []; confirm: [] }>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="preview">
|
||||
<header>
|
||||
<div><p class="kicker">GENERATED STARTING KIT</p><input v-model="draft.title" aria-label="World title"></div>
|
||||
<button class="revise" type="button" @click="$emit('revise')">← REVISE ANSWERS</button>
|
||||
</header>
|
||||
<p class="edit-note">Everything below is editable before launch.</p>
|
||||
<div class="grid">
|
||||
<section class="wide"><label>PREMISE</label><textarea v-model="draft.premise" rows="5" /></section>
|
||||
<section><label>GENRE</label><input v-model="draft.genre"></section>
|
||||
<section><label>TONE</label><input v-model="draft.tone"></section>
|
||||
<section><label>STARTING LOCATION</label><input v-model="draft.startingLocation.name"><textarea v-model="draft.startingLocation.summary" rows="3" /></section>
|
||||
<section><label>OPENING HOOK</label><textarea v-model="draft.hook" rows="5" /></section>
|
||||
<section class="wide"><label>KEY PEOPLE</label><div class="entities"><article v-for="npc in draft.npcs" :key="npc.id"><small>NPC</small><input v-model="npc.name"><textarea v-model="npc.summary" rows="3" /></article></div></section>
|
||||
<section class="wide"><label>FACTIONS</label><div class="entities two"><article v-for="faction in draft.factions" :key="faction.id"><small>FACTION</small><input v-model="faction.name"><textarea v-model="faction.summary" rows="3" /></article></div></section>
|
||||
<section><label>OPENING SCENE</label><textarea v-model="draft.openingScene" rows="6" /></section>
|
||||
<section><label>CONTENT BOUNDARIES</label><div v-for="(_, index) in draft.contentBoundaries" :key="index" class="boundary"><input v-model="draft.contentBoundaries[index]"><button type="button" aria-label="Remove boundary" @click="draft.contentBoundaries.splice(index, 1)">×</button></div><button class="add" type="button" @click="draft.contentBoundaries.push('')">+ ADD BOUNDARY</button></section>
|
||||
</div>
|
||||
<footer><p><b>PRIVATE WORLD</b><span>Only invited campaign members can see it.</span></p><button type="button" @click="$emit('confirm')">CONFIRM & ENTER WORLD →</button></footer>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.preview{max-width:1300px;width:100%;padding:clamp(40px,5vw,76px)}header{display:flex;justify-content:space-between;align-items:end;gap:20px}.kicker,label{font:600 8px var(--mono);letter-spacing:.15em;color:var(--acid)}header>div{flex:1}header 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}.revise,.add{background:transparent;color:var(--muted);border:1px solid var(--line);padding:12px;font:500 8px var(--mono)}.edit-note{color:var(--muted);font-size:11px}.grid{display:grid;grid-template-columns:1fr 1fr;border-top:1px solid var(--line);border-left:1px solid var(--line);margin-top:28px}.grid>section{padding:26px;border-right:1px solid var(--line);border-bottom:1px solid var(--line)}.wide{grid-column:1/-1}input,textarea{box-sizing:border-box;width:100%;margin-top:12px;padding:13px;resize:vertical;background:#10100f;border:1px solid var(--line);color:var(--ink);font:12px/1.55 var(--body)}.entities{display:grid;grid-template-columns:repeat(3,1fr);gap:12px;margin-top:18px}.entities.two{grid-template-columns:repeat(2,1fr)}.entities article{padding:16px;border:1px solid var(--line);background:#0c0c0b}.entities small{font:500 7px var(--mono);color:var(--acid)}.boundary{display:flex}.boundary input{margin-top:8px}.boundary button{margin-top:8px;width:42px;border:1px solid var(--line);background:#151513;color:var(--muted)}.add{margin-top:12px}footer{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}footer p{margin:0;display:flex;flex-direction:column;font:600 8px var(--mono);color:var(--acid)}footer p span{margin-top:5px;color:var(--muted);font-weight:400}footer>button{min-height:48px;border:0;background:var(--acid);color:#080808;padding:0 24px;font:600 9px var(--mono)}@media(max-width:760px){.preview{padding:36px 18px}header{align-items:stretch;flex-direction:column}.grid{grid-template-columns:1fr}.wide{grid-column:auto}.entities,.entities.two{grid-template-columns:1fr}footer{align-items:stretch;flex-direction:column;gap:14px}}
|
||||
</style>
|
||||
33
apps/web/composables/useDngApi.ts
Normal file
33
apps/web/composables/useDngApi.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
interface DngApiOptions {
|
||||
method?: 'GET' | 'POST' | 'PATCH' | 'PUT' | 'DELETE'
|
||||
body?: Record<string, unknown>
|
||||
query?: Record<string, string | number | boolean | undefined>
|
||||
}
|
||||
|
||||
export function useDngApi() {
|
||||
const auth = useDngAuth()
|
||||
|
||||
async function api<T>(path: string, options: DngApiOptions = {}): Promise<T> {
|
||||
const token = await auth.accessToken()
|
||||
try {
|
||||
const response = await $fetch(path, {
|
||||
...options,
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
} as Parameters<typeof $fetch>[1])
|
||||
return response as T
|
||||
} catch (cause) {
|
||||
const error = cause as { statusCode?: number; status?: number; response?: { status?: number } }
|
||||
const status = error.statusCode ?? error.status ?? error.response?.status
|
||||
if (status === 401) {
|
||||
auth.invalidate()
|
||||
if (import.meta.client) {
|
||||
localStorage.setItem('dng-post-auth-redirect', window.location.pathname + window.location.search)
|
||||
await navigateTo('/')
|
||||
}
|
||||
}
|
||||
throw cause
|
||||
}
|
||||
}
|
||||
|
||||
return { api }
|
||||
}
|
||||
188
apps/web/composables/useDngAuth.ts
Normal file
188
apps/web/composables/useDngAuth.ts
Normal file
@@ -0,0 +1,188 @@
|
||||
interface DngAuthUser {
|
||||
id: string
|
||||
email?: string | null
|
||||
is_anonymous?: boolean
|
||||
user_metadata?: Record<string, unknown>
|
||||
}
|
||||
|
||||
interface DngSession {
|
||||
accessToken: string
|
||||
refreshToken: string
|
||||
expiresAt: number
|
||||
user: DngAuthUser
|
||||
}
|
||||
|
||||
interface AuthResponse {
|
||||
access_token: string
|
||||
refresh_token: string
|
||||
expires_in: number
|
||||
user: DngAuthUser
|
||||
}
|
||||
|
||||
const STORAGE_KEY = 'dng-auth-session'
|
||||
|
||||
export function useDngAuth() {
|
||||
const config = useRuntimeConfig()
|
||||
const session = useState<DngSession | null>('dng-auth-session', () => null)
|
||||
const hydrated = useState('dng-auth-hydrated', () => false)
|
||||
|
||||
function authHeaders(accessToken?: string) {
|
||||
return {
|
||||
apikey: config.public.supabaseAnonKey,
|
||||
'Content-Type': 'application/json',
|
||||
...(accessToken ? { Authorization: `Bearer ${accessToken}` } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
function persist(next: DngSession | null) {
|
||||
session.value = next
|
||||
if (!import.meta.client) return
|
||||
if (next) localStorage.setItem(STORAGE_KEY, JSON.stringify(next))
|
||||
else localStorage.removeItem(STORAGE_KEY)
|
||||
}
|
||||
|
||||
function normalizeUser(user: DngAuthUser): DngAuthUser {
|
||||
return {
|
||||
...user,
|
||||
email: user.email || undefined,
|
||||
}
|
||||
}
|
||||
|
||||
function parseStoredSession(value: string): DngSession | null {
|
||||
try {
|
||||
const saved = JSON.parse(value) as Partial<DngSession>
|
||||
if (
|
||||
typeof saved.accessToken !== 'string' || !saved.accessToken
|
||||
|| typeof saved.refreshToken !== 'string' || !saved.refreshToken
|
||||
|| typeof saved.expiresAt !== 'number' || !Number.isFinite(saved.expiresAt)
|
||||
|| !saved.user || typeof saved.user.id !== 'string' || !saved.user.id
|
||||
) return null
|
||||
return { ...saved, user: normalizeUser(saved.user) } as DngSession
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchUser(accessToken: string) {
|
||||
const user = await $fetch<DngAuthUser>(`${config.public.supabaseUrl}/auth/v1/user`, {
|
||||
headers: authHeaders(accessToken),
|
||||
})
|
||||
return normalizeUser(user)
|
||||
}
|
||||
|
||||
function fromResponse(response: AuthResponse): DngSession {
|
||||
return {
|
||||
accessToken: response.access_token,
|
||||
refreshToken: response.refresh_token,
|
||||
expiresAt: Date.now() + response.expires_in * 1000,
|
||||
user: normalizeUser(response.user),
|
||||
}
|
||||
}
|
||||
|
||||
async function refresh() {
|
||||
if (!session.value?.refreshToken) return null
|
||||
try {
|
||||
const response = await $fetch<AuthResponse>(`${config.public.supabaseUrl}/auth/v1/token?grant_type=refresh_token`, {
|
||||
method: 'POST',
|
||||
headers: authHeaders(),
|
||||
body: { refresh_token: session.value.refreshToken },
|
||||
})
|
||||
const next = fromResponse(response)
|
||||
persist(next)
|
||||
return next
|
||||
} catch {
|
||||
persist(null)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
async function restore() {
|
||||
if (!import.meta.client || hydrated.value) return session.value
|
||||
|
||||
let restoredFromStorage = false
|
||||
const hash = new URLSearchParams(window.location.hash.replace(/^#/, ''))
|
||||
const hashAccessToken = hash.get('access_token')
|
||||
const hashRefreshToken = hash.get('refresh_token')
|
||||
if (hashAccessToken && hashRefreshToken) {
|
||||
const expiresIn = Number(hash.get('expires_in') ?? 3600)
|
||||
const user = await fetchUser(hashAccessToken)
|
||||
persist({ accessToken: hashAccessToken, refreshToken: hashRefreshToken, expiresAt: Date.now() + expiresIn * 1000, user })
|
||||
history.replaceState(null, '', `${window.location.pathname}${window.location.search}`)
|
||||
} else {
|
||||
const saved = localStorage.getItem(STORAGE_KEY)
|
||||
if (saved) {
|
||||
const parsed = parseStoredSession(saved)
|
||||
if (parsed) {
|
||||
session.value = parsed
|
||||
restoredFromStorage = true
|
||||
} else {
|
||||
persist(null)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (session.value && session.value.expiresAt <= Date.now() + 60_000) {
|
||||
await refresh()
|
||||
} else if (session.value && restoredFromStorage) {
|
||||
try {
|
||||
const user = await fetchUser(session.value.accessToken)
|
||||
persist({ ...session.value, user })
|
||||
} catch {
|
||||
// A saved token may belong to an old Supabase project or have been
|
||||
// revoked before its local expiry. Refresh once, then discard it.
|
||||
await refresh()
|
||||
}
|
||||
}
|
||||
hydrated.value = true
|
||||
return session.value
|
||||
}
|
||||
|
||||
async function requestMagicLink(email: string) {
|
||||
const redirectTo = `${window.location.origin}/auth/callback`
|
||||
await $fetch(`${config.public.supabaseUrl}/auth/v1/otp?redirect_to=${encodeURIComponent(redirectTo)}`, {
|
||||
method: 'POST',
|
||||
headers: authHeaders(),
|
||||
body: { email: email.trim().toLowerCase(), create_user: true },
|
||||
})
|
||||
}
|
||||
|
||||
async function signInAnonymously() {
|
||||
const response = await $fetch<AuthResponse>(`${config.public.supabaseUrl}/auth/v1/signup`, {
|
||||
method: 'POST',
|
||||
headers: authHeaders(),
|
||||
body: {
|
||||
data: { display_name: 'Guest Adventurer' },
|
||||
gotrue_meta_security: {},
|
||||
},
|
||||
})
|
||||
const next = fromResponse(response)
|
||||
persist(next)
|
||||
hydrated.value = true
|
||||
return next
|
||||
}
|
||||
|
||||
async function accessToken() {
|
||||
await restore()
|
||||
if (!session.value) throw new Error('Enter the alpha to continue.')
|
||||
if (session.value.expiresAt <= Date.now() + 60_000) await refresh()
|
||||
if (!session.value) throw new Error('Your session expired. Sign in again.')
|
||||
return session.value.accessToken
|
||||
}
|
||||
|
||||
async function signOut() {
|
||||
const token = session.value?.accessToken
|
||||
persist(null)
|
||||
if (!token) return
|
||||
await $fetch(`${config.public.supabaseUrl}/auth/v1/logout`, {
|
||||
method: 'POST',
|
||||
headers: authHeaders(token),
|
||||
}).catch(() => undefined)
|
||||
}
|
||||
|
||||
function invalidate() {
|
||||
persist(null)
|
||||
hydrated.value = true
|
||||
}
|
||||
|
||||
return { session, hydrated, restore, refresh, requestMagicLink, signInAnonymously, accessToken, signOut, invalidate }
|
||||
}
|
||||
29
apps/web/pages/auth/callback.vue
Normal file
29
apps/web/pages/auth/callback.vue
Normal file
@@ -0,0 +1,29 @@
|
||||
<script setup lang="ts">
|
||||
const { restore } = useDngAuth()
|
||||
const error = ref('')
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const session = await restore()
|
||||
if (!session) throw new Error('The sign-in link is invalid or expired.')
|
||||
const requested = localStorage.getItem('dng-post-auth-redirect')
|
||||
localStorage.removeItem('dng-post-auth-redirect')
|
||||
const destination = requested?.startsWith('/') && !requested.startsWith('//') ? requested : '/dashboard'
|
||||
await navigateTo(destination, { replace: true })
|
||||
} catch (cause) {
|
||||
error.value = cause instanceof Error ? cause.message : 'Could not complete sign-in.'
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="callback noise">
|
||||
<AppMark />
|
||||
<p v-if="!error">VERIFYING YOUR SIGNAL…</p>
|
||||
<template v-else><p class="error">{{ error }}</p><NuxtLink to="/">REQUEST A NEW LINK</NuxtLink></template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.callback{min-height:100vh;display:grid;place-content:center;justify-items:center;gap:28px}.callback p{font:500 10px var(--mono);letter-spacing:.16em;color:var(--muted)}.callback .error{max-width:420px;color:#ff9d85;text-align:center}.callback a{color:var(--acid);font:600 9px var(--mono)}
|
||||
</style>
|
||||
292
apps/web/pages/campaign/[id].vue
Normal file
292
apps/web/pages/campaign/[id].vue
Normal file
File diff suppressed because one or more lines are too long
@@ -1,6 +1,33 @@
|
||||
<script setup lang="ts">
|
||||
const { worlds } = useDemo()
|
||||
interface CampaignRow {
|
||||
id: string
|
||||
title: string
|
||||
current_scene: string
|
||||
status: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
const { api } = useDngApi()
|
||||
const filter = ref<'all' | 'active' | 'drafts'>('all')
|
||||
const campaigns = ref<CampaignRow[]>([])
|
||||
const loading = ref(true)
|
||||
const error = ref('')
|
||||
|
||||
const visibleCampaigns = computed(() => filter.value === 'active'
|
||||
? campaigns.value.filter(campaign => campaign.status === 'active')
|
||||
: campaigns.value)
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const result = await api<{ campaigns: CampaignRow[] }>('/api/v1/campaigns')
|
||||
campaigns.value = result.campaigns
|
||||
} catch (cause) {
|
||||
const value = cause as { data?: { statusMessage?: string }; message?: string }
|
||||
error.value = value.data?.statusMessage ?? value.message ?? 'Could not load your campaigns.'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -13,13 +40,15 @@ const filter = ref<'all' | 'active' | 'drafts'>('all')
|
||||
|
||||
<div class="filter-row">
|
||||
<button v-for="item in ['all','active','drafts']" :key="item" :class="{active:filter===item}" @click="filter=item as typeof filter">{{ item }}</button>
|
||||
<span>1 / 5 ALPHA WORLDS</span>
|
||||
<span>{{ campaigns.length }} / 5 ALPHA CAMPAIGNS</span>
|
||||
</div>
|
||||
|
||||
<section class="world-grid">
|
||||
<NuxtLink v-for="world in worlds" :key="world.title" to="/campaign/demo" class="world-card active-world">
|
||||
<div class="world-art"><div class="eclipse" /><span>ACTIVE CAMPAIGN</span></div>
|
||||
<div class="world-info"><small>{{ world.genre }}</small><h2>{{ world.title }}</h2><p>{{ world.premise }}</p><div><b>ROUND 03</b><span>2 PARTY MEMBERS</span></div></div>
|
||||
<p v-if="loading" class="load-state">RECEIVING PRIVATE CAMPAIGNS…</p>
|
||||
<p v-else-if="error" class="load-state error">{{ error }} <NuxtLink to="/">SIGN IN AGAIN</NuxtLink></p>
|
||||
<NuxtLink v-for="campaign in visibleCampaigns" :key="campaign.id" :to="`/campaign/${campaign.id}`" class="world-card active-world">
|
||||
<div class="world-art"><div class="eclipse" /><span>{{ campaign.status.toUpperCase() }} CAMPAIGN</span></div>
|
||||
<div class="world-info"><small>PRIVATE MULTIPLAYER</small><h2>{{ campaign.title }}</h2><p>{{ campaign.current_scene }}</p><div><b>CONTINUE STORY</b><span>{{ new Date(campaign.updated_at).toLocaleDateString() }}</span></div></div>
|
||||
</NuxtLink>
|
||||
<NuxtLink to="/worlds/new" class="world-card new-card">
|
||||
<span class="plus">+</span><h2>MAKE THE NEXT<br>IMPOSSIBLE PLACE</h2><p>Begin with a sentence. The coauthor will ask the rest.</p>
|
||||
@@ -32,5 +61,5 @@ const filter = ref<'all' | 'active' | 'drafts'>('all')
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.dash-wrap{min-height:calc(100vh - 76px);padding:clamp(42px,6vw,86px) clamp(20px,6vw,88px)}.dash-head{display:flex;align-items:end;justify-content:space-between;gap:30px}.kicker{font:500 9px var(--mono);letter-spacing:.2em;color:var(--acid)}.dash-head h1{margin:14px 0 10px;font:600 clamp(44px,6vw,82px)/1 var(--display);letter-spacing:-.06em}.dash-head h1 span{color:var(--acid)}.dash-head p{color:var(--muted)}.create-button{display:flex;align-items:center;gap:18px;padding:18px 22px;background:var(--acid);color:#090909;text-decoration:none;font:600 10px var(--mono);letter-spacing:.12em}.create-button span{font-size:20px}.filter-row{display:flex;align-items:center;gap:10px;margin:58px 0 24px;border-bottom:1px solid var(--line)}.filter-row button{padding:0 4px 16px;background:none;border:0;color:var(--muted);font:500 9px var(--mono);text-transform:uppercase;letter-spacing:.14em;margin-right:18px}.filter-row button.active{color:var(--ink);border-bottom:2px solid var(--acid)}.filter-row>span{margin-left:auto;padding-bottom:16px;font:500 8px var(--mono);color:var(--muted)}.world-grid{display:grid;grid-template-columns:1.35fr .65fr;gap:18px}.world-card{min-height:420px;border:1px solid var(--line);color:var(--ink);text-decoration:none;background:#0e0e0d;transition:.25s ease}.world-card:hover{border-color:#65655e;transform:translateY(-3px)}.active-world{display:grid;grid-template-columns:.9fr 1.1fr}.world-art{position:relative;overflow:hidden;display:grid;place-items:center;background:radial-gradient(circle at 50% 60%,#7d8240 0 2%,#303018 4%,#0b0b0a 36%,#030303 72%)}.world-art:before{content:"";position:absolute;width:320px;height:320px;border:1px solid #38382d;border-radius:50%;box-shadow:0 0 0 34px #111,0 0 0 35px #26261d}.eclipse{position:absolute;width:126px;height:126px;border-radius:50%;background:#020202;box-shadow:0 0 50px var(--acid-dim)}.world-art span{position:absolute;left:20px;top:20px;padding:9px 11px;background:var(--acid);color:#0a0a0a;font:600 8px var(--mono);letter-spacing:.12em}.world-info{padding:42px;display:flex;flex-direction:column}.world-info small{font:500 8px var(--mono);letter-spacing:.14em;color:var(--acid);text-transform:uppercase}.world-info h2,.new-card h2{font:600 clamp(25px,3vw,42px)/1.05 var(--display);letter-spacing:-.05em;margin:22px 0}.world-info p,.new-card p{color:var(--muted);font-size:13px;line-height:1.7}.world-info div{margin-top:auto;padding-top:26px;border-top:1px solid var(--line);display:flex;justify-content:space-between;font:500 8px var(--mono);color:var(--muted)}.world-info b{color:var(--acid)}.new-card{padding:48px;display:flex;flex-direction:column;justify-content:flex-end;background:linear-gradient(145deg,#121211,#090909)}.new-card .plus{margin-bottom:auto;font:300 42px var(--body);color:var(--acid)}.system-strip{margin-top:32px;padding:18px 0;border-top:1px solid var(--line);display:flex;justify-content:space-between;font:500 8px var(--mono);letter-spacing:.12em;color:var(--muted)}.system-strip i{display:inline-block;width:6px;height:6px;background:var(--acid);border-radius:50%;margin-right:8px}.system-strip b{color:var(--acid)}@media(max-width:850px){.dash-head{align-items:start;flex-direction:column}.world-grid{grid-template-columns:1fr}.active-world{grid-template-columns:1fr}.world-art{min-height:280px}.system-strip{gap:16px;flex-wrap:wrap}}@media(max-width:520px){.filter-row>span{display:none}.world-info,.new-card{padding:28px}.active-world{min-height:580px}}
|
||||
.dash-wrap{min-height:calc(100vh - 76px);padding:clamp(42px,6vw,86px) clamp(20px,6vw,88px)}.dash-head{display:flex;align-items:end;justify-content:space-between;gap:30px}.kicker{font:500 9px var(--mono);letter-spacing:.2em;color:var(--acid)}.dash-head h1{margin:14px 0 10px;font:600 clamp(44px,6vw,82px)/1 var(--display);letter-spacing:-.06em}.dash-head h1 span{color:var(--acid)}.dash-head p{color:var(--muted)}.create-button{display:flex;align-items:center;gap:18px;padding:18px 22px;background:var(--acid);color:#090909;text-decoration:none;font:600 10px var(--mono);letter-spacing:.12em}.create-button span{font-size:20px}.filter-row{display:flex;align-items:center;gap:10px;margin:58px 0 24px;border-bottom:1px solid var(--line)}.filter-row button{padding:0 4px 16px;background:none;border:0;color:var(--muted);font:500 9px var(--mono);text-transform:uppercase;letter-spacing:.14em;margin-right:18px}.filter-row button.active{color:var(--ink);border-bottom:2px solid var(--acid)}.filter-row>span{margin-left:auto;padding-bottom:16px;font:500 8px var(--mono);color:var(--muted)}.world-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:18px}.load-state{grid-column:1/-1;padding:34px;border:1px solid var(--line);font:500 9px var(--mono);color:var(--muted)}.load-state.error{color:#ff9d85}.load-state a{color:var(--acid)}.world-card{min-height:420px;border:1px solid var(--line);color:var(--ink);text-decoration:none;background:#0e0e0d;transition:.25s ease}.world-card:hover{border-color:#65655e;transform:translateY(-3px)}.active-world{display:grid;grid-template-columns:.75fr 1.25fr}.world-art{position:relative;overflow:hidden;display:grid;place-items:center;background:radial-gradient(circle at 50% 60%,#7d8240 0 2%,#303018 4%,#0b0b0a 36%,#030303 72%)}.world-art:before{content:"";position:absolute;width:320px;height:320px;border:1px solid #38382d;border-radius:50%;box-shadow:0 0 0 34px #111,0 0 0 35px #26261d}.eclipse{position:absolute;width:126px;height:126px;border-radius:50%;background:#020202;box-shadow:0 0 50px var(--acid-dim)}.world-art span{position:absolute;left:20px;top:20px;padding:9px 11px;background:var(--acid);color:#0a0a0a;font:600 8px var(--mono);letter-spacing:.12em}.world-info{padding:42px;display:flex;flex-direction:column}.world-info small{font:500 8px var(--mono);letter-spacing:.14em;color:var(--acid);text-transform:uppercase}.world-info h2,.new-card h2{font:600 clamp(25px,3vw,42px)/1.05 var(--display);letter-spacing:-.05em;margin:22px 0}.world-info p,.new-card p{color:var(--muted);font-size:13px;line-height:1.7;display:-webkit-box;-webkit-line-clamp:5;-webkit-box-orient:vertical;overflow:hidden}.world-info div{margin-top:auto;padding-top:26px;border-top:1px solid var(--line);display:flex;justify-content:space-between;font:500 8px var(--mono);color:var(--muted)}.world-info b{color:var(--acid)}.new-card{padding:48px;display:flex;flex-direction:column;justify-content:flex-end;background:linear-gradient(145deg,#121211,#090909)}.new-card .plus{margin-bottom:auto;font:300 42px var(--body);color:var(--acid)}.system-strip{margin-top:32px;padding:18px 0;border-top:1px solid var(--line);display:flex;justify-content:space-between;font:500 8px var(--mono);letter-spacing:.12em;color:var(--muted)}.system-strip i{display:inline-block;width:6px;height:6px;background:var(--acid);border-radius:50%;margin-right:8px}.system-strip b{color:var(--acid)}@media(max-width:1050px){.world-grid{grid-template-columns:1fr}}@media(max-width:850px){.dash-head{align-items:start;flex-direction:column}.active-world{grid-template-columns:1fr}.world-art{min-height:280px}.system-strip{gap:16px;flex-wrap:wrap}}@media(max-width:520px){.filter-row>span{display:none}.world-info,.new-card{padding:28px}.active-world{min-height:580px}}
|
||||
</style>
|
||||
|
||||
@@ -1,15 +1,51 @@
|
||||
<script setup lang="ts">
|
||||
const { signedIn } = useDemo()
|
||||
const email = ref('founder@example.com')
|
||||
const { session, restore, requestMagicLink, signInAnonymously } = useDngAuth()
|
||||
const email = ref('')
|
||||
const notice = ref('')
|
||||
const guestError = ref('')
|
||||
const sending = ref(false)
|
||||
const entering = ref(false)
|
||||
|
||||
function enterDemo() {
|
||||
signedIn.value = true
|
||||
navigateTo('/dashboard')
|
||||
onMounted(() => void restore())
|
||||
|
||||
async function enterAlpha() {
|
||||
if (entering.value) return
|
||||
entering.value = true
|
||||
notice.value = ''
|
||||
guestError.value = ''
|
||||
try {
|
||||
await restore()
|
||||
if (!session.value) await signInAnonymously()
|
||||
const requested = localStorage.getItem('dng-post-auth-redirect')
|
||||
localStorage.removeItem('dng-post-auth-redirect')
|
||||
const destination = requested?.startsWith('/') && !requested.startsWith('//') ? requested : '/dashboard'
|
||||
await navigateTo(destination)
|
||||
} catch (cause) {
|
||||
const error = cause as { data?: { msg?: string; message?: string }; message?: string }
|
||||
const rawMessage = error.data?.msg ?? error.data?.message ?? error.message ?? ''
|
||||
guestError.value = /anonymous.*(disabled|sign.?in)/i.test(rawMessage)
|
||||
? 'Guest access is disabled in Supabase Auth settings.'
|
||||
: /database error|saving new user/i.test(rawMessage)
|
||||
? 'The Supabase database migration is incomplete. Apply bootstrap.sql.'
|
||||
: rawMessage || 'Guest access is unavailable right now.'
|
||||
} finally {
|
||||
entering.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function requestAccess() {
|
||||
notice.value = `Invite request saved for ${email.value}. Demo access is available now.`
|
||||
async function requestAccess() {
|
||||
if (sending.value) return
|
||||
sending.value = true
|
||||
notice.value = ''
|
||||
try {
|
||||
await requestMagicLink(email.value)
|
||||
notice.value = `Magic link sent to ${email.value}. Check your inbox.`
|
||||
} catch (cause) {
|
||||
const error = cause as { data?: { msg?: string; message?: string }; message?: string }
|
||||
notice.value = error.data?.msg ?? error.data?.message ?? error.message ?? 'This email is not on the alpha allowlist.'
|
||||
} finally {
|
||||
sending.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -17,7 +53,7 @@ function requestAccess() {
|
||||
<div class="landing noise">
|
||||
<header class="landing-nav">
|
||||
<AppMark />
|
||||
<span class="alpha-tag">INVITE-ONLY ALPHA</span>
|
||||
<span class="alpha-tag">PRIVATE ALPHA</span>
|
||||
</header>
|
||||
|
||||
<main class="hero">
|
||||
@@ -26,9 +62,10 @@ function requestAccess() {
|
||||
<h1>STORIES THAT<br><em>WAIT FOR NO ONE.</em></h1>
|
||||
<p class="lede">Build an original universe with an AI coauthor. Gather real players and persistent AI companions. Take your turn when life allows—the Groundkeeper remembers everything.</p>
|
||||
<div class="hero-actions">
|
||||
<button class="btn btn-acid" @click="enterDemo">ENTER THE ALPHA <span>↗</span></button>
|
||||
<button class="btn btn-acid" :disabled="entering" @click="enterAlpha">{{ entering ? 'OPENING…' : session ? 'OPEN DASHBOARD' : 'ENTER THE ALPHA' }} <span>↗</span></button>
|
||||
<a href="#how" class="btn btn-ghost">SEE HOW IT WORKS</a>
|
||||
</div>
|
||||
<p v-if="guestError" class="guest-error" role="alert">{{ guestError }}</p>
|
||||
<div class="trust-row">
|
||||
<span>PRIVATE WORLDS</span><i />
|
||||
<span>SERVER-OWNED DICE</span><i />
|
||||
@@ -51,11 +88,11 @@ function requestAccess() {
|
||||
<article><b>01</b><h2>DESCRIBE THE IMPOSSIBLE</h2><p>A coauthor asks sharp questions, then turns your idea into a playable private world.</p></article>
|
||||
<article><b>02</b><h2>ASSEMBLE YOUR PARTY</h2><p>Invite friends, add AI companions, and decide who can step in when someone is away.</p></article>
|
||||
<article><b>03</b><h2>ACT ON YOUR TIME</h2><p>The Groundkeeper resolves each shared round only when the party is ready.</p></article>
|
||||
<form @submit.prevent="requestAccess"><label for="email">REQUEST AN INVITE</label><div><input id="email" v-model="email" type="email" required><button>→</button></div><small>{{ notice || 'We only email about alpha access.' }}</small></form>
|
||||
<form @submit.prevent="requestAccess"><label for="email">OPTIONAL EMAIL SIGN-IN</label><div><input id="email" v-model="email" type="email" autocomplete="email" placeholder="you@example.com" required><button :disabled="sending">{{ sending ? '…' : '→' }}</button></div><small>{{ notice || 'No email is needed. Use this only for an invited email account.' }}</small></form>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.landing{min-height:100vh;overflow:hidden}.landing-nav{height:90px;padding:0 clamp(22px,6vw,90px);display:flex;justify-content:space-between;align-items:center;border-bottom:1px solid var(--line)}.alpha-tag{font:500 9px var(--mono);letter-spacing:.18em;border:1px solid var(--line);padding:10px 14px;color:var(--muted)}.hero{min-height:680px;padding:72px clamp(22px,6vw,90px) 60px;display:grid;grid-template-columns:minmax(0,1.15fr) minmax(360px,.85fr);gap:7vw;align-items:center}.eyebrow{font:500 10px var(--mono);letter-spacing:.22em;color:var(--muted)}.eyebrow span{display:inline-block;width:26px;height:1px;background:var(--acid);vertical-align:middle;margin-right:10px}.hero h1{margin:22px 0 28px;font:600 clamp(52px,6.6vw,108px)/.91 var(--display);letter-spacing:-.07em}.hero h1 em{font-style:normal;color:transparent;-webkit-text-stroke:1px var(--acid)}.lede{max-width:650px;font-size:clamp(16px,1.5vw,20px);line-height:1.7;color:#b9b9b2}.hero-actions{display:flex;gap:12px;margin-top:38px}.btn{min-height:52px;padding:0 22px;border:1px solid var(--line);font:600 10px var(--mono);letter-spacing:.13em;text-decoration:none;display:inline-flex;align-items:center;justify-content:center;gap:20px}.btn-acid{border-color:var(--acid);background:var(--acid);color:#080808}.btn-ghost{color:var(--ink);background:transparent}.trust-row{display:flex;align-items:center;gap:14px;margin-top:42px;font:500 8px var(--mono);letter-spacing:.16em;color:var(--muted)}.trust-row i{width:3px;height:3px;background:var(--acid);border-radius:50%}.signal-card{position:relative;min-height:510px;border:1px solid var(--line);background:linear-gradient(145deg,#111 0%,#090909 60%);box-shadow:0 40px 120px #000;padding:22px;clip-path:polygon(0 0,calc(100% - 20px) 0,100% 20px,100% 100%,20px 100%,0 calc(100% - 20px))}.signal-top,.party-status{display:flex;justify-content:space-between;font:500 8px var(--mono);letter-spacing:.13em;color:var(--muted)}.signal-top b{color:var(--acid)}.orbit{position:relative;width:245px;height:245px;margin:48px auto 30px;border:1px solid #2b2b29;border-radius:50%;display:grid;place-items:center}.orbit:before,.orbit:after{content:"";position:absolute;border:1px solid #262624;border-radius:50%;inset:28px}.orbit:after{inset:61px;border-color:var(--acid-dim)}.orbit i{position:absolute;width:7px;height:7px;border-radius:50%;background:var(--acid);box-shadow:0 0 18px var(--acid)}.orbit i:nth-child(1){top:23px;left:52px}.orbit i:nth-child(2){right:-3px;top:113px}.orbit i:nth-child(3){bottom:20px;left:85px}.orbit span{font:600 28px var(--display);color:var(--acid)}.transmission{border-left:2px solid var(--acid);padding:8px 18px;margin:0 12px 34px}.transmission small{font:500 8px var(--mono);letter-spacing:.18em;color:var(--acid)}.transmission p{font:500 15px/1.6 var(--body);color:#d6d6d0}.party-status{padding:18px 12px 0;border-top:1px solid var(--line)}.party-status b{color:var(--acid)}.how-grid{display:grid;grid-template-columns:repeat(3,1fr) 1.2fr;border-top:1px solid var(--line)}.how-grid article,.how-grid form{min-height:215px;padding:34px;border-right:1px solid var(--line)}.how-grid article>b{font:500 9px var(--mono);color:var(--acid)}.how-grid h2{margin:34px 0 12px;font:600 13px var(--display);letter-spacing:-.03em}.how-grid p,.how-grid small{font-size:12px;line-height:1.6;color:var(--muted)}.how-grid label{font:600 10px var(--mono);letter-spacing:.12em}.how-grid form div{display:flex;margin:35px 0 12px}.how-grid input{width:100%;background:#10100f;border:1px solid var(--line);padding:14px;color:var(--ink)}.how-grid form button{width:50px;border:0;background:var(--acid);font-size:20px}@media(max-width:950px){.hero{grid-template-columns:1fr}.signal-card{max-width:620px}.how-grid{grid-template-columns:1fr 1fr}}@media(max-width:620px){.alpha-tag{display:none}.hero{padding-top:50px}.hero h1{font-size:48px}.hero-actions{flex-direction:column}.trust-row{flex-wrap:wrap}.signal-card{min-height:470px}.how-grid{grid-template-columns:1fr}.how-grid article,.how-grid form{border-bottom:1px solid var(--line)}}
|
||||
.landing{min-height:100vh;overflow:hidden}.landing-nav{height:90px;padding:0 clamp(22px,6vw,90px);display:flex;justify-content:space-between;align-items:center;border-bottom:1px solid var(--line)}.alpha-tag{font:500 9px var(--mono);letter-spacing:.18em;border:1px solid var(--line);padding:10px 14px;color:var(--muted)}.hero{min-height:680px;padding:72px clamp(22px,6vw,90px) 60px;display:grid;grid-template-columns:minmax(0,1.15fr) minmax(360px,.85fr);gap:7vw;align-items:center}.eyebrow{font:500 10px var(--mono);letter-spacing:.22em;color:var(--muted)}.eyebrow span{display:inline-block;width:26px;height:1px;background:var(--acid);vertical-align:middle;margin-right:10px}.hero h1{margin:22px 0 28px;font:600 clamp(52px,6.6vw,108px)/.91 var(--display);letter-spacing:-.07em}.hero h1 em{font-style:normal;color:transparent;-webkit-text-stroke:1px var(--acid)}.lede{max-width:650px;font-size:clamp(16px,1.5vw,20px);line-height:1.7;color:#b9b9b2}.hero-actions{display:flex;gap:12px;margin-top:38px}.guest-error{max-width:620px;margin:14px 0 0;color:#ff9d85;font:500 10px/1.5 var(--mono);letter-spacing:.04em}.btn{min-height:52px;padding:0 22px;border:1px solid var(--line);font:600 10px var(--mono);letter-spacing:.13em;text-decoration:none;display:inline-flex;align-items:center;justify-content:center;gap:20px}.btn:disabled{opacity:.7;cursor:wait}.btn-acid{border-color:var(--acid);background:var(--acid);color:#080808}.btn-ghost{color:var(--ink);background:transparent}.trust-row{display:flex;align-items:center;gap:14px;margin-top:42px;font:500 8px var(--mono);letter-spacing:.16em;color:var(--muted)}.trust-row i{width:3px;height:3px;background:var(--acid);border-radius:50%}.signal-card{position:relative;min-height:510px;border:1px solid var(--line);background:linear-gradient(145deg,#111 0%,#090909 60%);box-shadow:0 40px 120px #000;padding:22px;clip-path:polygon(0 0,calc(100% - 20px) 0,100% 20px,100% 100%,20px 100%,0 calc(100% - 20px))}.signal-top,.party-status{display:flex;justify-content:space-between;font:500 8px var(--mono);letter-spacing:.13em;color:var(--muted)}.signal-top b{color:var(--acid)}.orbit{position:relative;width:245px;height:245px;margin:48px auto 30px;border:1px solid #2b2b29;border-radius:50%;display:grid;place-items:center}.orbit:before,.orbit:after{content:"";position:absolute;border:1px solid #262624;border-radius:50%;inset:28px}.orbit:after{inset:61px;border-color:var(--acid-dim)}.orbit i{position:absolute;width:7px;height:7px;border-radius:50%;background:var(--acid);box-shadow:0 0 18px var(--acid)}.orbit i:nth-child(1){top:23px;left:52px}.orbit i:nth-child(2){right:-3px;top:113px}.orbit i:nth-child(3){bottom:20px;left:85px}.orbit span{font:600 28px var(--display);color:var(--acid)}.transmission{border-left:2px solid var(--acid);padding:8px 18px;margin:0 12px 34px}.transmission small{font:500 8px var(--mono);letter-spacing:.18em;color:var(--acid)}.transmission p{font:500 15px/1.6 var(--body);color:#d6d6d0}.party-status{padding:18px 12px 0;border-top:1px solid var(--line)}.party-status b{color:var(--acid)}.how-grid{display:grid;grid-template-columns:repeat(3,1fr) 1.2fr;border-top:1px solid var(--line)}.how-grid article,.how-grid form{min-height:215px;padding:34px;border-right:1px solid var(--line)}.how-grid article>b{font:500 9px var(--mono);color:var(--acid)}.how-grid h2{margin:34px 0 12px;font:600 13px var(--display);letter-spacing:-.03em}.how-grid p,.how-grid small{font-size:12px;line-height:1.6;color:var(--muted)}.how-grid label{font:600 10px var(--mono);letter-spacing:.12em}.how-grid form div{display:flex;margin:35px 0 12px}.how-grid input{width:100%;background:#10100f;border:1px solid var(--line);padding:14px;color:var(--ink)}.how-grid form button{width:50px;border:0;background:var(--acid);font-size:20px}@media(max-width:950px){.hero{grid-template-columns:1fr}.signal-card{max-width:620px}.how-grid{grid-template-columns:1fr 1fr}}@media(max-width:620px){.alpha-tag{display:none}.hero{padding-top:50px}.hero h1{font-size:48px}.hero-actions{flex-direction:column}.trust-row{flex-wrap:wrap}.signal-card{min-height:470px}.how-grid{grid-template-columns:1fr}.how-grid article,.how-grid form{border-bottom:1px solid var(--line)}}
|
||||
</style>
|
||||
|
||||
38
apps/web/pages/join/[token].vue
Normal file
38
apps/web/pages/join/[token].vue
Normal file
@@ -0,0 +1,38 @@
|
||||
<script setup lang="ts">
|
||||
const route = useRoute()
|
||||
const { session, restore } = useDngAuth()
|
||||
const { api } = useDngApi()
|
||||
const state = ref<'joining' | 'signin' | 'error'>('joining')
|
||||
const message = ref('')
|
||||
|
||||
onMounted(async () => {
|
||||
const token = String(route.params.token ?? '')
|
||||
await restore()
|
||||
if (!session.value) {
|
||||
localStorage.setItem('dng-post-auth-redirect', route.fullPath)
|
||||
state.value = 'signin'
|
||||
return
|
||||
}
|
||||
try {
|
||||
const result = await api<{ campaignId: string }>('/api/v1/invites/join', { method: 'POST', body: { token } })
|
||||
await navigateTo(`/campaign/${result.campaignId}`, { replace: true })
|
||||
} catch (cause) {
|
||||
const error = cause as { data?: { statusMessage?: string }; message?: string }
|
||||
message.value = error.data?.statusMessage ?? error.message ?? 'This invite is invalid or expired.'
|
||||
state.value = 'error'
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="join noise">
|
||||
<AppMark />
|
||||
<div v-if="state === 'joining'" class="card"><small>PARTY INVITE</small><h1>JOINING CAMPAIGN…</h1><p>Verifying your seat at the table.</p></div>
|
||||
<div v-else-if="state === 'signin'" class="card"><small>PARTY INVITE</small><h1>ENTER THE ALPHA FIRST</h1><p>Your invite is saved. Guest access needs no email; after entering, this campaign will open automatically.</p><NuxtLink to="/">ENTER THE ALPHA →</NuxtLink></div>
|
||||
<div v-else class="card"><small>INVITE ERROR</small><h1>THE DOOR STAYED SHUT</h1><p>{{ message }}</p><NuxtLink to="/dashboard">BACK TO DASHBOARD</NuxtLink></div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.join{min-height:100vh;display:grid;place-content:center;justify-items:center;gap:30px;padding:24px}.card{width:min(560px,calc(100vw - 40px));padding:42px;border:1px solid var(--line);background:#0d0d0c}.card small{font:600 8px var(--mono);letter-spacing:.16em;color:var(--acid)}.card h1{font:600 clamp(26px,5vw,45px)/1.05 var(--display);letter-spacing:-.05em}.card p{color:var(--muted);line-height:1.7}.card a{display:inline-flex;margin-top:20px;padding:15px 18px;background:var(--acid);color:#080808;text-decoration:none;font:600 9px var(--mono)}
|
||||
</style>
|
||||
@@ -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. I’ll 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', 'Let’s reshape it from the opening sentence. I’ll 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. I’ll 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>
|
||||
|
||||
56
apps/web/server/api/v1/campaigns/[id].get.ts
Normal file
56
apps/web/server/api/v1/campaigns/[id].get.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import { requireCampaignAccess, requireStageTwoUser, stageTwoApiError, stageTwoDatabase, stageTwoUuid } from '~/server/utils/stage-two-supabase'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
try {
|
||||
const user = await requireStageTwoUser(event)
|
||||
const campaignId = stageTwoUuid(getRouterParam(event, 'id'), 'campaign id')
|
||||
const access = await requireCampaignAccess(campaignId, user.id)
|
||||
const campaign = access.campaign
|
||||
const worldId = String(campaign.world_id)
|
||||
const [worlds, members, characters, openRounds, rounds] = await Promise.all([
|
||||
stageTwoDatabase<Array<Record<string, unknown>>>(
|
||||
`worlds?select=id,title,genre,tone,premise,content_boundaries,hook,opening_scene&id=eq.${worldId}&limit=1`,
|
||||
),
|
||||
stageTwoDatabase<Array<Record<string, unknown>>>(
|
||||
`campaign_members?select=id,user_id,role,active,ai_takeover_allowed,joined_at&campaign_id=eq.${campaignId}&order=joined_at.asc`,
|
||||
),
|
||||
stageTwoDatabase<Array<Record<string, unknown>>>(
|
||||
`characters?select=id,campaign_id,user_id,name,concept,controller,abilities,hp,max_hp,defense,proficiency,inventory,statuses,persona,created_at&campaign_id=eq.${campaignId}&order=created_at.asc`,
|
||||
),
|
||||
stageTwoDatabase<Array<Record<string, unknown>>>(
|
||||
`rounds?select=id,campaign_id,number,status,forced_by,narration,next_prompt,queued_at,resolved_at,error,created_at&campaign_id=eq.${campaignId}&status=eq.open&order=number.desc&limit=1`,
|
||||
),
|
||||
stageTwoDatabase<Array<Record<string, unknown>>>(
|
||||
`rounds?select=id,campaign_id,number,status,forced_by,narration,next_prompt,queued_at,resolved_at,error,created_at&campaign_id=eq.${campaignId}&order=number.desc&limit=30`,
|
||||
),
|
||||
])
|
||||
const fallbackRounds = openRounds.length ? [] : await stageTwoDatabase<Array<Record<string, unknown>>>(
|
||||
`rounds?select=id,campaign_id,number,status,forced_by,narration,next_prompt,queued_at,resolved_at,error,created_at&campaign_id=eq.${campaignId}&order=number.desc&limit=1`,
|
||||
)
|
||||
const round = openRounds[0] ?? fallbackRounds[0] ?? null
|
||||
const userIds = [...new Set(members.map(member => String(member.user_id)))]
|
||||
const profiles = userIds.length
|
||||
? await stageTwoDatabase<Array<{ id: string; display_name: string }>>(
|
||||
`profiles?select=id,display_name&id=in.(${userIds.join(',')})`,
|
||||
)
|
||||
: []
|
||||
const profileById = new Map(profiles.map(profile => [profile.id, profile]))
|
||||
const visibleMembers = members.map(member => ({ ...member, profile: profileById.get(String(member.user_id)) ?? null }))
|
||||
const visibleRounds = rounds.slice().reverse()
|
||||
const visibleRoundIds = visibleRounds.map(item => String(item.id))
|
||||
const [intents, events, diceRolls] = await Promise.all([
|
||||
round ? stageTwoDatabase<Array<Record<string, unknown>>>(
|
||||
`player_intents?select=id,round_id,member_id,character_id,action,ready,created_at,updated_at&round_id=eq.${String(round.id)}&order=created_at.asc`,
|
||||
) : Promise.resolve([]),
|
||||
stageTwoDatabase<Array<Record<string, unknown>>>(
|
||||
`game_events?select=id,campaign_id,round_id,event_type,payload,created_at&campaign_id=eq.${campaignId}&order=created_at.desc&limit=50`,
|
||||
),
|
||||
visibleRoundIds.length ? stageTwoDatabase<Array<Record<string, unknown>>>(
|
||||
`dice_rolls?select=id,round_id,actor_id,target_id,check_kind,formula,rolls,kept,modifier,total,difficulty,success,created_at&round_id=in.(${visibleRoundIds.join(',')})&order=created_at.asc`,
|
||||
) : Promise.resolve([]),
|
||||
])
|
||||
return { campaign, world: worlds[0] ?? null, members: visibleMembers, characters, round, rounds: visibleRounds, intents, events: events.slice().reverse(), diceRolls }
|
||||
} catch (error) {
|
||||
stageTwoApiError(error)
|
||||
}
|
||||
})
|
||||
53
apps/web/server/api/v1/campaigns/[id]/characters.post.ts
Normal file
53
apps/web/server/api/v1/campaigns/[id]/characters.post.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import { AbilityScoresSchema } from '@dng/shared'
|
||||
import { z } from 'zod'
|
||||
import { requireCampaignAccess, requireStageTwoSafeText, requireStageTwoUser, stageTwoApiError, stageTwoDatabase, stageTwoUuid } from '~/server/utils/stage-two-supabase'
|
||||
|
||||
const BodySchema = z.object({
|
||||
name: z.string().trim().min(1).max(80),
|
||||
concept: z.string().trim().min(3).max(600),
|
||||
controller: z.enum(['human', 'ai']).default('human'),
|
||||
abilities: AbilityScoresSchema,
|
||||
hp: z.number().int().min(0),
|
||||
maxHp: z.number().int().min(1),
|
||||
defense: z.number().int().min(1).max(40),
|
||||
proficiency: z.number().int().min(1).max(10).default(2),
|
||||
inventory: z.array(z.string().trim().min(1).max(120)).max(50).default([]),
|
||||
statuses: z.array(z.string().trim().min(1).max(80)).max(12).default([]),
|
||||
persona: z.record(z.string(), z.unknown()).default({}),
|
||||
}).strict().refine(value => value.hp <= value.maxHp, { message: 'hp must not exceed maxHp', path: ['hp'] })
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
try {
|
||||
const user = await requireStageTwoUser(event)
|
||||
const campaignId = stageTwoUuid(getRouterParam(event, 'id'), 'campaign id')
|
||||
const access = await requireCampaignAccess(campaignId, user.id)
|
||||
const body = BodySchema.parse(await readBody(event))
|
||||
if (body.controller === 'ai' && !access.owner) {
|
||||
throw createError({ statusCode: 403, statusMessage: 'Only the campaign owner can add AI heroes.' })
|
||||
}
|
||||
requireStageTwoSafeText([body.name, body.concept, ...body.inventory, ...body.statuses, JSON.stringify(body.persona)].join('\n'))
|
||||
const rows = await stageTwoDatabase<Array<Record<string, unknown>>>('characters', {
|
||||
method: 'POST',
|
||||
prefer: 'return=representation',
|
||||
body: JSON.stringify({
|
||||
campaign_id: campaignId,
|
||||
user_id: body.controller === 'human' ? user.id : null,
|
||||
name: body.name,
|
||||
concept: body.concept,
|
||||
controller: body.controller,
|
||||
abilities: body.abilities,
|
||||
hp: body.hp,
|
||||
max_hp: body.maxHp,
|
||||
defense: body.defense,
|
||||
proficiency: body.proficiency,
|
||||
inventory: body.inventory,
|
||||
statuses: body.statuses,
|
||||
persona: body.persona,
|
||||
}),
|
||||
})
|
||||
setResponseStatus(event, 201)
|
||||
return { character: rows[0] }
|
||||
} catch (error) {
|
||||
stageTwoApiError(error)
|
||||
}
|
||||
})
|
||||
32
apps/web/server/api/v1/campaigns/[id]/invites.post.ts
Normal file
32
apps/web/server/api/v1/campaigns/[id]/invites.post.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import { createHash, randomBytes } from 'node:crypto'
|
||||
import { z } from 'zod'
|
||||
import { requireCampaignAccess, requireStageTwoUser, stageTwoApiError, stageTwoDatabase, stageTwoUuid } from '~/server/utils/stage-two-supabase'
|
||||
|
||||
const BodySchema = z.object({
|
||||
maxUses: z.number().int().min(1).max(20).default(1),
|
||||
expiresInHours: z.number().int().min(1).max(720).default(72),
|
||||
}).strict()
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
try {
|
||||
const user = await requireStageTwoUser(event)
|
||||
const campaignId = stageTwoUuid(getRouterParam(event, 'id'), 'campaign id')
|
||||
await requireCampaignAccess(campaignId, user.id, true)
|
||||
const body = BodySchema.parse((await readBody(event)) ?? {})
|
||||
const token = randomBytes(32).toString('base64url')
|
||||
const tokenHash = createHash('sha256').update(token).digest('hex')
|
||||
const expiresAt = new Date(Date.now() + body.expiresInHours * 3_600_000).toISOString()
|
||||
await stageTwoDatabase('invites', {
|
||||
method: 'POST',
|
||||
prefer: 'return=minimal',
|
||||
body: JSON.stringify({
|
||||
campaign_id: campaignId, created_by: user.id, token_hash: tokenHash,
|
||||
max_uses: body.maxUses, expires_at: expiresAt,
|
||||
}),
|
||||
})
|
||||
setResponseStatus(event, 201)
|
||||
return { token, expiresAt, maxUses: body.maxUses }
|
||||
} catch (error) {
|
||||
stageTwoApiError(error)
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,26 @@
|
||||
import { z } from 'zod'
|
||||
import { requireCampaignAccess, requireStageTwoUser, stageTwoApiError, stageTwoDatabase, stageTwoUuid } from '~/server/utils/stage-two-supabase'
|
||||
|
||||
const BodySchema = z.object({ aiTakeoverAllowed: z.boolean() }).strict()
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
try {
|
||||
const user = await requireStageTwoUser(event)
|
||||
const campaignId = stageTwoUuid(getRouterParam(event, 'id'), 'campaign id')
|
||||
const memberId = stageTwoUuid(getRouterParam(event, 'memberId'), 'member id')
|
||||
await requireCampaignAccess(campaignId, user.id, true)
|
||||
const body = BodySchema.parse(await readBody(event))
|
||||
const members = await stageTwoDatabase<Array<Record<string, unknown>>>(
|
||||
`campaign_members?id=eq.${memberId}&campaign_id=eq.${campaignId}`,
|
||||
{
|
||||
method: 'PATCH',
|
||||
prefer: 'return=representation',
|
||||
body: JSON.stringify({ ai_takeover_allowed: body.aiTakeoverAllowed }),
|
||||
},
|
||||
)
|
||||
if (!members[0]) throw createError({ statusCode: 404, statusMessage: 'Campaign member not found.' })
|
||||
return { member: members[0] }
|
||||
} catch (error) {
|
||||
stageTwoApiError(error)
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,22 @@
|
||||
import { requireCampaignAccess, requireStageTwoUser, stageTwoApiError, stageTwoDatabase, stageTwoRpc, stageTwoUuid } from '~/server/utils/stage-two-supabase'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
try {
|
||||
const user = await requireStageTwoUser(event)
|
||||
const campaignId = stageTwoUuid(getRouterParam(event, 'id'), 'campaign id')
|
||||
const roundId = stageTwoUuid(getRouterParam(event, 'roundId'), 'round id')
|
||||
await requireCampaignAccess(campaignId, user.id, true)
|
||||
const rounds = await stageTwoDatabase<Array<{ id: string; status: string }>>(
|
||||
`rounds?select=id,status&id=eq.${roundId}&campaign_id=eq.${campaignId}&limit=1`,
|
||||
)
|
||||
if (!rounds[0]) throw createError({ statusCode: 404, statusMessage: 'Round not found.' })
|
||||
if (rounds[0].status !== 'open') throw createError({ statusCode: 409, statusMessage: 'Round is not open.' })
|
||||
const jobId = await stageTwoRpc<string>('stage_two_force_round', {
|
||||
p_round_id: roundId, p_owner_id: user.id,
|
||||
})
|
||||
setResponseStatus(event, 202)
|
||||
return { jobId, status: 'queued' }
|
||||
} catch (error) {
|
||||
stageTwoApiError(error)
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,34 @@
|
||||
import { z } from 'zod'
|
||||
import { requireCampaignAccess, requireStageTwoSafeText, requireStageTwoUser, stageTwoApiError, stageTwoDatabase, stageTwoRpc, stageTwoUuid } from '~/server/utils/stage-two-supabase'
|
||||
|
||||
const BodySchema = z.object({
|
||||
characterId: z.string().uuid(),
|
||||
action: z.string().trim().min(1).max(2000),
|
||||
ready: z.boolean().default(false),
|
||||
}).strict()
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
try {
|
||||
const user = await requireStageTwoUser(event)
|
||||
const campaignId = stageTwoUuid(getRouterParam(event, 'id'), 'campaign id')
|
||||
const roundId = stageTwoUuid(getRouterParam(event, 'roundId'), 'round id')
|
||||
await requireCampaignAccess(campaignId, user.id)
|
||||
const rounds = await stageTwoDatabase<Array<{ id: string; status: string }>>(
|
||||
`rounds?select=id,status&id=eq.${roundId}&campaign_id=eq.${campaignId}&limit=1`,
|
||||
)
|
||||
if (!rounds[0]) throw createError({ statusCode: 404, statusMessage: 'Round not found.' })
|
||||
if (rounds[0].status !== 'open') throw createError({ statusCode: 409, statusMessage: 'Round is not open.' })
|
||||
const body = BodySchema.parse(await readBody(event))
|
||||
requireStageTwoSafeText(body.action)
|
||||
const result = await stageTwoRpc<Record<string, unknown>>('stage_two_submit_intent', {
|
||||
p_round_id: roundId,
|
||||
p_user_id: user.id,
|
||||
p_character_id: body.characterId,
|
||||
p_action: body.action,
|
||||
p_ready: body.ready,
|
||||
})
|
||||
return result
|
||||
} catch (error) {
|
||||
stageTwoApiError(error)
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,36 @@
|
||||
import { z } from 'zod'
|
||||
import { requireCampaignAccess, requireStageTwoUser, stageTwoApiError, stageTwoDatabase, stageTwoRpc, stageTwoUuid } from '~/server/utils/stage-two-supabase'
|
||||
|
||||
const BodySchema = z.object({ ready: z.boolean().default(true) }).strict()
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
try {
|
||||
const user = await requireStageTwoUser(event)
|
||||
const campaignId = stageTwoUuid(getRouterParam(event, 'id'), 'campaign id')
|
||||
const roundId = stageTwoUuid(getRouterParam(event, 'roundId'), 'round id')
|
||||
await requireCampaignAccess(campaignId, user.id)
|
||||
const body = BodySchema.parse((await readBody(event)) ?? {})
|
||||
const rounds = await stageTwoDatabase<Array<{ id: string; status: string }>>(
|
||||
`rounds?select=id,status&id=eq.${roundId}&campaign_id=eq.${campaignId}&limit=1`,
|
||||
)
|
||||
if (!rounds[0]) throw createError({ statusCode: 404, statusMessage: 'Round not found.' })
|
||||
if (rounds[0].status !== 'open') throw createError({ statusCode: 409, statusMessage: 'Round is not open.' })
|
||||
const members = await stageTwoDatabase<Array<{ id: string }>>(
|
||||
`campaign_members?select=id&campaign_id=eq.${campaignId}&user_id=eq.${user.id}&active=eq.true&limit=1`,
|
||||
)
|
||||
if (!members[0]) throw createError({ statusCode: 403, statusMessage: 'Active campaign membership not found.' })
|
||||
const intents = await stageTwoDatabase<Array<{ character_id: string; action: string }>>(
|
||||
`player_intents?select=character_id,action&round_id=eq.${roundId}&member_id=eq.${members[0].id}&limit=1`,
|
||||
)
|
||||
if (!intents[0]) throw createError({ statusCode: 409, statusMessage: 'Submit an action before marking ready.' })
|
||||
return await stageTwoRpc<Record<string, unknown>>('stage_two_submit_intent', {
|
||||
p_round_id: roundId,
|
||||
p_user_id: user.id,
|
||||
p_character_id: intents[0].character_id,
|
||||
p_action: intents[0].action,
|
||||
p_ready: body.ready,
|
||||
})
|
||||
} catch (error) {
|
||||
stageTwoApiError(error)
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,23 @@
|
||||
import { requireCampaignAccess, requireStageTwoUser, stageTwoApiError, stageTwoDatabase, stageTwoRpc, stageTwoUuid } from '~/server/utils/stage-two-supabase'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
try {
|
||||
const user = await requireStageTwoUser(event)
|
||||
const campaignId = stageTwoUuid(getRouterParam(event, 'id'), 'campaign id')
|
||||
const roundId = stageTwoUuid(getRouterParam(event, 'roundId'), 'round id')
|
||||
await requireCampaignAccess(campaignId, user.id, true)
|
||||
const rounds = await stageTwoDatabase<Array<{ id: string; status: string }>>(
|
||||
`rounds?select=id,status&id=eq.${roundId}&campaign_id=eq.${campaignId}&limit=1`,
|
||||
)
|
||||
if (!rounds[0]) throw createError({ statusCode: 404, statusMessage: 'Round not found.' })
|
||||
if (rounds[0].status !== 'failed') throw createError({ statusCode: 409, statusMessage: 'Only a failed round can be retried.' })
|
||||
const jobId = await stageTwoRpc<string>('stage_two_retry_failed_round', {
|
||||
p_round_id: roundId,
|
||||
p_owner_id: user.id,
|
||||
})
|
||||
setResponseStatus(event, 202)
|
||||
return { jobId, status: 'queued' }
|
||||
} catch (error) {
|
||||
stageTwoApiError(error)
|
||||
}
|
||||
})
|
||||
17
apps/web/server/api/v1/campaigns/index.get.ts
Normal file
17
apps/web/server/api/v1/campaigns/index.get.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { requireStageTwoUser, stageTwoApiError, stageTwoDatabase } from '~/server/utils/stage-two-supabase'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
try {
|
||||
const user = await requireStageTwoUser(event)
|
||||
const memberships = await stageTwoDatabase<Array<{ campaign_id: string }>>(
|
||||
`campaign_members?select=campaign_id&user_id=eq.${user.id}&active=eq.true`,
|
||||
)
|
||||
const filters = [`owner_id.eq.${user.id}`, ...memberships.map(item => `id.eq.${item.campaign_id}`)]
|
||||
const campaigns = await stageTwoDatabase<Array<Record<string, unknown>>>(
|
||||
`campaigns?select=id,world_id,owner_id,title,current_scene,next_prompt,status,created_at,updated_at&or=(${filters.join(',')})&order=updated_at.desc`,
|
||||
)
|
||||
return { campaigns }
|
||||
} catch (error) {
|
||||
stageTwoApiError(error)
|
||||
}
|
||||
})
|
||||
19
apps/web/server/api/v1/campaigns/index.post.ts
Normal file
19
apps/web/server/api/v1/campaigns/index.post.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { z } from 'zod'
|
||||
import { requireStageTwoSafeText, requireStageTwoUser, stageTwoApiError, stageTwoRpc, stageTwoUuid } from '~/server/utils/stage-two-supabase'
|
||||
|
||||
const BodySchema = z.object({ worldId: z.string().uuid(), title: z.string().trim().min(3).max(100) }).strict()
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
try {
|
||||
const user = await requireStageTwoUser(event)
|
||||
const body = BodySchema.parse(await readBody(event))
|
||||
requireStageTwoSafeText(body.title)
|
||||
const campaignId = await stageTwoRpc<string>('stage_two_create_campaign', {
|
||||
p_world_id: stageTwoUuid(body.worldId, 'world id'), p_owner_id: user.id, p_title: body.title,
|
||||
})
|
||||
setResponseStatus(event, 201)
|
||||
return { campaignId }
|
||||
} catch (error) {
|
||||
stageTwoApiError(error)
|
||||
}
|
||||
})
|
||||
15
apps/web/server/api/v1/coauthor/sessions/[id].get.ts
Normal file
15
apps/web/server/api/v1/coauthor/sessions/[id].get.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { requireStageTwoUser, stageTwoApiError, stageTwoDatabase, stageTwoUuid } from '~/server/utils/stage-two-supabase'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
try {
|
||||
const user = await requireStageTwoUser(event)
|
||||
const id = stageTwoUuid(getRouterParam(event, 'id'))
|
||||
const sessions = await stageTwoDatabase<Array<Record<string, unknown>>>(
|
||||
`coauthor_sessions?select=id,status,messages,generated_world,confirmed_world_id,created_at,updated_at&id=eq.${id}&owner_id=eq.${user.id}&limit=1`,
|
||||
)
|
||||
if (!sessions[0]) throw createError({ statusCode: 404, statusMessage: 'Coauthor session not found.' })
|
||||
return { session: { ...sessions[0], generatedWorld: sessions[0].generated_world ?? null } }
|
||||
} catch (error) {
|
||||
stageTwoApiError(error)
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,30 @@
|
||||
import { WorldStarterSchema } from '@dng/shared'
|
||||
import { z } from 'zod'
|
||||
import { requireStageTwoSafeText, requireStageTwoUser, stageTwoApiError, stageTwoDatabase, stageTwoRpc, stageTwoUuid } from '~/server/utils/stage-two-supabase'
|
||||
|
||||
const BodySchema = z.object({ world: WorldStarterSchema.optional() }).strict()
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
try {
|
||||
const user = await requireStageTwoUser(event)
|
||||
const sessionId = stageTwoUuid(getRouterParam(event, 'id'), 'session id')
|
||||
const body = BodySchema.parse((await readBody(event)) ?? {})
|
||||
const sessions = await stageTwoDatabase<Array<{ generated_world: unknown }>>(
|
||||
`coauthor_sessions?select=generated_world&id=eq.${sessionId}&owner_id=eq.${user.id}&limit=1`,
|
||||
)
|
||||
if (!sessions[0]) throw createError({ statusCode: 404, statusMessage: 'Coauthor session not found.' })
|
||||
const world = WorldStarterSchema.parse(body.world ?? sessions[0].generated_world)
|
||||
requireStageTwoSafeText(JSON.stringify(world))
|
||||
if (body.world) {
|
||||
await stageTwoDatabase(`coauthor_sessions?id=eq.${sessionId}&owner_id=eq.${user.id}`, {
|
||||
method: 'PATCH', prefer: 'return=minimal', body: JSON.stringify({ generated_world: world, updated_at: new Date().toISOString() }),
|
||||
})
|
||||
}
|
||||
const worldId = await stageTwoRpc<string>('stage_two_confirm_world', {
|
||||
p_session_id: sessionId, p_owner_id: user.id,
|
||||
})
|
||||
return { worldId }
|
||||
} catch (error) {
|
||||
stageTwoApiError(error)
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,15 @@
|
||||
import { requireStageTwoUser, stageTwoApiError, stageTwoRpc, stageTwoUuid } from '~/server/utils/stage-two-supabase'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
try {
|
||||
const user = await requireStageTwoUser(event)
|
||||
const sessionId = stageTwoUuid(getRouterParam(event, 'id'), 'session id')
|
||||
const jobId = await stageTwoRpc<string>('stage_two_enqueue_world_generation', {
|
||||
p_session_id: sessionId, p_owner_id: user.id,
|
||||
})
|
||||
setResponseStatus(event, 202)
|
||||
return { jobId, status: 'queued' }
|
||||
} catch (error) {
|
||||
stageTwoApiError(error)
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,19 @@
|
||||
import { z } from 'zod'
|
||||
import { requireStageTwoSafeText, requireStageTwoUser, stageTwoApiError, stageTwoRpc, stageTwoUuid } from '~/server/utils/stage-two-supabase'
|
||||
|
||||
const BodySchema = z.object({ content: z.string().trim().min(1).max(5000) }).strict()
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
try {
|
||||
const user = await requireStageTwoUser(event)
|
||||
const sessionId = stageTwoUuid(getRouterParam(event, 'id'), 'session id')
|
||||
const body = BodySchema.parse(await readBody(event))
|
||||
requireStageTwoSafeText(body.content)
|
||||
const session = await stageTwoRpc<Record<string, unknown>>('stage_two_append_coauthor_message', {
|
||||
p_session_id: sessionId, p_owner_id: user.id, p_content: body.content,
|
||||
})
|
||||
return { session }
|
||||
} catch (error) {
|
||||
stageTwoApiError(error)
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,54 @@
|
||||
import { z } from 'zod'
|
||||
import { buildJsonCompletion, parseJsonCompletion } from '~/server/utils/ai-provider'
|
||||
import { requireStageTwoSafeText, requireStageTwoUser, stageTwoApiError, stageTwoDatabase, stageTwoRpc, stageTwoUuid } from '~/server/utils/stage-two-supabase'
|
||||
|
||||
const MessageSchema = z.object({ role: z.enum(['user', 'assistant']), content: z.string().min(1).max(5000) })
|
||||
const QuestionSchema = z.object({
|
||||
question: z.string().trim().min(5).max(300),
|
||||
options: z.array(z.string().trim().min(1).max(100)).length(3),
|
||||
})
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
try {
|
||||
const user = await requireStageTwoUser(event)
|
||||
const sessionId = stageTwoUuid(getRouterParam(event, 'id'), 'session id')
|
||||
const sessions = await stageTwoDatabase<Array<{ status: string; messages: unknown }>>(
|
||||
`coauthor_sessions?select=status,messages&id=eq.${sessionId}&owner_id=eq.${user.id}&limit=1`,
|
||||
)
|
||||
const session = sessions[0]
|
||||
if (!session) throw createError({ statusCode: 404, statusMessage: 'Coauthor session not found.' })
|
||||
if (session.status !== 'collecting') throw createError({ statusCode: 409, statusMessage: 'This coauthor session is not accepting answers.' })
|
||||
const messages = z.array(MessageSchema).min(1).max(12).parse(session.messages)
|
||||
const answerCount = Math.max(0, messages.filter(message => message.role === 'user').length - 1)
|
||||
if (answerCount >= 4) return { readyToGenerate: true }
|
||||
|
||||
const completion = buildJsonCompletion(useRuntimeConfig(), {
|
||||
system: `You are the Dungeons & Ground coauthor. Based only on this conversation, ask one focused question that makes the original TTRPG world more playable. This is clarification ${answerCount + 1} of 4. Do not repeat a topic already answered. Adapt to the requested genre. Provide exactly three concise, mutually distinct answer options. Keep everything suitable for ages 13+.`,
|
||||
messages,
|
||||
schemaName: 'coauthor_question',
|
||||
jsonSchema: QuestionSchema.toJSONSchema(),
|
||||
})
|
||||
const controller = new AbortController()
|
||||
const timeout = setTimeout(() => controller.abort(), 30_000)
|
||||
let response: Response
|
||||
try {
|
||||
response = await fetch(completion.endpoint, {
|
||||
method: 'POST', headers: completion.headers, body: JSON.stringify(completion.body), signal: controller.signal,
|
||||
})
|
||||
} finally {
|
||||
clearTimeout(timeout)
|
||||
}
|
||||
if (!response.ok) throw createError({ statusCode: 502, statusMessage: 'The coauthor is temporarily unavailable.' })
|
||||
const question = QuestionSchema.parse(parseJsonCompletion(await response.json()))
|
||||
requireStageTwoSafeText([question.question, ...question.options].join('\n'))
|
||||
await stageTwoRpc('stage_two_append_coauthor_assistant_message', {
|
||||
p_session_id: sessionId, p_owner_id: user.id, p_content: question.question,
|
||||
})
|
||||
return {
|
||||
readyToGenerate: false,
|
||||
question: { id: `question-${messages.length}`, label: question.question, options: question.options },
|
||||
}
|
||||
} catch (error) {
|
||||
stageTwoApiError(error)
|
||||
}
|
||||
})
|
||||
13
apps/web/server/api/v1/coauthor/sessions/index.get.ts
Normal file
13
apps/web/server/api/v1/coauthor/sessions/index.get.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { requireStageTwoUser, stageTwoApiError, stageTwoDatabase } from '~/server/utils/stage-two-supabase'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
try {
|
||||
const user = await requireStageTwoUser(event)
|
||||
const sessions = await stageTwoDatabase<Array<Record<string, unknown>>>(
|
||||
`coauthor_sessions?select=id,status,messages,generated_world,confirmed_world_id,created_at,updated_at&owner_id=eq.${user.id}&order=updated_at.desc`,
|
||||
)
|
||||
return { sessions }
|
||||
} catch (error) {
|
||||
stageTwoApiError(error)
|
||||
}
|
||||
})
|
||||
24
apps/web/server/api/v1/coauthor/sessions/index.post.ts
Normal file
24
apps/web/server/api/v1/coauthor/sessions/index.post.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { z } from 'zod'
|
||||
import { requireStageTwoSafeText, requireStageTwoUser, stageTwoApiError, stageTwoDatabase } from '~/server/utils/stage-two-supabase'
|
||||
|
||||
const BodySchema = z.object({ message: z.string().trim().min(1).max(5000).optional() }).strict()
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
try {
|
||||
const user = await requireStageTwoUser(event)
|
||||
const body = BodySchema.parse((await readBody(event)) ?? {})
|
||||
if (body.message) requireStageTwoSafeText(body.message)
|
||||
const sessions = await stageTwoDatabase<Array<Record<string, unknown>>>('coauthor_sessions', {
|
||||
method: 'POST',
|
||||
prefer: 'return=representation',
|
||||
body: JSON.stringify({
|
||||
owner_id: user.id,
|
||||
messages: body.message ? [{ role: 'user', content: body.message }] : [],
|
||||
}),
|
||||
})
|
||||
setResponseStatus(event, 201)
|
||||
return { session: sessions[0] }
|
||||
} catch (error) {
|
||||
stageTwoApiError(error)
|
||||
}
|
||||
})
|
||||
19
apps/web/server/api/v1/invites/join.post.ts
Normal file
19
apps/web/server/api/v1/invites/join.post.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { createHash } from 'node:crypto'
|
||||
import { z } from 'zod'
|
||||
import { requireStageTwoUser, stageTwoApiError, stageTwoRpc } from '~/server/utils/stage-two-supabase'
|
||||
|
||||
const BodySchema = z.object({ token: z.string().trim().min(20).max(200) }).strict()
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
try {
|
||||
const user = await requireStageTwoUser(event)
|
||||
const body = BodySchema.parse(await readBody(event))
|
||||
const tokenHash = createHash('sha256').update(body.token).digest('hex')
|
||||
const campaignId = await stageTwoRpc<string>('stage_two_join_campaign', {
|
||||
p_token_hash: tokenHash, p_user_id: user.id,
|
||||
})
|
||||
return { campaignId }
|
||||
} catch (error) {
|
||||
stageTwoApiError(error)
|
||||
}
|
||||
})
|
||||
@@ -9,4 +9,12 @@ describe('required Supabase runtime config', () => {
|
||||
it('reports missing credentials clearly', () => {
|
||||
expect(() => assertRequiredSupabaseConfig({ public: {} })).toThrow('SUPABASE_SERVICE_ROLE_KEY is required')
|
||||
})
|
||||
|
||||
it('rejects credentials from different Supabase projects', () => {
|
||||
expect(() => assertRequiredSupabaseConfig({
|
||||
supabaseUrl: 'https://server-project.supabase.co',
|
||||
supabaseServiceRoleKey: 'service',
|
||||
public: { supabaseUrl: 'https://public-project.supabase.co', supabaseAnonKey: 'anon' },
|
||||
})).toThrow('must point to the same project')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -15,4 +15,10 @@ export function assertRequiredSupabaseConfig(config: unknown): void {
|
||||
const details = result.error.issues.map(issue => issue.message).join('; ')
|
||||
throw new Error(`Invalid Supabase runtime configuration: ${details}`)
|
||||
}
|
||||
|
||||
const serverOrigin = new URL(result.data.supabaseUrl).origin
|
||||
const publicOrigin = new URL(result.data.public.supabaseUrl).origin
|
||||
if (serverOrigin !== publicOrigin) {
|
||||
throw new Error('Invalid Supabase runtime configuration: SUPABASE_URL and NUXT_PUBLIC_SUPABASE_URL must point to the same project')
|
||||
}
|
||||
}
|
||||
|
||||
21
apps/web/server/utils/stage-two-supabase.test.ts
Normal file
21
apps/web/server/utils/stage-two-supabase.test.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { stageTwoDatabase } from './stage-two-supabase'
|
||||
|
||||
describe('stage-two Supabase client', () => {
|
||||
afterEach(() => vi.unstubAllGlobals())
|
||||
|
||||
it('accepts successful PostgREST return=minimal responses with an empty body', async () => {
|
||||
vi.stubGlobal('useRuntimeConfig', () => ({
|
||||
supabaseUrl: 'https://example.supabase.co',
|
||||
supabaseServiceRoleKey: 'service-role-key',
|
||||
public: { supabaseAnonKey: 'anon-key' },
|
||||
}))
|
||||
vi.stubGlobal('fetch', vi.fn(async () => new Response(null, { status: 201 })))
|
||||
|
||||
await expect(stageTwoDatabase('invites', {
|
||||
method: 'POST',
|
||||
prefer: 'return=minimal',
|
||||
body: '{}',
|
||||
})).resolves.toBeUndefined()
|
||||
})
|
||||
})
|
||||
126
apps/web/server/utils/stage-two-supabase.ts
Normal file
126
apps/web/server/utils/stage-two-supabase.ts
Normal file
@@ -0,0 +1,126 @@
|
||||
import { z } from 'zod'
|
||||
import type { H3Event } from 'h3'
|
||||
import { moderate13Plus } from '@dng/shared'
|
||||
|
||||
const UuidSchema = z.string().uuid()
|
||||
const AuthEmailSchema = z.preprocess(
|
||||
value => value === null || value === '' ? undefined : value,
|
||||
z.string().email().optional(),
|
||||
)
|
||||
const AuthUserSchema = z.object({ id: UuidSchema, email: AuthEmailSchema })
|
||||
|
||||
interface RequestOptions extends RequestInit {
|
||||
prefer?: string
|
||||
}
|
||||
|
||||
export interface StageTwoUser {
|
||||
id: string
|
||||
email?: string
|
||||
}
|
||||
|
||||
export class StageTwoDatabaseError extends Error {
|
||||
constructor(public status: number, message: string, public code?: string) {
|
||||
super(message)
|
||||
}
|
||||
}
|
||||
|
||||
function runtime() {
|
||||
const config = useRuntimeConfig()
|
||||
const url = z.string().url().parse(config.supabaseUrl)
|
||||
const serviceKey = z.string().min(1).parse(config.supabaseServiceRoleKey)
|
||||
const anonKey = z.string().min(1).parse(config.public.supabaseAnonKey)
|
||||
return { url: url.replace(/\/$/, ''), serviceKey, anonKey }
|
||||
}
|
||||
|
||||
export function stageTwoUuid(value: unknown, label = 'id'): string {
|
||||
const parsed = UuidSchema.safeParse(value)
|
||||
if (!parsed.success) throw createError({ statusCode: 400, statusMessage: `Invalid ${label}.` })
|
||||
return parsed.data
|
||||
}
|
||||
|
||||
export function requireStageTwoSafeText(text: string): void {
|
||||
const result = moderate13Plus(text)
|
||||
if (!result.allowed) {
|
||||
throw createError({ statusCode: 422, statusMessage: `Content is outside the 13+ policy: ${result.categories.join(', ')}.` })
|
||||
}
|
||||
}
|
||||
|
||||
export async function stageTwoDatabase<T>(path: string, options: RequestOptions = {}): Promise<T> {
|
||||
const { url, serviceKey } = runtime()
|
||||
const response = await fetch(new URL(`/rest/v1/${path}`, url), {
|
||||
...options,
|
||||
headers: {
|
||||
apikey: serviceKey,
|
||||
Authorization: `Bearer ${serviceKey}`,
|
||||
'Content-Type': 'application/json',
|
||||
...(options.prefer ? { Prefer: options.prefer } : {}),
|
||||
...options.headers,
|
||||
},
|
||||
})
|
||||
if (!response.ok) {
|
||||
const payload = await response.json().catch(() => null) as { code?: string; message?: string } | null
|
||||
throw new StageTwoDatabaseError(response.status, payload?.message || `Supabase request failed (${response.status}).`, payload?.code)
|
||||
}
|
||||
if (response.status === 204) return undefined as T
|
||||
// PostgREST commonly returns 201/200 with an empty body for
|
||||
// `Prefer: return=minimal`; parsing that as JSON throws after a successful
|
||||
// database mutation and incorrectly turns the route into HTTP 500.
|
||||
const text = await response.text()
|
||||
if (!text.trim()) return undefined as T
|
||||
return JSON.parse(text) as T
|
||||
}
|
||||
|
||||
export function stageTwoRpc<T>(name: string, body: Record<string, unknown>): Promise<T> {
|
||||
return stageTwoDatabase<T>(`rpc/${name}`, { method: 'POST', body: JSON.stringify(body) })
|
||||
}
|
||||
|
||||
export async function requireStageTwoUser(event: H3Event): Promise<StageTwoUser> {
|
||||
const authorization = getHeader(event, 'authorization')
|
||||
if (!authorization?.startsWith('Bearer ') || authorization.length <= 7) {
|
||||
throw createError({ statusCode: 401, statusMessage: 'A Supabase access token is required.' })
|
||||
}
|
||||
const { url, anonKey } = runtime()
|
||||
const response = await fetch(new URL('/auth/v1/user', url), {
|
||||
headers: { apikey: anonKey, Authorization: authorization },
|
||||
})
|
||||
if (!response.ok) throw createError({ statusCode: 401, statusMessage: 'The access token is invalid or expired.' })
|
||||
const parsed = AuthUserSchema.safeParse(await response.json())
|
||||
if (!parsed.success) throw createError({ statusCode: 401, statusMessage: 'Supabase returned an invalid user.' })
|
||||
|
||||
const profiles = await stageTwoDatabase<Array<{ id: string }>>(`profiles?select=id&id=eq.${parsed.data.id}&limit=1`)
|
||||
if (!profiles[0]) throw createError({ statusCode: 403, statusMessage: 'This account is not enabled for the alpha.' })
|
||||
return parsed.data
|
||||
}
|
||||
|
||||
export async function requireCampaignAccess(campaignId: string, userId: string, ownerOnly = false) {
|
||||
const campaigns = await stageTwoDatabase<Array<Record<string, unknown>>>(
|
||||
`campaigns?select=*&id=eq.${campaignId}&limit=1`,
|
||||
)
|
||||
const campaign = campaigns[0]
|
||||
if (!campaign) throw createError({ statusCode: 404, statusMessage: 'Campaign not found.' })
|
||||
if (campaign.owner_id === userId) return { campaign, owner: true }
|
||||
if (ownerOnly) throw createError({ statusCode: 403, statusMessage: 'Only the campaign owner can do that.' })
|
||||
const members = await stageTwoDatabase<Array<{ id: string; active: boolean }>>(
|
||||
`campaign_members?select=id,active&campaign_id=eq.${campaignId}&user_id=eq.${userId}&active=eq.true&limit=1`,
|
||||
)
|
||||
if (!members[0]) throw createError({ statusCode: 404, statusMessage: 'Campaign not found.' })
|
||||
return { campaign, owner: false, memberId: members[0].id }
|
||||
}
|
||||
|
||||
export function stageTwoApiError(error: unknown): never {
|
||||
if (error && typeof error === 'object' && 'statusCode' in error) throw error
|
||||
if (error instanceof StageTwoDatabaseError) {
|
||||
if (error.code === 'PGRST202' || error.code === 'PGRST205') {
|
||||
throw createError({
|
||||
statusCode: 503,
|
||||
statusMessage: 'The Supabase database schema is not installed or is out of date. Apply supabase/bootstrap.sql.',
|
||||
})
|
||||
}
|
||||
const conflict = /duplicate key|already exists|not claimable/i.test(error.message)
|
||||
throw createError({ statusCode: conflict ? 409 : error.status >= 500 ? 502 : 400, statusMessage: error.message })
|
||||
}
|
||||
if (error instanceof z.ZodError) {
|
||||
throw createError({ statusCode: 400, statusMessage: error.issues.map(issue => issue.message).join('; ') })
|
||||
}
|
||||
throw createError({ statusCode: 500, statusMessage: error instanceof Error ? error.message : 'Unexpected server error.' })
|
||||
}
|
||||
@@ -34,5 +34,5 @@ describe('worker AI providers', () => {
|
||||
const { generateWorld } = await import('./ai')
|
||||
await expect(generateWorld([{ role: 'user', content: 'Create a science fiction frontier.' }])).resolves.toMatchObject({ title: 'Test Reach' })
|
||||
expect(fetchMock).toHaveBeenCalledWith('https://api.deepseek.com/chat/completions', expect.any(Object))
|
||||
}, 15_000)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { RoundPlanSchema, RoundResolutionSchema, WorldStarterSchema, moderate13Plus, type Character, type PlayerIntent, type RoundPlan, type RoundResolution, type WorldStarter } from '@dng/shared'
|
||||
import { RoundPlanSchema, RoundResolutionSchema, StorySummarySchema, WorldStarterSchema, moderate13Plus, type RoundContext, type RoundPlan, type RoundResolution, type StorySummary, type WorldStarter } from '@dng/shared'
|
||||
import type { DiceRoll } from '@dng/shared'
|
||||
|
||||
type JsonSchema = Record<string, unknown>
|
||||
@@ -96,18 +96,27 @@ export async function generateWorld(messages: Array<{ role: 'user' | 'assistant'
|
||||
return world
|
||||
}
|
||||
|
||||
export async function planRound(input: { scene: string; intents: PlayerIntent[]; characters: Character[]; memories: string[] }): Promise<RoundPlan> {
|
||||
export async function planRound(input: RoundContext): Promise<RoundPlan> {
|
||||
return structuredRequest('round_plan', RoundPlanSchema.toJSONSchema(), [
|
||||
{ role: 'system', content: 'You plan one asynchronous TTRPG round. You may request checks and propose story events. Never invent dice results and never directly mutate mechanical state. The server owns rules, HP, inventory, statuses and randomness.' },
|
||||
{ role: 'system', content: 'You plan one asynchronous TTRPG round. Human intents are authoritative and happen first. Then provide at most one action for each supplied aiCharacter, in the supplied order; never create AI actions for human-controlled characters. You may request checks and propose story events. Never invent dice results and never directly mutate mechanical state. The server owns rules, HP, inventory, statuses and randomness.' },
|
||||
{ role: 'user', content: JSON.stringify(input) },
|
||||
], value => RoundPlanSchema.parse(value))
|
||||
}
|
||||
|
||||
export async function narrateRound(input: { scene: string; intents: PlayerIntent[]; aiActions: RoundPlan['aiActions']; rolls: DiceRoll[]; permittedEvents: RoundPlan['proposedEvents'] }): Promise<RoundResolution> {
|
||||
export async function narrateRound(input: { context: RoundContext; actionSequence: Array<{ phase: 'human' | 'ai'; characterId: string; action: string }>; rolls: DiceRoll[]; permittedEvents: RoundPlan['proposedEvents'] }): Promise<RoundResolution> {
|
||||
const resolution = await structuredRequest('round_resolution', RoundResolutionSchema.toJSONSchema(), [
|
||||
{ role: 'system', content: 'Narrate the resolved TTRPG round using the supplied, authoritative dice results. Do not change them. Keep player agency, maintain a 13+ rating, and end with a clear prompt for the next round.' },
|
||||
{ role: 'system', content: 'Narrate the resolved TTRPG round in the exact supplied actionSequence order: all human actions first, then AI/delegated hero actions. Use the authoritative dice results without changing them. Keep player agency, maintain a 13+ rating, and end with a clear prompt for the next round.' },
|
||||
{ role: 'user', content: JSON.stringify(input) },
|
||||
], value => RoundResolutionSchema.parse(value))
|
||||
assert13Plus(resolution.narration, resolution.nextPrompt, resolution.memory?.summary ?? '')
|
||||
return resolution
|
||||
}
|
||||
|
||||
export async function summarizeStory(input: { previousSummary: string | null; rounds: Array<{ number: number; narration: string }> }): Promise<StorySummary> {
|
||||
const result = await structuredRequest('story_summary', StorySummarySchema.toJSONSchema(), [
|
||||
{ role: 'system', content: 'Update a durable TTRPG campaign summary. Preserve established facts, goals, relationships and unresolved consequences. Use only the supplied previous summary and round narrations. Be concise and do not invent details.' },
|
||||
{ role: 'user', content: JSON.stringify(input) },
|
||||
], value => StorySummarySchema.parse(value))
|
||||
assert13Plus(result.summary)
|
||||
return result
|
||||
}
|
||||
|
||||
@@ -3,7 +3,8 @@ import { Worker } from 'bullmq'
|
||||
import IORedis from 'ioredis'
|
||||
import { applyMechanicalEvents, bindMechanicalEventsToRolls, resolveCheck } from '@dng/game-engine'
|
||||
import { CharacterSchema, PlayerIntentSchema, makeId } from '@dng/shared'
|
||||
import { generateWorld, narrateRound, planRound } from './ai'
|
||||
import { generateWorld, narrateRound, planRound, summarizeStory } from './ai'
|
||||
import { buildActionSequence, buildRoundContext, sanitizeRoundMemory, shouldUpdateStorySummary, validateAndOrderRoundPlan } from './orchestration'
|
||||
|
||||
const redisUrl = process.env.REDIS_URL
|
||||
const supabaseUrl = process.env.SUPABASE_URL
|
||||
@@ -36,7 +37,7 @@ if (!supabaseUrl || !serviceKey) {
|
||||
) {
|
||||
const rpcName = path.startsWith('rpc/') ? path.slice('rpc/'.length) : null
|
||||
const message = status === 404 && rpcName
|
||||
? `Supabase RPC "${rpcName}" was not found. Apply supabase/migrations/0002_supabase_ai_queue.sql in the Supabase SQL Editor, then restart the worker.`
|
||||
? `Supabase RPC "${rpcName}" was not found. The database schema is incomplete. Run the generated supabase/bootstrap.sql in the Supabase SQL Editor, then restart the worker.`
|
||||
: `Supabase returned ${status} for ${path}: ${detail}`
|
||||
super(message)
|
||||
this.name = 'SupabaseRequestError'
|
||||
@@ -57,14 +58,37 @@ if (!supabaseUrl || !serviceKey) {
|
||||
const detail = await response.text()
|
||||
throw new SupabaseRequestError(response.status, path, detail)
|
||||
}
|
||||
if (response.status === 204) return undefined as T
|
||||
return response.json() as Promise<T>
|
||||
const responseBody = await response.text()
|
||||
if (!responseBody) return undefined as T
|
||||
return JSON.parse(responseBody) as T
|
||||
}
|
||||
|
||||
async function selectRows<T>(table: string, query: Record<string, string>): Promise<T[]> {
|
||||
return databaseRequest<T[]>(`${table}?${new URLSearchParams(query)}`)
|
||||
}
|
||||
|
||||
async function persistStorySummary(jobId: string, campaignId: string, throughRound: number, summary: string): Promise<void> {
|
||||
let lastError: unknown
|
||||
for (let attempt = 1; attempt <= 3; attempt += 1) {
|
||||
try {
|
||||
await databaseRequest('rpc/stage_two_upsert_story_summary', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
p_job_id: jobId,
|
||||
p_campaign_id: campaignId,
|
||||
p_through_round: throughRound,
|
||||
p_summary: summary,
|
||||
}),
|
||||
})
|
||||
return
|
||||
} catch (error) {
|
||||
lastError = error
|
||||
if (attempt < 3) await new Promise(resolve => setTimeout(resolve, attempt * 250))
|
||||
}
|
||||
}
|
||||
throw lastError
|
||||
}
|
||||
|
||||
async function processWorld(job: JobPayload) {
|
||||
let messages = job.messages
|
||||
if (!messages) {
|
||||
@@ -94,32 +118,57 @@ if (!supabaseUrl || !serviceKey) {
|
||||
if (!round) throw new Error('Round not found')
|
||||
if (round.status === 'resolved') return { duplicate: true }
|
||||
|
||||
const [rawCharacters, rawIntents, rawMemories] = await Promise.all([
|
||||
const [rawCharacters, rawIntents, rawMemories, rawRecentRounds, rawStorySummaries] = await Promise.all([
|
||||
selectRows<Record<string, any>>('characters', { select: '*', campaign_id: `eq.${round.campaign_id}` }),
|
||||
selectRows<Record<string, any>>('player_intents', { select: '*', round_id: `eq.${round.id}` }),
|
||||
selectRows<{ summary: string }>('memories', { select: 'summary', campaign_id: `eq.${round.campaign_id}`, order: 'importance.desc', limit: '12' }),
|
||||
selectRows<{ summary: string; importance: number; tags: string[]; entity_ids: string[] }>('memories', {
|
||||
select: 'summary,importance,tags,entity_ids', campaign_id: `eq.${round.campaign_id}`, order: 'importance.desc,created_at.desc', limit: '24',
|
||||
}),
|
||||
selectRows<{ number: number; narration: string; next_prompt: string | null }>('rounds', {
|
||||
select: 'number,narration,next_prompt', campaign_id: `eq.${round.campaign_id}`, status: 'eq.resolved', order: 'number.desc', limit: '2',
|
||||
}),
|
||||
selectRows<{ through_round: number; summary: string }>('story_summaries', {
|
||||
select: 'through_round,summary', campaign_id: `eq.${round.campaign_id}`, order: 'through_round.desc', limit: '1',
|
||||
}),
|
||||
])
|
||||
|
||||
const characters = rawCharacters.map(row => CharacterSchema.parse({
|
||||
id: row.id, name: row.name, concept: row.concept, controller: row.controller,
|
||||
userId: row.user_id, abilities: row.abilities, hp: row.hp, maxHp: row.max_hp,
|
||||
defense: row.defense, proficiency: row.proficiency, inventory: row.inventory, statuses: row.statuses,
|
||||
defense: row.defense, proficiency: row.proficiency, inventory: row.inventory, statuses: row.statuses, persona: row.persona,
|
||||
}))
|
||||
const intents = rawIntents.map(row => PlayerIntentSchema.parse({
|
||||
id: row.id, roundId: row.round_id, memberId: row.member_id, characterId: row.character_id,
|
||||
action: row.action, ready: row.ready, createdAt: row.created_at, updatedAt: row.updated_at,
|
||||
}))
|
||||
const scene = String(round.campaigns.current_scene ?? '')
|
||||
const plan = await planRound({ scene, intents, characters, memories: rawMemories.map(row => String(row.summary)) })
|
||||
const context = buildRoundContext({
|
||||
scene,
|
||||
storySummary: rawStorySummaries[0]?.summary ?? null,
|
||||
recentRounds: rawRecentRounds.map(previous => ({ number: previous.number, narration: previous.narration, nextPrompt: previous.next_prompt })),
|
||||
intents,
|
||||
characters,
|
||||
memories: rawMemories.map(memory => ({
|
||||
summary: memory.summary, importance: memory.importance, tags: memory.tags, entityIds: memory.entity_ids,
|
||||
})),
|
||||
})
|
||||
const plan = validateAndOrderRoundPlan(await planRound(context), context)
|
||||
const rolls = plan.checks.map(check => {
|
||||
const actor = characters.find(character => character.id === check.actorId)
|
||||
if (!actor) throw new Error(`Unknown check actor ${check.actorId}`)
|
||||
return resolveCheck(check, actor)
|
||||
})
|
||||
const permittedEvents = bindMechanicalEventsToRolls(plan.proposedEvents, rolls)
|
||||
const resolution = await narrateRound({ scene, intents, aiActions: plan.aiActions, rolls, permittedEvents })
|
||||
const resolution = await narrateRound({ context, actionSequence: buildActionSequence(context, plan), rolls, permittedEvents })
|
||||
const safeEvents = resolution.events.filter(event => permittedEvents.some(permitted => JSON.stringify(permitted) === JSON.stringify(event)))
|
||||
const applied = applyMechanicalEvents(characters, safeEvents)
|
||||
const shouldSummarize = shouldUpdateStorySummary(Number(round.number))
|
||||
const summary = shouldSummarize
|
||||
? await summarizeStory({
|
||||
previousSummary: context.storySummary,
|
||||
rounds: [...context.recentRounds.map(previous => ({ number: previous.number, narration: previous.narration })), { number: Number(round.number), narration: resolution.narration }],
|
||||
})
|
||||
: null
|
||||
|
||||
await databaseRequest(job.claimToken ? 'rpc/commit_claimed_round_resolution' : 'rpc/commit_round_resolution', {
|
||||
method: 'POST',
|
||||
@@ -130,11 +179,18 @@ if (!supabaseUrl || !serviceKey) {
|
||||
p_rolls: rolls,
|
||||
p_events: safeEvents,
|
||||
p_character_states: applied.characters.map(character => ({ id: character.id, hp: character.hp, inventory: character.inventory, statuses: character.statuses })),
|
||||
p_memory: resolution.memory,
|
||||
p_memory: sanitizeRoundMemory(resolution.memory),
|
||||
p_idempotency_key: job.id,
|
||||
...(job.claimToken ? { p_worker_id: job.claimToken } : {}),
|
||||
}),
|
||||
})
|
||||
if (summary) {
|
||||
try {
|
||||
await persistStorySummary(job.id, round.campaign_id, Number(round.number), summary.summary)
|
||||
} catch (error) {
|
||||
console.error(`[worker] round ${round.id} committed but story summary persistence failed:`, error)
|
||||
}
|
||||
}
|
||||
return { narration: resolution.narration, rolls: rolls.length }
|
||||
}
|
||||
|
||||
@@ -201,6 +257,7 @@ if (!supabaseUrl || !serviceKey) {
|
||||
const pollInterval = Math.max(250, Number(process.env.AI_JOB_POLL_INTERVAL_MS ?? 1_000))
|
||||
const workerId = process.env.RAILWAY_REPLICA_ID ?? makeId('worker')
|
||||
let stopped = false
|
||||
let consecutivePollFailures = 0
|
||||
|
||||
const stop = () => { stopped = true }
|
||||
process.once('SIGTERM', stop)
|
||||
@@ -212,6 +269,7 @@ if (!supabaseUrl || !serviceKey) {
|
||||
const claimed = await databaseRequest<ClaimedJob[]>('rpc/claim_ai_job', {
|
||||
method: 'POST', body: JSON.stringify({ p_worker_id: workerId }),
|
||||
})
|
||||
consecutivePollFailures = 0
|
||||
const job = claimed[0]
|
||||
if (!job) {
|
||||
await new Promise(resolve => setTimeout(resolve, pollInterval))
|
||||
@@ -233,8 +291,15 @@ if (!supabaseUrl || !serviceKey) {
|
||||
process.exitCode = 1
|
||||
break
|
||||
}
|
||||
console.error('[worker] polling failed:', error)
|
||||
await new Promise(resolve => setTimeout(resolve, pollInterval))
|
||||
consecutivePollFailures += 1
|
||||
const retryDelay = Math.min(30_000, pollInterval * 2 ** Math.min(consecutivePollFailures - 1, 5))
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
// Avoid flooding the terminal and Supabase during a network outage.
|
||||
// Log the first failure and then only when the backoff duration grows.
|
||||
if (consecutivePollFailures === 1 || (consecutivePollFailures & (consecutivePollFailures - 1)) === 0) {
|
||||
console.error(`[worker] polling failed; retrying in ${retryDelay}ms: ${message}`)
|
||||
}
|
||||
await new Promise(resolve => setTimeout(resolve, retryDelay))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
56
apps/worker/src/orchestration.test.ts
Normal file
56
apps/worker/src/orchestration.test.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { Character, PlayerIntent, RoundPlan } from '@dng/shared'
|
||||
import { buildActionSequence, buildRoundContext, sanitizeRoundMemory, shouldUpdateStorySummary, validateAndOrderRoundPlan } from './orchestration'
|
||||
|
||||
const character = (id: string, controller: Character['controller']): Character => ({
|
||||
id, controller, name: id, concept: 'A campaign hero', userId: controller === 'human' ? 'user' : null,
|
||||
abilities: { str: 10, dex: 10, con: 10, int: 10, wis: 10, cha: 10 },
|
||||
hp: 10, maxHp: 10, defense: 10, proficiency: 2, inventory: [], statuses: [], persona: {},
|
||||
})
|
||||
const intent = (id: string, characterId: string, createdAt: string): PlayerIntent => ({
|
||||
id, roundId: 'round', memberId: id, characterId, action: `Action ${id}`, ready: true, createdAt, updatedAt: createdAt,
|
||||
})
|
||||
|
||||
describe('round orchestration', () => {
|
||||
it('keeps a minimal context and orders humans before AI/delegated heroes', () => {
|
||||
const context = buildRoundContext({
|
||||
scene: 'Current scene', storySummary: 'Story so far',
|
||||
recentRounds: [1, 2, 3].map(number => ({ number, narration: `Round ${number}` })),
|
||||
intents: [intent('late', 'human', '2026-01-01T00:00:02.000Z'), intent('early', 'human', '2026-01-01T00:00:01.000Z')],
|
||||
characters: [character('human', 'human'), character('companion', 'ai'), character('stand-in', 'delegated')],
|
||||
memories: Array.from({ length: 10 }, (_, index) => ({ summary: `Memory ${index}`, importance: (index % 5) + 1 })),
|
||||
})
|
||||
const plan: RoundPlan = { checks: [], proposedEvents: [], relevantMemoryQueries: [], aiActions: [
|
||||
{ characterId: 'stand-in', action: 'Covers the retreat' },
|
||||
{ characterId: 'human', action: 'Illegally overrides the player' },
|
||||
{ characterId: 'companion', action: 'Scouts ahead' },
|
||||
] }
|
||||
const validated = validateAndOrderRoundPlan(plan, context)
|
||||
|
||||
expect(context.recentRounds.map(round => round.number)).toEqual([2, 3])
|
||||
expect(context.memories).toHaveLength(8)
|
||||
expect(validated.aiActions.map(action => action.characterId)).toEqual(['companion', 'stand-in'])
|
||||
expect(buildActionSequence(context, validated).map(action => action.phase)).toEqual(['human', 'human', 'ai', 'ai'])
|
||||
})
|
||||
|
||||
it('rejects mechanical references outside the active campaign', () => {
|
||||
const context = buildRoundContext({ scene: 'Scene', recentRounds: [], intents: [], characters: [character('hero', 'human')], memories: [] })
|
||||
expect(() => validateAndOrderRoundPlan({
|
||||
checks: [{ actorId: 'outsider', kind: 'ability', mode: 'normal', reason: 'Invalid' }],
|
||||
aiActions: [], proposedEvents: [], relevantMemoryQueries: [],
|
||||
}, context)).toThrow('Unknown check actor outsider')
|
||||
})
|
||||
|
||||
it('schedules durable story summaries every third completed round', () => {
|
||||
expect([1, 2, 3, 4, 5, 6].filter(shouldUpdateStorySummary)).toEqual([3, 6])
|
||||
})
|
||||
|
||||
it('drops model-authored memory labels that PostgreSQL cannot cast to uuid[]', () => {
|
||||
expect(sanitizeRoundMemory({
|
||||
summary: 'The party learned who controls the gate.',
|
||||
importance: 4,
|
||||
tags: ['gate'],
|
||||
entityIds: ['the-gatekeeper', '9ea0ef06-34bf-4cf3-8f2f-af1a65180d0e', '9ea0ef06-34bf-4cf3-8f2f-af1a65180d0e'],
|
||||
})?.entityIds).toEqual(['9ea0ef06-34bf-4cf3-8f2f-af1a65180d0e'])
|
||||
})
|
||||
})
|
||||
110
apps/worker/src/orchestration.ts
Normal file
110
apps/worker/src/orchestration.ts
Normal file
@@ -0,0 +1,110 @@
|
||||
import {
|
||||
RoundContextSchema,
|
||||
RoundPlanSchema,
|
||||
type Character,
|
||||
type PlayerIntent,
|
||||
type RoundContext,
|
||||
type RoundPlan,
|
||||
type RoundResolution,
|
||||
} from '@dng/shared'
|
||||
|
||||
const postgresUuidPattern = /^[0-9a-f]{8}-(?:[0-9a-f]{4}-){3}[0-9a-f]{12}$/i
|
||||
|
||||
export interface RoundContextInput {
|
||||
scene: string
|
||||
storySummary?: string | null
|
||||
recentRounds: Array<{ number: number; narration: string; nextPrompt?: string | null }>
|
||||
intents: PlayerIntent[]
|
||||
characters: Character[]
|
||||
memories: Array<{ summary: string; importance?: number; tags?: string[]; entityIds?: string[] }>
|
||||
}
|
||||
|
||||
export function buildRoundContext(input: RoundContextInput): RoundContext {
|
||||
const characterIds = new Set(input.characters.map(character => character.id))
|
||||
const actingCharacterIds = new Set(input.intents.filter(intent => intent.ready).map(intent => intent.characterId))
|
||||
const humanIntents = input.intents
|
||||
.filter(intent => intent.ready && characterIds.has(intent.characterId))
|
||||
.sort((left, right) => left.createdAt.localeCompare(right.createdAt))
|
||||
const aiCharacters = input.characters.filter(character =>
|
||||
character.controller === 'ai'
|
||||
|| (character.controller === 'delegated' && !actingCharacterIds.has(character.id)),
|
||||
)
|
||||
const relevanceText = [
|
||||
input.scene,
|
||||
...humanIntents.map(intent => intent.action),
|
||||
...input.characters.flatMap(character => [character.id, character.name]),
|
||||
].join(' ').toLowerCase()
|
||||
const rankedMemories = input.memories
|
||||
.map(memory => {
|
||||
const terms = [...(memory.tags ?? []), ...(memory.entityIds ?? [])]
|
||||
const relevance = terms.reduce((score, term) => score + (term && relevanceText.includes(term.toLowerCase()) ? 10 : 0), 0)
|
||||
return { memory, score: relevance + (memory.importance ?? 0) }
|
||||
})
|
||||
.sort((left, right) => right.score - left.score)
|
||||
.slice(0, 8)
|
||||
.map(entry => entry.memory)
|
||||
|
||||
return RoundContextSchema.parse({
|
||||
scene: input.scene,
|
||||
storySummary: input.storySummary ?? null,
|
||||
recentRounds: [...input.recentRounds].sort((a, b) => a.number - b.number).slice(-2),
|
||||
humanIntents,
|
||||
aiCharacters,
|
||||
activeCharacters: input.characters,
|
||||
memories: rankedMemories,
|
||||
})
|
||||
}
|
||||
|
||||
export function validateAndOrderRoundPlan(planInput: unknown, context: RoundContext): RoundPlan {
|
||||
const plan = RoundPlanSchema.parse(planInput)
|
||||
const characterIds = new Set(context.activeCharacters.map(character => character.id))
|
||||
const aiOrder = new Map(context.aiCharacters.map((character, index) => [character.id, index]))
|
||||
const seenAiActors = new Set<string>()
|
||||
|
||||
for (const check of plan.checks) {
|
||||
if (!characterIds.has(check.actorId)) throw new Error(`Unknown check actor ${check.actorId}`)
|
||||
if (check.targetId && !characterIds.has(check.targetId)) throw new Error(`Unknown check target ${check.targetId}`)
|
||||
}
|
||||
for (const event of plan.proposedEvents) {
|
||||
if (event.actorId && !characterIds.has(event.actorId)) throw new Error(`Unknown event actor ${event.actorId}`)
|
||||
if (event.targetId && !characterIds.has(event.targetId)) throw new Error(`Unknown event target ${event.targetId}`)
|
||||
if (['damage', 'healing', 'inventory', 'status'].includes(event.type) && !event.targetId) {
|
||||
throw new Error(`${event.type} event requires a target`)
|
||||
}
|
||||
}
|
||||
|
||||
const aiActions = plan.aiActions
|
||||
.filter(action => aiOrder.has(action.characterId))
|
||||
.filter(action => {
|
||||
if (seenAiActors.has(action.characterId)) return false
|
||||
seenAiActors.add(action.characterId)
|
||||
return true
|
||||
})
|
||||
.sort((left, right) => aiOrder.get(left.characterId)! - aiOrder.get(right.characterId)!)
|
||||
|
||||
return { ...plan, aiActions }
|
||||
}
|
||||
|
||||
export function buildActionSequence(context: RoundContext, plan: RoundPlan) {
|
||||
return [
|
||||
...context.humanIntents.map(intent => ({ phase: 'human' as const, characterId: intent.characterId, action: intent.action })),
|
||||
...plan.aiActions.map(action => ({ phase: 'ai' as const, characterId: action.characterId, action: action.action })),
|
||||
]
|
||||
}
|
||||
|
||||
export function shouldUpdateStorySummary(roundNumber: number): boolean {
|
||||
return Number.isInteger(roundNumber) && roundNumber > 0 && roundNumber % 3 === 0
|
||||
}
|
||||
|
||||
/**
|
||||
* PostgreSQL stores memory entity references as uuid[]. Model-authored labels
|
||||
* are useful prose but cannot be cast to that column and would roll back an
|
||||
* otherwise valid round. Preserve only storage-compatible references.
|
||||
*/
|
||||
export function sanitizeRoundMemory(memory: RoundResolution['memory']): RoundResolution['memory'] {
|
||||
if (!memory) return null
|
||||
return {
|
||||
...memory,
|
||||
entityIds: [...new Set(memory.entityIds.filter(id => postgresUuidPattern.test(id)))],
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user