The first 2 weeks is ended.
Some checks failed
CI / validate (push) Failing after 14m50s

This commit is contained in:
2026-08-14 10:48:54 +05:00
commit 1774496cf9
48 changed files with 10825 additions and 0 deletions

23
.env.example Normal file
View File

@@ -0,0 +1,23 @@
# Web / Supabase (required)
NUXT_PUBLIC_SUPABASE_URL=
SUPABASE_URL=
NUXT_PUBLIC_SUPABASE_ANON_KEY=
SUPABASE_SERVICE_ROLE_KEY=
# AI provider (choose openrouter or deepseek)
AI_PROVIDER=openrouter
OPENROUTER_API_KEY=
OPENROUTER_MODEL=deepseek/deepseek-v4-flash
OPENROUTER_API_ENDPOINT=https://openrouter.ai/api/v1/chat/completions
DEEPSEEK_API_KEY=
DEEPSEEK_MODEL=deepseek-v4-flash
DEEPSEEK_API_ENDPOINT=https://api.deepseek.com/chat/completions
# Queue (optional for the web process)
REDIS_URL=
AI_JOB_POLL_INTERVAL_MS=1000
# Alpha controls
INVITE_ALLOWLIST=founder@example.com
DAILY_AI_TOKEN_LIMIT=250000
NUXT_PUBLIC_APP_URL=http://localhost:3000

22
.github/workflows/ci.yml vendored Normal file
View File

@@ -0,0 +1,22 @@
name: CI
on:
push:
pull_request:
jobs:
validate:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 18.20.8
cache: pnpm
- uses: pnpm/action-setup@v4
with:
version: 10.15.0
- run: pnpm install --frozen-lockfile
- run: pnpm test
- run: pnpm typecheck
- run: pnpm build

12
.gitignore vendored Normal file
View File

@@ -0,0 +1,12 @@
node_modules
.pnpm-store
.nuxt
.output
dist
coverage
.env
.env.*
!.env.example
.DS_Store
playwright-report
test-results

1
.nvmrc Normal file
View File

@@ -0,0 +1 @@
18.20.8

5
LEGAL.md Normal file
View File

@@ -0,0 +1,5 @@
# Legal notices
This work includes material from the System Reference Document 5.2.1 (“SRD 5.2.1”) by Wizards of the Coast LLC, available at https://www.dndbeyond.com/srd. The SRD 5.2.1 is licensed under the Creative Commons Attribution 4.0 International License, available at https://creativecommons.org/licenses/by/4.0/legalcode.
Dungeons & Ground is not affiliated with, endorsed, sponsored, or specifically approved by Wizards of the Coast LLC. Product copy and generated content must not use protected settings, characters, creatures, or product identity that is not included in SRD 5.2.1.

48
README.md Normal file
View File

@@ -0,0 +1,48 @@
# 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.
## Local start
```bash
cp .env.example .env
pnpm install
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.
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.
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.
Use Node.js 18.20.5 or newer. The lockfile pins the web toolchain to the Node 18-compatible Nuxt 3.15, Nitro 2.10, and Vite 6 line; CI verifies the project on Node 18.20.8.
## Workspace
- `apps/web` — Nuxt SSR UI and Nitro API
- `apps/worker` — BullMQ worker for AI world and round jobs
- `packages/shared` — shared Zod contracts and domain types
- `packages/game-engine` — deterministic d20 rules and state transition guards
- `supabase/migrations` — PostgreSQL schema, indexes, triggers, and RLS policies
## Alpha safety
- Worlds are private by default.
- Content is limited to 13+; explicit sexual content and sexual content involving minors are rejected.
- AI responses are parsed against strict schemas.
- AI can propose checks and events, but cannot directly set dice outcomes or mechanical values.
- SRD-derived work must retain the attribution in `LEGAL.md`.
## 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`.
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`.
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.

3
apps/web/app.vue Normal file
View File

@@ -0,0 +1,3 @@
<template>
<NuxtPage />
</template>

View File

@@ -0,0 +1 @@
:root{--acid:#d9f75f;--acid-dim:#596522;--ink:#eeeeE7;--muted:#888881;--line:#272725;--display:'Unbounded',sans-serif;--body:'Manrope',sans-serif;--mono:'DM Mono',monospace;color-scheme:dark}*{box-sizing:border-box}html{background:#0a0a0a;color:var(--ink);font-family:var(--body);scroll-behavior:smooth}body{margin:0;background:#0a0a0a}button,input,textarea{font:inherit}button,a{transition:filter .2s ease,opacity .2s ease}button:not(:disabled),a{cursor:pointer}button:hover:not(:disabled),a:hover{filter:brightness(1.12)}::selection{background:var(--acid);color:#070707}.noise{background-color:#0a0a0a;background-image:linear-gradient(rgba(255,255,255,.013) 1px,transparent 1px),linear-gradient(90deg,rgba(255,255,255,.013) 1px,transparent 1px);background-size:34px 34px}textarea:focus,input:focus,button:focus-visible,a:focus-visible{outline:1px solid var(--acid);outline-offset:2px}@media(prefers-reduced-motion:reduce){*{animation-duration:.01ms!important;animation-iteration-count:1!important;scroll-behavior:auto!important}}

View File

@@ -0,0 +1,10 @@
<template>
<NuxtLink to="/" class="mark" aria-label="Dungeons & Ground home">
<span class="mark-icon" aria-hidden="true">D<span>&</span>G</span>
<span class="mark-copy"><strong>DUNGEONS</strong><small>&amp; GROUND</small></span>
</NuxtLink>
</template>
<style scoped>
.mark{display:inline-flex;align-items:center;gap:12px;color:var(--ink);text-decoration:none}.mark-icon{display:grid;place-items:center;width:44px;height:44px;border:1px solid var(--acid);font:600 14px/1 var(--display);letter-spacing:-1px;clip-path:polygon(8px 0,100% 0,100% calc(100% - 8px),calc(100% - 8px) 100%,0 100%,0 8px)}.mark-icon span{color:var(--acid);font-size:9px}.mark-copy{display:flex;flex-direction:column;font-family:var(--display);letter-spacing:.08em}.mark-copy strong{font-size:12px}.mark-copy small{font-size:8px;color:var(--muted);letter-spacing:.32em}
</style>

View File

@@ -0,0 +1,21 @@
<script setup lang="ts">
defineProps<{ section?: string }>()
</script>
<template>
<div class="shell">
<header class="topbar">
<AppMark />
<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>
</div>
</header>
<main><slot /></main>
</div>
</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}}
</style>

View File

@@ -0,0 +1,45 @@
import type { Character, WorldStarter } from '@dng/shared'
const defaultWorld: WorldStarter = {
title: 'Signal at Black Meridian',
genre: 'Solar gothic science fiction',
tone: 'Tense, mysterious, hopeful under pressure',
premise: 'At the edge of charted space, a dead relay begins broadcasting tomorrows distress calls. The crew of the courier Orison is the only ship close enough to answer.',
contentBoundaries: ['13+ adventure', 'No explicit sexual content', 'No graphic gore'],
startingLocation: { id: 'loc_meridian', kind: 'location', name: 'Black Meridian Relay', summary: 'A lightless communications cathedral orbiting a cold blue star.', tags: ['station', 'derelict'], secrets: ['The distress calls are being sent by the crew themselves, three days in the future.'] },
npcs: [
{ id: 'npc_lyra', kind: 'npc', name: 'Lyra Venn', summary: 'A calm signal analyst who hears patterns as music.', tags: ['analyst'], secrets: ['She recognizes the voice in the transmission.'] },
{ id: 'npc_cassian', kind: 'npc', name: 'Cassian Holt', summary: 'A company marshal with a sealed retrieval order.', tags: ['marshal'], secrets: ['His orders prioritize the relay core over the crew.'] },
{ id: 'npc_moth', kind: 'npc', name: 'Moth', summary: 'A maintenance intelligence speaking through antique service drones.', tags: ['ai'], secrets: ['Moth has been awake for eighty-seven years.'] },
],
factions: [
{ id: 'fac_heliograph', kind: 'faction', name: 'Heliograph Compact', summary: 'A mercantile coalition that owns the relay network.', tags: ['corporate'], secrets: [] },
{ id: 'fac_quiet', kind: 'faction', name: 'The Quiet Choir', summary: 'Pilgrims who believe signals can remember the dead.', tags: ['mystic'], secrets: [] },
],
hook: 'Dock with the silent relay, locate the source of the impossible distress call, and decide whether its warning should be believed.',
hiddenThreat: 'A causality fracture is teaching the relay to choose which future becomes real.',
openingScene: 'The Orison drifts beneath the relays vast black vanes. Every console shows the same countdown: 00:17:42. Then your own voice breaks through the static: “Do not open the central archive.”',
}
const defaultCharacters: Character[] = [
{ id: 'char_mara', name: 'Mara Vale', concept: 'Human salvage pilot with an instinct for impossible routes', controller: 'human', userId: 'demo', abilities: { str: 10, dex: 16, con: 13, int: 12, wis: 14, cha: 11 }, hp: 11, maxHp: 11, defense: 14, proficiency: 2, inventory: ['Pulse cutter', 'Vacuum cloak'], statuses: [] },
{ id: 'char_rook', name: 'Rook-7', concept: 'AI companion inhabiting a weathered security frame', controller: 'ai', userId: null, abilities: { str: 16, dex: 11, con: 15, int: 10, wis: 12, cha: 8 }, hp: 15, maxHp: 15, defense: 15, proficiency: 2, inventory: ['Arc baton', 'Emergency beacon'], statuses: [] },
]
export function useDemo() {
const signedIn = useState('signed-in', () => false)
const worlds = useState<WorldStarter[]>('worlds', () => [defaultWorld])
const activeWorld = useState<WorldStarter>('active-world', () => defaultWorld)
const characters = useState<Character[]>('characters', () => defaultCharacters)
const roundNumber = useState('round-number', () => 3)
const scene = useState('scene', () => defaultWorld.openingScene)
const history = useState<Array<{ type: 'narration' | 'action' | 'roll'; author: string; body: string; meta?: string }>>('history', () => [
{ type: 'narration', author: 'GROUNDKEEPER', body: defaultWorld.openingScene, meta: 'ROUND 03 · NOW' },
{ type: 'action', author: 'MARA VALE', body: 'I ask the Orison to isolate the transmission and compare the voiceprint to mine.', meta: 'READY' },
{ type: 'roll', author: 'SYSTEM CHECK', body: 'Intelligence check · 1d20 + 3 = 17', meta: 'SUCCESS · DC 14' },
])
const currentAction = useState('current-action', () => '')
const ready = useState('ready', () => false)
return { signedIn, worlds, activeWorld, characters, roundNumber, scene, history, currentAction, ready }
}

40
apps/web/nuxt.config.ts Normal file
View File

@@ -0,0 +1,40 @@
export default defineNuxtConfig({
compatibilityDate: '2025-07-15',
devtools: { enabled: false },
css: ['~/assets/css/main.css'],
modules: [],
runtimeConfig: {
aiProvider: process.env.AI_PROVIDER ?? 'openrouter',
openrouterApiKey: process.env.OPENROUTER_API_KEY,
openrouterModel: process.env.OPENROUTER_MODEL ?? 'deepseek/deepseek-v4-flash',
openrouterApiEndpoint: process.env.OPENROUTER_API_ENDPOINT ?? 'https://openrouter.ai/api/v1/chat/completions',
deepseekApiKey: process.env.DEEPSEEK_API_KEY,
deepseekModel: process.env.DEEPSEEK_MODEL ?? 'deepseek-v4-flash',
deepseekApiEndpoint: process.env.DEEPSEEK_API_ENDPOINT ?? 'https://api.deepseek.com/chat/completions',
redisUrl: process.env.REDIS_URL,
supabaseUrl: process.env.SUPABASE_URL,
supabaseServiceRoleKey: process.env.SUPABASE_SERVICE_ROLE_KEY,
public: {
supabaseUrl: process.env.NUXT_PUBLIC_SUPABASE_URL,
supabaseAnonKey: process.env.NUXT_PUBLIC_SUPABASE_ANON_KEY,
appUrl: process.env.NUXT_PUBLIC_APP_URL ?? 'http://localhost:3000',
},
},
// Run `nuxt typecheck` separately. Keeping build-time checking off also avoids
// shell quoting issues when the repository path contains an ampersand.
typescript: { strict: true, typeCheck: false },
app: {
head: {
title: 'Dungeons & Ground — Stories that wait for no one',
meta: [
{ name: 'description', content: 'Create a private universe, gather your party, and let an AI Game Master keep the adventure moving.' },
{ name: 'theme-color', content: '#0a0a0a' },
],
link: [
{ rel: 'preconnect', href: 'https://fonts.googleapis.com' },
{ rel: 'preconnect', href: 'https://fonts.gstatic.com', crossorigin: '' },
{ rel: 'stylesheet', href: 'https://fonts.googleapis.com/css2?family=DM+Mono:wght@400;500&family=Manrope:wght@400;500;600;700&family=Unbounded:wght@500;600;700&display=swap' },
],
},
},
})

28
apps/web/package.json Normal file
View File

@@ -0,0 +1,28 @@
{
"name": "@dng/web",
"version": "0.1.0",
"private": true,
"type": "module",
"engines": {
"node": "^18.20.5 || ^20.9.0 || >=22.0.0"
},
"scripts": {
"dev": "nuxt dev --dotenv ../../.env",
"build": "nuxt build --dotenv ../../.env",
"preview": "nuxt preview --dotenv ../../.env",
"test": "vitest run",
"typecheck": "nuxt typecheck"
},
"dependencies": {
"@dng/game-engine": "workspace:*",
"@dng/shared": "workspace:*",
"nuxt": "3.15.4",
"zod": "^4.1.5"
},
"devDependencies": {
"@nuxt/test-utils": "3.15.4",
"@types/node": "^18.19.0",
"typescript": "^5.9.2",
"vue-tsc": "2.2.0"
}
}

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,36 @@
<script setup lang="ts">
const { worlds } = useDemo()
const filter = ref<'all' | 'active' | 'drafts'>('all')
</script>
<template>
<AppShell section="COMMAND DECK">
<div class="dash-wrap noise">
<section class="dash-head">
<div><p class="kicker">WELCOME BACK, MARA</p><h1>YOUR UNIVERSES<span>.</span></h1><p>Every world remembers where you left it.</p></div>
<NuxtLink to="/worlds/new" class="create-button"><span></span> CREATE A UNIVERSE</NuxtLink>
</section>
<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>
</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>
</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>
</NuxtLink>
</section>
<section class="system-strip"><span><i /> OPENROUTER ADAPTER READY</span><span>SERVER DICE <b>ONLINE</b></span><span>PRIVATE BY DEFAULT</span></section>
</div>
</AppShell>
</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}}
</style>

61
apps/web/pages/index.vue Normal file
View File

@@ -0,0 +1,61 @@
<script setup lang="ts">
const { signedIn } = useDemo()
const email = ref('founder@example.com')
const notice = ref('')
function enterDemo() {
signedIn.value = true
navigateTo('/dashboard')
}
function requestAccess() {
notice.value = `Invite request saved for ${email.value}. Demo access is available now.`
}
</script>
<template>
<div class="landing noise">
<header class="landing-nav">
<AppMark />
<span class="alpha-tag">INVITE-ONLY ALPHA</span>
</header>
<main class="hero">
<section class="hero-copy">
<p class="eyebrow"><span /> ASYNC AI-POWERED TTRPG</p>
<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 allowsthe Groundkeeper remembers everything.</p>
<div class="hero-actions">
<button class="btn btn-acid" @click="enterDemo">ENTER THE ALPHA <span></span></button>
<a href="#how" class="btn btn-ghost">SEE HOW IT WORKS</a>
</div>
<div class="trust-row">
<span>PRIVATE WORLDS</span><i />
<span>SERVER-OWNED DICE</span><i />
<span>13+ ADVENTURES</span>
</div>
</section>
<section class="signal-card" aria-label="Live campaign preview">
<div class="signal-top"><span>LIVE SIGNAL / CAMPAIGN 04</span><b>ROUND 17</b></div>
<div class="orbit" aria-hidden="true"><i /><i /><i /><span>D&G</span></div>
<div class="transmission">
<small>GROUNDKEEPER</small>
<p>The voice in the relay is yoursthree days from now. It tells you not to open the archive.</p>
</div>
<div class="party-status"><span><b>3</b> HUMANS READY</span><span><b>1</b> AI COMPANION THINKING</span></div>
</section>
</main>
<section id="how" class="how-grid">
<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>
</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)}}
</style>

View File

@@ -0,0 +1,100 @@
<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'] },
]
const draft = ref<WorldStarter | null>(null)
function answerFor(key: string) {
return answers[key as keyof typeof answers]
}
function begin() {
if (!input.value.trim()) return
questionIndex.value = 1
}
function choose(key: string, value: string) {
;(answers as Record<string, string>)[key] = value
if (questionIndex.value < questions.length) questionIndex.value += 1
}
async function generate() {
step.value = 'generating'
try {
draft.value = await $fetch<WorldStarter>('/api/worlds/generate', { method: 'POST', body: { prompt: input.value, answers } })
} catch {
draft.value = structuredClone(activeWorld.value)
}
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')
}
</script>
<template>
<AppShell section="WORLD FORGE">
<div class="forge noise">
<aside>
<p class="step-label">CREATION PROTOCOL</p>
<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>
</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">
<p class="kicker">COAUTHOR / SESSION 01</p>
<h1>WHAT SHOULD<br>WE BUILD<span>?</span></h1>
<div class="chat-line ai"><small>COAUTHOR</small><p>Give me the first impossible sentence. Ill help turn it into a world your party can enter tonight.</p></div>
<form class="seed" @submit.prevent="begin"><textarea v-model="input" aria-label="World idea" rows="3"/><button>BEGIN <span></span></button></form>
<div v-for="(question, index) in questions" v-show="questionIndex > index" :key="question.key" class="question-block">
<div class="chat-line ai"><small>COAUTHOR · {{ String(index+2).padStart(2,'0') }}</small><p>{{ question.label }}</p></div>
<div class="choices">
<button v-for="option in question.options" :key="option" :class="{selected:answerFor(question.key)===option}" @click="choose(question.key, option)">{{ option }}</button>
</div>
</div>
<button v-if="Object.values(answers).every(Boolean)" class="generate" @click="generate">GENERATE STARTING WORLD <span></span></button>
</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>
</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}}
</style>

View File

@@ -0,0 +1,24 @@
import { resolveCheck } from '@dng/game-engine'
import { CharacterSchema, moderate13Plus } from '@dng/shared'
export default defineEventHandler(async event => {
const body = await readBody(event)
const action = String(body?.action ?? '')
if (!action.trim()) throw createError({ statusCode: 400, statusMessage: 'An action is required.' })
if (!moderate13Plus(action).allowed) throw createError({ statusCode: 422, statusMessage: 'The action falls outside the 13+ boundary.' })
const actor = CharacterSchema.parse({
id: 'char_mara', name: 'Mara Vale', concept: 'Salvage pilot', controller: 'human', userId: 'demo',
abilities: { str: 10, dex: 16, con: 13, int: 12, wis: 14, cha: 11 }, hp: 11, maxHp: 11,
defense: 14, proficiency: 2, inventory: ['Pulse cutter', 'Vacuum cloak'], statuses: [],
})
const check = resolveCheck({ actorId: actor.id, kind: 'ability', ability: 'int', difficulty: 13, mode: 'normal', reason: action }, actor)
const success = check.success === true
return {
roll: `Intelligence check · ${check.formula} = ${check.total}`,
outcome: `${success ? 'SUCCESS' : 'COMPLICATION'} · DC ${check.difficulty}`,
narration: success
? 'The transmission separates into two layers. Beneath your future voice is a maintenance handshake signed by Moth—dated eighty-seven years ago. Rook-7 turns toward the sealed archive as its door unlocks one deliberate centimeter. “That,” the machine says, “was not me.”'
: 'The signal fractures when you isolate it. For one breath every screen shows a different version of the archive—open, burning, empty. Rook-7 catches one surviving packet before the rest vanish: a map leading below the station, marked in your own handwriting.',
}
})

View File

@@ -0,0 +1,36 @@
import { CreateWorldRequestSchema, WorldStarterSchema, moderate13Plus } from '@dng/shared'
import { buildJsonCompletion, parseJsonCompletion } from '../../utils/ai-provider'
export default defineEventHandler(async event => {
const raw = await readBody(event)
const prompt = typeof raw?.prompt === 'string' ? raw.prompt : ''
const answers = raw?.answers && typeof raw.answers === 'object' ? raw.answers : {}
const moderation = moderate13Plus(`${prompt} ${Object.values(answers).join(' ')}`)
if (!moderation.allowed) throw createError({ statusCode: 422, statusMessage: 'This concept falls outside the alphas 13+ content boundary.' })
const config = useRuntimeConfig()
const request = CreateWorldRequestSchema.parse({ messages: [{ role: 'user', content: `${prompt}\nPreferences: ${JSON.stringify(answers)}` }] })
let completion
try {
completion = buildJsonCompletion(config, {
system: 'Create an original 13+ TTRPG starting world. Return only valid JSON matching the supplied WorldStarter structure, with exactly 3 NPCs and 2 factions. Do not use protected franchises.',
messages: request.messages,
schemaName: 'world_starter',
jsonSchema: WorldStarterSchema.toJSONSchema(),
})
} catch (error) {
throw createError({ statusCode: 500, statusMessage: error instanceof Error ? error.message : 'Invalid AI provider configuration.' })
}
const response = await fetch(completion.endpoint, {
method: 'POST',
headers: completion.headers,
body: JSON.stringify(completion.body),
})
if (!response.ok) throw createError({ statusCode: 502, statusMessage: 'The coauthor is temporarily unavailable.' })
try {
return WorldStarterSchema.parse(parseJsonCompletion(await response.json()))
} catch {
throw createError({ statusCode: 502, statusMessage: 'The coauthor returned an invalid world structure.' })
}
})

View File

@@ -0,0 +1,7 @@
export default defineNitroPlugin(() => {
// Keep builds and unit tests credential-free, but fail before serving traffic
// when a production Nitro process starts with an incomplete Supabase setup.
if (process.env.NODE_ENV === 'production') {
assertRequiredSupabaseConfig(useRuntimeConfig())
}
})

View File

@@ -0,0 +1,25 @@
import { describe, expect, it } from 'vitest'
import { buildJsonCompletion, parseJsonCompletion, resolveAiProvider } from './ai-provider'
const request = { system: 'Return JSON.', messages: [{ role: 'user' as const, content: 'Create a world' }], schemaName: 'world', jsonSchema: { type: 'object' } }
describe('AI provider adapter', () => {
it('keeps OpenRouter structured outputs', () => {
const completion = buildJsonCompletion({ aiProvider: 'openrouter', openrouterApiKey: 'key', openrouterModel: 'model' }, request)
expect(completion.endpoint).toBe('https://openrouter.ai/api/v1/chat/completions')
expect(completion.body.response_format).toEqual({ type: 'json_schema', json_schema: { name: 'world', strict: true, schema: { type: 'object' } } })
})
it('uses official DeepSeek JSON mode', () => {
const completion = buildJsonCompletion({ aiProvider: 'deepseek', deepseekApiKey: 'key', deepseekModel: 'deepseek-v4-flash' }, request)
expect(completion.endpoint).toBe('https://api.deepseek.com/chat/completions')
expect(completion.body.response_format).toEqual({ type: 'json_object' })
expect(completion.body['thinking']).toEqual({ type: 'disabled' })
})
it('rejects missing credentials and malformed responses', () => {
expect(() => resolveAiProvider({ aiProvider: 'deepseek' })).toThrow('DEEPSEEK_API_KEY')
expect(() => parseJsonCompletion({ choices: [] })).toThrow()
expect(parseJsonCompletion({ choices: [{ message: { content: '{"ok":true}' } }] })).toEqual({ ok: true })
})
})

View File

@@ -0,0 +1,103 @@
import { z } from 'zod'
const ProviderSchema = z.enum(['openrouter', 'deepseek'])
const CompletionResponseSchema = z.object({
choices: z.array(z.object({
message: z.object({ content: z.string().min(1) }),
})).min(1),
})
export type AiProvider = z.infer<typeof ProviderSchema>
export interface AiRuntimeConfig {
aiProvider?: unknown
openrouterApiKey?: unknown
openrouterModel?: unknown
openrouterApiEndpoint?: unknown
deepseekApiKey?: unknown
deepseekModel?: unknown
deepseekApiEndpoint?: unknown
}
export interface JsonCompletionRequest {
system: string
messages: Array<{ role: 'user' | 'assistant'; content: string }>
schemaName: string
jsonSchema: Record<string, unknown>
}
interface JsonCompletion {
endpoint: string
headers: Record<string, string>
body: Record<string, unknown>
}
const nonEmptyString = (value: unknown, name: string) => {
const result = z.string().trim().min(1).safeParse(value)
if (!result.success) throw new Error(`${name} is required for the selected AI provider`)
return result.data
}
const endpoint = (value: unknown, fallback: string, name: string) => {
const result = z.string().url().safeParse(value || fallback)
if (!result.success) throw new Error(`${name} must be a valid URL`)
return result.data
}
export function resolveAiProvider(config: AiRuntimeConfig) {
const providerResult = ProviderSchema.safeParse(config.aiProvider || 'openrouter')
if (!providerResult.success) throw new Error('AI_PROVIDER must be either "openrouter" or "deepseek"')
if (providerResult.data === 'deepseek') {
return {
provider: 'deepseek' as const,
apiKey: nonEmptyString(config.deepseekApiKey, 'DEEPSEEK_API_KEY'),
model: nonEmptyString(config.deepseekModel || 'deepseek-v4-flash', 'DEEPSEEK_MODEL'),
endpoint: endpoint(config.deepseekApiEndpoint, 'https://api.deepseek.com/chat/completions', 'DEEPSEEK_API_ENDPOINT'),
}
}
return {
provider: 'openrouter' as const,
apiKey: nonEmptyString(config.openrouterApiKey, 'OPENROUTER_API_KEY'),
model: nonEmptyString(config.openrouterModel || 'deepseek/deepseek-v4-flash', 'OPENROUTER_MODEL'),
endpoint: endpoint(config.openrouterApiEndpoint, 'https://openrouter.ai/api/v1/chat/completions', 'OPENROUTER_API_ENDPOINT'),
}
}
export function buildJsonCompletion(config: AiRuntimeConfig, request: JsonCompletionRequest): JsonCompletion {
const provider = resolveAiProvider(config)
const messages = [{ role: 'system' as const, content: request.system }, ...request.messages]
const common = { model: provider.model, messages, stream: false }
if (provider.provider === 'deepseek') {
return {
endpoint: provider.endpoint,
headers: { Authorization: `Bearer ${provider.apiKey}`, 'Content-Type': 'application/json' },
body: {
...common,
messages: [
{ role: 'system' as const, content: `${request.system}\nReturn only valid JSON matching this JSON Schema: ${JSON.stringify(request.jsonSchema)}` },
...request.messages,
],
response_format: { type: 'json_object' },
thinking: { type: 'disabled' },
},
}
}
return {
endpoint: provider.endpoint,
headers: { Authorization: `Bearer ${provider.apiKey}`, 'Content-Type': 'application/json', 'X-Title': 'Dungeons & Ground' },
body: {
...common,
response_format: { type: 'json_schema', json_schema: { name: request.schemaName, strict: true, schema: request.jsonSchema } },
provider: { require_parameters: true, data_collection: 'deny' },
},
}
}
export function parseJsonCompletion(payload: unknown): unknown {
const completion = CompletionResponseSchema.parse(payload)
return JSON.parse(completion.choices[0]!.message.content)
}

View File

@@ -0,0 +1,12 @@
import { describe, expect, it } from 'vitest'
import { assertRequiredSupabaseConfig } from './runtime-config'
describe('required Supabase runtime config', () => {
it('accepts complete credentials', () => {
expect(() => assertRequiredSupabaseConfig({ supabaseUrl: 'https://example.supabase.co', supabaseServiceRoleKey: 'service', public: { supabaseUrl: 'https://example.supabase.co', supabaseAnonKey: 'anon' } })).not.toThrow()
})
it('reports missing credentials clearly', () => {
expect(() => assertRequiredSupabaseConfig({ public: {} })).toThrow('SUPABASE_SERVICE_ROLE_KEY is required')
})
})

View File

@@ -0,0 +1,18 @@
import { z } from 'zod'
const RequiredSupabaseConfigSchema = z.object({
supabaseUrl: z.string({ error: 'SUPABASE_URL is required' }).trim().url('SUPABASE_URL must be a valid URL'),
supabaseServiceRoleKey: z.string({ error: 'SUPABASE_SERVICE_ROLE_KEY is required' }).trim().min(1, 'SUPABASE_SERVICE_ROLE_KEY is required'),
public: z.object({
supabaseUrl: z.string({ error: 'NUXT_PUBLIC_SUPABASE_URL is required' }).trim().url('NUXT_PUBLIC_SUPABASE_URL must be a valid URL'),
supabaseAnonKey: z.string({ error: 'NUXT_PUBLIC_SUPABASE_ANON_KEY is required' }).trim().min(1, 'NUXT_PUBLIC_SUPABASE_ANON_KEY is required'),
}),
})
export function assertRequiredSupabaseConfig(config: unknown): void {
const result = RequiredSupabaseConfigSchema.safeParse(config)
if (!result.success) {
const details = result.error.issues.map(issue => issue.message).join('; ')
throw new Error(`Invalid Supabase runtime configuration: ${details}`)
}
}

3
apps/web/tsconfig.json Normal file
View File

@@ -0,0 +1,3 @@
{
"extends": "./.nuxt/tsconfig.json"
}

27
apps/worker/package.json Normal file
View File

@@ -0,0 +1,27 @@
{
"name": "@dng/worker",
"version": "0.1.0",
"private": true,
"type": "module",
"engines": {
"node": ">=18.17"
},
"scripts": {
"dev": "tsx watch src/index.ts",
"start": "tsx src/index.ts",
"typecheck": "tsc --noEmit -p tsconfig.json"
},
"dependencies": {
"@dng/game-engine": "workspace:*",
"@dng/shared": "workspace:*",
"bullmq": "^5.58.5",
"dotenv": "16.6.1",
"ioredis": "^5.7.0",
"zod": "^4.1.5"
},
"devDependencies": {
"@types/node": "^18.19.0",
"tsx": "^4.20.5",
"typescript": "^5.9.2"
}
}

View File

@@ -0,0 +1,38 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
const originalEnv = { ...process.env }
afterEach(() => {
process.env = { ...originalEnv }
vi.unstubAllGlobals()
})
describe('worker AI providers', () => {
it('uses official DeepSeek JSON mode and validates the parsed response', async () => {
process.env.AI_PROVIDER = 'deepseek'
process.env.DEEPSEEK_API_KEY = 'secret'
process.env.DEEPSEEK_MODEL = 'deepseek-v4-flash'
const world = {
title: 'Test Reach', genre: 'Science fiction', tone: 'Tense', premise: 'A sufficiently long premise for a generated private campaign world.',
contentBoundaries: ['13+'],
startingLocation: { id: 'location', kind: 'location', name: 'Gate', summary: 'A station beyond known space.', tags: [], secrets: [] },
npcs: [1, 2, 3].map(index => ({ id: `npc-${index}`, kind: 'npc', name: `NPC ${index}`, summary: 'A useful person with their own agenda.', tags: [], secrets: [] })),
factions: [1, 2].map(index => ({ id: `faction-${index}`, kind: 'faction', name: `Faction ${index}`, summary: 'An organization pursuing a hidden objective.', tags: [], secrets: [] })),
hook: 'A sufficiently long hook that immediately gives the party something to investigate.',
hiddenThreat: 'A hidden threat waits beyond the gate.',
openingScene: 'The gate opens without warning, and an impossible signal calls every hero by name.',
}
const fetchMock = vi.fn(async (_url: string, init?: RequestInit) => {
const body = JSON.parse(String(init?.body))
expect(body.response_format).toEqual({ type: 'json_object' })
expect(body.thinking).toEqual({ type: 'disabled' })
expect(body.model).toBe('deepseek-v4-flash')
expect(body.messages[0].content).toContain('JSON Schema')
return new Response(JSON.stringify({ choices: [{ message: { content: JSON.stringify(world) } }] }), { status: 200 })
})
vi.stubGlobal('fetch', fetchMock)
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)
})

113
apps/worker/src/ai.ts Normal file
View File

@@ -0,0 +1,113 @@
import { RoundPlanSchema, RoundResolutionSchema, WorldStarterSchema, moderate13Plus, type Character, type PlayerIntent, type RoundPlan, type RoundResolution, type WorldStarter } from '@dng/shared'
import type { DiceRoll } from '@dng/shared'
type JsonSchema = Record<string, unknown>
interface AiProvider {
name: 'openrouter' | 'deepseek'
endpoint: string
apiKey: string
model: string
headers: Record<string, string>
providerOptions?: Record<string, unknown>
}
function getProvider(): AiProvider {
const provider = (process.env.AI_PROVIDER ?? 'openrouter').toLowerCase()
if (provider === 'deepseek') {
const apiKey = process.env.DEEPSEEK_API_KEY
if (!apiKey) throw new Error('DEEPSEEK_API_KEY is not configured')
return {
name: 'deepseek',
endpoint: process.env.DEEPSEEK_API_ENDPOINT ?? 'https://api.deepseek.com/chat/completions',
apiKey,
model: process.env.DEEPSEEK_MODEL ?? 'deepseek-v4-flash',
headers: {},
}
}
if (provider !== 'openrouter') throw new Error(`Unsupported AI_PROVIDER: ${provider}`)
const apiKey = process.env.OPENROUTER_API_KEY
if (!apiKey) throw new Error('OPENROUTER_API_KEY is not configured')
return {
name: 'openrouter',
endpoint: process.env.OPENROUTER_API_ENDPOINT ?? 'https://openrouter.ai/api/v1/chat/completions',
apiKey,
model: process.env.OPENROUTER_MODEL ?? 'deepseek/deepseek-v4-flash',
headers: {
'HTTP-Referer': process.env.APP_URL ?? 'http://localhost:3000',
'X-Title': 'Dungeons & Ground',
},
providerOptions: { require_parameters: true, data_collection: 'deny', allow_fallbacks: true },
}
}
function assert13Plus(...texts: string[]): void {
const result = moderate13Plus(texts.join('\n'))
if (!result.allowed) throw new Error(`Content policy rejected: ${result.categories.join(', ')}`)
}
async function structuredRequest<T>(name: string, schema: JsonSchema, messages: Array<{ role: string; content: string }>, parse: (value: unknown) => T): Promise<T> {
const provider = getProvider()
const controller = new AbortController()
const timeout = setTimeout(() => controller.abort(), 45_000)
try {
const response = await fetch(provider.endpoint, {
method: 'POST',
headers: {
Authorization: `Bearer ${provider.apiKey}`,
'Content-Type': 'application/json',
...provider.headers,
},
body: JSON.stringify({
model: provider.model,
messages: provider.name === 'deepseek'
? [
{ role: 'system', content: `${messages[0]?.content ?? ''}\nReturn only valid JSON matching this JSON Schema: ${JSON.stringify(schema)}` },
...messages.slice(1),
]
: messages,
temperature: 0.65,
max_tokens: 4000,
...(provider.providerOptions ? { provider: provider.providerOptions } : {}),
...(provider.name === 'deepseek' ? { thinking: { type: 'disabled' } } : {}),
response_format: provider.name === 'deepseek'
? { type: 'json_object' }
: { type: 'json_schema', json_schema: { name, strict: true, schema } },
}),
signal: controller.signal,
})
if (!response.ok) throw new Error(`AI provider returned ${response.status}`)
const payload = await response.json() as { choices?: Array<{ message?: { content?: string } }>; usage?: unknown }
const content = payload.choices?.[0]?.message?.content
if (!content) throw new Error('AI provider returned an empty response')
return parse(JSON.parse(content))
} finally {
clearTimeout(timeout)
}
}
export async function generateWorld(messages: Array<{ role: 'user' | 'assistant'; content: string }>): Promise<WorldStarter> {
assert13Plus(...messages.map(message => message.content))
const world = await structuredRequest('world_starter', WorldStarterSchema.toJSONSchema(), [
{ role: 'system', content: 'You are the Dungeons & Ground world coauthor. Create an original 13+ private TTRPG setting in any requested genre. Never use protected settings or characters. Return exactly three NPCs and two factions. Make the opening immediately playable.' },
...messages,
], value => WorldStarterSchema.parse(value))
assert13Plus(world.title, world.premise, world.hook, world.hiddenThreat, world.openingScene)
return world
}
export async function planRound(input: { scene: string; intents: PlayerIntent[]; characters: Character[]; memories: string[] }): 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: '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> {
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: 'user', content: JSON.stringify(input) },
], value => RoundResolutionSchema.parse(value))
assert13Plus(resolution.narration, resolution.nextPrompt, resolution.memory?.summary ?? '')
return resolution
}

12
apps/worker/src/env.ts Normal file
View File

@@ -0,0 +1,12 @@
import { config } from 'dotenv'
import { fileURLToPath } from 'node:url'
// pnpm runs filtered workspace scripts with apps/worker as the current working
// directory. Resolve from this module so the monorepo root .env is found in
// dev, while real process variables supplied by Railway/CI keep precedence.
const rootEnvPath = fileURLToPath(new URL('../../../.env', import.meta.url))
const result = config({ path: rootEnvPath })
if (result.error && 'code' in result.error && result.error.code !== 'ENOENT') {
throw result.error
}

241
apps/worker/src/index.ts Normal file
View File

@@ -0,0 +1,241 @@
import './env'
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'
const redisUrl = process.env.REDIS_URL
const supabaseUrl = process.env.SUPABASE_URL
const serviceKey = process.env.SUPABASE_SERVICE_ROLE_KEY
if (!supabaseUrl || !serviceKey) {
throw new Error('[worker] SUPABASE_URL and SUPABASE_SERVICE_ROLE_KEY are required.')
} else {
const databaseUrl = supabaseUrl
const databaseServiceKey = serviceKey
type JobType = 'generate-world' | 'resolve-round'
interface JobPayload {
id: string
name: JobType
entityId: string
messages?: Array<{ role: 'user' | 'assistant'; content: string }>
claimToken?: string
}
interface ClaimedJob {
id: string
job_type: JobType
entity_id: string
}
class SupabaseRequestError extends Error {
constructor(
readonly status: number,
readonly path: string,
detail: string,
) {
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 returned ${status} for ${path}: ${detail}`
super(message)
this.name = 'SupabaseRequestError'
}
}
async function databaseRequest<T>(path: string, init: RequestInit = {}): Promise<T> {
const response = await fetch(new URL(`/rest/v1/${path}`, databaseUrl), {
...init,
headers: {
apikey: databaseServiceKey,
Authorization: `Bearer ${databaseServiceKey}`,
'Content-Type': 'application/json',
...init.headers,
},
})
if (!response.ok) {
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>
}
async function selectRows<T>(table: string, query: Record<string, string>): Promise<T[]> {
return databaseRequest<T[]>(`${table}?${new URLSearchParams(query)}`)
}
async function processWorld(job: JobPayload) {
let messages = job.messages
if (!messages) {
const [session] = await selectRows<{ messages: Array<{ role: 'user' | 'assistant'; content: string }> }>('coauthor_sessions', {
select: 'messages', id: `eq.${job.entityId}`, limit: '1',
})
if (!session) throw new Error('Coauthor session not found')
messages = session.messages
}
const world = await generateWorld(messages)
await databaseRequest(`coauthor_sessions?id=eq.${encodeURIComponent(job.entityId)}`, {
method: 'PATCH',
body: JSON.stringify({ status: 'ready', generated_world: world, updated_at: new Date().toISOString() }),
})
await databaseRequest('rpc/complete_ai_job', {
method: 'POST', body: JSON.stringify({ p_job_id: job.id, p_worker_id: job.claimToken ?? null }),
})
return world
}
async function processRound(job: JobPayload) {
const [round] = await selectRows<Record<string, any>>('rounds', {
select: '*,campaigns!inner(*)',
id: `eq.${job.entityId}`,
limit: '1',
})
if (!round) throw new Error('Round not found')
if (round.status === 'resolved') return { duplicate: true }
const [rawCharacters, rawIntents, rawMemories] = 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' }),
])
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,
}))
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 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 safeEvents = resolution.events.filter(event => permittedEvents.some(permitted => JSON.stringify(permitted) === JSON.stringify(event)))
const applied = applyMechanicalEvents(characters, safeEvents)
await databaseRequest(job.claimToken ? 'rpc/commit_claimed_round_resolution' : 'rpc/commit_round_resolution', {
method: 'POST',
body: JSON.stringify({
p_round_id: round.id,
p_narration: resolution.narration,
p_next_prompt: resolution.nextPrompt,
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_idempotency_key: job.id,
...(job.claimToken ? { p_worker_id: job.claimToken } : {}),
}),
})
return { narration: resolution.narration, rolls: rolls.length }
}
async function processJob(job: JobPayload) {
if (job.name === 'generate-world') return processWorld(job)
if (job.name === 'resolve-round') return processRound(job)
throw new Error(`Unknown job type: ${job.name}`)
}
async function withLeaseHeartbeat<T>(jobId: string, workerId: string, task: () => Promise<T>): Promise<T> {
let heartbeatError: unknown
let renewing = false
const heartbeat = setInterval(() => {
if (renewing) return
renewing = true
void databaseRequest('rpc/renew_ai_job_lease', {
method: 'POST', body: JSON.stringify({ p_job_id: jobId, p_worker_id: workerId }),
}).catch(error => { heartbeatError = error }).finally(() => { renewing = false })
}, 60_000)
heartbeat.unref()
try {
const result = await task()
if (heartbeatError) throw heartbeatError
return result
} finally {
clearInterval(heartbeat)
}
}
if (redisUrl) {
const connection = new IORedis(redisUrl, { maxRetriesPerRequest: null })
const workerId = process.env.RAILWAY_REPLICA_ID ?? makeId('worker')
const worker = new Worker('dng-ai', async job => {
if (!job.id) throw new Error('BullMQ job id must match an ai_jobs id')
const [claimed] = await databaseRequest<ClaimedJob[]>('rpc/claim_ai_job_by_id', {
method: 'POST', body: JSON.stringify({ p_job_id: String(job.id), p_worker_id: workerId }),
})
if (!claimed) throw new Error(`AI job ${job.id} is not claimable`)
try {
return await withLeaseHeartbeat(claimed.id, workerId, () => processJob({
id: claimed.id,
name: claimed.job_type,
entityId: claimed.entity_id,
messages: job.data.messages,
claimToken: workerId,
}))
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
await databaseRequest('rpc/retry_ai_job', {
method: 'POST', body: JSON.stringify({ p_job_id: claimed.id, p_worker_id: workerId, p_error: message.slice(0, 2_000) }),
})
throw error
}
}, { connection, concurrency: 4, lockDuration: 120_000 })
console.info('[worker] Dungeons & Ground worker ready (BullMQ)')
const close = async () => {
await worker.close()
await connection.quit()
}
process.once('SIGTERM', () => void close())
process.once('SIGINT', () => void close())
} else {
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
const stop = () => { stopped = true }
process.once('SIGTERM', stop)
process.once('SIGINT', stop)
console.info(`[worker] Dungeons & Ground worker ready (Supabase polling every ${pollInterval}ms)`)
while (!stopped) {
try {
const claimed = await databaseRequest<ClaimedJob[]>('rpc/claim_ai_job', {
method: 'POST', body: JSON.stringify({ p_worker_id: workerId }),
})
const job = claimed[0]
if (!job) {
await new Promise(resolve => setTimeout(resolve, pollInterval))
continue
}
try {
await withLeaseHeartbeat(job.id, workerId, () => processJob({ id: job.id, name: job.job_type, entityId: job.entity_id, claimToken: workerId }))
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
console.error(`[worker] ${job.job_type} ${job.id} failed: ${message}`)
await databaseRequest('rpc/retry_ai_job', {
method: 'POST',
body: JSON.stringify({ p_job_id: job.id, p_worker_id: workerId, p_error: message.slice(0, 2_000) }),
})
}
} catch (error) {
if (error instanceof SupabaseRequestError && error.status === 404 && error.path.startsWith('rpc/')) {
console.error(`[worker] ${error.message}`)
process.exitCode = 1
break
}
console.error('[worker] polling failed:', error)
await new Promise(resolve => setTimeout(resolve, pollInterval))
}
}
}
}

View File

@@ -0,0 +1,4 @@
{
"extends": "../../tsconfig.json",
"include": ["src/**/*.ts"]
}

37
package.json Normal file
View File

@@ -0,0 +1,37 @@
{
"name": "dungeons-and-ground",
"private": true,
"type": "module",
"version": "0.1.0",
"engines": {
"node": "^18.20.5 || ^20.9.0 || >=22.0.0"
},
"packageManager": "pnpm@10.15.0",
"workspaces": [
"apps/*",
"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",
"build": "pnpm -r --if-present build",
"test": "vitest run",
"test:watch": "vitest",
"typecheck": "pnpm -r --if-present typecheck"
},
"devDependencies": {
"@types/node": "^18.19.0",
"typescript": "^5.9.2",
"vite": "6.0.11",
"vitest": "3.2.4"
},
"pnpm": {
"overrides": {
"@nuxt/cli": "3.21.1",
"@nuxt/test-utils": "3.15.4",
"nitropack": "2.10.4",
"vite": "6.0.11"
}
}
}

View File

@@ -0,0 +1,20 @@
{
"name": "@dng/game-engine",
"version": "0.1.0",
"private": true,
"type": "module",
"engines": {
"node": ">=18.17"
},
"exports": "./src/index.ts",
"scripts": {
"typecheck": "tsc --noEmit -p tsconfig.json"
},
"dependencies": {
"@dng/shared": "workspace:*"
},
"devDependencies": {
"@types/node": "^18.19.0",
"typescript": "^5.9.2"
}
}

View File

@@ -0,0 +1,59 @@
import { describe, expect, it } from 'vitest'
import { abilityModifier, applyMechanicalEvents, bindMechanicalEventsToRolls, resolveCheck, shouldCloseRound, type RandomSource } from './index'
import type { Character } from '@dng/shared'
const fixed: RandomSource = { integer: () => 12 }
const hero: Character = {
id: 'hero', name: 'Rook', concept: 'Relic runner', controller: 'human', userId: 'user',
abilities: { str: 14, dex: 16, con: 12, int: 10, wis: 8, cha: 13 },
hp: 10, maxHp: 12, defense: 14, proficiency: 2, inventory: [], statuses: [],
}
describe('game engine', () => {
it('calculates ability modifiers', () => {
expect(abilityModifier(8)).toBe(-1)
expect(abilityModifier(16)).toBe(3)
})
it('keeps dice server-owned and auditable', () => {
const roll = resolveCheck({ actorId: 'hero', kind: 'ability', ability: 'dex', difficulty: 15, mode: 'normal', reason: 'Leap' }, hero, fixed)
expect(roll.rolls).toEqual([12])
expect(roll.total).toBe(17)
expect(roll.success).toBe(true)
})
it('labels disadvantage rolls correctly', () => {
const values = [18, 4]
const roll = resolveCheck(
{ actorId: 'hero', kind: 'ability', ability: 'dex', difficulty: 10, mode: 'disadvantage', reason: 'Sneak' },
hero,
{ integer: () => values.shift()! },
)
expect(roll.formula).toBe('2d20kl1+5')
expect(roll.kept).toEqual([4])
})
it('replaces model-proposed damage with the authoritative roll total', () => {
const damage = resolveCheck(
{ actorId: 'hero', targetId: 'target', kind: 'damage', dice: '1d6+2', mode: 'normal', reason: 'Strike' },
hero,
{ integer: () => 4 },
)
const events = bindMechanicalEventsToRolls([
{ type: 'damage', actorId: 'hero', targetId: 'target', value: 999, item: null, description: 'Strike' },
], [damage])
expect(events[0]?.value).toBe(6)
})
it('clamps mechanical state', () => {
const result = applyMechanicalEvents([hero], [{ type: 'damage', actorId: null, targetId: 'hero', value: 99, item: null, description: 'Catastrophic hit' }])
expect(result.characters[0]?.hp).toBe(0)
expect(result.audit).toHaveLength(1)
})
it('closes only complete or forced rounds', () => {
expect(shouldCloseRound(['a', 'b'], ['a'])).toBe(false)
expect(shouldCloseRound(['a', 'b'], ['a', 'b'])).toBe(true)
expect(shouldCloseRound(['a', 'b'], [], true)).toBe(true)
})
})

View File

@@ -0,0 +1,139 @@
import { randomInt } 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
}
export const secureRandom: RandomSource = {
integer(min, max) {
if (!Number.isInteger(min) || !Number.isInteger(max) || max < min) throw new Error('Invalid random range')
return randomInt(min, max + 1)
},
}
export function abilityModifier(score: number): number {
return Math.floor((score - 10) / 2)
}
export function rollDice(formula: string, random: RandomSource = secureRandom) {
const match = /^(\d+)d(\d+)(?:([+-])(\d+))?$/.exec(formula)
if (!match) throw new Error(`Unsupported dice formula: ${formula}`)
const count = Number(match[1])
const sides = Number(match[2])
const modifier = match[3] ? Number(`${match[3]}${match[4]}`) : 0
if (count < 1 || count > 100 || sides < 2 || sides > 1000) throw new Error('Dice formula outside safe limits')
const rolls = Array.from({ length: count }, () => random.integer(1, sides))
return { rolls, modifier, total: rolls.reduce((sum, value) => sum + value, modifier) }
}
/**
* Converts model-proposed damage/healing events into server-authoritative events.
* The model may decide that a roll is needed, but it cannot choose its result.
*/
export function bindMechanicalEventsToRolls(events: ProposedEvent[], rolls: DiceRoll[]): ProposedEvent[] {
const unusedRollIds = new Set(rolls.map(roll => roll.id))
return events.flatMap(event => {
if (event.type !== 'damage' && event.type !== 'healing') return [event]
if (!event.targetId) return []
const roll = rolls.find(candidate =>
unusedRollIds.has(candidate.id)
&& candidate.checkKind === event.type
&& candidate.actorId === event.actorId
&& candidate.targetId === event.targetId,
)
if (!roll) return []
unusedRollIds.delete(roll.id)
return [{ ...event, value: Math.max(0, roll.total) }]
})
}
function abilityForCheck(request: CheckRequest): AbilityKey {
if (request.ability) return request.ability
if (request.kind === 'initiative') return 'dex'
return 'str'
}
export function resolveCheck(request: CheckRequest, actor: Character, random: RandomSource = secureRandom): DiceRoll {
if (request.kind === 'damage' || request.kind === 'healing') {
const rolled = rollDice(request.dice ?? '1d6', random)
return {
id: makeId('roll'),
checkKind: request.kind,
formula: request.dice ?? '1d6',
rolls: rolled.rolls,
kept: rolled.rolls,
modifier: rolled.modifier,
total: rolled.total,
difficulty: null,
success: null,
actorId: actor.id,
targetId: request.targetId ?? null,
createdAt: new Date().toISOString(),
}
}
const ability = abilityForCheck(request)
const diceCount = request.mode === 'normal' ? 1 : 2
const rolls = Array.from({ length: diceCount }, () => random.integer(1, 20))
const keptValue = request.mode === 'advantage' ? Math.max(...rolls) : request.mode === 'disadvantage' ? Math.min(...rolls) : rolls[0]!
const modifier = abilityModifier(actor.abilities[ability]) + actor.proficiency
const total = keptValue + modifier
const difficulty = request.difficulty ?? null
return {
id: makeId('roll'),
checkKind: request.kind,
formula: request.mode === 'normal'
? `1d20${modifier >= 0 ? '+' : ''}${modifier}`
: `2d20${request.mode === 'advantage' ? 'kh1' : 'kl1'}${modifier >= 0 ? '+' : ''}${modifier}`,
rolls,
kept: [keptValue],
modifier,
total,
difficulty,
success: difficulty === null ? null : total >= difficulty,
actorId: actor.id,
targetId: request.targetId ?? null,
createdAt: new Date().toISOString(),
}
}
export interface AppliedState {
characters: Character[]
audit: Array<{ event: ProposedEvent; before: unknown; after: unknown }>
}
export function applyMechanicalEvents(characters: Character[], events: ProposedEvent[]): AppliedState {
const next = characters.map(character => ({ ...character, inventory: [...character.inventory], statuses: [...character.statuses] }))
const audit: AppliedState['audit'] = []
for (const event of events) {
if (!['damage', 'healing', 'inventory', 'status'].includes(event.type)) continue
const target = next.find(character => character.id === event.targetId)
if (!target) throw new Error(`Unknown event target: ${event.targetId}`)
const before = structuredClone(target)
if (event.type === 'damage') target.hp = Math.max(0, target.hp - Math.max(0, event.value ?? 0))
if (event.type === 'healing') target.hp = Math.min(target.maxHp, target.hp + Math.max(0, event.value ?? 0))
if (event.type === 'inventory' && event.item) {
if ((event.value ?? 1) >= 0 && !target.inventory.includes(event.item)) target.inventory.push(event.item)
if ((event.value ?? 1) < 0) target.inventory = target.inventory.filter(item => item !== event.item)
}
if (event.type === 'status' && event.item) {
if ((event.value ?? 1) >= 0 && !target.statuses.includes(event.item)) target.statuses.push(event.item)
if ((event.value ?? 1) < 0) target.statuses = target.statuses.filter(status => status !== event.item)
}
audit.push({ event, before, after: structuredClone(target) })
}
return { characters: next, audit }
}
export function shouldCloseRound(activeHumanMemberIds: string[], readyMemberIds: string[], forcedByOwner = false): boolean {
if (forcedByOwner) return true
return activeHumanMemberIds.length > 0 && activeHumanMemberIds.every(id => readyMemberIds.includes(id))
}

View File

@@ -0,0 +1,4 @@
{
"extends": "../../tsconfig.json",
"include": ["src/**/*.ts"]
}

View File

@@ -0,0 +1,20 @@
{
"name": "@dng/shared",
"version": "0.1.0",
"private": true,
"type": "module",
"engines": {
"node": ">=18.17"
},
"exports": "./src/index.ts",
"scripts": {
"typecheck": "tsc --noEmit -p tsconfig.json"
},
"dependencies": {
"zod": "^4.1.5"
},
"devDependencies": {
"@types/node": "^18.19.0",
"typescript": "^5.9.2"
}
}

View File

@@ -0,0 +1,157 @@
import { z } from 'zod'
export * from './moderation'
export const abilityKeys = ['str', 'dex', 'con', 'int', 'wis', 'cha'] as const
export const AbilityKeySchema = z.enum(abilityKeys)
export type AbilityKey = z.infer<typeof AbilityKeySchema>
export const AbilityScoresSchema = z.object({
str: z.number().int().min(1).max(30),
dex: z.number().int().min(1).max(30),
con: z.number().int().min(1).max(30),
int: z.number().int().min(1).max(30),
wis: z.number().int().min(1).max(30),
cha: z.number().int().min(1).max(30),
})
export type AbilityScores = z.infer<typeof AbilityScoresSchema>
export const WorldEntitySchema = z.object({
id: z.string().min(1),
kind: z.enum(['location', 'npc', 'faction', 'quest']),
name: z.string().min(1).max(120),
summary: z.string().min(1).max(1200),
tags: z.array(z.string().max(40)).max(12).default([]),
secrets: z.array(z.string().max(500)).max(6).default([]),
})
export type WorldEntity = z.infer<typeof WorldEntitySchema>
export const WorldStarterSchema = z.object({
title: z.string().min(3).max(100),
genre: z.string().min(2).max(80),
tone: z.string().min(2).max(160),
premise: z.string().min(20).max(1800),
contentBoundaries: z.array(z.string().max(120)).min(1).max(8),
startingLocation: WorldEntitySchema.extend({ kind: z.literal('location') }),
npcs: z.array(WorldEntitySchema.extend({ kind: z.literal('npc') })).length(3),
factions: z.array(WorldEntitySchema.extend({ kind: z.literal('faction') })).length(2),
hook: z.string().min(20).max(1200),
hiddenThreat: z.string().min(10).max(1000),
openingScene: z.string().min(40).max(2400),
})
export type WorldStarter = z.infer<typeof WorldStarterSchema>
export const CharacterSchema = z.object({
id: z.string().min(1),
name: z.string().min(1).max(80),
concept: z.string().min(3).max(600),
controller: z.enum(['human', 'ai', 'delegated']),
userId: z.string().nullable().default(null),
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),
inventory: z.array(z.string().max(120)).max(50).default([]),
statuses: z.array(z.string().max(80)).max(12).default([]),
})
export type Character = z.infer<typeof CharacterSchema>
export const PlayerIntentSchema = z.object({
id: z.string().min(1),
roundId: z.string().min(1),
memberId: z.string().min(1),
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(),
})
export type PlayerIntent = z.infer<typeof PlayerIntentSchema>
export const CheckRequestSchema = z.object({
actorId: z.string().min(1),
kind: z.enum(['ability', 'attack', 'initiative', 'damage', 'healing']),
ability: AbilityKeySchema.optional(),
difficulty: z.number().int().min(1).max(40).optional(),
targetId: z.string().optional(),
mode: z.enum(['normal', 'advantage', 'disadvantage']).default('normal'),
dice: z.string().regex(/^\d+d\d+(?:[+-]\d+)?$/).optional(),
reason: z.string().min(1).max(500),
})
export type CheckRequest = z.infer<typeof CheckRequestSchema>
export const DiceRollSchema = z.object({
id: z.string(),
checkKind: CheckRequestSchema.shape.kind,
formula: z.string(),
rolls: z.array(z.number().int()),
kept: z.array(z.number().int()),
modifier: z.number().int(),
total: z.number().int(),
difficulty: z.number().int().nullable(),
success: z.boolean().nullable(),
actorId: z.string(),
targetId: z.string().nullable(),
createdAt: z.string().datetime(),
})
export type DiceRoll = z.infer<typeof DiceRollSchema>
export const ProposedEventSchema = z.object({
type: z.enum(['narrative', 'relationship', 'quest', 'inventory', 'status', 'damage', 'healing']),
actorId: z.string().nullable().default(null),
targetId: z.string().nullable().default(null),
value: z.number().int().nullable().default(null),
item: z.string().max(120).nullable().default(null),
description: z.string().min(1).max(1000),
})
export type ProposedEvent = z.infer<typeof ProposedEventSchema>
export const RoundPlanSchema = z.object({
checks: z.array(CheckRequestSchema).max(20),
aiActions: z.array(z.object({
characterId: z.string(),
action: z.string().min(1).max(800),
})).max(8),
proposedEvents: z.array(ProposedEventSchema).max(30),
relevantMemoryQueries: z.array(z.string().max(120)).max(8),
})
export type RoundPlan = z.infer<typeof RoundPlanSchema>
export const RoundResolutionSchema = z.object({
narration: z.string().min(20).max(6000),
events: z.array(ProposedEventSchema).max(30),
memory: z.object({
summary: z.string().min(10).max(1200),
importance: z.number().int().min(1).max(5),
tags: z.array(z.string().max(40)).max(12),
entityIds: z.array(z.string()).max(20),
}).nullable(),
nextPrompt: z.string().min(3).max(500),
})
export type RoundResolution = z.infer<typeof RoundResolutionSchema>
export const CoauthorMessageSchema = z.object({
role: z.enum(['user', 'assistant']),
content: z.string().trim().min(1).max(5000),
})
export const CreateWorldRequestSchema = z.object({
messages: z.array(CoauthorMessageSchema).min(1).max(12),
})
export const SubmitIntentRequestSchema = z.object({
campaignId: z.string().min(1),
characterId: z.string().min(1),
action: z.string().trim().min(1).max(2000),
ready: z.boolean().default(true),
})
export type ID = string
export function makeId(prefix: string): string {
const randomUuid = globalThis.crypto?.randomUUID?.()
// IDs are identifiers, not authentication secrets. The fallback keeps the
// shared browser/server contract usable in Node 18 VM contexts without Web Crypto.
const id = randomUuid ?? `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}-${Math.random().toString(36).slice(2)}`
return `${prefix}_${id}`
}

View File

@@ -0,0 +1,12 @@
import { describe, expect, it } from 'vitest'
import { moderate13Plus } from './moderation'
describe('13+ moderation', () => {
it('allows ordinary dark fantasy', () => {
expect(moderate13Plus('A haunted knight fights skeletons beneath a ruined abbey.').allowed).toBe(true)
})
it('rejects explicit material', () => {
expect(moderate13Plus('Include explicit sex in the story.').allowed).toBe(false)
})
})

View File

@@ -0,0 +1,21 @@
export interface ModerationResult {
allowed: boolean
categories: string[]
}
const explicitPatterns = [
/\b(?:explicit sex|porn(?:ography)?|rape|sexual assault)\b/i,
/\b(?:nude|naked)\s+(?:child|minor|teen)\b/i,
/\b(?:child|minor|underage)\s+(?:sex|sexual|erotic)\b/i,
]
const extremeGorePatterns = [
/\b(?:graphic dismemberment|torture porn|extreme gore)\b/i,
]
export function moderate13Plus(text: string): ModerationResult {
const categories: string[] = []
if (explicitPatterns.some(pattern => pattern.test(text))) categories.push('sexual_explicit')
if (extremeGorePatterns.some(pattern => pattern.test(text))) categories.push('extreme_graphic_violence')
return { allowed: categories.length === 0, categories }
}

View File

@@ -0,0 +1,4 @@
{
"extends": "../../tsconfig.json",
"include": ["src/**/*.ts"]
}

7750
pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

3
pnpm-workspace.yaml Normal file
View File

@@ -0,0 +1,3 @@
packages:
- apps/*
- packages/*

View File

@@ -0,0 +1,852 @@
create extension if not exists pgcrypto;
create type public.member_role as enum ('owner', 'player');
create type public.character_controller as enum ('human', 'ai', 'delegated');
create type public.round_status as enum ('open', 'queued', 'resolving', 'resolved', 'failed');
create table public.allowlist (
email text primary key check (email <> '' and email = lower(btrim(email))),
invited_by uuid references auth.users(id) on delete set null,
created_at timestamptz not null default now()
);
create table public.profiles (
id uuid primary key references auth.users(id) on delete cascade,
display_name text not null default 'Adventurer' check (char_length(display_name) between 1 and 80),
created_at timestamptz not null default now()
);
create table public.worlds (
id uuid primary key default gen_random_uuid(),
owner_id uuid not null references public.profiles(id),
title text not null check (char_length(title) between 3 and 100),
genre text not null,
tone text not null,
premise text not null,
content_boundaries jsonb not null default '[]' check (jsonb_typeof(content_boundaries) = 'array'),
hidden_threat text not null,
status text not null default 'draft' check (status in ('draft', 'confirmed')),
created_at timestamptz not null default now(),
updated_at timestamptz not null default now()
);
create table public.world_entities (
id uuid primary key default gen_random_uuid(),
world_id uuid not null references public.worlds(id) on delete cascade,
kind text not null check (kind in ('location', 'npc', 'faction', 'quest')),
name text not null,
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,
created_at timestamptz not null default now()
);
create index world_entities_search_idx on public.world_entities using gin(search_document);
create table public.coauthor_sessions (
id uuid primary key default gen_random_uuid(),
owner_id uuid not null references public.profiles(id),
status text not null default 'collecting' check (status in ('collecting', 'generating', 'ready', 'failed', 'confirmed')),
messages jsonb not null default '[]' check (jsonb_typeof(messages) = 'array'),
generated_world jsonb check (generated_world is null or jsonb_typeof(generated_world) = 'object'),
created_at timestamptz not null default now(),
updated_at timestamptz not null default now()
);
create table public.campaigns (
id uuid primary key default gen_random_uuid(),
world_id uuid not null references public.worlds(id),
owner_id uuid not null references public.profiles(id),
title text not null,
current_scene text not null,
next_prompt text not null default 'What do you do?',
status text not null default 'lobby' check (status in ('lobby', 'active', 'archived')),
created_at timestamptz not null default now(),
updated_at timestamptz not null default now()
);
create table public.campaign_members (
id uuid primary key default gen_random_uuid(),
campaign_id uuid not null references public.campaigns(id) on delete cascade,
user_id uuid not null references public.profiles(id) on delete cascade,
role public.member_role not null default 'player',
active boolean not null default true,
ai_takeover_allowed boolean not null default false,
joined_at timestamptz not null default now(),
unique (campaign_id, user_id)
);
create table public.invites (
id uuid primary key default gen_random_uuid(),
campaign_id uuid not null references public.campaigns(id) on delete cascade,
created_by uuid not null references public.profiles(id),
token_hash text not null unique,
max_uses integer not null default 1 check (max_uses between 1 and 20),
uses integer not null default 0 check (uses >= 0 and uses <= max_uses),
expires_at timestamptz not null,
created_at timestamptz not null default now()
);
create table public.characters (
id uuid primary key default gen_random_uuid(),
campaign_id uuid not null references public.campaigns(id) on delete cascade,
user_id uuid references public.profiles(id) on delete set null,
name text not null,
concept text not null,
controller public.character_controller not null,
abilities jsonb not null check (jsonb_typeof(abilities) = 'object'),
hp integer not null check (hp >= 0),
max_hp integer not null check (max_hp > 0),
defense integer not null check (defense between 1 and 40),
proficiency integer not null default 2 check (proficiency between 1 and 10),
inventory jsonb not null default '[]' check (jsonb_typeof(inventory) = 'array'),
statuses jsonb not null default '[]' check (jsonb_typeof(statuses) = 'array'),
persona jsonb not null default '{}' check (jsonb_typeof(persona) = 'object'),
created_at timestamptz not null default now()
);
alter table public.characters add constraint characters_hp_within_max check (hp <= max_hp);
create table public.rounds (
id uuid primary key default gen_random_uuid(),
campaign_id uuid not null references public.campaigns(id) on delete cascade,
number integer not null check (number > 0),
status public.round_status not null default 'open',
forced_by uuid references public.profiles(id),
narration text,
next_prompt text,
queued_at timestamptz,
resolved_at timestamptz,
error text,
created_at timestamptz not null default now(),
unique (campaign_id, number)
);
create table public.player_intents (
id uuid primary key default gen_random_uuid(),
round_id uuid not null references public.rounds(id) on delete cascade,
member_id uuid not null references public.campaign_members(id) on delete cascade,
character_id uuid not null references public.characters(id),
action text not null check (char_length(action) between 1 and 2000),
ready boolean not null default false,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now(),
unique (round_id, member_id)
);
create table public.game_events (
id uuid primary key default gen_random_uuid(),
campaign_id uuid not null references public.campaigns(id) on delete cascade,
round_id uuid references public.rounds(id),
event_type text not null,
payload jsonb not null,
created_at timestamptz not null default now()
);
create table public.dice_rolls (
id uuid primary key,
round_id uuid not null references public.rounds(id) on delete cascade,
actor_id uuid not null references public.characters(id),
target_id uuid references public.characters(id),
check_kind text not null,
formula text not null,
rolls integer[] not null,
kept integer[] not null,
modifier integer not null,
total integer not null,
difficulty integer,
success boolean,
created_at timestamptz not null
);
create table public.relationships (
campaign_id uuid not null references public.campaigns(id) on delete cascade,
source_entity_id uuid not null,
target_entity_id uuid not null,
score integer not null default 0 check (score between -100 and 100),
notes text not null default '',
primary key (campaign_id, source_entity_id, target_entity_id)
);
create table public.memories (
id uuid primary key default gen_random_uuid(),
campaign_id uuid not null references public.campaigns(id) on delete cascade,
round_id uuid references public.rounds(id),
summary text not null,
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,
created_at timestamptz not null default now()
);
create index memories_search_idx on public.memories using gin(search_document);
create table public.story_summaries (
id uuid primary key default gen_random_uuid(),
campaign_id uuid not null references public.campaigns(id) on delete cascade,
through_round integer not null,
summary text not null,
created_at timestamptz not null default now(),
unique (campaign_id, through_round)
);
create table public.ai_jobs (
id uuid primary key default gen_random_uuid(),
job_type text not null,
entity_id uuid not null,
idempotency_key text not null unique,
status text not null default 'queued' check (status in ('queued', 'running', 'complete', 'failed')),
attempts integer not null default 0 check (attempts >= 0),
claimed_by text,
lease_expires_at timestamptz,
available_at timestamptz not null default now(),
max_attempts integer not null default 3 check (max_attempts between 1 and 10),
error text,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now(),
check (
(status = 'running' and claimed_by is not null and lease_expires_at is not null)
or (status <> 'running' and claimed_by is null and lease_expires_at is null)
)
);
create unique index ai_jobs_round_resolution_unique
on public.ai_jobs(entity_id) where job_type = 'resolve-round';
create index ai_jobs_poll_idx
on public.ai_jobs(available_at, created_at) where status in ('queued', 'running');
create index ai_jobs_expired_lease_idx
on public.ai_jobs(lease_expires_at) where status = 'running';
create table public.ai_usage (
id uuid primary key default gen_random_uuid(),
user_id uuid references public.profiles(id),
campaign_id uuid references public.campaigns(id),
job_id uuid references public.ai_jobs(id),
model text not null,
input_tokens integer not null default 0 check (input_tokens >= 0),
output_tokens integer not null default 0 check (output_tokens >= 0),
cost_usd numeric(12, 6) not null default 0 check (cost_usd >= 0),
latency_ms integer check (latency_ms is null or latency_ms >= 0),
created_at timestamptz not null default now()
);
create table public.audit_entries (
id uuid primary key default gen_random_uuid(),
campaign_id uuid not null references public.campaigns(id) on delete cascade,
actor_id uuid references public.profiles(id),
action text not null,
entity_type text not null,
entity_id uuid not null,
before_state jsonb,
after_state jsonb,
created_at timestamptz not null default now()
);
create index campaigns_owner_idx on public.campaigns(owner_id);
create index campaign_members_user_idx on public.campaign_members(user_id, campaign_id) where active;
create index characters_campaign_idx on public.characters(campaign_id);
create index rounds_campaign_status_idx on public.rounds(campaign_id, status);
create index player_intents_round_ready_idx on public.player_intents(round_id, ready);
create index game_events_campaign_round_idx on public.game_events(campaign_id, round_id);
create index memories_campaign_importance_idx on public.memories(campaign_id, importance desc);
create or replace function public.create_profile_for_allowlisted_user()
returns trigger language plpgsql security definer set search_path = '' as $$
begin
if 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;
insert into public.profiles(id) values (new.id);
return new;
end;
$$;
revoke all on function public.create_profile_for_allowlisted_user() from public;
create trigger create_profile_for_allowlisted_user
after insert on auth.users
for each row execute function public.create_profile_for_allowlisted_user();
create or replace function public.is_campaign_member(p_campaign_id uuid)
returns boolean language sql stable security definer set search_path = '' as $$
select exists(
select 1
from public.campaign_members
where campaign_id = p_campaign_id
and user_id = auth.uid()
and active
);
$$;
create or replace function public.is_campaign_owner(p_campaign_id uuid)
returns boolean language sql stable security definer set search_path = '' as $$
select exists(
select 1
from public.campaigns
where id = p_campaign_id and owner_id = auth.uid()
);
$$;
revoke all on function public.is_campaign_member(uuid) from public;
revoke all on function public.is_campaign_owner(uuid) from public;
grant execute on function public.is_campaign_member(uuid) to authenticated, service_role;
grant execute on function public.is_campaign_owner(uuid) to authenticated, service_role;
create or replace function public.validate_player_intent_membership()
returns trigger language plpgsql security definer set search_path = '' as $$
declare
v_round_campaign_id uuid;
v_member_campaign_id uuid;
v_member_user_id uuid;
v_member_active boolean;
v_character_campaign_id uuid;
v_character_user_id uuid;
begin
select campaign_id into v_round_campaign_id
from public.rounds where id = new.round_id;
select campaign_id, user_id, active
into v_member_campaign_id, v_member_user_id, v_member_active
from public.campaign_members where id = new.member_id;
select campaign_id, user_id
into v_character_campaign_id, v_character_user_id
from public.characters where id = new.character_id;
if v_round_campaign_id is null
or v_member_campaign_id is distinct from v_round_campaign_id
or v_character_campaign_id is distinct from v_round_campaign_id
or not coalesce(v_member_active, false)
or v_character_user_id is distinct from v_member_user_id then
raise exception 'intent member and character must belong to the round campaign';
end if;
return new;
end;
$$;
revoke all on function public.validate_player_intent_membership() from public;
create trigger validate_player_intent_membership
before insert or update of round_id, member_id, character_id on public.player_intents
for each row execute function public.validate_player_intent_membership();
alter table public.allowlist enable row level security;
alter table public.profiles enable row level security;
alter table public.worlds enable row level security;
alter table public.world_entities enable row level security;
alter table public.coauthor_sessions enable row level security;
alter table public.campaigns enable row level security;
alter table public.campaign_members enable row level security;
alter table public.invites enable row level security;
alter table public.characters enable row level security;
alter table public.rounds enable row level security;
alter table public.player_intents enable row level security;
alter table public.game_events enable row level security;
alter table public.dice_rolls enable row level security;
alter table public.relationships enable row level security;
alter table public.memories enable row level security;
alter table public.story_summaries enable row level security;
alter table public.ai_jobs enable row level security;
alter table public.ai_usage enable row level security;
alter table public.audit_entries enable row level security;
create policy profiles_self on public.profiles for select using (id = auth.uid());
create policy profiles_self_update on public.profiles for update using (id = auth.uid()) with check (id = auth.uid());
create policy worlds_owner_all on public.worlds for all using (owner_id = auth.uid()) with check (owner_id = auth.uid());
create policy entities_owner_all on public.world_entities for all using (exists(select 1 from worlds where worlds.id = world_id and worlds.owner_id = auth.uid()));
create policy coauthor_owner_all on public.coauthor_sessions for all using (owner_id = auth.uid()) with check (owner_id = auth.uid());
create policy campaigns_members_read on public.campaigns for select using (is_campaign_member(id) or owner_id = auth.uid());
create policy campaigns_owner_write on public.campaigns for all using (owner_id = auth.uid()) with check (
owner_id = auth.uid()
and exists (
select 1 from public.worlds
where worlds.id = world_id and worlds.owner_id = auth.uid() and worlds.status = 'confirmed'
)
);
create policy members_member_read on public.campaign_members for select using (is_campaign_member(campaign_id) or is_campaign_owner(campaign_id));
create policy members_owner_insert on public.campaign_members for insert with check (
is_campaign_owner(campaign_id)
and (
(user_id = auth.uid() and role = 'owner')
or (user_id <> auth.uid() and role = 'player')
)
);
create policy members_owner_update on public.campaign_members for update using (is_campaign_owner(campaign_id)) with check (
is_campaign_owner(campaign_id)
and (
(user_id = auth.uid() and role = 'owner')
or (user_id <> auth.uid() and role = 'player')
)
);
create policy members_owner_delete on public.campaign_members for delete using (is_campaign_owner(campaign_id) and user_id <> auth.uid());
create policy characters_member_read on public.characters for select using (is_campaign_member(campaign_id) or is_campaign_owner(campaign_id));
create policy characters_member_insert on public.characters for insert with check (
(is_campaign_member(campaign_id) and user_id = auth.uid() and controller = 'human')
or (is_campaign_owner(campaign_id) and (user_id = auth.uid() or (user_id is null and controller = 'ai')))
);
create policy rounds_member_read on public.rounds for select using (is_campaign_member(campaign_id) or is_campaign_owner(campaign_id));
create policy intents_member_read on public.player_intents for select using (
exists(
select 1 from public.rounds
where rounds.id = round_id
and (is_campaign_member(rounds.campaign_id) or is_campaign_owner(rounds.campaign_id))
)
);
create policy intents_self_insert on public.player_intents for insert with check (
exists(
select 1
from public.rounds
join public.campaign_members on campaign_members.campaign_id = rounds.campaign_id
join public.characters on characters.campaign_id = rounds.campaign_id
where rounds.id = round_id
and rounds.status = 'open'
and campaign_members.id = member_id
and campaign_members.user_id = auth.uid()
and campaign_members.active
and characters.id = character_id
and characters.user_id = auth.uid()
and characters.controller = 'human'
)
);
create policy intents_self_update on public.player_intents for update using (
exists(
select 1 from public.campaign_members
where campaign_members.id = member_id
and campaign_members.user_id = auth.uid()
and campaign_members.active
)
) with check (
exists(
select 1
from public.rounds
join public.campaign_members on campaign_members.campaign_id = rounds.campaign_id
join public.characters on characters.campaign_id = rounds.campaign_id
where rounds.id = round_id
and rounds.status = 'open'
and campaign_members.id = member_id
and campaign_members.user_id = auth.uid()
and campaign_members.active
and characters.id = character_id
and characters.user_id = auth.uid()
and characters.controller = 'human'
)
);
create policy events_member_read on public.game_events for select using (is_campaign_member(campaign_id) or is_campaign_owner(campaign_id));
create policy rolls_member_read on public.dice_rolls for select using (exists(select 1 from public.rounds where rounds.id = round_id and (is_campaign_member(rounds.campaign_id) or is_campaign_owner(rounds.campaign_id))));
create policy relationships_member_read on public.relationships for select using (is_campaign_member(campaign_id) or is_campaign_owner(campaign_id));
create policy memories_member_read on public.memories for select using (is_campaign_member(campaign_id) or is_campaign_owner(campaign_id));
create policy summaries_member_read on public.story_summaries for select using (is_campaign_member(campaign_id) or is_campaign_owner(campaign_id));
create policy usage_self_read on public.ai_usage for select using (user_id = auth.uid());
create policy audit_owner_read on public.audit_entries for select using (is_campaign_owner(campaign_id));
create or replace function public.enqueue_round_resolution(
p_round_id uuid,
p_forced_by uuid default null
) 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 then raise exception 'round not found'; end if;
select id into v_job_id
from public.ai_jobs
where job_type = 'resolve-round' and entity_id = p_round_id;
if v_round.status in ('queued', 'resolving', 'resolved') then
if v_job_id is null and v_round.status <> 'resolved' then
raise exception 'round status and job outbox are inconsistent';
end if;
return v_job_id;
end if;
if v_round.status <> 'open' then
raise exception 'round cannot be queued from status %', v_round.status;
end if;
if p_forced_by is not null then
if not exists (
select 1 from public.campaigns
where id = v_round.campaign_id and owner_id = p_forced_by
) then
raise exception 'only the campaign owner can force a round';
end if;
elsif 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
raise exception 'not all active players are ready';
end if;
v_job_id := gen_random_uuid();
insert into public.ai_jobs(id, job_type, entity_id, idempotency_key, status)
values (v_job_id, 'resolve-round', p_round_id, v_job_id::text, 'queued');
update public.rounds
set status = 'queued', queued_at = now(), forced_by = p_forced_by, error = null
where id = p_round_id;
return v_job_id;
end;
$$;
revoke all on function public.enqueue_round_resolution(uuid, uuid) from public, anon, authenticated;
grant execute on function public.enqueue_round_resolution(uuid, uuid) to service_role;
create or replace function public.claim_ai_job(
p_worker_id text
) returns table(id uuid, job_type text, entity_id uuid)
language plpgsql security definer set search_path = '' as $$
declare
v_job public.ai_jobs%rowtype;
begin
if p_worker_id is null or btrim(p_worker_id) = '' then
raise exception 'worker id is required';
end if;
select * into v_job
from public.ai_jobs
where attempts < max_attempts
and available_at <= now()
and (
status = 'queued'
or (status = 'running' and lease_expires_at <= now())
)
order by created_at
for update skip locked
limit 1;
if not found then return; end if;
update public.ai_jobs
set status = 'running',
attempts = attempts + 1,
claimed_by = p_worker_id,
lease_expires_at = now() + interval '3 minutes',
error = null,
updated_at = now()
where public.ai_jobs.id = v_job.id;
if v_job.job_type = 'resolve-round' then
update public.rounds set status = 'resolving', error = null where public.rounds.id = v_job.entity_id;
elsif v_job.job_type = 'generate-world' then
update public.coauthor_sessions set status = 'generating', updated_at = now() where public.coauthor_sessions.id = v_job.entity_id;
end if;
return query select v_job.id, v_job.job_type, v_job.entity_id;
end;
$$;
create or replace function public.claim_ai_job_by_id(
p_job_id uuid,
p_worker_id text
) returns table(id uuid, job_type text, entity_id uuid)
language plpgsql security definer set search_path = '' as $$
declare
v_job public.ai_jobs%rowtype;
begin
if p_job_id is null then
raise exception 'job id is required';
end if;
if p_worker_id is null or btrim(p_worker_id) = '' then
raise exception 'worker id is required';
end if;
select * into v_job
from public.ai_jobs
where public.ai_jobs.id = p_job_id
and attempts < max_attempts
and available_at <= now()
and (
status = 'queued'
or (status = 'running' and lease_expires_at <= now())
)
for update;
if not found then return; end if;
update public.ai_jobs
set status = 'running',
attempts = attempts + 1,
claimed_by = p_worker_id,
lease_expires_at = now() + interval '3 minutes',
error = null,
updated_at = now()
where public.ai_jobs.id = v_job.id;
if v_job.job_type = 'resolve-round' then
update public.rounds set status = 'resolving', error = null where public.rounds.id = v_job.entity_id;
elsif v_job.job_type = 'generate-world' then
update public.coauthor_sessions set status = 'generating', updated_at = now() where public.coauthor_sessions.id = v_job.entity_id;
end if;
return query select v_job.id, v_job.job_type, v_job.entity_id;
end;
$$;
create or replace function public.complete_ai_job(
p_job_id uuid,
p_worker_id text
) returns void language plpgsql security definer set search_path = '' as $$
begin
if p_worker_id is null or btrim(p_worker_id) = '' then
raise exception 'worker id is required';
end if;
update public.ai_jobs
set status = 'complete', claimed_by = null, lease_expires_at = null, updated_at = now()
where id = p_job_id
and status = 'running'
and claimed_by = p_worker_id
and lease_expires_at > now();
if not found then raise exception 'job is not owned by this worker'; end if;
end;
$$;
create or replace function public.retry_ai_job(
p_job_id uuid,
p_worker_id text,
p_error text
) returns void language plpgsql security definer set search_path = '' as $$
declare
v_job public.ai_jobs%rowtype;
v_retry boolean;
begin
if p_worker_id is null or btrim(p_worker_id) = '' then
raise exception 'worker id is required';
end if;
select * into v_job from public.ai_jobs where id = p_job_id for update;
if not found or v_job.status <> 'running' or v_job.claimed_by <> p_worker_id then
raise exception 'job is not owned by this worker';
end if;
if v_job.lease_expires_at <= now() then
raise exception 'job lease has expired';
end if;
v_retry := v_job.attempts < v_job.max_attempts;
update public.ai_jobs
set status = case when v_retry then 'queued' else 'failed' end,
claimed_by = null,
lease_expires_at = null,
available_at = case when v_retry then now() + make_interval(secs => 5 * power(2, v_job.attempts - 1)::integer) else available_at end,
error = left(p_error, 2000),
updated_at = now()
where id = p_job_id;
if v_job.job_type = 'resolve-round' then
update public.rounds
set status = case when v_retry then 'queued'::public.round_status else 'failed'::public.round_status end,
error = left(p_error, 2000)
where id = v_job.entity_id and status <> 'resolved';
elsif v_job.job_type = 'generate-world' then
update public.coauthor_sessions
set status = case when v_retry then 'generating' else 'failed' end, updated_at = now()
where id = v_job.entity_id and status <> 'ready';
end if;
end;
$$;
create or replace function public.renew_ai_job_lease(
p_job_id uuid,
p_worker_id text
) returns timestamptz language plpgsql security definer set search_path = '' as $$
declare
v_lease_expires_at timestamptz;
begin
if p_worker_id is null or btrim(p_worker_id) = '' then
raise exception 'worker id is required';
end if;
update public.ai_jobs
set lease_expires_at = now() + interval '3 minutes', updated_at = now()
where id = p_job_id
and status = 'running'
and claimed_by = p_worker_id
and lease_expires_at > now()
returning lease_expires_at into v_lease_expires_at;
if not found then raise exception 'job is not owned by this worker or lease expired'; end if;
return v_lease_expires_at;
end;
$$;
revoke all on function public.claim_ai_job(text) from public, anon, authenticated;
revoke all on function public.claim_ai_job_by_id(uuid, text) from public, anon, authenticated;
revoke all on function public.complete_ai_job(uuid, text) from public, anon, authenticated;
revoke all on function public.retry_ai_job(uuid, text, text) from public, anon, authenticated;
revoke all on function public.renew_ai_job_lease(uuid, text) from public, anon, authenticated;
grant execute on function public.claim_ai_job(text) to service_role;
grant execute on function public.claim_ai_job_by_id(uuid, text) to service_role;
grant execute on function public.complete_ai_job(uuid, text) to service_role;
grant execute on function public.retry_ai_job(uuid, text, text) to service_role;
grant execute on function public.renew_ai_job_lease(uuid, text) to service_role;
create or replace function public.commit_round_resolution(
p_round_id uuid,
p_narration text,
p_next_prompt text,
p_rolls jsonb,
p_events jsonb,
p_character_states jsonb,
p_memory jsonb,
p_idempotency_key text
) returns void language plpgsql security definer set search_path = '' as $$
declare
v_round public.rounds%rowtype;
v_job public.ai_jobs%rowtype;
v_roll jsonb;
v_event jsonb;
v_state jsonb;
v_character_id uuid;
v_seen_character_ids uuid[] := '{}';
v_row_count integer;
begin
if p_idempotency_key is null or btrim(p_idempotency_key) = '' then
raise exception 'idempotency key is required';
end if;
if p_narration is null or btrim(p_narration) = '' or p_next_prompt is null or btrim(p_next_prompt) = '' then
raise exception 'narration and next prompt are required';
end if;
if coalesce(jsonb_typeof(p_rolls), '') <> 'array'
or coalesce(jsonb_typeof(p_events), '') <> 'array'
or coalesce(jsonb_typeof(p_character_states), '') <> 'array' then
raise exception 'rolls, events, and character states must be arrays';
end if;
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 = 'resolved' then return; end if;
if v_round.status not in ('queued', 'resolving') then
raise exception 'round is not queued for resolution';
end if;
select * into v_job
from public.ai_jobs
where idempotency_key = p_idempotency_key
or id::text = p_idempotency_key
for update;
if not found then
raise exception 'resolution job not found';
end if;
if v_job.job_type <> 'resolve-round' or v_job.entity_id <> p_round_id then
raise exception 'idempotency key belongs to another job';
end if;
if v_job.status = 'complete' then return; end if;
if v_job.status <> 'running' then
raise exception 'resolution job must be claimed before commit';
end if;
update public.ai_jobs
set status = 'running',
error = null,
updated_at = now()
where id = v_job.id;
update public.rounds set status = 'resolving', error = null where id = p_round_id;
for v_roll in select * from jsonb_array_elements(p_rolls) loop
if not exists (
select 1 from public.characters
where id = (v_roll->>'actorId')::uuid and campaign_id = v_round.campaign_id
) or (
nullif(v_roll->>'targetId', '') is not null
and not exists (
select 1 from public.characters
where id = nullif(v_roll->>'targetId','')::uuid and campaign_id = v_round.campaign_id
)
) then
raise exception 'roll actor or target is outside the round campaign';
end if;
insert into public.dice_rolls(id, round_id, actor_id, target_id, check_kind, formula, rolls, kept, modifier, total, difficulty, success, created_at)
values ((v_roll->>'id')::uuid, p_round_id, (v_roll->>'actorId')::uuid, nullif(v_roll->>'targetId','')::uuid,
v_roll->>'checkKind', v_roll->>'formula', array(select jsonb_array_elements_text(v_roll->'rolls')::integer),
array(select jsonb_array_elements_text(v_roll->'kept')::integer), (v_roll->>'modifier')::integer,
(v_roll->>'total')::integer, nullif(v_roll->>'difficulty','')::integer, (v_roll->>'success')::boolean,
(v_roll->>'createdAt')::timestamptz);
end loop;
for v_event in select * from jsonb_array_elements(p_events) loop
if coalesce(btrim(v_event->>'type'), '') = '' then
raise exception 'event type is required';
end if;
insert into public.game_events(campaign_id, round_id, event_type, payload)
values (v_round.campaign_id, p_round_id, v_event->>'type', v_event);
end loop;
for v_state in select * from jsonb_array_elements(p_character_states) loop
v_character_id := (v_state->>'id')::uuid;
if v_character_id = any(v_seen_character_ids) then
raise exception 'duplicate character state for %', v_character_id;
end if;
v_seen_character_ids := array_append(v_seen_character_ids, v_character_id);
if coalesce(jsonb_typeof(v_state->'inventory'), '') <> 'array'
or coalesce(jsonb_typeof(v_state->'statuses'), '') <> 'array' then
raise exception 'inventory and statuses must be arrays';
end if;
update public.characters
set hp = (v_state->>'hp')::integer,
inventory = v_state->'inventory',
statuses = v_state->'statuses'
where id = v_character_id and campaign_id = v_round.campaign_id;
get diagnostics v_row_count = row_count;
if v_row_count <> 1 then
raise exception 'character % is outside the round campaign', v_character_id;
end if;
end loop;
if p_memory is not null and p_memory <> 'null'::jsonb then
if coalesce(jsonb_typeof(p_memory), '') <> 'object' then raise exception 'memory must be an object'; end if;
insert into public.memories(campaign_id, round_id, summary, importance, tags, entity_ids)
values (v_round.campaign_id, p_round_id, p_memory->>'summary', (p_memory->>'importance')::integer,
array(select jsonb_array_elements_text(coalesce(p_memory->'tags', '[]'::jsonb))),
array(select jsonb_array_elements_text(coalesce(p_memory->'entityIds', '[]'::jsonb))::uuid));
end if;
update public.rounds set status = 'resolved', narration = p_narration, next_prompt = p_next_prompt, resolved_at = now() where id = p_round_id;
update public.campaigns set current_scene = p_narration, next_prompt = p_next_prompt, updated_at = now() where id = v_round.campaign_id;
update public.ai_jobs
set status = 'complete', claimed_by = null, lease_expires_at = null, updated_at = now()
where id = v_job.id;
end;
$$;
revoke all on function public.commit_round_resolution(uuid, text, text, jsonb, jsonb, jsonb, jsonb, text) from public, anon, authenticated;
grant execute on function public.commit_round_resolution(uuid, text, text, jsonb, jsonb, jsonb, jsonb, text) to service_role;
create or replace function public.commit_claimed_round_resolution(
p_round_id uuid,
p_narration text,
p_next_prompt text,
p_rolls jsonb,
p_events jsonb,
p_character_states jsonb,
p_memory jsonb,
p_idempotency_key text,
p_worker_id text
) returns void language plpgsql security definer set search_path = '' as $$
begin
if p_worker_id is null or btrim(p_worker_id) = '' then
raise exception 'worker id is required';
end if;
if not exists (
select 1 from public.ai_jobs
where idempotency_key = p_idempotency_key
and job_type = 'resolve-round'
and entity_id = p_round_id
and status = 'running'
and claimed_by = p_worker_id
and lease_expires_at > now()
) then
raise exception 'resolution job is not owned by this worker';
end if;
perform public.commit_round_resolution(p_round_id, p_narration, p_next_prompt, p_rolls, p_events, p_character_states, p_memory, p_idempotency_key);
end;
$$;
revoke all on function public.commit_claimed_round_resolution(uuid, text, text, jsonb, jsonb, jsonb, jsonb, text, text) from public, anon, authenticated;
grant execute on function public.commit_claimed_round_resolution(uuid, text, text, jsonb, jsonb, jsonb, jsonb, text, text) to service_role;

View File

@@ -0,0 +1,486 @@
-- Adds the Redis-free Supabase outbox worker contract to databases created
-- before queue leases were introduced. Safe to apply after the current 0001.
alter table public.ai_jobs add column if not exists claimed_by text;
alter table public.ai_jobs add column if not exists lease_expires_at timestamptz;
alter table public.ai_jobs add column if not exists available_at timestamptz not null default now();
alter table public.ai_jobs add column if not exists max_attempts integer not null default 3;
-- A worker from the old implementation cannot own a database lease. Return
-- such unfinished jobs to the queue before enforcing lease consistency.
update public.ai_jobs
set status = 'queued',
claimed_by = null,
lease_expires_at = null,
available_at = now(),
updated_at = now()
where status = 'running'
and (claimed_by is null or lease_expires_at is null);
update public.ai_jobs set available_at = now() where available_at is null;
update public.ai_jobs set max_attempts = 3 where max_attempts is null;
alter table public.ai_jobs alter column available_at set default now();
alter table public.ai_jobs alter column available_at set not null;
alter table public.ai_jobs alter column max_attempts set default 3;
alter table public.ai_jobs alter column max_attempts set not null;
alter table public.ai_jobs enable row level security;
do $$
begin
if not exists (
select 1 from pg_constraint
where conrelid = 'public.ai_jobs'::regclass
and conname = 'ai_jobs_max_attempts_check'
) then
alter table public.ai_jobs
add constraint ai_jobs_max_attempts_check check (max_attempts between 1 and 10);
end if;
if not exists (
select 1 from pg_constraint
where conrelid = 'public.ai_jobs'::regclass
and conname = 'ai_jobs_lease_consistency_check'
) then
alter table public.ai_jobs
add constraint ai_jobs_lease_consistency_check check (
(status = 'running' and claimed_by is not null and lease_expires_at is not null)
or (status <> 'running' and claimed_by is null and lease_expires_at is null)
);
end if;
end;
$$;
create index if not exists ai_jobs_poll_idx
on public.ai_jobs(available_at, created_at) where status in ('queued', 'running');
create index if not exists ai_jobs_expired_lease_idx
on public.ai_jobs(lease_expires_at) where status = 'running';
create or replace function public.claim_ai_job(
p_worker_id text
) returns table(id uuid, job_type text, entity_id uuid)
language plpgsql security definer set search_path = '' as $$
declare
v_job public.ai_jobs%rowtype;
v_exhausted public.ai_jobs%rowtype;
begin
if p_worker_id is null or btrim(p_worker_id) = '' then
raise exception 'worker id is required';
end if;
for v_exhausted in
select * from public.ai_jobs
where (
(status = 'running' and lease_expires_at <= now())
or (status = 'queued' and available_at <= now())
)
and attempts >= max_attempts
order by created_at
for update skip locked
limit 25
loop
update public.ai_jobs
set status = 'failed', claimed_by = null, lease_expires_at = null,
error = coalesce(error, 'Worker lease expired after the final attempt'), updated_at = now()
where public.ai_jobs.id = v_exhausted.id;
if v_exhausted.job_type = 'resolve-round' then
update public.rounds
set status = 'failed', error = 'Worker lease expired after the final attempt'
where public.rounds.id = v_exhausted.entity_id and status <> 'resolved';
elsif v_exhausted.job_type = 'generate-world' then
update public.coauthor_sessions
set status = 'failed', updated_at = now()
where public.coauthor_sessions.id = v_exhausted.entity_id and status <> 'ready';
end if;
end loop;
select * into v_job
from public.ai_jobs
where attempts < max_attempts
and available_at <= now()
and (
status = 'queued'
or (status = 'running' and lease_expires_at <= now())
)
order by created_at
for update skip locked
limit 1;
if not found then return; end if;
update public.ai_jobs
set status = 'running',
attempts = attempts + 1,
claimed_by = p_worker_id,
lease_expires_at = now() + interval '3 minutes',
error = null,
updated_at = now()
where public.ai_jobs.id = v_job.id;
if v_job.job_type = 'resolve-round' then
update public.rounds set status = 'resolving', error = null where public.rounds.id = v_job.entity_id;
elsif v_job.job_type = 'generate-world' then
update public.coauthor_sessions set status = 'generating', updated_at = now() where public.coauthor_sessions.id = v_job.entity_id;
end if;
return query select v_job.id, v_job.job_type, v_job.entity_id;
end;
$$;
create or replace function public.claim_ai_job_by_id(
p_job_id uuid,
p_worker_id text
) returns table(id uuid, job_type text, entity_id uuid)
language plpgsql security definer set search_path = '' as $$
declare
v_job public.ai_jobs%rowtype;
begin
if p_job_id is null then raise exception 'job id is required'; end if;
if p_worker_id is null or btrim(p_worker_id) = '' then
raise exception 'worker id is required';
end if;
select * into v_job from public.ai_jobs where public.ai_jobs.id = p_job_id for update;
if not found then return; end if;
if (
(v_job.status = 'running' and v_job.lease_expires_at <= now())
or (v_job.status = 'queued' and v_job.available_at <= now())
)
and v_job.attempts >= v_job.max_attempts then
update public.ai_jobs
set status = 'failed', claimed_by = null, lease_expires_at = null,
error = coalesce(error, 'Worker lease expired after the final attempt'), updated_at = now()
where public.ai_jobs.id = v_job.id;
if v_job.job_type = 'resolve-round' then
update public.rounds
set status = 'failed', error = 'Worker lease expired after the final attempt'
where public.rounds.id = v_job.entity_id and status <> 'resolved';
elsif v_job.job_type = 'generate-world' then
update public.coauthor_sessions
set status = 'failed', updated_at = now()
where public.coauthor_sessions.id = v_job.entity_id and status <> 'ready';
end if;
return;
end if;
if v_job.attempts >= v_job.max_attempts
or v_job.available_at > now()
or not (
v_job.status = 'queued'
or (v_job.status = 'running' and v_job.lease_expires_at <= now())
) then
return;
end if;
update public.ai_jobs
set status = 'running',
attempts = attempts + 1,
claimed_by = p_worker_id,
lease_expires_at = now() + interval '3 minutes',
error = null,
updated_at = now()
where public.ai_jobs.id = v_job.id;
if v_job.job_type = 'resolve-round' then
update public.rounds set status = 'resolving', error = null where public.rounds.id = v_job.entity_id;
elsif v_job.job_type = 'generate-world' then
update public.coauthor_sessions set status = 'generating', updated_at = now() where public.coauthor_sessions.id = v_job.entity_id;
end if;
return query select v_job.id, v_job.job_type, v_job.entity_id;
end;
$$;
create or replace function public.complete_ai_job(
p_job_id uuid,
p_worker_id text
) returns void language plpgsql security definer set search_path = '' as $$
begin
if p_worker_id is null or btrim(p_worker_id) = '' then
raise exception 'worker id is required';
end if;
update public.ai_jobs
set status = 'complete', claimed_by = null, lease_expires_at = null, updated_at = now()
where id = p_job_id
and status = 'running'
and claimed_by = p_worker_id
and lease_expires_at > now();
if not found then raise exception 'job is not owned by this worker'; end if;
end;
$$;
create or replace function public.retry_ai_job(
p_job_id uuid,
p_worker_id text,
p_error text
) returns void language plpgsql security definer set search_path = '' as $$
declare
v_job public.ai_jobs%rowtype;
v_retry boolean;
begin
if p_worker_id is null or btrim(p_worker_id) = '' then
raise exception 'worker id is required';
end if;
select * into v_job from public.ai_jobs where id = p_job_id for update;
if not found or v_job.status <> 'running' or v_job.claimed_by <> p_worker_id then
raise exception 'job is not owned by this worker';
end if;
if v_job.lease_expires_at <= now() then
raise exception 'job lease has expired';
end if;
v_retry := v_job.attempts < v_job.max_attempts;
update public.ai_jobs
set status = case when v_retry then 'queued' else 'failed' end,
claimed_by = null,
lease_expires_at = null,
available_at = case
when v_retry then now() + make_interval(secs => 5 * power(2, v_job.attempts - 1)::integer)
else available_at
end,
error = left(p_error, 2000),
updated_at = now()
where id = p_job_id;
if v_job.job_type = 'resolve-round' then
update public.rounds
set status = case
when v_retry then 'queued'::public.round_status
else 'failed'::public.round_status
end,
error = left(p_error, 2000)
where id = v_job.entity_id and status <> 'resolved';
elsif v_job.job_type = 'generate-world' then
update public.coauthor_sessions
set status = case when v_retry then 'generating' else 'failed' end,
updated_at = now()
where id = v_job.entity_id and status <> 'ready';
end if;
end;
$$;
create or replace function public.renew_ai_job_lease(
p_job_id uuid,
p_worker_id text
) returns timestamptz language plpgsql security definer set search_path = '' as $$
declare
v_lease_expires_at timestamptz;
begin
if p_worker_id is null or btrim(p_worker_id) = '' then
raise exception 'worker id is required';
end if;
update public.ai_jobs
set lease_expires_at = now() + interval '3 minutes', updated_at = now()
where id = p_job_id
and status = 'running'
and claimed_by = p_worker_id
and lease_expires_at > now()
returning lease_expires_at into v_lease_expires_at;
if not found then raise exception 'job is not owned by this worker or lease expired'; end if;
return v_lease_expires_at;
end;
$$;
-- Replace the pre-lease commit function as well: old databases completed a
-- job without clearing its lease, which conflicts with the new invariant.
create or replace function public.commit_round_resolution(
p_round_id uuid,
p_narration text,
p_next_prompt text,
p_rolls jsonb,
p_events jsonb,
p_character_states jsonb,
p_memory jsonb,
p_idempotency_key text
) returns void language plpgsql security definer set search_path = '' as $$
declare
v_round public.rounds%rowtype;
v_job public.ai_jobs%rowtype;
v_roll jsonb;
v_event jsonb;
v_state jsonb;
v_character_id uuid;
v_seen_character_ids uuid[] := '{}';
v_row_count integer;
begin
if p_idempotency_key is null or btrim(p_idempotency_key) = '' then
raise exception 'idempotency key is required';
end if;
if p_narration is null or btrim(p_narration) = '' or p_next_prompt is null or btrim(p_next_prompt) = '' then
raise exception 'narration and next prompt are required';
end if;
if coalesce(jsonb_typeof(p_rolls), '') <> 'array'
or coalesce(jsonb_typeof(p_events), '') <> 'array'
or coalesce(jsonb_typeof(p_character_states), '') <> 'array' then
raise exception 'rolls, events, and character states must be arrays';
end if;
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 = 'resolved' then return; end if;
if v_round.status not in ('queued', 'resolving') then
raise exception 'round is not queued for resolution';
end if;
select * into v_job
from public.ai_jobs
where idempotency_key = p_idempotency_key
or id::text = p_idempotency_key
for update;
if not found then raise exception 'resolution job not found'; end if;
if v_job.job_type <> 'resolve-round' or v_job.entity_id <> p_round_id then
raise exception 'idempotency key belongs to another job';
end if;
if v_job.status = 'complete' then return; end if;
if v_job.status <> 'running' then
raise exception 'resolution job must be claimed before commit';
end if;
update public.ai_jobs set error = null, updated_at = now() where id = v_job.id;
update public.rounds set status = 'resolving', error = null where id = p_round_id;
for v_roll in select * from jsonb_array_elements(p_rolls) loop
if not exists (
select 1 from public.characters
where id = (v_roll->>'actorId')::uuid and campaign_id = v_round.campaign_id
) or (
nullif(v_roll->>'targetId', '') is not null
and not exists (
select 1 from public.characters
where id = nullif(v_roll->>'targetId', '')::uuid and campaign_id = v_round.campaign_id
)
) then
raise exception 'roll actor or target is outside the round campaign';
end if;
insert into public.dice_rolls(
id, round_id, actor_id, target_id, check_kind, formula, rolls, kept,
modifier, total, difficulty, success, created_at
) values (
(v_roll->>'id')::uuid, p_round_id, (v_roll->>'actorId')::uuid,
nullif(v_roll->>'targetId', '')::uuid, v_roll->>'checkKind', v_roll->>'formula',
array(select jsonb_array_elements_text(v_roll->'rolls')::integer),
array(select jsonb_array_elements_text(v_roll->'kept')::integer),
(v_roll->>'modifier')::integer, (v_roll->>'total')::integer,
nullif(v_roll->>'difficulty', '')::integer, (v_roll->>'success')::boolean,
(v_roll->>'createdAt')::timestamptz
);
end loop;
for v_event in select * from jsonb_array_elements(p_events) loop
if coalesce(btrim(v_event->>'type'), '') = '' then
raise exception 'event type is required';
end if;
insert into public.game_events(campaign_id, round_id, event_type, payload)
values (v_round.campaign_id, p_round_id, v_event->>'type', v_event);
end loop;
for v_state in select * from jsonb_array_elements(p_character_states) loop
v_character_id := (v_state->>'id')::uuid;
if v_character_id = any(v_seen_character_ids) then
raise exception 'duplicate character state for %', v_character_id;
end if;
v_seen_character_ids := array_append(v_seen_character_ids, v_character_id);
if coalesce(jsonb_typeof(v_state->'inventory'), '') <> 'array'
or coalesce(jsonb_typeof(v_state->'statuses'), '') <> 'array' then
raise exception 'inventory and statuses must be arrays';
end if;
update public.characters
set hp = (v_state->>'hp')::integer,
inventory = v_state->'inventory',
statuses = v_state->'statuses'
where id = v_character_id and campaign_id = v_round.campaign_id;
get diagnostics v_row_count = row_count;
if v_row_count <> 1 then
raise exception 'character % is outside the round campaign', v_character_id;
end if;
end loop;
if p_memory is not null and p_memory <> 'null'::jsonb then
if coalesce(jsonb_typeof(p_memory), '') <> 'object' then
raise exception 'memory must be an object';
end if;
insert into public.memories(campaign_id, round_id, summary, importance, tags, entity_ids)
values (
v_round.campaign_id,
p_round_id,
p_memory->>'summary',
(p_memory->>'importance')::integer,
array(select jsonb_array_elements_text(coalesce(p_memory->'tags', '[]'::jsonb))),
array(select jsonb_array_elements_text(coalesce(p_memory->'entityIds', '[]'::jsonb))::uuid)
);
end if;
update public.rounds
set status = 'resolved', narration = p_narration, next_prompt = p_next_prompt, resolved_at = now()
where id = p_round_id;
update public.campaigns
set current_scene = p_narration, next_prompt = p_next_prompt, updated_at = now()
where id = v_round.campaign_id;
update public.ai_jobs
set status = 'complete', claimed_by = null, lease_expires_at = null, updated_at = now()
where id = v_job.id;
end;
$$;
create or replace function public.commit_claimed_round_resolution(
p_round_id uuid,
p_narration text,
p_next_prompt text,
p_rolls jsonb,
p_events jsonb,
p_character_states jsonb,
p_memory jsonb,
p_idempotency_key text,
p_worker_id text
) returns void language plpgsql security definer set search_path = '' as $$
declare
v_job public.ai_jobs%rowtype;
begin
if p_worker_id is null or btrim(p_worker_id) = '' then
raise exception 'worker id is required';
end if;
select * into v_job
from public.ai_jobs
where (idempotency_key = p_idempotency_key or id::text = p_idempotency_key)
and job_type = 'resolve-round'
and entity_id = p_round_id
for update;
if not found
or v_job.status <> 'running'
or v_job.claimed_by <> p_worker_id
or v_job.lease_expires_at <= now() then
raise exception 'resolution job is not owned by this worker';
end if;
perform public.commit_round_resolution(
p_round_id,
p_narration,
p_next_prompt,
p_rolls,
p_events,
p_character_states,
p_memory,
p_idempotency_key
);
end;
$$;
revoke all on function public.claim_ai_job(text) from public, anon, authenticated;
revoke all on function public.claim_ai_job_by_id(uuid, text) from public, anon, authenticated;
revoke all on function public.complete_ai_job(uuid, text) from public, anon, authenticated;
revoke all on function public.retry_ai_job(uuid, text, text) from public, anon, authenticated;
revoke all on function public.renew_ai_job_lease(uuid, text) from public, anon, authenticated;
revoke all on function public.commit_round_resolution(uuid, text, text, jsonb, jsonb, jsonb, jsonb, text) from public, anon, authenticated;
revoke all on function public.commit_claimed_round_resolution(uuid, text, text, jsonb, jsonb, jsonb, jsonb, text, text) from public, anon, authenticated;
grant execute on function public.claim_ai_job(text) to service_role;
grant execute on function public.claim_ai_job_by_id(uuid, text) to service_role;
grant execute on function public.complete_ai_job(uuid, text) to service_role;
grant execute on function public.retry_ai_job(uuid, text, text) to service_role;
grant execute on function public.renew_ai_job_lease(uuid, text) to service_role;
grant execute on function public.commit_round_resolution(uuid, text, text, jsonb, jsonb, jsonb, jsonb, text) to service_role;
grant execute on function public.commit_claimed_round_resolution(uuid, text, text, jsonb, jsonb, jsonb, jsonb, text, text) to service_role;
-- Make newly-created RPCs visible to PostgREST immediately.
notify pgrst, 'reload schema';

2
supabase/seed.sql Normal file
View File

@@ -0,0 +1,2 @@
-- Replace with invited tester emails before applying in a shared environment.
insert into public.allowlist(email) values ('founder@example.com') on conflict do nothing;

17
tsconfig.json Normal file
View File

@@ -0,0 +1,17 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "Bundler",
"strict": true,
"skipLibCheck": true,
"esModuleInterop": true,
"resolveJsonModule": true,
"types": ["node"],
"baseUrl": ".",
"paths": {
"@dng/shared": ["packages/shared/src/index.ts"],
"@dng/game-engine": ["packages/game-engine/src/index.ts"]
}
}
}

15
vitest.config.ts Normal file
View File

@@ -0,0 +1,15 @@
import { defineConfig } from 'vitest/config'
import { fileURLToPath } from 'node:url'
export default defineConfig({
test: {
include: ['packages/**/*.test.ts', 'apps/**/*.test.ts'],
environment: 'node',
},
resolve: {
alias: {
'@dng/shared': fileURLToPath(new URL('./packages/shared/src/index.ts', import.meta.url)),
'@dng/game-engine': fileURLToPath(new URL('./packages/game-engine/src/index.ts', import.meta.url)),
},
},
})