This commit is contained in:
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"
|
||||
}
|
||||
Reference in New Issue
Block a user