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:
@@ -3,6 +3,9 @@ NUXT_PUBLIC_SUPABASE_URL=
|
||||
SUPABASE_URL=
|
||||
NUXT_PUBLIC_SUPABASE_ANON_KEY=
|
||||
SUPABASE_SERVICE_ROLE_KEY=
|
||||
# Used only by db:ensure to create a missing schema. Copy the Session pooler
|
||||
# URI from Supabase Dashboard → Connect and replace its password placeholder.
|
||||
SUPABASE_DB_URL=
|
||||
|
||||
# AI provider (choose openrouter or deepseek)
|
||||
AI_PROVIDER=openrouter
|
||||
|
||||
23
README.md
23
README.md
@@ -1,6 +1,6 @@
|
||||
# Dungeons & Ground
|
||||
|
||||
An English-first, invite-only web alpha for asynchronous text TTRPG campaigns. Players create private worlds with an AI coauthor, submit actions into shared rounds, and play alongside persistent AI companions. The rules engine—not the language model—owns dice and mechanical state.
|
||||
An English-first private web alpha for asynchronous text TTRPG campaigns. Players create private worlds with an AI coauthor, submit actions into shared rounds, and play alongside persistent AI companions. The rules engine—not the language model—owns dice and mechanical state.
|
||||
|
||||
## Local start
|
||||
|
||||
@@ -12,9 +12,13 @@ pnpm dev:all
|
||||
|
||||
Open `http://localhost:3000`. `pnpm dev:all` starts the Nuxt site and the AI worker in one terminal. To run them separately, use `pnpm dev` in the first terminal and `pnpm dev:worker` in the second.
|
||||
|
||||
To verify the complete hosted-Supabase multiplayer path (two temporary guests, invite, shared readiness, AI resolution, and the next round), keep `dev:all` running and execute `pnpm smoke:multiplayer` in another terminal.
|
||||
|
||||
Before starting either process, the root commands check the Supabase schema. If it is missing and `SUPABASE_DB_URL` is configured, they transactionally create all D&G tables, functions, triggers, indexes, and policies from `supabase/bootstrap.sql`. Copy the **Session pooler** URI from the Supabase Dashboard **Connect** panel into the root `.env` and replace the password placeholder with the URL-encoded database password. The database URL is server-only and must never use a `NUXT_PUBLIC_` prefix.
|
||||
|
||||
The web and worker commands both load this root `.env` file explicitly. Environment variables supplied by Railway or CI take precedence over values in the file.
|
||||
|
||||
The static demo UI can be built and tested without credentials. A production Nitro server and every worker process require `SUPABASE_URL`, `SUPABASE_SERVICE_ROLE_KEY`, `NUXT_PUBLIC_SUPABASE_URL`, and `NUXT_PUBLIC_SUPABASE_ANON_KEY`; startup fails with a clear error when required server credentials are missing. Redis is optional: when `REDIS_URL` is absent, the worker safely claims jobs from the Supabase `ai_jobs` outbox using short database leases and retries failed jobs up to three times.
|
||||
The UI can be built without credentials. A running Nitro server and every worker process require `SUPABASE_URL`, `SUPABASE_SERVICE_ROLE_KEY`, `NUXT_PUBLIC_SUPABASE_URL`, and `NUXT_PUBLIC_SUPABASE_ANON_KEY`; startup fails with a clear error when required server credentials are missing. Redis is optional: when `REDIS_URL` is absent, the worker safely claims jobs from the Supabase `ai_jobs` outbox using short database leases and retries failed jobs up to three times.
|
||||
|
||||
Select OpenRouter or the official DeepSeek API with `AI_PROVIDER=openrouter|deepseek` and provide the matching API key. OpenRouter retains JSON Schema structured output; DeepSeek uses its official JSON mode, followed by the same Zod validation.
|
||||
|
||||
@@ -38,11 +42,18 @@ Use Node.js 18.20.5 or newer. The lockfile pins the web toolchain to the Node 18
|
||||
|
||||
## Production services
|
||||
|
||||
1. Create a Supabase project and apply `supabase/migrations/0001_alpha_schema.sql`, then `supabase/migrations/0002_supabase_ai_queue.sql`, and finally the seed file. If `0001` was already applied earlier, apply only `0002`; it adds the Supabase-backed worker queue and reloads the REST API schema cache.
|
||||
2. Add invited tester emails to `public.allowlist`.
|
||||
1. Copy the Supabase **Session pooler** connection URI from **Connect** into `SUPABASE_DB_URL`. `pnpm dev:all` now detects an empty project and creates the complete schema automatically. `pnpm db:ensure` runs the same guarded bootstrap without starting the app. Manual SQL Editor installation remains available through `supabase/bootstrap.sql`.
|
||||
2. In Supabase Auth settings, enable **Allow anonymous sign-ins**. In Auth URL Configuration, set the Site URL to `http://localhost:3000` and add `http://localhost:3000/auth/callback` as a Redirect URL. Email magic-link accounts remain optional and must be added to `public.allowlist`. Before external testing, enable CAPTCHA and review Supabase's anonymous sign-in rate limits so disposable guests cannot be used to evade AI quotas.
|
||||
3. Run `pnpm dev:worker` alongside the web process. Provision Redis only when BullMQ delivery is desired; otherwise the worker polls the Supabase outbox.
|
||||
4. Configure either `OPENROUTER_API_KEY` (default model `deepseek/deepseek-v4-flash`) or set `AI_PROVIDER=deepseek` with `DEEPSEEK_API_KEY` (default model `deepseek-v4-flash`, endpoint `https://api.deepseek.com/chat/completions`).
|
||||
|
||||
If the worker reports `POST /rest/v1/rpc/claim_ai_job 404`, the database is missing migration `0002_supabase_ai_queue.sql`. Run that file in the Supabase SQL Editor and restart `pnpm dev:all`.
|
||||
If the worker reports `POST /rest/v1/rpc/claim_ai_job 404` and `/rest/v1/profiles` also returns 404, the base schema was never installed. Run the generated `supabase/bootstrap.sql` in the SQL Editor and restart `pnpm dev:all`. Do not run only `0002`: it depends on the tables created by `0001`.
|
||||
|
||||
The current UI intentionally includes a complete local vertical slice. Production auth/session binding and queue-enqueue endpoints should be connected to the supplied schema before inviting external users.
|
||||
## Stage two flow
|
||||
|
||||
1. Click **Enter the Alpha** to create a persistent guest session without email. Do not sign out or clear site storage unless you are prepared to lose that guest account. An allowlisted email magic link is still available as an alternative.
|
||||
2. Create a world in the coauthor chat, review the editable result, and confirm it.
|
||||
3. Create human characters and optional AI companions in the campaign lobby.
|
||||
4. The owner creates an expiring private invite link. Guest or allowlisted-email users join through `/join/:token`.
|
||||
5. Players save actions and mark them ready. The final ready action queues the round automatically; the owner can also continue without waiting.
|
||||
6. The worker resolves server-owned rolls and state, publishes narration, and opens the next round. Visible campaign tabs synchronize every two seconds and immediately when the tab regains focus.
|
||||
|
||||
@@ -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)))],
|
||||
}
|
||||
}
|
||||
14
package.json
14
package.json
@@ -12,13 +12,19 @@
|
||||
"packages/*"
|
||||
],
|
||||
"scripts": {
|
||||
"dev": "pnpm --filter @dng/web dev",
|
||||
"dev:all": "pnpm --parallel --filter @dng/web --filter @dng/worker dev",
|
||||
"dev:worker": "pnpm --filter @dng/worker dev",
|
||||
"db:bundle": "node scripts/build-supabase-bootstrap.mjs",
|
||||
"db:ensure": "pnpm db:bundle && node scripts/ensure-supabase-schema.mjs",
|
||||
"dev": "pnpm db:ensure && pnpm --filter @dng/web dev",
|
||||
"dev:all": "pnpm db:ensure && pnpm --parallel --filter @dng/web --filter @dng/worker dev",
|
||||
"dev:worker": "pnpm db:ensure && pnpm --filter @dng/worker dev",
|
||||
"build": "pnpm -r --if-present build",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"typecheck": "pnpm -r --if-present typecheck"
|
||||
"typecheck": "pnpm -r --if-present typecheck",
|
||||
"smoke:multiplayer": "node scripts/smoke-multiplayer.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"postgres": "3.4.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^18.19.0",
|
||||
|
||||
@@ -20,6 +20,7 @@ describe('game engine', () => {
|
||||
expect(roll.rolls).toEqual([12])
|
||||
expect(roll.total).toBe(17)
|
||||
expect(roll.success).toBe(true)
|
||||
expect(roll.id).toMatch(/^[0-9a-f]{8}-(?:[0-9a-f]{4}-){3}[0-9a-f]{12}$/i)
|
||||
})
|
||||
|
||||
it('labels disadvantage rolls correctly', () => {
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { randomInt } from 'node:crypto'
|
||||
import { randomInt, randomUUID } from 'node:crypto'
|
||||
import type { AbilityKey, Character, CheckRequest, DiceRoll, ProposedEvent } from '@dng/shared'
|
||||
import { makeId } from '@dng/shared'
|
||||
|
||||
export interface RandomSource {
|
||||
integer(min: number, max: number): number
|
||||
@@ -62,7 +61,9 @@ export function resolveCheck(request: CheckRequest, actor: Character, random: Ra
|
||||
if (request.kind === 'damage' || request.kind === 'healing') {
|
||||
const rolled = rollDice(request.dice ?? '1d6', random)
|
||||
return {
|
||||
id: makeId('roll'),
|
||||
// dice_rolls.id is a native PostgreSQL uuid. Keep persistence IDs free
|
||||
// of the human-readable prefixes used for logs and worker identities.
|
||||
id: randomUUID(),
|
||||
checkKind: request.kind,
|
||||
formula: request.dice ?? '1d6',
|
||||
rolls: rolled.rolls,
|
||||
@@ -85,7 +86,7 @@ export function resolveCheck(request: CheckRequest, actor: Character, random: Ra
|
||||
const total = keptValue + modifier
|
||||
const difficulty = request.difficulty ?? null
|
||||
return {
|
||||
id: makeId('roll'),
|
||||
id: randomUUID(),
|
||||
checkKind: request.kind,
|
||||
formula: request.mode === 'normal'
|
||||
? `1d20${modifier >= 0 ? '+' : ''}${modifier}`
|
||||
|
||||
19
packages/shared/src/index.test.ts
Normal file
19
packages/shared/src/index.test.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { PlayerIntentSchema } from './index'
|
||||
|
||||
describe('shared database contracts', () => {
|
||||
it('accepts PostgREST timestamptz values with an explicit UTC offset', () => {
|
||||
const intent = PlayerIntentSchema.parse({
|
||||
id: '2c321216-ef17-48a2-9aa3-6250bb4354f5',
|
||||
roundId: '940fc00f-76e1-45dc-b401-55a2084e3e0e',
|
||||
memberId: '85701d65-0b2c-4c7e-aa30-d67fc77f1f54',
|
||||
characterId: 'bfc4b38f-bf3c-4ee1-b875-aa98b2b3a756',
|
||||
action: 'Inspect the access panel.',
|
||||
ready: true,
|
||||
createdAt: '2026-08-15T08:42:10.123456+00:00',
|
||||
updatedAt: '2026-08-15T08:42:11+00:00',
|
||||
})
|
||||
|
||||
expect(intent.ready).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -1,6 +1,11 @@
|
||||
import { z } from 'zod'
|
||||
export * from './moderation'
|
||||
|
||||
// PostgreSQL/PostgREST serializes timestamptz values with an explicit offset
|
||||
// (for example `+00:00`). Browser-created timestamps normally use `Z`.
|
||||
// Both are valid ISO-8601 timestamps and must be accepted at the API boundary.
|
||||
const IsoDatetimeSchema = z.string().datetime({ offset: true })
|
||||
|
||||
export const abilityKeys = ['str', 'dex', 'con', 'int', 'wis', 'cha'] as const
|
||||
export const AbilityKeySchema = z.enum(abilityKeys)
|
||||
export type AbilityKey = z.infer<typeof AbilityKeySchema>
|
||||
@@ -53,6 +58,7 @@ export const CharacterSchema = z.object({
|
||||
proficiency: z.number().int().min(1).max(10),
|
||||
inventory: z.array(z.string().max(120)).max(50).default([]),
|
||||
statuses: z.array(z.string().max(80)).max(12).default([]),
|
||||
persona: z.record(z.string(), z.unknown()).optional(),
|
||||
})
|
||||
export type Character = z.infer<typeof CharacterSchema>
|
||||
|
||||
@@ -63,11 +69,31 @@ export const PlayerIntentSchema = z.object({
|
||||
characterId: z.string().min(1),
|
||||
action: z.string().trim().min(1).max(2000),
|
||||
ready: z.boolean().default(false),
|
||||
createdAt: z.string().datetime(),
|
||||
updatedAt: z.string().datetime(),
|
||||
createdAt: IsoDatetimeSchema,
|
||||
updatedAt: IsoDatetimeSchema,
|
||||
})
|
||||
export type PlayerIntent = z.infer<typeof PlayerIntentSchema>
|
||||
|
||||
export const RoundContextSchema = z.object({
|
||||
scene: z.string().max(6000),
|
||||
storySummary: z.string().max(6000).nullable(),
|
||||
recentRounds: z.array(z.object({
|
||||
number: z.number().int().positive(),
|
||||
narration: z.string().max(6000),
|
||||
nextPrompt: z.string().max(500).nullable().optional(),
|
||||
})).max(2),
|
||||
humanIntents: z.array(PlayerIntentSchema).max(8),
|
||||
aiCharacters: z.array(CharacterSchema).max(8),
|
||||
activeCharacters: z.array(CharacterSchema).max(16),
|
||||
memories: z.array(z.object({
|
||||
summary: z.string().min(1).max(1200),
|
||||
importance: z.number().int().min(1).max(5).optional(),
|
||||
tags: z.array(z.string().max(40)).max(12).optional(),
|
||||
entityIds: z.array(z.string()).max(20).optional(),
|
||||
})).max(8),
|
||||
})
|
||||
export type RoundContext = z.infer<typeof RoundContextSchema>
|
||||
|
||||
export const CheckRequestSchema = z.object({
|
||||
actorId: z.string().min(1),
|
||||
kind: z.enum(['ability', 'attack', 'initiative', 'damage', 'healing']),
|
||||
@@ -81,7 +107,7 @@ export const CheckRequestSchema = z.object({
|
||||
export type CheckRequest = z.infer<typeof CheckRequestSchema>
|
||||
|
||||
export const DiceRollSchema = z.object({
|
||||
id: z.string(),
|
||||
id: z.string().uuid(),
|
||||
checkKind: CheckRequestSchema.shape.kind,
|
||||
formula: z.string(),
|
||||
rolls: z.array(z.number().int()),
|
||||
@@ -92,7 +118,7 @@ export const DiceRollSchema = z.object({
|
||||
success: z.boolean().nullable(),
|
||||
actorId: z.string(),
|
||||
targetId: z.string().nullable(),
|
||||
createdAt: z.string().datetime(),
|
||||
createdAt: IsoDatetimeSchema,
|
||||
})
|
||||
export type DiceRoll = z.infer<typeof DiceRollSchema>
|
||||
|
||||
@@ -130,6 +156,11 @@ export const RoundResolutionSchema = z.object({
|
||||
})
|
||||
export type RoundResolution = z.infer<typeof RoundResolutionSchema>
|
||||
|
||||
export const StorySummarySchema = z.object({
|
||||
summary: z.string().min(20).max(6000),
|
||||
})
|
||||
export type StorySummary = z.infer<typeof StorySummarySchema>
|
||||
|
||||
export const CoauthorMessageSchema = z.object({
|
||||
role: z.enum(['user', 'assistant']),
|
||||
content: z.string().trim().min(1).max(5000),
|
||||
|
||||
10
pnpm-lock.yaml
generated
10
pnpm-lock.yaml
generated
@@ -13,6 +13,10 @@ overrides:
|
||||
importers:
|
||||
|
||||
.:
|
||||
dependencies:
|
||||
postgres:
|
||||
specifier: 3.4.7
|
||||
version: 3.4.7
|
||||
devDependencies:
|
||||
'@types/node':
|
||||
specifier: ^18.19.0
|
||||
@@ -2902,6 +2906,10 @@ packages:
|
||||
resolution: {integrity: sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==}
|
||||
engines: {node: ^10 || ^12 || >=14}
|
||||
|
||||
postgres@3.4.7:
|
||||
resolution: {integrity: sha512-Jtc2612XINuBjIl/QTWsV5UvE8UHuNblcO3vVADSrKsrc6RqGX6lOW1cEo3CM2v0XG4Nat8nI+YM7/f26VxXLw==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
pretty-bytes@6.1.1:
|
||||
resolution: {integrity: sha512-mQUvGU6aUFQ+rNvTIAcZuWGRT9a6f6Yrg9bHs4ImKF+HZCEK+plBvnAZYSIQztknZF2qnzNtr6F8s0+IuptdlQ==}
|
||||
engines: {node: ^14.13.1 || >=16.0.0}
|
||||
@@ -6804,6 +6812,8 @@ snapshots:
|
||||
picocolors: 1.1.1
|
||||
source-map-js: 1.2.1
|
||||
|
||||
postgres@3.4.7: {}
|
||||
|
||||
pretty-bytes@6.1.1: {}
|
||||
|
||||
process-nextick-args@2.0.1: {}
|
||||
|
||||
89
scripts/build-supabase-bootstrap.mjs
Normal file
89
scripts/build-supabase-bootstrap.mjs
Normal file
@@ -0,0 +1,89 @@
|
||||
import { readdir, readFile, writeFile } from 'node:fs/promises'
|
||||
import { dirname, join, resolve } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
const root = resolve(dirname(fileURLToPath(import.meta.url)), '..')
|
||||
const migrationsDirectory = join(root, 'supabase', 'migrations')
|
||||
const outputPath = join(root, 'supabase', 'bootstrap.sql')
|
||||
const migrationNames = (await readdir(migrationsDirectory))
|
||||
.filter(name => /^\d+_.+\.sql$/.test(name))
|
||||
.sort((left, right) => left.localeCompare(right))
|
||||
|
||||
if (migrationNames.length === 0) throw new Error('No Supabase migrations were found.')
|
||||
|
||||
const sections = await Promise.all(migrationNames.map(async (name) => {
|
||||
const sql = await readFile(join(migrationsDirectory, name), 'utf8')
|
||||
return `\n-- ============================================================================\n-- ${name}\n-- ============================================================================\n\n${sql.trim()}\n`
|
||||
}))
|
||||
|
||||
const preflight = `
|
||||
do $$
|
||||
begin
|
||||
if to_regclass('auth.users') is null then
|
||||
raise exception 'D&G bootstrap preflight failed: auth.users is missing';
|
||||
end if;
|
||||
if not exists (
|
||||
select 1
|
||||
from information_schema.columns
|
||||
where table_schema = 'auth'
|
||||
and table_name = 'users'
|
||||
and column_name = 'is_anonymous'
|
||||
) then
|
||||
raise exception 'D&G bootstrap preflight failed: auth.users.is_anonymous is missing';
|
||||
end if;
|
||||
if to_regclass('public.profiles') is not null
|
||||
or to_regclass('public.ai_jobs') is not null
|
||||
or to_regtype('public.member_role') is not null
|
||||
or to_regprocedure('public.claim_ai_job(text)') is not null then
|
||||
raise exception 'D&G bootstrap preflight failed: a partial/existing D&G schema was found. Do not run the fresh bootstrap over it.';
|
||||
end if;
|
||||
end;
|
||||
$$;
|
||||
`
|
||||
|
||||
const verification = `
|
||||
do $$
|
||||
begin
|
||||
if to_regclass('public.profiles') is null then
|
||||
raise exception 'D&G bootstrap verification failed: public.profiles is missing';
|
||||
end if;
|
||||
if to_regclass('public.ai_jobs') is null then
|
||||
raise exception 'D&G bootstrap verification failed: public.ai_jobs is missing';
|
||||
end if;
|
||||
if to_regprocedure('public.claim_ai_job(text)') is null then
|
||||
raise exception 'D&G bootstrap verification failed: public.claim_ai_job(text) is missing';
|
||||
end if;
|
||||
if to_regprocedure('public.stage_two_create_campaign(uuid,uuid,text)') is null then
|
||||
raise exception 'D&G bootstrap verification failed: stage-two RPCs are missing';
|
||||
end if;
|
||||
if to_regprocedure('public.stage_two_retry_failed_round(uuid,uuid)') is null then
|
||||
raise exception 'D&G bootstrap verification failed: failed-round recovery RPC is missing';
|
||||
end if;
|
||||
if to_regprocedure('public.dng_schema_version()') is null then
|
||||
raise exception 'D&G bootstrap verification failed: schema version RPC is missing';
|
||||
end if;
|
||||
end;
|
||||
$$;
|
||||
|
||||
notify pgrst, 'reload schema';
|
||||
commit;
|
||||
|
||||
select
|
||||
'Dungeons & Ground database is ready' as result,
|
||||
to_regclass('public.profiles') as profiles,
|
||||
to_regclass('public.ai_jobs') as ai_jobs,
|
||||
to_regprocedure('public.claim_ai_job(text)') as worker_rpc;
|
||||
`
|
||||
|
||||
const output = `-- GENERATED FILE. Rebuild with: pnpm db:bundle
|
||||
-- Paste this entire file into a new Supabase SQL Editor query and click Run.
|
||||
-- It is intended for a fresh Dungeons & Ground project. The explicit
|
||||
-- transaction ensures a failure cannot leave a half-created schema.
|
||||
|
||||
begin;
|
||||
${preflight}
|
||||
${sections.join('')}
|
||||
${verification}`
|
||||
|
||||
await writeFile(outputPath, output)
|
||||
console.log(`Wrote ${outputPath} from ${migrationNames.length} migrations.`)
|
||||
183
scripts/ensure-supabase-schema.mjs
Normal file
183
scripts/ensure-supabase-schema.mjs
Normal file
@@ -0,0 +1,183 @@
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import { dirname, join, resolve } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import postgres from 'postgres'
|
||||
|
||||
const root = resolve(dirname(fileURLToPath(import.meta.url)), '..')
|
||||
|
||||
async function loadRootEnvironment() {
|
||||
let contents
|
||||
try {
|
||||
contents = await readFile(join(root, '.env'), 'utf8')
|
||||
} catch (error) {
|
||||
if (error && typeof error === 'object' && error.code === 'ENOENT') return
|
||||
throw error
|
||||
}
|
||||
|
||||
for (const line of contents.split(/\r?\n/)) {
|
||||
const match = line.match(/^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)=(.*)$/)
|
||||
if (!match || process.env[match[1]] !== undefined) continue
|
||||
let value = match[2].trim()
|
||||
if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
|
||||
value = value.slice(1, -1)
|
||||
}
|
||||
process.env[match[1]] = value
|
||||
}
|
||||
}
|
||||
|
||||
function required(name) {
|
||||
const value = process.env[name]?.trim()
|
||||
if (!value) throw new Error(`[database] ${name} is required.`)
|
||||
return value
|
||||
}
|
||||
|
||||
function assertMatchingProject(databaseUrl, supabaseUrl) {
|
||||
let database
|
||||
let api
|
||||
try {
|
||||
database = new URL(databaseUrl)
|
||||
api = new URL(supabaseUrl)
|
||||
} catch {
|
||||
throw new Error('[database] SUPABASE_DB_URL and SUPABASE_URL must be valid URLs.')
|
||||
}
|
||||
if (!['postgres:', 'postgresql:'].includes(database.protocol)) {
|
||||
throw new Error('[database] SUPABASE_DB_URL must use the postgres:// or postgresql:// protocol.')
|
||||
}
|
||||
if (/\[(?:your-?)?password\]/i.test(databaseUrl) || /YOUR_PASSWORD/i.test(databaseUrl)) {
|
||||
throw new Error('[database] Replace the password placeholder in SUPABASE_DB_URL with your database password.')
|
||||
}
|
||||
|
||||
const projectRef = api.hostname.split('.')[0]
|
||||
const directMatch = database.hostname === `db.${projectRef}.supabase.co`
|
||||
const poolerMatch = database.username === `postgres.${projectRef}`
|
||||
if (!directMatch && !poolerMatch) {
|
||||
throw new Error('[database] SUPABASE_DB_URL does not belong to the project configured by SUPABASE_URL.')
|
||||
}
|
||||
}
|
||||
|
||||
async function dataApiHasSchema(supabaseUrl, serviceKey) {
|
||||
const base = supabaseUrl.replace(/\/$/, '')
|
||||
const headers = { apikey: serviceKey, Authorization: `Bearer ${serviceKey}`, 'Content-Type': 'application/json' }
|
||||
const [profiles, workerRpc, retryRpc, versionRpc] = await Promise.all([
|
||||
fetch(`${base}/rest/v1/profiles?select=id&limit=1`, { headers }),
|
||||
fetch(`${base}/rest/v1/rpc/claim_ai_job`, {
|
||||
method: 'POST', headers, body: JSON.stringify({ p_worker_id: '' }),
|
||||
}),
|
||||
fetch(`${base}/rest/v1/rpc/stage_two_retry_failed_round`, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify({
|
||||
p_round_id: '00000000-0000-0000-0000-000000000000',
|
||||
p_owner_id: '00000000-0000-0000-0000-000000000000',
|
||||
}),
|
||||
}),
|
||||
fetch(`${base}/rest/v1/rpc/dng_schema_version`, {
|
||||
method: 'POST', headers, body: '{}',
|
||||
}),
|
||||
])
|
||||
if ([profiles, workerRpc, retryRpc, versionRpc].some(response => response.status === 401 || response.status === 403)) {
|
||||
throw new Error('SUPABASE_SERVICE_ROLE_KEY was rejected by the configured project.')
|
||||
}
|
||||
if ([profiles, workerRpc, retryRpc, versionRpc].some(response => response.status === 404)) return false
|
||||
if (profiles.status !== 200 || workerRpc.status !== 400 || retryRpc.status !== 400 || versionRpc.status !== 200) {
|
||||
throw new Error(
|
||||
`Unexpected Supabase preflight response (profiles ${profiles.status}, claim_ai_job ${workerRpc.status}, retry_round ${retryRpc.status}, schema_version ${versionRpc.status}).`,
|
||||
)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
async function ensureSchema() {
|
||||
await loadRootEnvironment()
|
||||
const supabaseUrl = required('SUPABASE_URL')
|
||||
const serviceKey = required('SUPABASE_SERVICE_ROLE_KEY')
|
||||
|
||||
try {
|
||||
if (await dataApiHasSchema(supabaseUrl, serviceKey)) {
|
||||
console.log('[database] Supabase schema is ready.')
|
||||
return
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn(`[database] Data API preflight failed: ${error instanceof Error ? error.message : String(error)}`)
|
||||
}
|
||||
|
||||
const databaseUrl = process.env.SUPABASE_DB_URL?.trim()
|
||||
if (!databaseUrl) {
|
||||
throw new Error(
|
||||
'[database] The Supabase schema is missing. Add SUPABASE_DB_URL from Dashboard → Connect → Session pooler to the root .env; the next start will create it automatically.',
|
||||
)
|
||||
}
|
||||
assertMatchingProject(databaseUrl, supabaseUrl)
|
||||
|
||||
const sql = postgres(databaseUrl, {
|
||||
max: 1,
|
||||
prepare: false,
|
||||
ssl: 'require',
|
||||
connect_timeout: 15,
|
||||
idle_timeout: 5,
|
||||
})
|
||||
|
||||
try {
|
||||
const [state] = await sql.unsafe(`
|
||||
select
|
||||
to_regclass('public.profiles')::text as profiles,
|
||||
to_regclass('public.ai_jobs')::text as ai_jobs,
|
||||
to_regtype('public.member_role')::text as member_role,
|
||||
to_regprocedure('public.claim_ai_job(text)')::text as worker_rpc,
|
||||
to_regprocedure('public.stage_two_create_campaign(uuid,uuid,text)')::text as campaign_rpc,
|
||||
to_regprocedure('public.stage_two_retry_failed_round(uuid,uuid)')::text as retry_rpc,
|
||||
to_regprocedure('public.dng_schema_version()')::text as version_rpc
|
||||
`)
|
||||
const baseEntries = Object.entries(state).filter(([name]) => !['retry_rpc', 'version_rpc'].includes(name))
|
||||
if (baseEntries.every(([, value]) => Boolean(value)) && state.retry_rpc && state.version_rpc) {
|
||||
await sql.unsafe("notify pgrst, 'reload schema'")
|
||||
console.log('[database] Supabase schema is ready; PostgREST cache reload requested.')
|
||||
return
|
||||
}
|
||||
if (baseEntries.every(([, value]) => Boolean(value)) && (!state.retry_rpc || !state.version_rpc)) {
|
||||
console.log('[database] Applying the pending failed-round recovery upgrade…')
|
||||
const migration = await readFile(join(root, 'supabase', 'migrations', '0006_retry_job_compatibility.sql'), 'utf8')
|
||||
await sql.begin(async transaction => {
|
||||
await transaction.unsafe(migration)
|
||||
})
|
||||
const [verified] = await sql.unsafe(`
|
||||
select
|
||||
to_regprocedure('public.stage_two_retry_failed_round(uuid,uuid)') is not null as retry_rpc,
|
||||
to_regprocedure('public.dng_schema_version()') is not null as version_rpc
|
||||
`)
|
||||
if (!verified.retry_rpc || !verified.version_rpc) throw new Error('[database] Failed-round recovery upgrade verification failed.')
|
||||
await sql.unsafe("notify pgrst, 'reload schema'")
|
||||
console.log('[database] Supabase schema upgraded successfully.')
|
||||
return
|
||||
}
|
||||
if (baseEntries.some(([, value]) => Boolean(value))) {
|
||||
const missing = baseEntries.filter(([, value]) => !value).map(([name]) => name).join(', ')
|
||||
throw new Error(`[database] A partial D&G schema exists; automatic bootstrap stopped without changing data. Missing: ${missing}.`)
|
||||
}
|
||||
|
||||
console.log('[database] No D&G schema found. Applying the transactional bootstrap…')
|
||||
const bootstrap = await readFile(join(root, 'supabase', 'bootstrap.sql'), 'utf8')
|
||||
await sql.unsafe(bootstrap)
|
||||
|
||||
const [verified] = await sql.unsafe(`
|
||||
select
|
||||
to_regclass('public.profiles') is not null as profiles,
|
||||
to_regclass('public.ai_jobs') is not null as ai_jobs,
|
||||
to_regprocedure('public.claim_ai_job(text)') is not null as worker_rpc,
|
||||
to_regprocedure('public.stage_two_create_campaign(uuid,uuid,text)') is not null as campaign_rpc,
|
||||
to_regprocedure('public.stage_two_retry_failed_round(uuid,uuid)') is not null as retry_rpc,
|
||||
to_regprocedure('public.dng_schema_version()') is not null as version_rpc
|
||||
`)
|
||||
if (!Object.values(verified).every(Boolean)) {
|
||||
throw new Error('[database] Bootstrap completed but schema verification failed.')
|
||||
}
|
||||
console.log('[database] Dungeons & Ground schema created successfully.')
|
||||
} finally {
|
||||
await sql.end({ timeout: 5 })
|
||||
}
|
||||
}
|
||||
|
||||
await ensureSchema().catch((error) => {
|
||||
console.error(error instanceof Error ? error.message : error)
|
||||
process.exitCode = 1
|
||||
})
|
||||
170
scripts/smoke-multiplayer.mjs
Normal file
170
scripts/smoke-multiplayer.mjs
Normal file
@@ -0,0 +1,170 @@
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import { dirname, join, resolve } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
const root = resolve(dirname(fileURLToPath(import.meta.url)), '..')
|
||||
|
||||
async function loadRootEnvironment() {
|
||||
const contents = await readFile(join(root, '.env'), 'utf8')
|
||||
for (const line of contents.split(/\r?\n/)) {
|
||||
const match = line.match(/^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)=(.*)$/)
|
||||
if (!match || process.env[match[1]] !== undefined) continue
|
||||
let value = match[2].trim()
|
||||
if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) value = value.slice(1, -1)
|
||||
process.env[match[1]] = value
|
||||
}
|
||||
}
|
||||
|
||||
function required(name) {
|
||||
const value = process.env[name]?.trim()
|
||||
if (!value) throw new Error(`[smoke] ${name} is required.`)
|
||||
return value
|
||||
}
|
||||
|
||||
await loadRootEnvironment()
|
||||
const supabaseUrl = required('SUPABASE_URL').replace(/\/$/, '')
|
||||
const anonKey = required('NUXT_PUBLIC_SUPABASE_ANON_KEY')
|
||||
const serviceKey = required('SUPABASE_SERVICE_ROLE_KEY')
|
||||
const appUrl = (process.env.DNG_SMOKE_BASE_URL?.trim() || 'http://127.0.0.1:3001').replace(/\/$/, '')
|
||||
|
||||
async function jsonRequest(url, options = {}) {
|
||||
const response = await fetch(url, options)
|
||||
const payload = await response.json().catch(() => null)
|
||||
if (!response.ok) {
|
||||
const message = payload?.statusMessage || payload?.message || payload?.msg || JSON.stringify(payload) || response.statusText
|
||||
throw new Error(`[smoke] ${options.method || 'GET'} ${url} -> ${response.status}: ${message}`)
|
||||
}
|
||||
return payload
|
||||
}
|
||||
|
||||
async function createGuest(label) {
|
||||
const session = await jsonRequest(`${supabaseUrl}/auth/v1/signup`, {
|
||||
method: 'POST',
|
||||
headers: { apikey: anonKey, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ data: { display_name: label }, gotrue_meta_security: {} }),
|
||||
})
|
||||
if (!session?.access_token || !session?.user?.id) throw new Error('[smoke] Supabase returned an incomplete anonymous session.')
|
||||
return { token: session.access_token, id: session.user.id }
|
||||
}
|
||||
|
||||
function appRequest(path, token, options = {}) {
|
||||
return jsonRequest(`${appUrl}${path}`, {
|
||||
...options,
|
||||
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json', ...options.headers },
|
||||
})
|
||||
}
|
||||
|
||||
async function servicePatch(path, body) {
|
||||
await jsonRequest(`${supabaseUrl}/rest/v1/${path}`, {
|
||||
method: 'PATCH',
|
||||
headers: {
|
||||
apikey: serviceKey,
|
||||
Authorization: `Bearer ${serviceKey}`,
|
||||
'Content-Type': 'application/json',
|
||||
Prefer: 'return=representation',
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
}
|
||||
|
||||
const suffix = Date.now().toString(36).toUpperCase()
|
||||
console.log('[smoke] Creating two isolated guest sessions…')
|
||||
const [owner, player] = await Promise.all([
|
||||
createGuest(`Smoke Owner ${suffix}`),
|
||||
createGuest(`Smoke Player ${suffix}`),
|
||||
])
|
||||
|
||||
console.log('[smoke] Creating a private world and campaign through the public API…')
|
||||
const sessionResult = await appRequest('/api/v1/coauthor/sessions', owner.token, {
|
||||
method: 'POST', body: JSON.stringify({ message: 'A sealed orbital garden wakes after a century of silence.' }),
|
||||
})
|
||||
const sessionId = sessionResult?.session?.id
|
||||
if (!sessionId) throw new Error('[smoke] Coauthor session id is missing.')
|
||||
const entity = (kind, name, summary) => ({ id: randomUUID(), kind, name, summary, tags: [], secrets: [] })
|
||||
const world = {
|
||||
title: `Silent Garden ${suffix}`,
|
||||
genre: 'Science-Fiction Mystery',
|
||||
tone: 'Hopeful, tense, and strange',
|
||||
premise: 'Two salvagers enter a sealed orbital garden whose caretaker has been waiting for people who do not exist yet.',
|
||||
contentBoundaries: ['13+ adventure', 'No explicit sexual content', 'No graphic gore'],
|
||||
startingLocation: entity('location', 'Airlock Orchard', 'A frost-covered orchard grows around the station airlock.'),
|
||||
npcs: [
|
||||
entity('npc', 'Caretaker Ilex', 'A patient horticultural intelligence with fragmented memories.'),
|
||||
entity('npc', 'Dr. Sable', 'A missing botanist whose messages arrive out of order.'),
|
||||
entity('npc', 'Moth-9', 'A damaged pollination drone that follows warm voices.'),
|
||||
],
|
||||
factions: [
|
||||
entity('faction', 'The Reclaimers', 'Salvagers who want the station stripped for parts.'),
|
||||
entity('faction', 'The Seed Vault', 'An automated network protecting the last viable specimens.'),
|
||||
],
|
||||
hook: 'The airlock opens only after both visitors speak names the station already knows.',
|
||||
hiddenThreat: 'The garden predicts visitors by growing imperfect biological copies of them.',
|
||||
openingScene: 'The inner airlock opens on warm rain and rows of silver trees. A voice welcomes both salvagers by name, then asks why one of them has returned without the other.',
|
||||
}
|
||||
await servicePatch(`coauthor_sessions?id=eq.${sessionId}`, { status: 'ready', generated_world: world })
|
||||
const { worldId } = await appRequest(`/api/v1/coauthor/sessions/${sessionId}/confirm`, owner.token, {
|
||||
method: 'POST', body: JSON.stringify({ world }),
|
||||
})
|
||||
const { campaignId } = await appRequest('/api/v1/campaigns', owner.token, {
|
||||
method: 'POST', body: JSON.stringify({ worldId, title: `Smoke Party ${suffix}` }),
|
||||
})
|
||||
|
||||
console.log('[smoke] Joining the second player through an invite…')
|
||||
const { token: inviteToken } = await appRequest(`/api/v1/campaigns/${campaignId}/invites`, owner.token, {
|
||||
method: 'POST', body: JSON.stringify({ maxUses: 2, expiresInHours: 1 }),
|
||||
})
|
||||
const joined = await appRequest('/api/v1/invites/join', player.token, {
|
||||
method: 'POST', body: JSON.stringify({ token: inviteToken }),
|
||||
})
|
||||
if (joined.campaignId !== campaignId) throw new Error('[smoke] Invite joined the wrong campaign.')
|
||||
|
||||
const characterBody = (name, concept) => JSON.stringify({
|
||||
name, concept, controller: 'human', abilities: { str: 10, dex: 10, con: 10, int: 12, wis: 11, cha: 9 },
|
||||
hp: 12, maxHp: 12, defense: 12, proficiency: 2, inventory: [], statuses: [], persona: {},
|
||||
})
|
||||
const ownerCharacter = await appRequest(`/api/v1/campaigns/${campaignId}/characters`, owner.token, {
|
||||
method: 'POST', body: characterBody('Iris Vale', 'A methodical station engineer.'),
|
||||
})
|
||||
const playerCharacter = await appRequest(`/api/v1/campaigns/${campaignId}/characters`, player.token, {
|
||||
method: 'POST', body: characterBody('Rowan Pike', 'A curious xenobotanist.'),
|
||||
})
|
||||
|
||||
const initial = await appRequest(`/api/v1/campaigns/${campaignId}`, owner.token)
|
||||
if (initial.members.length !== 2) throw new Error(`[smoke] Expected 2 members, received ${initial.members.length}.`)
|
||||
if (initial.characters.length !== 2 || initial.round?.status !== 'open') throw new Error('[smoke] Multiplayer lobby did not expose two characters and an open round.')
|
||||
const roundId = initial.round.id
|
||||
|
||||
console.log('[smoke] Verifying that the round waits for both humans…')
|
||||
await appRequest(`/api/v1/campaigns/${campaignId}/rounds/${roundId}/intent`, owner.token, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ characterId: ownerCharacter.character.id, action: 'Iris checks the airlock telemetry for a safe path.', ready: true }),
|
||||
})
|
||||
await new Promise(resolve => setTimeout(resolve, 1_500))
|
||||
const waiting = await appRequest(`/api/v1/campaigns/${campaignId}`, player.token)
|
||||
if (waiting.round?.status !== 'open' || !waiting.intents.some(intent => intent.ready)) {
|
||||
throw new Error('[smoke] The round did not remain open while waiting for the second player.')
|
||||
}
|
||||
|
||||
console.log('[smoke] Submitting the second action and waiting for the worker…')
|
||||
await appRequest(`/api/v1/campaigns/${campaignId}/rounds/${roundId}/intent`, player.token, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ characterId: playerCharacter.character.id, action: 'Rowan studies the silver trees for signs of movement.', ready: true }),
|
||||
})
|
||||
|
||||
const deadline = Date.now() + 120_000
|
||||
let completed
|
||||
while (Date.now() < deadline) {
|
||||
await new Promise(resolve => setTimeout(resolve, 2_000))
|
||||
const next = await appRequest(`/api/v1/campaigns/${campaignId}`, owner.token)
|
||||
const resolved = next.rounds.find(round => round.id === roundId)
|
||||
if (resolved?.status === 'failed') throw new Error(`[smoke] Worker failed the multiplayer round: ${resolved.error || 'unknown error'}`)
|
||||
if (resolved?.status === 'resolved' && next.round?.status === 'open' && next.round.id !== roundId) {
|
||||
completed = next
|
||||
break
|
||||
}
|
||||
}
|
||||
if (!completed) throw new Error('[smoke] Multiplayer round did not resolve within 120 seconds.')
|
||||
if (!completed.rounds.find(round => round.id === roundId)?.narration) throw new Error('[smoke] Resolved round has no narration.')
|
||||
|
||||
console.log(`[smoke] PASS — 2 players joined, shared readiness, worker resolution, and next round all work (campaign ${campaignId}).`)
|
||||
2064
supabase/bootstrap.sql
Normal file
2064
supabase/bootstrap.sql
Normal file
File diff suppressed because it is too large
Load Diff
@@ -38,7 +38,10 @@ create table public.world_entities (
|
||||
summary text not null,
|
||||
tags text[] not null default '{}',
|
||||
secrets jsonb not null default '[]' check (jsonb_typeof(secrets) = 'array'),
|
||||
search_document tsvector generated always as (to_tsvector('english', coalesce(name, '') || ' ' || coalesce(summary, '') || ' ' || array_to_string(tags, ' '))) stored,
|
||||
-- Generated expressions may only call IMMUTABLE functions. PostgreSQL marks
|
||||
-- array_to_string as STABLE, so tags stay queryable through their text[]
|
||||
-- column while the FTS document indexes the entity's searchable prose.
|
||||
search_document tsvector generated always as (to_tsvector('english'::regconfig, coalesce(name, '') || ' ' || coalesce(summary, ''))) stored,
|
||||
created_at timestamptz not null default now()
|
||||
);
|
||||
create index world_entities_search_idx on public.world_entities using gin(search_document);
|
||||
@@ -175,7 +178,7 @@ create table public.memories (
|
||||
importance integer not null check (importance between 1 and 5),
|
||||
tags text[] not null default '{}',
|
||||
entity_ids uuid[] not null default '{}',
|
||||
search_document tsvector generated always as (to_tsvector('english', coalesce(summary, '') || ' ' || array_to_string(tags, ' '))) stored,
|
||||
search_document tsvector generated always as (to_tsvector('english'::regconfig, coalesce(summary, ''))) stored,
|
||||
created_at timestamptz not null default now()
|
||||
);
|
||||
create index memories_search_idx on public.memories using gin(search_document);
|
||||
|
||||
384
supabase/migrations/0003_stage_two_multiplayer.sql
Normal file
384
supabase/migrations/0003_stage_two_multiplayer.sql
Normal file
@@ -0,0 +1,384 @@
|
||||
-- Stage two multiplayer primitives. All mutating RPCs are service-role only;
|
||||
-- Nitro authenticates the caller and passes the verified auth.users id.
|
||||
|
||||
alter table public.coauthor_sessions
|
||||
add column if not exists confirmed_world_id uuid references public.worlds(id) on delete set null;
|
||||
alter table public.worlds add column if not exists hook text;
|
||||
alter table public.worlds add column if not exists opening_scene text;
|
||||
|
||||
create index if not exists coauthor_sessions_owner_updated_idx
|
||||
on public.coauthor_sessions(owner_id, updated_at desc);
|
||||
create index if not exists invites_campaign_expiry_idx
|
||||
on public.invites(campaign_id, expires_at);
|
||||
create unique index if not exists ai_jobs_world_generation_unique
|
||||
on public.ai_jobs(entity_id) where job_type = 'generate-world';
|
||||
|
||||
create or replace function public.stage_two_append_coauthor_message(
|
||||
p_session_id uuid,
|
||||
p_owner_id uuid,
|
||||
p_content text
|
||||
) returns jsonb language plpgsql security definer set search_path = '' as $$
|
||||
declare
|
||||
v_session public.coauthor_sessions%rowtype;
|
||||
v_result jsonb;
|
||||
begin
|
||||
if p_content is null or char_length(btrim(p_content)) not between 1 and 5000 then
|
||||
raise exception 'message must be between 1 and 5000 characters';
|
||||
end if;
|
||||
select * into v_session from public.coauthor_sessions
|
||||
where id = p_session_id and owner_id = p_owner_id for update;
|
||||
if not found then raise exception 'coauthor session not found'; end if;
|
||||
if v_session.status in ('generating', 'confirmed') then
|
||||
raise exception 'coauthor session cannot be edited in status %', v_session.status;
|
||||
end if;
|
||||
update public.coauthor_sessions
|
||||
set messages = messages || jsonb_build_array(jsonb_build_object('role', 'user', 'content', btrim(p_content))),
|
||||
generated_world = null,
|
||||
status = 'collecting',
|
||||
updated_at = now()
|
||||
where id = p_session_id
|
||||
returning jsonb_build_object('id', id, 'status', status, 'messages', messages, 'updatedAt', updated_at)
|
||||
into v_result;
|
||||
return v_result;
|
||||
end;
|
||||
$$;
|
||||
|
||||
create or replace function public.stage_two_append_coauthor_assistant_message(
|
||||
p_session_id uuid,
|
||||
p_owner_id uuid,
|
||||
p_content text
|
||||
) returns void language plpgsql security definer set search_path = '' as $$
|
||||
begin
|
||||
if p_content is null or char_length(btrim(p_content)) not between 1 and 5000 then
|
||||
raise exception 'assistant message must be between 1 and 5000 characters';
|
||||
end if;
|
||||
update public.coauthor_sessions
|
||||
set messages = messages || jsonb_build_array(jsonb_build_object('role', 'assistant', 'content', btrim(p_content))),
|
||||
updated_at = now()
|
||||
where id = p_session_id and owner_id = p_owner_id and status = 'collecting';
|
||||
if not found then raise exception 'collecting coauthor session not found'; end if;
|
||||
end;
|
||||
$$;
|
||||
|
||||
create or replace function public.stage_two_open_next_round()
|
||||
returns trigger language plpgsql security definer set search_path = '' as $$
|
||||
begin
|
||||
if old.status is distinct from new.status
|
||||
and new.status in ('resolved'::public.round_status, 'failed'::public.round_status) then
|
||||
update public.characters
|
||||
set controller = 'human'::public.character_controller
|
||||
where campaign_id = new.campaign_id
|
||||
and controller = 'delegated'::public.character_controller
|
||||
and user_id is not null;
|
||||
if new.status = 'resolved'::public.round_status
|
||||
and exists (select 1 from public.campaigns where id = new.campaign_id and status = 'active') then
|
||||
insert into public.rounds(campaign_id, number, status)
|
||||
values (new.campaign_id, new.number + 1, 'open')
|
||||
on conflict (campaign_id, number) do nothing;
|
||||
end if;
|
||||
end if;
|
||||
return new;
|
||||
end;
|
||||
$$;
|
||||
|
||||
drop trigger if exists stage_two_round_resolved_open_next on public.rounds;
|
||||
create trigger stage_two_round_resolved_open_next
|
||||
after update of status on public.rounds
|
||||
for each row execute function public.stage_two_open_next_round();
|
||||
|
||||
insert into public.rounds(campaign_id, number, status)
|
||||
select latest.campaign_id, latest.number + 1, 'open'
|
||||
from public.rounds latest
|
||||
join public.campaigns campaign on campaign.id = latest.campaign_id and campaign.status = 'active'
|
||||
where latest.status = 'resolved'
|
||||
and not exists (
|
||||
select 1 from public.rounds newer
|
||||
where newer.campaign_id = latest.campaign_id and newer.number > latest.number
|
||||
)
|
||||
on conflict (campaign_id, number) do nothing;
|
||||
|
||||
create or replace function public.stage_two_upsert_story_summary(
|
||||
p_job_id uuid,
|
||||
p_campaign_id uuid,
|
||||
p_through_round integer,
|
||||
p_summary text
|
||||
) returns uuid language plpgsql security definer set search_path = '' as $$
|
||||
declare
|
||||
v_summary_id uuid;
|
||||
begin
|
||||
if p_through_round < 1 or p_summary is null or char_length(btrim(p_summary)) not between 20 and 6000 then
|
||||
raise exception 'invalid story summary';
|
||||
end if;
|
||||
if not exists (
|
||||
select 1
|
||||
from public.ai_jobs job
|
||||
join public.rounds round on round.id = job.entity_id
|
||||
where job.id = p_job_id
|
||||
and job.job_type = 'resolve-round'
|
||||
and job.status = 'complete'
|
||||
and round.campaign_id = p_campaign_id
|
||||
and round.number = p_through_round
|
||||
and round.status = 'resolved'
|
||||
) then
|
||||
raise exception 'completed round job not found';
|
||||
end if;
|
||||
insert into public.story_summaries(campaign_id, through_round, summary)
|
||||
values (p_campaign_id, p_through_round, btrim(p_summary))
|
||||
on conflict (campaign_id, through_round) do update set summary = excluded.summary
|
||||
returning id into v_summary_id;
|
||||
return v_summary_id;
|
||||
end;
|
||||
$$;
|
||||
|
||||
create or replace function public.stage_two_enqueue_world_generation(
|
||||
p_session_id uuid,
|
||||
p_owner_id uuid
|
||||
) returns uuid language plpgsql security definer set search_path = '' as $$
|
||||
declare
|
||||
v_session public.coauthor_sessions%rowtype;
|
||||
v_job public.ai_jobs%rowtype;
|
||||
begin
|
||||
select * into v_session from public.coauthor_sessions
|
||||
where id = p_session_id and owner_id = p_owner_id for update;
|
||||
if not found then raise exception 'coauthor session not found'; end if;
|
||||
if v_session.status = 'confirmed' then raise exception 'coauthor session is already confirmed'; end if;
|
||||
if jsonb_array_length(v_session.messages) < 1 then raise exception 'at least one message is required'; end if;
|
||||
|
||||
select * into v_job from public.ai_jobs
|
||||
where job_type = 'generate-world' and entity_id = p_session_id for update;
|
||||
if found then
|
||||
if v_job.status in ('queued', 'running') then return v_job.id; end if;
|
||||
update public.ai_jobs
|
||||
set status = 'queued', attempts = 0, claimed_by = null, lease_expires_at = null,
|
||||
available_at = now(), error = null, updated_at = now()
|
||||
where id = v_job.id;
|
||||
else
|
||||
v_job.id := gen_random_uuid();
|
||||
insert into public.ai_jobs(id, job_type, entity_id, idempotency_key, status)
|
||||
values (v_job.id, 'generate-world', p_session_id, v_job.id::text, 'queued');
|
||||
end if;
|
||||
update public.coauthor_sessions
|
||||
set status = 'generating', generated_world = null, updated_at = now()
|
||||
where id = p_session_id;
|
||||
return v_job.id;
|
||||
end;
|
||||
$$;
|
||||
|
||||
create or replace function public.stage_two_confirm_world(
|
||||
p_session_id uuid,
|
||||
p_owner_id uuid
|
||||
) returns uuid language plpgsql security definer set search_path = '' as $$
|
||||
declare
|
||||
v_session public.coauthor_sessions%rowtype;
|
||||
v_world jsonb;
|
||||
v_world_id uuid;
|
||||
v_entity jsonb;
|
||||
begin
|
||||
select * into v_session from public.coauthor_sessions
|
||||
where id = p_session_id and owner_id = p_owner_id for update;
|
||||
if not found then raise exception 'coauthor session not found'; end if;
|
||||
if v_session.status = 'confirmed' and v_session.confirmed_world_id is not null then
|
||||
return v_session.confirmed_world_id;
|
||||
end if;
|
||||
if v_session.status <> 'ready' or v_session.generated_world is null then
|
||||
raise exception 'coauthor session is not ready to confirm';
|
||||
end if;
|
||||
v_world := v_session.generated_world;
|
||||
if coalesce(v_world->>'title', '') = '' or coalesce(v_world->>'openingScene', '') = '' then
|
||||
raise exception 'generated world is incomplete';
|
||||
end if;
|
||||
|
||||
insert into public.worlds(
|
||||
owner_id, title, genre, tone, premise, content_boundaries,
|
||||
hidden_threat, hook, opening_scene, status
|
||||
) values (
|
||||
p_owner_id, v_world->>'title', v_world->>'genre', v_world->>'tone', v_world->>'premise',
|
||||
coalesce(v_world->'contentBoundaries', '[]'::jsonb), v_world->>'hiddenThreat',
|
||||
v_world->>'hook', v_world->>'openingScene', 'confirmed'
|
||||
) returning id into v_world_id;
|
||||
|
||||
v_entity := v_world->'startingLocation';
|
||||
insert into public.world_entities(world_id, kind, name, summary, tags, secrets)
|
||||
values (v_world_id, 'location', v_entity->>'name', v_entity->>'summary',
|
||||
array(select jsonb_array_elements_text(coalesce(v_entity->'tags', '[]'::jsonb))),
|
||||
coalesce(v_entity->'secrets', '[]'::jsonb));
|
||||
for v_entity in select * from jsonb_array_elements(v_world->'npcs') loop
|
||||
insert into public.world_entities(world_id, kind, name, summary, tags, secrets)
|
||||
values (v_world_id, 'npc', v_entity->>'name', v_entity->>'summary',
|
||||
array(select jsonb_array_elements_text(coalesce(v_entity->'tags', '[]'::jsonb))),
|
||||
coalesce(v_entity->'secrets', '[]'::jsonb));
|
||||
end loop;
|
||||
for v_entity in select * from jsonb_array_elements(v_world->'factions') loop
|
||||
insert into public.world_entities(world_id, kind, name, summary, tags, secrets)
|
||||
values (v_world_id, 'faction', v_entity->>'name', v_entity->>'summary',
|
||||
array(select jsonb_array_elements_text(coalesce(v_entity->'tags', '[]'::jsonb))),
|
||||
coalesce(v_entity->'secrets', '[]'::jsonb));
|
||||
end loop;
|
||||
insert into public.world_entities(world_id, kind, name, summary, tags, secrets)
|
||||
values (v_world_id, 'quest', 'Opening Hook', v_world->>'hook', array['opening'], '[]'::jsonb);
|
||||
|
||||
update public.coauthor_sessions
|
||||
set status = 'confirmed', confirmed_world_id = v_world_id, updated_at = now()
|
||||
where id = p_session_id;
|
||||
return v_world_id;
|
||||
end;
|
||||
$$;
|
||||
|
||||
create or replace function public.stage_two_create_campaign(
|
||||
p_world_id uuid,
|
||||
p_owner_id uuid,
|
||||
p_title text
|
||||
) returns uuid language plpgsql security definer set search_path = '' as $$
|
||||
declare
|
||||
v_world public.worlds%rowtype;
|
||||
v_campaign_id uuid;
|
||||
begin
|
||||
if p_title is null or char_length(btrim(p_title)) not between 3 and 100 then
|
||||
raise exception 'campaign title must be between 3 and 100 characters';
|
||||
end if;
|
||||
select * into v_world from public.worlds
|
||||
where id = p_world_id and owner_id = p_owner_id and status = 'confirmed' for share;
|
||||
if not found then raise exception 'confirmed world not found'; end if;
|
||||
insert into public.campaigns(world_id, owner_id, title, current_scene, next_prompt, status)
|
||||
values (p_world_id, p_owner_id, btrim(p_title), coalesce(v_world.opening_scene, v_world.premise), 'What do you do?', 'active')
|
||||
returning id into v_campaign_id;
|
||||
insert into public.campaign_members(campaign_id, user_id, role)
|
||||
values (v_campaign_id, p_owner_id, 'owner');
|
||||
insert into public.rounds(campaign_id, number) values (v_campaign_id, 1);
|
||||
return v_campaign_id;
|
||||
end;
|
||||
$$;
|
||||
|
||||
create or replace function public.stage_two_join_campaign(
|
||||
p_token_hash text,
|
||||
p_user_id uuid
|
||||
) returns uuid language plpgsql security definer set search_path = '' as $$
|
||||
declare
|
||||
v_invite public.invites%rowtype;
|
||||
v_campaign_id uuid;
|
||||
begin
|
||||
if p_token_hash is null or char_length(p_token_hash) <> 64 then raise exception 'invalid invite token'; end if;
|
||||
select * into v_invite from public.invites where token_hash = p_token_hash for update;
|
||||
if not found or v_invite.expires_at <= now() or v_invite.uses >= v_invite.max_uses then
|
||||
raise exception 'invite is invalid or expired';
|
||||
end if;
|
||||
if not exists (select 1 from public.profiles where id = p_user_id) then raise exception 'profile not found'; end if;
|
||||
if not exists (select 1 from public.campaigns where id = v_invite.campaign_id and status <> 'archived') then
|
||||
raise exception 'campaign is not available';
|
||||
end if;
|
||||
v_campaign_id := v_invite.campaign_id;
|
||||
if exists (select 1 from public.campaign_members where campaign_id = v_campaign_id and user_id = p_user_id) then
|
||||
update public.campaign_members set active = true where campaign_id = v_campaign_id and user_id = p_user_id;
|
||||
return v_campaign_id;
|
||||
end if;
|
||||
insert into public.campaign_members(campaign_id, user_id, role) values (v_campaign_id, p_user_id, 'player');
|
||||
update public.invites set uses = uses + 1 where id = v_invite.id;
|
||||
return v_campaign_id;
|
||||
end;
|
||||
$$;
|
||||
|
||||
create or replace function public.stage_two_submit_intent(
|
||||
p_round_id uuid,
|
||||
p_user_id uuid,
|
||||
p_character_id uuid,
|
||||
p_action text,
|
||||
p_ready boolean default false
|
||||
) returns jsonb language plpgsql security definer set search_path = '' as $$
|
||||
declare
|
||||
v_round public.rounds%rowtype;
|
||||
v_member public.campaign_members%rowtype;
|
||||
v_intent_id uuid;
|
||||
v_job_id uuid;
|
||||
begin
|
||||
if p_action is null or char_length(btrim(p_action)) not between 1 and 2000 then
|
||||
raise exception 'action must be between 1 and 2000 characters';
|
||||
end if;
|
||||
select * into v_round from public.rounds where id = p_round_id for update;
|
||||
if not found or v_round.status <> 'open' then raise exception 'round is not open'; end if;
|
||||
select * into v_member from public.campaign_members
|
||||
where campaign_id = v_round.campaign_id and user_id = p_user_id and active;
|
||||
if not found then raise exception 'active campaign membership not found'; end if;
|
||||
if not exists (
|
||||
select 1 from public.characters
|
||||
where id = p_character_id and campaign_id = v_round.campaign_id
|
||||
and user_id = p_user_id and controller = 'human'
|
||||
) then raise exception 'controlled character not found'; end if;
|
||||
|
||||
insert into public.player_intents(round_id, member_id, character_id, action, ready)
|
||||
values (p_round_id, v_member.id, p_character_id, btrim(p_action), coalesce(p_ready, false))
|
||||
on conflict (round_id, member_id) do update
|
||||
set character_id = excluded.character_id, action = excluded.action,
|
||||
ready = excluded.ready, updated_at = now()
|
||||
returning id into v_intent_id;
|
||||
|
||||
if coalesce(p_ready, false) and not exists (
|
||||
select 1 from public.campaign_members member
|
||||
where member.campaign_id = v_round.campaign_id and member.active
|
||||
and not exists (
|
||||
select 1 from public.player_intents intent
|
||||
where intent.round_id = p_round_id and intent.member_id = member.id and intent.ready
|
||||
)
|
||||
) then
|
||||
v_job_id := public.enqueue_round_resolution(p_round_id, null);
|
||||
end if;
|
||||
return jsonb_build_object('intentId', v_intent_id, 'jobId', v_job_id);
|
||||
end;
|
||||
$$;
|
||||
|
||||
create or replace function public.stage_two_force_round(
|
||||
p_round_id uuid,
|
||||
p_owner_id uuid
|
||||
) returns uuid language plpgsql security definer set search_path = '' as $$
|
||||
declare
|
||||
v_round public.rounds%rowtype;
|
||||
v_job_id uuid;
|
||||
begin
|
||||
select * into v_round from public.rounds where id = p_round_id for update;
|
||||
if not found or v_round.status <> 'open' then raise exception 'round is not open'; end if;
|
||||
if not exists (
|
||||
select 1 from public.campaigns
|
||||
where id = v_round.campaign_id and owner_id = p_owner_id
|
||||
) then raise exception 'only the campaign owner can force a round'; end if;
|
||||
|
||||
update public.characters character
|
||||
set controller = 'delegated'::public.character_controller
|
||||
from public.campaign_members member
|
||||
where character.campaign_id = v_round.campaign_id
|
||||
and character.user_id = member.user_id
|
||||
and character.controller = 'human'::public.character_controller
|
||||
and member.campaign_id = v_round.campaign_id
|
||||
and member.active
|
||||
and member.ai_takeover_allowed
|
||||
and not exists (
|
||||
select 1 from public.player_intents intent
|
||||
where intent.round_id = p_round_id
|
||||
and intent.member_id = member.id
|
||||
and intent.ready
|
||||
);
|
||||
|
||||
v_job_id := public.enqueue_round_resolution(p_round_id, p_owner_id);
|
||||
return v_job_id;
|
||||
end;
|
||||
$$;
|
||||
|
||||
revoke all on function public.stage_two_append_coauthor_message(uuid, uuid, text) from public, anon, authenticated;
|
||||
revoke all on function public.stage_two_append_coauthor_assistant_message(uuid, uuid, text) from public, anon, authenticated;
|
||||
revoke all on function public.stage_two_enqueue_world_generation(uuid, uuid) from public, anon, authenticated;
|
||||
revoke all on function public.stage_two_confirm_world(uuid, uuid) from public, anon, authenticated;
|
||||
revoke all on function public.stage_two_create_campaign(uuid, uuid, text) from public, anon, authenticated;
|
||||
revoke all on function public.stage_two_join_campaign(text, uuid) from public, anon, authenticated;
|
||||
revoke all on function public.stage_two_submit_intent(uuid, uuid, uuid, text, boolean) from public, anon, authenticated;
|
||||
revoke all on function public.stage_two_force_round(uuid, uuid) from public, anon, authenticated;
|
||||
revoke all on function public.stage_two_open_next_round() from public, anon, authenticated;
|
||||
revoke all on function public.stage_two_upsert_story_summary(uuid, uuid, integer, text) from public, anon, authenticated;
|
||||
grant execute on function public.stage_two_append_coauthor_message(uuid, uuid, text) to service_role;
|
||||
grant execute on function public.stage_two_append_coauthor_assistant_message(uuid, uuid, text) to service_role;
|
||||
grant execute on function public.stage_two_enqueue_world_generation(uuid, uuid) to service_role;
|
||||
grant execute on function public.stage_two_confirm_world(uuid, uuid) to service_role;
|
||||
grant execute on function public.stage_two_create_campaign(uuid, uuid, text) to service_role;
|
||||
grant execute on function public.stage_two_join_campaign(text, uuid) to service_role;
|
||||
grant execute on function public.stage_two_submit_intent(uuid, uuid, uuid, text, boolean) to service_role;
|
||||
grant execute on function public.stage_two_force_round(uuid, uuid) to service_role;
|
||||
grant execute on function public.stage_two_upsert_story_summary(uuid, uuid, integer, text) to service_role;
|
||||
|
||||
notify pgrst, 'reload schema';
|
||||
100
supabase/migrations/0004_anonymous_alpha_access.sql
Normal file
100
supabase/migrations/0004_anonymous_alpha_access.sql
Normal file
@@ -0,0 +1,100 @@
|
||||
-- Guest access still uses a real authenticated Supabase user. This keeps the
|
||||
-- existing ownership checks, RLS policies, and multiplayer membership model.
|
||||
-- Email accounts remain restricted to the allowlist.
|
||||
|
||||
create or replace function public.create_profile_for_allowlisted_user()
|
||||
returns trigger
|
||||
language plpgsql
|
||||
security definer
|
||||
set search_path = ''
|
||||
as $$
|
||||
declare
|
||||
v_is_anonymous boolean := coalesce(new.is_anonymous, false);
|
||||
v_display_name text;
|
||||
begin
|
||||
if not v_is_anonymous and (
|
||||
new.email is null or not exists (
|
||||
select 1
|
||||
from public.allowlist
|
||||
where email = lower(btrim(new.email))
|
||||
)
|
||||
) then
|
||||
raise exception using
|
||||
errcode = '42501',
|
||||
message = 'This email is not invited to the Dungeons & Ground alpha.';
|
||||
end if;
|
||||
|
||||
v_display_name := case
|
||||
when v_is_anonymous then 'Guest ' || upper(substr(replace(new.id::text, '-', ''), 1, 6))
|
||||
else coalesce(nullif(btrim(new.raw_user_meta_data ->> 'display_name'), ''), 'Adventurer')
|
||||
end;
|
||||
|
||||
insert into public.profiles(id, display_name)
|
||||
values (new.id, v_display_name)
|
||||
on conflict (id) do nothing;
|
||||
|
||||
return new;
|
||||
end;
|
||||
$$;
|
||||
|
||||
revoke all on function public.create_profile_for_allowlisted_user() from public;
|
||||
|
||||
-- An anonymous account can be upgraded later. Recheck the allowlist on the
|
||||
-- relevant auth.users transition so linking an email cannot bypass the alpha
|
||||
-- gate. Unrelated token and metadata updates do not fire this trigger.
|
||||
create or replace function public.enforce_alpha_email_allowlist()
|
||||
returns trigger
|
||||
language plpgsql
|
||||
security definer
|
||||
set search_path = ''
|
||||
as $$
|
||||
begin
|
||||
if not coalesce(new.is_anonymous, false) and (
|
||||
new.email is null or not exists (
|
||||
select 1
|
||||
from public.allowlist
|
||||
where email = lower(btrim(new.email))
|
||||
)
|
||||
) then
|
||||
raise exception using
|
||||
errcode = '42501',
|
||||
message = 'This email is not invited to the Dungeons & Ground alpha.';
|
||||
end if;
|
||||
|
||||
return new;
|
||||
end;
|
||||
$$;
|
||||
|
||||
revoke all on function public.enforce_alpha_email_allowlist() from public;
|
||||
|
||||
drop trigger if exists enforce_alpha_email_allowlist on auth.users;
|
||||
create trigger enforce_alpha_email_allowlist
|
||||
before update of email, is_anonymous on auth.users
|
||||
for each row
|
||||
when (
|
||||
old.email is distinct from new.email
|
||||
or old.is_anonymous is distinct from new.is_anonymous
|
||||
)
|
||||
execute function public.enforce_alpha_email_allowlist();
|
||||
|
||||
-- The schema may be installed after Auth users already exist. Backfill only
|
||||
-- anonymous users and explicitly allowlisted email accounts; do not silently
|
||||
-- enable unrelated accounts.
|
||||
insert into public.profiles(id, display_name)
|
||||
select
|
||||
auth_user.id,
|
||||
case
|
||||
when coalesce(auth_user.is_anonymous, false)
|
||||
then 'Guest ' || upper(substr(replace(auth_user.id::text, '-', ''), 1, 6))
|
||||
else coalesce(nullif(btrim(auth_user.raw_user_meta_data ->> 'display_name'), ''), 'Adventurer')
|
||||
end
|
||||
from auth.users auth_user
|
||||
where coalesce(auth_user.is_anonymous, false)
|
||||
or exists (
|
||||
select 1
|
||||
from public.allowlist
|
||||
where allowlist.email = lower(btrim(auth_user.email))
|
||||
)
|
||||
on conflict (id) do nothing;
|
||||
|
||||
notify pgrst, 'reload schema';
|
||||
83
supabase/migrations/0005_failed_round_retry.sql
Normal file
83
supabase/migrations/0005_failed_round_retry.sql
Normal file
@@ -0,0 +1,83 @@
|
||||
-- Owners can safely retry a round after all worker attempts have failed.
|
||||
-- The queue has one durable outbox row per round. A retry records the failed
|
||||
-- state in the audit log, then safely re-queues that same row.
|
||||
create or replace function public.stage_two_retry_failed_round(
|
||||
p_round_id uuid,
|
||||
p_owner_id uuid
|
||||
) returns uuid language plpgsql security definer set search_path = '' as $$
|
||||
declare
|
||||
v_round public.rounds%rowtype;
|
||||
v_job public.ai_jobs%rowtype;
|
||||
begin
|
||||
select * into v_round
|
||||
from public.rounds
|
||||
where id = p_round_id
|
||||
for update;
|
||||
|
||||
if not found then raise exception 'round not found'; end if;
|
||||
if v_round.status <> 'failed'::public.round_status then
|
||||
raise exception 'round is not failed';
|
||||
end if;
|
||||
if not exists (
|
||||
select 1 from public.campaigns
|
||||
where id = v_round.campaign_id
|
||||
and owner_id = p_owner_id
|
||||
and status = 'active'
|
||||
) then
|
||||
raise exception 'only the campaign owner can retry a failed round';
|
||||
end if;
|
||||
if exists (
|
||||
select 1 from public.rounds
|
||||
where campaign_id = v_round.campaign_id
|
||||
and id <> p_round_id
|
||||
and status in ('open'::public.round_status, 'queued'::public.round_status, 'resolving'::public.round_status)
|
||||
) then
|
||||
raise exception 'campaign already has an active round';
|
||||
end if;
|
||||
|
||||
select * into v_job
|
||||
from public.ai_jobs
|
||||
where job_type = 'resolve-round' and entity_id = p_round_id
|
||||
for update;
|
||||
if not found or v_job.status <> 'failed' then
|
||||
raise exception 'failed round job not found';
|
||||
end if;
|
||||
|
||||
insert into public.audit_entries(
|
||||
campaign_id, actor_id, action, entity_type, entity_id, before_state, after_state
|
||||
) values (
|
||||
v_round.campaign_id,
|
||||
p_owner_id,
|
||||
'retry_failed_round',
|
||||
'round',
|
||||
p_round_id,
|
||||
jsonb_build_object('roundError', v_round.error, 'jobError', v_job.error, 'attempts', v_job.attempts),
|
||||
jsonb_build_object('status', 'queued')
|
||||
);
|
||||
|
||||
update public.ai_jobs
|
||||
set status = 'queued',
|
||||
attempts = 0,
|
||||
claimed_by = null,
|
||||
lease_expires_at = null,
|
||||
available_at = now(),
|
||||
error = null,
|
||||
updated_at = now()
|
||||
where id = v_job.id;
|
||||
|
||||
update public.rounds
|
||||
set status = 'queued'::public.round_status,
|
||||
error = null,
|
||||
queued_at = now(),
|
||||
resolved_at = null,
|
||||
forced_by = p_owner_id
|
||||
where id = p_round_id;
|
||||
|
||||
return v_job.id;
|
||||
end;
|
||||
$$;
|
||||
|
||||
revoke all on function public.stage_two_retry_failed_round(uuid, uuid) from public, anon, authenticated;
|
||||
grant execute on function public.stage_two_retry_failed_round(uuid, uuid) to service_role;
|
||||
|
||||
notify pgrst, 'reload schema';
|
||||
63
supabase/migrations/0006_retry_job_compatibility.sql
Normal file
63
supabase/migrations/0006_retry_job_compatibility.sql
Normal file
@@ -0,0 +1,63 @@
|
||||
-- Replace the first retry implementation for installations that already ran
|
||||
-- migration 0005 against the one-job-per-round queue constraint.
|
||||
create or replace function public.stage_two_retry_failed_round(
|
||||
p_round_id uuid,
|
||||
p_owner_id uuid
|
||||
) returns uuid language plpgsql security definer set search_path = '' as $$
|
||||
declare
|
||||
v_round public.rounds%rowtype;
|
||||
v_job public.ai_jobs%rowtype;
|
||||
begin
|
||||
select * into v_round from public.rounds where id = p_round_id for update;
|
||||
if not found then raise exception 'round not found'; end if;
|
||||
if v_round.status <> 'failed'::public.round_status then raise exception 'round is not failed'; end if;
|
||||
if not exists (
|
||||
select 1 from public.campaigns
|
||||
where id = v_round.campaign_id and owner_id = p_owner_id and status = 'active'
|
||||
) then raise exception 'only the campaign owner can retry a failed round'; end if;
|
||||
if exists (
|
||||
select 1 from public.rounds
|
||||
where campaign_id = v_round.campaign_id
|
||||
and id <> p_round_id
|
||||
and status in ('open'::public.round_status, 'queued'::public.round_status, 'resolving'::public.round_status)
|
||||
) then raise exception 'campaign already has an active round'; end if;
|
||||
|
||||
select * into v_job
|
||||
from public.ai_jobs
|
||||
where job_type = 'resolve-round' and entity_id = p_round_id
|
||||
for update;
|
||||
if not found or v_job.status <> 'failed' then raise exception 'failed round job not found'; end if;
|
||||
|
||||
insert into public.audit_entries(
|
||||
campaign_id, actor_id, action, entity_type, entity_id, before_state, after_state
|
||||
) values (
|
||||
v_round.campaign_id, p_owner_id, 'retry_failed_round', 'round', p_round_id,
|
||||
jsonb_build_object('roundError', v_round.error, 'jobError', v_job.error, 'attempts', v_job.attempts),
|
||||
jsonb_build_object('status', 'queued')
|
||||
);
|
||||
|
||||
update public.ai_jobs
|
||||
set status = 'queued', attempts = 0, claimed_by = null, lease_expires_at = null,
|
||||
available_at = now(), error = null, updated_at = now()
|
||||
where id = v_job.id;
|
||||
|
||||
update public.rounds
|
||||
set status = 'queued'::public.round_status, error = null, queued_at = now(),
|
||||
resolved_at = null, forced_by = p_owner_id
|
||||
where id = p_round_id;
|
||||
|
||||
return v_job.id;
|
||||
end;
|
||||
$$;
|
||||
|
||||
create or replace function public.dng_schema_version()
|
||||
returns integer language sql stable security definer set search_path = '' as $$
|
||||
select 6;
|
||||
$$;
|
||||
|
||||
revoke all on function public.stage_two_retry_failed_round(uuid, uuid) from public, anon, authenticated;
|
||||
revoke all on function public.dng_schema_version() from public, anon, authenticated;
|
||||
grant execute on function public.stage_two_retry_failed_round(uuid, uuid) to service_role;
|
||||
grant execute on function public.dng_schema_version() to service_role;
|
||||
|
||||
notify pgrst, 'reload schema';
|
||||
@@ -5,6 +5,12 @@ export default defineConfig({
|
||||
test: {
|
||||
include: ['packages/**/*.test.ts', 'apps/**/*.test.ts'],
|
||||
environment: 'node',
|
||||
// Nuxt/TypeScript transforms are CPU-heavy on small Node 18 machines.
|
||||
// Running files sequentially avoids false timeout failures caused by
|
||||
// several transform workers competing for the same memory and CPU.
|
||||
fileParallelism: false,
|
||||
testTimeout: 60_000,
|
||||
hookTimeout: 60_000,
|
||||
},
|
||||
resolve: {
|
||||
alias: {
|
||||
|
||||
Reference in New Issue
Block a user