From 1774496cf9762d34ac375d47a1373ae7324cd6a3 Mon Sep 17 00:00:00 2001 From: pavel444-byte Date: Fri, 14 Aug 2026 10:48:54 +0500 Subject: [PATCH] The first 2 weeks is ended. --- .env.example | 23 + .github/workflows/ci.yml | 22 + .gitignore | 12 + .nvmrc | 1 + LEGAL.md | 5 + README.md | 48 + apps/web/app.vue | 3 + apps/web/assets/css/main.css | 1 + apps/web/components/AppMark.vue | 10 + apps/web/components/AppShell.vue | 21 + apps/web/composables/useDemo.ts | 45 + apps/web/nuxt.config.ts | 40 + apps/web/package.json | 28 + apps/web/pages/campaign/demo.vue | 108 + apps/web/pages/dashboard.vue | 36 + apps/web/pages/index.vue | 61 + apps/web/pages/worlds/new.vue | 100 + .../server/api/rounds/demo/resolve.post.ts | 24 + apps/web/server/api/worlds/generate.post.ts | 36 + .../server/plugins/validate-runtime-config.ts | 7 + apps/web/server/utils/ai-provider.test.ts | 25 + apps/web/server/utils/ai-provider.ts | 103 + apps/web/server/utils/runtime-config.test.ts | 12 + apps/web/server/utils/runtime-config.ts | 18 + apps/web/tsconfig.json | 3 + apps/worker/package.json | 27 + apps/worker/src/ai.test.ts | 38 + apps/worker/src/ai.ts | 113 + apps/worker/src/env.ts | 12 + apps/worker/src/index.ts | 241 + apps/worker/tsconfig.json | 4 + package.json | 37 + packages/game-engine/package.json | 20 + packages/game-engine/src/index.test.ts | 59 + packages/game-engine/src/index.ts | 139 + packages/game-engine/tsconfig.json | 4 + packages/shared/package.json | 20 + packages/shared/src/index.ts | 157 + packages/shared/src/moderation.test.ts | 12 + packages/shared/src/moderation.ts | 21 + packages/shared/tsconfig.json | 4 + pnpm-lock.yaml | 7750 +++++++++++++++++ pnpm-workspace.yaml | 3 + supabase/migrations/0001_alpha_schema.sql | 852 ++ .../migrations/0002_supabase_ai_queue.sql | 486 ++ supabase/seed.sql | 2 + tsconfig.json | 17 + vitest.config.ts | 15 + 48 files changed, 10825 insertions(+) create mode 100644 .env.example create mode 100644 .github/workflows/ci.yml create mode 100644 .gitignore create mode 100644 .nvmrc create mode 100644 LEGAL.md create mode 100644 README.md create mode 100644 apps/web/app.vue create mode 100644 apps/web/assets/css/main.css create mode 100644 apps/web/components/AppMark.vue create mode 100644 apps/web/components/AppShell.vue create mode 100644 apps/web/composables/useDemo.ts create mode 100644 apps/web/nuxt.config.ts create mode 100644 apps/web/package.json create mode 100644 apps/web/pages/campaign/demo.vue create mode 100644 apps/web/pages/dashboard.vue create mode 100644 apps/web/pages/index.vue create mode 100644 apps/web/pages/worlds/new.vue create mode 100644 apps/web/server/api/rounds/demo/resolve.post.ts create mode 100644 apps/web/server/api/worlds/generate.post.ts create mode 100644 apps/web/server/plugins/validate-runtime-config.ts create mode 100644 apps/web/server/utils/ai-provider.test.ts create mode 100644 apps/web/server/utils/ai-provider.ts create mode 100644 apps/web/server/utils/runtime-config.test.ts create mode 100644 apps/web/server/utils/runtime-config.ts create mode 100644 apps/web/tsconfig.json create mode 100644 apps/worker/package.json create mode 100644 apps/worker/src/ai.test.ts create mode 100644 apps/worker/src/ai.ts create mode 100644 apps/worker/src/env.ts create mode 100644 apps/worker/src/index.ts create mode 100644 apps/worker/tsconfig.json create mode 100644 package.json create mode 100644 packages/game-engine/package.json create mode 100644 packages/game-engine/src/index.test.ts create mode 100644 packages/game-engine/src/index.ts create mode 100644 packages/game-engine/tsconfig.json create mode 100644 packages/shared/package.json create mode 100644 packages/shared/src/index.ts create mode 100644 packages/shared/src/moderation.test.ts create mode 100644 packages/shared/src/moderation.ts create mode 100644 packages/shared/tsconfig.json create mode 100644 pnpm-lock.yaml create mode 100644 pnpm-workspace.yaml create mode 100644 supabase/migrations/0001_alpha_schema.sql create mode 100644 supabase/migrations/0002_supabase_ai_queue.sql create mode 100644 supabase/seed.sql create mode 100644 tsconfig.json create mode 100644 vitest.config.ts diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..e636fea --- /dev/null +++ b/.env.example @@ -0,0 +1,23 @@ +# Web / Supabase (required) +NUXT_PUBLIC_SUPABASE_URL= +SUPABASE_URL= +NUXT_PUBLIC_SUPABASE_ANON_KEY= +SUPABASE_SERVICE_ROLE_KEY= + +# AI provider (choose openrouter or deepseek) +AI_PROVIDER=openrouter +OPENROUTER_API_KEY= +OPENROUTER_MODEL=deepseek/deepseek-v4-flash +OPENROUTER_API_ENDPOINT=https://openrouter.ai/api/v1/chat/completions +DEEPSEEK_API_KEY= +DEEPSEEK_MODEL=deepseek-v4-flash +DEEPSEEK_API_ENDPOINT=https://api.deepseek.com/chat/completions + +# Queue (optional for the web process) +REDIS_URL= +AI_JOB_POLL_INTERVAL_MS=1000 + +# Alpha controls +INVITE_ALLOWLIST=founder@example.com +DAILY_AI_TOKEN_LIMIT=250000 +NUXT_PUBLIC_APP_URL=http://localhost:3000 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..6fc79be --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,22 @@ +name: CI + +on: + push: + pull_request: + +jobs: + validate: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 18.20.8 + cache: pnpm + - uses: pnpm/action-setup@v4 + with: + version: 10.15.0 + - run: pnpm install --frozen-lockfile + - run: pnpm test + - run: pnpm typecheck + - run: pnpm build diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..36bd3a7 --- /dev/null +++ b/.gitignore @@ -0,0 +1,12 @@ +node_modules +.pnpm-store +.nuxt +.output +dist +coverage +.env +.env.* +!.env.example +.DS_Store +playwright-report +test-results diff --git a/.nvmrc b/.nvmrc new file mode 100644 index 0000000..08b7109 --- /dev/null +++ b/.nvmrc @@ -0,0 +1 @@ +18.20.8 diff --git a/LEGAL.md b/LEGAL.md new file mode 100644 index 0000000..2fbe988 --- /dev/null +++ b/LEGAL.md @@ -0,0 +1,5 @@ +# Legal notices + +This work includes material from the System Reference Document 5.2.1 (“SRD 5.2.1”) by Wizards of the Coast LLC, available at https://www.dndbeyond.com/srd. The SRD 5.2.1 is licensed under the Creative Commons Attribution 4.0 International License, available at https://creativecommons.org/licenses/by/4.0/legalcode. + +Dungeons & Ground is not affiliated with, endorsed, sponsored, or specifically approved by Wizards of the Coast LLC. Product copy and generated content must not use protected settings, characters, creatures, or product identity that is not included in SRD 5.2.1. diff --git a/README.md b/README.md new file mode 100644 index 0000000..5ca2e90 --- /dev/null +++ b/README.md @@ -0,0 +1,48 @@ +# Dungeons & Ground + +An English-first, invite-only web alpha for asynchronous text TTRPG campaigns. Players create private worlds with an AI coauthor, submit actions into shared rounds, and play alongside persistent AI companions. The rules engine—not the language model—owns dice and mechanical state. + +## Local start + +```bash +cp .env.example .env +pnpm install +pnpm dev:all +``` + +Open `http://localhost:3000`. `pnpm dev:all` starts the Nuxt site and the AI worker in one terminal. To run them separately, use `pnpm dev` in the first terminal and `pnpm dev:worker` in the second. + +The web and worker commands both load this root `.env` file explicitly. Environment variables supplied by Railway or CI take precedence over values in the file. + +The static demo UI can be built and tested without credentials. A production Nitro server and every worker process require `SUPABASE_URL`, `SUPABASE_SERVICE_ROLE_KEY`, `NUXT_PUBLIC_SUPABASE_URL`, and `NUXT_PUBLIC_SUPABASE_ANON_KEY`; startup fails with a clear error when required server credentials are missing. Redis is optional: when `REDIS_URL` is absent, the worker safely claims jobs from the Supabase `ai_jobs` outbox using short database leases and retries failed jobs up to three times. + +Select OpenRouter or the official DeepSeek API with `AI_PROVIDER=openrouter|deepseek` and provide the matching API key. OpenRouter retains JSON Schema structured output; DeepSeek uses its official JSON mode, followed by the same Zod validation. + +Use Node.js 18.20.5 or newer. The lockfile pins the web toolchain to the Node 18-compatible Nuxt 3.15, Nitro 2.10, and Vite 6 line; CI verifies the project on Node 18.20.8. + +## Workspace + +- `apps/web` — Nuxt SSR UI and Nitro API +- `apps/worker` — BullMQ worker for AI world and round jobs +- `packages/shared` — shared Zod contracts and domain types +- `packages/game-engine` — deterministic d20 rules and state transition guards +- `supabase/migrations` — PostgreSQL schema, indexes, triggers, and RLS policies + +## Alpha safety + +- Worlds are private by default. +- Content is limited to 13+; explicit sexual content and sexual content involving minors are rejected. +- AI responses are parsed against strict schemas. +- AI can propose checks and events, but cannot directly set dice outcomes or mechanical values. +- SRD-derived work must retain the attribution in `LEGAL.md`. + +## Production services + +1. Create a Supabase project and apply `supabase/migrations/0001_alpha_schema.sql`, then `supabase/migrations/0002_supabase_ai_queue.sql`, and finally the seed file. If `0001` was already applied earlier, apply only `0002`; it adds the Supabase-backed worker queue and reloads the REST API schema cache. +2. Add invited tester emails to `public.allowlist`. +3. Run `pnpm dev:worker` alongside the web process. Provision Redis only when BullMQ delivery is desired; otherwise the worker polls the Supabase outbox. +4. Configure either `OPENROUTER_API_KEY` (default model `deepseek/deepseek-v4-flash`) or set `AI_PROVIDER=deepseek` with `DEEPSEEK_API_KEY` (default model `deepseek-v4-flash`, endpoint `https://api.deepseek.com/chat/completions`). + +If the worker reports `POST /rest/v1/rpc/claim_ai_job 404`, the database is missing migration `0002_supabase_ai_queue.sql`. Run that file in the Supabase SQL Editor and restart `pnpm dev:all`. + +The current UI intentionally includes a complete local vertical slice. Production auth/session binding and queue-enqueue endpoints should be connected to the supplied schema before inviting external users. diff --git a/apps/web/app.vue b/apps/web/app.vue new file mode 100644 index 0000000..8f62b8b --- /dev/null +++ b/apps/web/app.vue @@ -0,0 +1,3 @@ + diff --git a/apps/web/assets/css/main.css b/apps/web/assets/css/main.css new file mode 100644 index 0000000..88fa4db --- /dev/null +++ b/apps/web/assets/css/main.css @@ -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}} diff --git a/apps/web/components/AppMark.vue b/apps/web/components/AppMark.vue new file mode 100644 index 0000000..0349bd1 --- /dev/null +++ b/apps/web/components/AppMark.vue @@ -0,0 +1,10 @@ + + + diff --git a/apps/web/components/AppShell.vue b/apps/web/components/AppShell.vue new file mode 100644 index 0000000..20cf163 --- /dev/null +++ b/apps/web/components/AppShell.vue @@ -0,0 +1,21 @@ + + + + + diff --git a/apps/web/composables/useDemo.ts b/apps/web/composables/useDemo.ts new file mode 100644 index 0000000..f1f9d13 --- /dev/null +++ b/apps/web/composables/useDemo.ts @@ -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('worlds', () => [defaultWorld]) + const activeWorld = useState('active-world', () => defaultWorld) + const characters = useState('characters', () => defaultCharacters) + const roundNumber = useState('round-number', () => 3) + const scene = useState('scene', () => defaultWorld.openingScene) + const history = useState>('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 } +} diff --git a/apps/web/nuxt.config.ts b/apps/web/nuxt.config.ts new file mode 100644 index 0000000..4683740 --- /dev/null +++ b/apps/web/nuxt.config.ts @@ -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' }, + ], + }, + }, +}) diff --git a/apps/web/package.json b/apps/web/package.json new file mode 100644 index 0000000..98d2741 --- /dev/null +++ b/apps/web/package.json @@ -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" + } +} diff --git a/apps/web/pages/campaign/demo.vue b/apps/web/pages/campaign/demo.vue new file mode 100644 index 0000000..eb31c7d --- /dev/null +++ b/apps/web/pages/campaign/demo.vue @@ -0,0 +1,108 @@ + + +