This commit is contained in:
23
.env.example
Normal file
23
.env.example
Normal 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
22
.github/workflows/ci.yml
vendored
Normal 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
12
.gitignore
vendored
Normal file
@@ -0,0 +1,12 @@
|
||||
node_modules
|
||||
.pnpm-store
|
||||
.nuxt
|
||||
.output
|
||||
dist
|
||||
coverage
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
.DS_Store
|
||||
playwright-report
|
||||
test-results
|
||||
5
LEGAL.md
Normal file
5
LEGAL.md
Normal 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
48
README.md
Normal 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
3
apps/web/app.vue
Normal file
@@ -0,0 +1,3 @@
|
||||
<template>
|
||||
<NuxtPage />
|
||||
</template>
|
||||
1
apps/web/assets/css/main.css
Normal file
1
apps/web/assets/css/main.css
Normal 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}}
|
||||
10
apps/web/components/AppMark.vue
Normal file
10
apps/web/components/AppMark.vue
Normal 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>& 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>
|
||||
21
apps/web/components/AppShell.vue
Normal file
21
apps/web/components/AppShell.vue
Normal 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>
|
||||
45
apps/web/composables/useDemo.ts
Normal file
45
apps/web/composables/useDemo.ts
Normal 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 tomorrow’s 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 relay’s 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
40
apps/web/nuxt.config.ts
Normal 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
28
apps/web/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
108
apps/web/pages/campaign/demo.vue
Normal file
108
apps/web/pages/campaign/demo.vue
Normal file
File diff suppressed because one or more lines are too long
36
apps/web/pages/dashboard.vue
Normal file
36
apps/web/pages/dashboard.vue
Normal 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
61
apps/web/pages/index.vue
Normal 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 allows—the 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 yours—three 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>
|
||||
100
apps/web/pages/worlds/new.vue
Normal file
100
apps/web/pages/worlds/new.vue
Normal 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. I’ll help turn it into a world your party can enter tonight.</p></div>
|
||||
<form class="seed" @submit.prevent="begin"><textarea v-model="input" aria-label="World idea" rows="3"/><button>BEGIN <span>→</span></button></form>
|
||||
|
||||
<div v-for="(question, index) in questions" v-show="questionIndex > index" :key="question.key" class="question-block">
|
||||
<div class="chat-line ai"><small>COAUTHOR · {{ String(index+2).padStart(2,'0') }}</small><p>{{ question.label }}</p></div>
|
||||
<div class="choices">
|
||||
<button v-for="option in question.options" :key="option" :class="{selected:answerFor(question.key)===option}" @click="choose(question.key, option)">{{ option }}</button>
|
||||
</div>
|
||||
</div>
|
||||
<button v-if="Object.values(answers).every(Boolean)" class="generate" @click="generate">GENERATE STARTING WORLD <span>↗</span></button>
|
||||
</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>
|
||||
24
apps/web/server/api/rounds/demo/resolve.post.ts
Normal file
24
apps/web/server/api/rounds/demo/resolve.post.ts
Normal 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.',
|
||||
}
|
||||
})
|
||||
36
apps/web/server/api/worlds/generate.post.ts
Normal file
36
apps/web/server/api/worlds/generate.post.ts
Normal 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 alpha’s 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.' })
|
||||
}
|
||||
})
|
||||
7
apps/web/server/plugins/validate-runtime-config.ts
Normal file
7
apps/web/server/plugins/validate-runtime-config.ts
Normal 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())
|
||||
}
|
||||
})
|
||||
25
apps/web/server/utils/ai-provider.test.ts
Normal file
25
apps/web/server/utils/ai-provider.test.ts
Normal 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 })
|
||||
})
|
||||
})
|
||||
103
apps/web/server/utils/ai-provider.ts
Normal file
103
apps/web/server/utils/ai-provider.ts
Normal 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)
|
||||
}
|
||||
12
apps/web/server/utils/runtime-config.test.ts
Normal file
12
apps/web/server/utils/runtime-config.test.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
18
apps/web/server/utils/runtime-config.ts
Normal file
18
apps/web/server/utils/runtime-config.ts
Normal 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
3
apps/web/tsconfig.json
Normal file
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"extends": "./.nuxt/tsconfig.json"
|
||||
}
|
||||
27
apps/worker/package.json
Normal file
27
apps/worker/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
38
apps/worker/src/ai.test.ts
Normal file
38
apps/worker/src/ai.test.ts
Normal 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
113
apps/worker/src/ai.ts
Normal 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
12
apps/worker/src/env.ts
Normal 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
241
apps/worker/src/index.ts
Normal 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))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
4
apps/worker/tsconfig.json
Normal file
4
apps/worker/tsconfig.json
Normal file
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"include": ["src/**/*.ts"]
|
||||
}
|
||||
37
package.json
Normal file
37
package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
20
packages/game-engine/package.json
Normal file
20
packages/game-engine/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
59
packages/game-engine/src/index.test.ts
Normal file
59
packages/game-engine/src/index.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
139
packages/game-engine/src/index.ts
Normal file
139
packages/game-engine/src/index.ts
Normal 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))
|
||||
}
|
||||
4
packages/game-engine/tsconfig.json
Normal file
4
packages/game-engine/tsconfig.json
Normal file
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"include": ["src/**/*.ts"]
|
||||
}
|
||||
20
packages/shared/package.json
Normal file
20
packages/shared/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
157
packages/shared/src/index.ts
Normal file
157
packages/shared/src/index.ts
Normal 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}`
|
||||
}
|
||||
12
packages/shared/src/moderation.test.ts
Normal file
12
packages/shared/src/moderation.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
21
packages/shared/src/moderation.ts
Normal file
21
packages/shared/src/moderation.ts
Normal 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 }
|
||||
}
|
||||
4
packages/shared/tsconfig.json
Normal file
4
packages/shared/tsconfig.json
Normal file
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"include": ["src/**/*.ts"]
|
||||
}
|
||||
7750
pnpm-lock.yaml
generated
Normal file
7750
pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
3
pnpm-workspace.yaml
Normal file
3
pnpm-workspace.yaml
Normal file
@@ -0,0 +1,3 @@
|
||||
packages:
|
||||
- apps/*
|
||||
- packages/*
|
||||
852
supabase/migrations/0001_alpha_schema.sql
Normal file
852
supabase/migrations/0001_alpha_schema.sql
Normal 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;
|
||||
486
supabase/migrations/0002_supabase_ai_queue.sql
Normal file
486
supabase/migrations/0002_supabase_ai_queue.sql
Normal 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
2
supabase/seed.sql
Normal 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
17
tsconfig.json
Normal 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
15
vitest.config.ts
Normal 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)),
|
||||
},
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user