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

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

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

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

View File

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

View File

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

View File

@@ -0,0 +1,21 @@
<script setup lang="ts">
defineProps<{ section?: string }>()
</script>
<template>
<div class="shell">
<header class="topbar">
<AppMark />
<div class="topbar-center"><span class="status-dot" /> PRIVATE ALPHA <b v-if="section">/ {{ section }}</b></div>
<div class="topbar-actions">
<NuxtLink to="/dashboard" class="icon-link" aria-label="Dashboard"></NuxtLink>
<div class="avatar">MV</div>
</div>
</header>
<main><slot /></main>
</div>
</template>
<style scoped>
.shell{min-height:100vh}.topbar{position:sticky;top:0;z-index:20;height:76px;padding:0 clamp(18px,4vw,64px);display:grid;grid-template-columns:1fr auto 1fr;align-items:center;border-bottom:1px solid var(--line);background:rgba(10,10,10,.9);backdrop-filter:blur(18px)}.topbar-center{font:500 10px/1 var(--mono);color:var(--muted);letter-spacing:.18em}.topbar-center b{color:var(--ink);font-weight:500}.status-dot{display:inline-block;width:6px;height:6px;margin-right:8px;border-radius:50%;background:var(--acid);box-shadow:0 0 12px var(--acid)}.topbar-actions{justify-self:end;display:flex;align-items:center;gap:14px}.icon-link{display:grid;place-items:center;width:36px;height:36px;border:1px solid var(--line);color:var(--muted);text-decoration:none}.avatar{display:grid;place-items:center;width:36px;height:36px;border-radius:50%;background:var(--acid);color:#0a0a0a;font:700 10px var(--mono)}@media(max-width:700px){.topbar{grid-template-columns:1fr auto}.topbar-center{display:none}}
</style>

View File

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

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

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

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

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

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,36 @@
<script setup lang="ts">
const { worlds } = useDemo()
const filter = ref<'all' | 'active' | 'drafts'>('all')
</script>
<template>
<AppShell section="COMMAND DECK">
<div class="dash-wrap noise">
<section class="dash-head">
<div><p class="kicker">WELCOME BACK, MARA</p><h1>YOUR UNIVERSES<span>.</span></h1><p>Every world remembers where you left it.</p></div>
<NuxtLink to="/worlds/new" class="create-button"><span></span> CREATE A UNIVERSE</NuxtLink>
</section>
<div class="filter-row">
<button v-for="item in ['all','active','drafts']" :key="item" :class="{active:filter===item}" @click="filter=item as typeof filter">{{ item }}</button>
<span>1 / 5 ALPHA WORLDS</span>
</div>
<section class="world-grid">
<NuxtLink v-for="world in worlds" :key="world.title" to="/campaign/demo" class="world-card active-world">
<div class="world-art"><div class="eclipse" /><span>ACTIVE CAMPAIGN</span></div>
<div class="world-info"><small>{{ world.genre }}</small><h2>{{ world.title }}</h2><p>{{ world.premise }}</p><div><b>ROUND 03</b><span>2 PARTY MEMBERS</span></div></div>
</NuxtLink>
<NuxtLink to="/worlds/new" class="world-card new-card">
<span class="plus"></span><h2>MAKE THE NEXT<br>IMPOSSIBLE PLACE</h2><p>Begin with a sentence. The coauthor will ask the rest.</p>
</NuxtLink>
</section>
<section class="system-strip"><span><i /> OPENROUTER ADAPTER READY</span><span>SERVER DICE <b>ONLINE</b></span><span>PRIVATE BY DEFAULT</span></section>
</div>
</AppShell>
</template>
<style scoped>
.dash-wrap{min-height:calc(100vh - 76px);padding:clamp(42px,6vw,86px) clamp(20px,6vw,88px)}.dash-head{display:flex;align-items:end;justify-content:space-between;gap:30px}.kicker{font:500 9px var(--mono);letter-spacing:.2em;color:var(--acid)}.dash-head h1{margin:14px 0 10px;font:600 clamp(44px,6vw,82px)/1 var(--display);letter-spacing:-.06em}.dash-head h1 span{color:var(--acid)}.dash-head p{color:var(--muted)}.create-button{display:flex;align-items:center;gap:18px;padding:18px 22px;background:var(--acid);color:#090909;text-decoration:none;font:600 10px var(--mono);letter-spacing:.12em}.create-button span{font-size:20px}.filter-row{display:flex;align-items:center;gap:10px;margin:58px 0 24px;border-bottom:1px solid var(--line)}.filter-row button{padding:0 4px 16px;background:none;border:0;color:var(--muted);font:500 9px var(--mono);text-transform:uppercase;letter-spacing:.14em;margin-right:18px}.filter-row button.active{color:var(--ink);border-bottom:2px solid var(--acid)}.filter-row>span{margin-left:auto;padding-bottom:16px;font:500 8px var(--mono);color:var(--muted)}.world-grid{display:grid;grid-template-columns:1.35fr .65fr;gap:18px}.world-card{min-height:420px;border:1px solid var(--line);color:var(--ink);text-decoration:none;background:#0e0e0d;transition:.25s ease}.world-card:hover{border-color:#65655e;transform:translateY(-3px)}.active-world{display:grid;grid-template-columns:.9fr 1.1fr}.world-art{position:relative;overflow:hidden;display:grid;place-items:center;background:radial-gradient(circle at 50% 60%,#7d8240 0 2%,#303018 4%,#0b0b0a 36%,#030303 72%)}.world-art:before{content:"";position:absolute;width:320px;height:320px;border:1px solid #38382d;border-radius:50%;box-shadow:0 0 0 34px #111,0 0 0 35px #26261d}.eclipse{position:absolute;width:126px;height:126px;border-radius:50%;background:#020202;box-shadow:0 0 50px var(--acid-dim)}.world-art span{position:absolute;left:20px;top:20px;padding:9px 11px;background:var(--acid);color:#0a0a0a;font:600 8px var(--mono);letter-spacing:.12em}.world-info{padding:42px;display:flex;flex-direction:column}.world-info small{font:500 8px var(--mono);letter-spacing:.14em;color:var(--acid);text-transform:uppercase}.world-info h2,.new-card h2{font:600 clamp(25px,3vw,42px)/1.05 var(--display);letter-spacing:-.05em;margin:22px 0}.world-info p,.new-card p{color:var(--muted);font-size:13px;line-height:1.7}.world-info div{margin-top:auto;padding-top:26px;border-top:1px solid var(--line);display:flex;justify-content:space-between;font:500 8px var(--mono);color:var(--muted)}.world-info b{color:var(--acid)}.new-card{padding:48px;display:flex;flex-direction:column;justify-content:flex-end;background:linear-gradient(145deg,#121211,#090909)}.new-card .plus{margin-bottom:auto;font:300 42px var(--body);color:var(--acid)}.system-strip{margin-top:32px;padding:18px 0;border-top:1px solid var(--line);display:flex;justify-content:space-between;font:500 8px var(--mono);letter-spacing:.12em;color:var(--muted)}.system-strip i{display:inline-block;width:6px;height:6px;background:var(--acid);border-radius:50%;margin-right:8px}.system-strip b{color:var(--acid)}@media(max-width:850px){.dash-head{align-items:start;flex-direction:column}.world-grid{grid-template-columns:1fr}.active-world{grid-template-columns:1fr}.world-art{min-height:280px}.system-strip{gap:16px;flex-wrap:wrap}}@media(max-width:520px){.filter-row>span{display:none}.world-info,.new-card{padding:28px}.active-world{min-height:580px}}
</style>

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

@@ -0,0 +1,61 @@
<script setup lang="ts">
const { signedIn } = useDemo()
const email = ref('founder@example.com')
const notice = ref('')
function enterDemo() {
signedIn.value = true
navigateTo('/dashboard')
}
function requestAccess() {
notice.value = `Invite request saved for ${email.value}. Demo access is available now.`
}
</script>
<template>
<div class="landing noise">
<header class="landing-nav">
<AppMark />
<span class="alpha-tag">INVITE-ONLY ALPHA</span>
</header>
<main class="hero">
<section class="hero-copy">
<p class="eyebrow"><span /> ASYNC AI-POWERED TTRPG</p>
<h1>STORIES THAT<br><em>WAIT FOR NO ONE.</em></h1>
<p class="lede">Build an original universe with an AI coauthor. Gather real players and persistent AI companions. Take your turn when life allowsthe Groundkeeper remembers everything.</p>
<div class="hero-actions">
<button class="btn btn-acid" @click="enterDemo">ENTER THE ALPHA <span></span></button>
<a href="#how" class="btn btn-ghost">SEE HOW IT WORKS</a>
</div>
<div class="trust-row">
<span>PRIVATE WORLDS</span><i />
<span>SERVER-OWNED DICE</span><i />
<span>13+ ADVENTURES</span>
</div>
</section>
<section class="signal-card" aria-label="Live campaign preview">
<div class="signal-top"><span>LIVE SIGNAL / CAMPAIGN 04</span><b>ROUND 17</b></div>
<div class="orbit" aria-hidden="true"><i /><i /><i /><span>D&G</span></div>
<div class="transmission">
<small>GROUNDKEEPER</small>
<p>The voice in the relay is yoursthree days from now. It tells you not to open the archive.</p>
</div>
<div class="party-status"><span><b>3</b> HUMANS READY</span><span><b>1</b> AI COMPANION THINKING</span></div>
</section>
</main>
<section id="how" class="how-grid">
<article><b>01</b><h2>DESCRIBE THE IMPOSSIBLE</h2><p>A coauthor asks sharp questions, then turns your idea into a playable private world.</p></article>
<article><b>02</b><h2>ASSEMBLE YOUR PARTY</h2><p>Invite friends, add AI companions, and decide who can step in when someone is away.</p></article>
<article><b>03</b><h2>ACT ON YOUR TIME</h2><p>The Groundkeeper resolves each shared round only when the party is ready.</p></article>
<form @submit.prevent="requestAccess"><label for="email">REQUEST AN INVITE</label><div><input id="email" v-model="email" type="email" required><button></button></div><small>{{ notice || 'We only email about alpha access.' }}</small></form>
</section>
</div>
</template>
<style scoped>
.landing{min-height:100vh;overflow:hidden}.landing-nav{height:90px;padding:0 clamp(22px,6vw,90px);display:flex;justify-content:space-between;align-items:center;border-bottom:1px solid var(--line)}.alpha-tag{font:500 9px var(--mono);letter-spacing:.18em;border:1px solid var(--line);padding:10px 14px;color:var(--muted)}.hero{min-height:680px;padding:72px clamp(22px,6vw,90px) 60px;display:grid;grid-template-columns:minmax(0,1.15fr) minmax(360px,.85fr);gap:7vw;align-items:center}.eyebrow{font:500 10px var(--mono);letter-spacing:.22em;color:var(--muted)}.eyebrow span{display:inline-block;width:26px;height:1px;background:var(--acid);vertical-align:middle;margin-right:10px}.hero h1{margin:22px 0 28px;font:600 clamp(52px,6.6vw,108px)/.91 var(--display);letter-spacing:-.07em}.hero h1 em{font-style:normal;color:transparent;-webkit-text-stroke:1px var(--acid)}.lede{max-width:650px;font-size:clamp(16px,1.5vw,20px);line-height:1.7;color:#b9b9b2}.hero-actions{display:flex;gap:12px;margin-top:38px}.btn{min-height:52px;padding:0 22px;border:1px solid var(--line);font:600 10px var(--mono);letter-spacing:.13em;text-decoration:none;display:inline-flex;align-items:center;justify-content:center;gap:20px}.btn-acid{border-color:var(--acid);background:var(--acid);color:#080808}.btn-ghost{color:var(--ink);background:transparent}.trust-row{display:flex;align-items:center;gap:14px;margin-top:42px;font:500 8px var(--mono);letter-spacing:.16em;color:var(--muted)}.trust-row i{width:3px;height:3px;background:var(--acid);border-radius:50%}.signal-card{position:relative;min-height:510px;border:1px solid var(--line);background:linear-gradient(145deg,#111 0%,#090909 60%);box-shadow:0 40px 120px #000;padding:22px;clip-path:polygon(0 0,calc(100% - 20px) 0,100% 20px,100% 100%,20px 100%,0 calc(100% - 20px))}.signal-top,.party-status{display:flex;justify-content:space-between;font:500 8px var(--mono);letter-spacing:.13em;color:var(--muted)}.signal-top b{color:var(--acid)}.orbit{position:relative;width:245px;height:245px;margin:48px auto 30px;border:1px solid #2b2b29;border-radius:50%;display:grid;place-items:center}.orbit:before,.orbit:after{content:"";position:absolute;border:1px solid #262624;border-radius:50%;inset:28px}.orbit:after{inset:61px;border-color:var(--acid-dim)}.orbit i{position:absolute;width:7px;height:7px;border-radius:50%;background:var(--acid);box-shadow:0 0 18px var(--acid)}.orbit i:nth-child(1){top:23px;left:52px}.orbit i:nth-child(2){right:-3px;top:113px}.orbit i:nth-child(3){bottom:20px;left:85px}.orbit span{font:600 28px var(--display);color:var(--acid)}.transmission{border-left:2px solid var(--acid);padding:8px 18px;margin:0 12px 34px}.transmission small{font:500 8px var(--mono);letter-spacing:.18em;color:var(--acid)}.transmission p{font:500 15px/1.6 var(--body);color:#d6d6d0}.party-status{padding:18px 12px 0;border-top:1px solid var(--line)}.party-status b{color:var(--acid)}.how-grid{display:grid;grid-template-columns:repeat(3,1fr) 1.2fr;border-top:1px solid var(--line)}.how-grid article,.how-grid form{min-height:215px;padding:34px;border-right:1px solid var(--line)}.how-grid article>b{font:500 9px var(--mono);color:var(--acid)}.how-grid h2{margin:34px 0 12px;font:600 13px var(--display);letter-spacing:-.03em}.how-grid p,.how-grid small{font-size:12px;line-height:1.6;color:var(--muted)}.how-grid label{font:600 10px var(--mono);letter-spacing:.12em}.how-grid form div{display:flex;margin:35px 0 12px}.how-grid input{width:100%;background:#10100f;border:1px solid var(--line);padding:14px;color:var(--ink)}.how-grid form button{width:50px;border:0;background:var(--acid);font-size:20px}@media(max-width:950px){.hero{grid-template-columns:1fr}.signal-card{max-width:620px}.how-grid{grid-template-columns:1fr 1fr}}@media(max-width:620px){.alpha-tag{display:none}.hero{padding-top:50px}.hero h1{font-size:48px}.hero-actions{flex-direction:column}.trust-row{flex-wrap:wrap}.signal-card{min-height:470px}.how-grid{grid-template-columns:1fr}.how-grid article,.how-grid form{border-bottom:1px solid var(--line)}}
</style>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

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

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

View File

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

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

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

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

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

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

@@ -0,0 +1,241 @@
import './env'
import { Worker } from 'bullmq'
import IORedis from 'ioredis'
import { applyMechanicalEvents, bindMechanicalEventsToRolls, resolveCheck } from '@dng/game-engine'
import { CharacterSchema, PlayerIntentSchema, makeId } from '@dng/shared'
import { generateWorld, narrateRound, planRound } from './ai'
const redisUrl = process.env.REDIS_URL
const supabaseUrl = process.env.SUPABASE_URL
const serviceKey = process.env.SUPABASE_SERVICE_ROLE_KEY
if (!supabaseUrl || !serviceKey) {
throw new Error('[worker] SUPABASE_URL and SUPABASE_SERVICE_ROLE_KEY are required.')
} else {
const databaseUrl = supabaseUrl
const databaseServiceKey = serviceKey
type JobType = 'generate-world' | 'resolve-round'
interface JobPayload {
id: string
name: JobType
entityId: string
messages?: Array<{ role: 'user' | 'assistant'; content: string }>
claimToken?: string
}
interface ClaimedJob {
id: string
job_type: JobType
entity_id: string
}
class SupabaseRequestError extends Error {
constructor(
readonly status: number,
readonly path: string,
detail: string,
) {
const rpcName = path.startsWith('rpc/') ? path.slice('rpc/'.length) : null
const message = status === 404 && rpcName
? `Supabase RPC "${rpcName}" was not found. Apply supabase/migrations/0002_supabase_ai_queue.sql in the Supabase SQL Editor, then restart the worker.`
: `Supabase returned ${status} for ${path}: ${detail}`
super(message)
this.name = 'SupabaseRequestError'
}
}
async function databaseRequest<T>(path: string, init: RequestInit = {}): Promise<T> {
const response = await fetch(new URL(`/rest/v1/${path}`, databaseUrl), {
...init,
headers: {
apikey: databaseServiceKey,
Authorization: `Bearer ${databaseServiceKey}`,
'Content-Type': 'application/json',
...init.headers,
},
})
if (!response.ok) {
const detail = await response.text()
throw new SupabaseRequestError(response.status, path, detail)
}
if (response.status === 204) return undefined as T
return response.json() as Promise<T>
}
async function selectRows<T>(table: string, query: Record<string, string>): Promise<T[]> {
return databaseRequest<T[]>(`${table}?${new URLSearchParams(query)}`)
}
async function processWorld(job: JobPayload) {
let messages = job.messages
if (!messages) {
const [session] = await selectRows<{ messages: Array<{ role: 'user' | 'assistant'; content: string }> }>('coauthor_sessions', {
select: 'messages', id: `eq.${job.entityId}`, limit: '1',
})
if (!session) throw new Error('Coauthor session not found')
messages = session.messages
}
const world = await generateWorld(messages)
await databaseRequest(`coauthor_sessions?id=eq.${encodeURIComponent(job.entityId)}`, {
method: 'PATCH',
body: JSON.stringify({ status: 'ready', generated_world: world, updated_at: new Date().toISOString() }),
})
await databaseRequest('rpc/complete_ai_job', {
method: 'POST', body: JSON.stringify({ p_job_id: job.id, p_worker_id: job.claimToken ?? null }),
})
return world
}
async function processRound(job: JobPayload) {
const [round] = await selectRows<Record<string, any>>('rounds', {
select: '*,campaigns!inner(*)',
id: `eq.${job.entityId}`,
limit: '1',
})
if (!round) throw new Error('Round not found')
if (round.status === 'resolved') return { duplicate: true }
const [rawCharacters, rawIntents, rawMemories] = await Promise.all([
selectRows<Record<string, any>>('characters', { select: '*', campaign_id: `eq.${round.campaign_id}` }),
selectRows<Record<string, any>>('player_intents', { select: '*', round_id: `eq.${round.id}` }),
selectRows<{ summary: string }>('memories', { select: 'summary', campaign_id: `eq.${round.campaign_id}`, order: 'importance.desc', limit: '12' }),
])
const characters = rawCharacters.map(row => CharacterSchema.parse({
id: row.id, name: row.name, concept: row.concept, controller: row.controller,
userId: row.user_id, abilities: row.abilities, hp: row.hp, maxHp: row.max_hp,
defense: row.defense, proficiency: row.proficiency, inventory: row.inventory, statuses: row.statuses,
}))
const intents = rawIntents.map(row => PlayerIntentSchema.parse({
id: row.id, roundId: row.round_id, memberId: row.member_id, characterId: row.character_id,
action: row.action, ready: row.ready, createdAt: row.created_at, updatedAt: row.updated_at,
}))
const scene = String(round.campaigns.current_scene ?? '')
const plan = await planRound({ scene, intents, characters, memories: rawMemories.map(row => String(row.summary)) })
const rolls = plan.checks.map(check => {
const actor = characters.find(character => character.id === check.actorId)
if (!actor) throw new Error(`Unknown check actor ${check.actorId}`)
return resolveCheck(check, actor)
})
const permittedEvents = bindMechanicalEventsToRolls(plan.proposedEvents, rolls)
const resolution = await narrateRound({ scene, intents, aiActions: plan.aiActions, rolls, permittedEvents })
const safeEvents = resolution.events.filter(event => permittedEvents.some(permitted => JSON.stringify(permitted) === JSON.stringify(event)))
const applied = applyMechanicalEvents(characters, safeEvents)
await databaseRequest(job.claimToken ? 'rpc/commit_claimed_round_resolution' : 'rpc/commit_round_resolution', {
method: 'POST',
body: JSON.stringify({
p_round_id: round.id,
p_narration: resolution.narration,
p_next_prompt: resolution.nextPrompt,
p_rolls: rolls,
p_events: safeEvents,
p_character_states: applied.characters.map(character => ({ id: character.id, hp: character.hp, inventory: character.inventory, statuses: character.statuses })),
p_memory: resolution.memory,
p_idempotency_key: job.id,
...(job.claimToken ? { p_worker_id: job.claimToken } : {}),
}),
})
return { narration: resolution.narration, rolls: rolls.length }
}
async function processJob(job: JobPayload) {
if (job.name === 'generate-world') return processWorld(job)
if (job.name === 'resolve-round') return processRound(job)
throw new Error(`Unknown job type: ${job.name}`)
}
async function withLeaseHeartbeat<T>(jobId: string, workerId: string, task: () => Promise<T>): Promise<T> {
let heartbeatError: unknown
let renewing = false
const heartbeat = setInterval(() => {
if (renewing) return
renewing = true
void databaseRequest('rpc/renew_ai_job_lease', {
method: 'POST', body: JSON.stringify({ p_job_id: jobId, p_worker_id: workerId }),
}).catch(error => { heartbeatError = error }).finally(() => { renewing = false })
}, 60_000)
heartbeat.unref()
try {
const result = await task()
if (heartbeatError) throw heartbeatError
return result
} finally {
clearInterval(heartbeat)
}
}
if (redisUrl) {
const connection = new IORedis(redisUrl, { maxRetriesPerRequest: null })
const workerId = process.env.RAILWAY_REPLICA_ID ?? makeId('worker')
const worker = new Worker('dng-ai', async job => {
if (!job.id) throw new Error('BullMQ job id must match an ai_jobs id')
const [claimed] = await databaseRequest<ClaimedJob[]>('rpc/claim_ai_job_by_id', {
method: 'POST', body: JSON.stringify({ p_job_id: String(job.id), p_worker_id: workerId }),
})
if (!claimed) throw new Error(`AI job ${job.id} is not claimable`)
try {
return await withLeaseHeartbeat(claimed.id, workerId, () => processJob({
id: claimed.id,
name: claimed.job_type,
entityId: claimed.entity_id,
messages: job.data.messages,
claimToken: workerId,
}))
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
await databaseRequest('rpc/retry_ai_job', {
method: 'POST', body: JSON.stringify({ p_job_id: claimed.id, p_worker_id: workerId, p_error: message.slice(0, 2_000) }),
})
throw error
}
}, { connection, concurrency: 4, lockDuration: 120_000 })
console.info('[worker] Dungeons & Ground worker ready (BullMQ)')
const close = async () => {
await worker.close()
await connection.quit()
}
process.once('SIGTERM', () => void close())
process.once('SIGINT', () => void close())
} else {
const pollInterval = Math.max(250, Number(process.env.AI_JOB_POLL_INTERVAL_MS ?? 1_000))
const workerId = process.env.RAILWAY_REPLICA_ID ?? makeId('worker')
let stopped = false
const stop = () => { stopped = true }
process.once('SIGTERM', stop)
process.once('SIGINT', stop)
console.info(`[worker] Dungeons & Ground worker ready (Supabase polling every ${pollInterval}ms)`)
while (!stopped) {
try {
const claimed = await databaseRequest<ClaimedJob[]>('rpc/claim_ai_job', {
method: 'POST', body: JSON.stringify({ p_worker_id: workerId }),
})
const job = claimed[0]
if (!job) {
await new Promise(resolve => setTimeout(resolve, pollInterval))
continue
}
try {
await withLeaseHeartbeat(job.id, workerId, () => processJob({ id: job.id, name: job.job_type, entityId: job.entity_id, claimToken: workerId }))
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
console.error(`[worker] ${job.job_type} ${job.id} failed: ${message}`)
await databaseRequest('rpc/retry_ai_job', {
method: 'POST',
body: JSON.stringify({ p_job_id: job.id, p_worker_id: workerId, p_error: message.slice(0, 2_000) }),
})
}
} catch (error) {
if (error instanceof SupabaseRequestError && error.status === 404 && error.path.startsWith('rpc/')) {
console.error(`[worker] ${error.message}`)
process.exitCode = 1
break
}
console.error('[worker] polling failed:', error)
await new Promise(resolve => setTimeout(resolve, pollInterval))
}
}
}
}

View File

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