Compare commits

...

42 Commits

Author SHA1 Message Date
031d094867 feat(memory): implement stability stage
Some checks failed
CI / validate (push) Has been cancelled
CI / validate (pull_request) Has been cancelled
Co-authored-by: multica-agent <github@multica.ai>
2026-09-02 15:04:18 +05:00
4cd0b7b390 fix(campaigns): delete universes in dependency order
Some checks failed
CI / validate (push) Has been cancelled
Co-authored-by: multica-agent <github@multica.ai>
2026-09-02 10:42:59 +05:00
68b4fe9f33 Merge pull request 'DUNGEONS-25: add custom site context menu' (#18) from agent/codex-chatgpt/e4e799bb into main
Some checks failed
CI / validate (push) Failing after 12s
Reviewed-on: #18
2026-08-29 12:01:48 +00:00
fdf02446f5 feat(ui): add site context menu
Some checks failed
CI / validate (push) Has been cancelled
CI / validate (pull_request) Failing after 11s
Co-authored-by: multica-agent <github@multica.ai>
2026-08-29 16:56:14 +05:00
034966f9ef feat(dashboard): add edit button per universe card with rename/delete actions
Some checks failed
CI / validate (push) Has been cancelled
Co-authored-by: multica-agent <github@multica.ai>
2026-08-28 15:44:23 +05:00
0a38ffdaf0 Merge pull request 'DUNGEONS-21: add versioned SRD rules migration' (#17) from agent/codex-chatgpt/f19040e1 into main
Some checks failed
CI / validate (push) Failing after 38m20s
Reviewed-on: #17
2026-08-26 03:53:24 +00:00
6c39188027 feat(gameplay): add versioned SRD rules migration
Some checks failed
CI / validate (push) Failing after 3m36s
CI / validate (pull_request) Failing after 11s
Co-authored-by: multica-agent <github@multica.ai>
2026-08-26 08:45:50 +05:00
89fd1fd984 Merge pull request 'DUNGEONS-20: enforce core SRD d20 rules' (#16) from agent/codex-chatgpt/36e1fd11 into main
All checks were successful
CI / validate (push) Successful in 16m28s
Reviewed-on: #16
2026-08-23 14:02:38 +00:00
4e0c38c925 feat(gameplay): enforce core SRD d20 rules
All checks were successful
CI / validate (push) Successful in 21m53s
CI / validate (pull_request) Successful in 15m15s
Co-authored-by: multica-agent <github@multica.ai>
2026-08-23 18:50:55 +05:00
ace1e47a6d Merge pull request 'DUNGEONS-18: add first-visit homepage intro' (#15) from agent/codex-chatgpt/b56d3ed9 into main
All checks were successful
CI / validate (push) Successful in 18m26s
Reviewed-on: #15
2026-08-22 15:20:21 +00:00
0ade57b5a1 feat(home): add first-visit intro animation
Some checks failed
CI / validate (push) Successful in 19m50s
CI / validate (pull_request) Failing after 1m3s
Co-authored-by: multica-agent <github@multica.ai>
2026-08-22 20:07:25 +05:00
56b4586d1a Merge pull request 'DUNGEONS-16: use universe-generated AI companions' (#14) from agent/codex-chatgpt/dungeons-16-recreated into main
Some checks failed
CI / validate (push) Failing after 11m2s
Reviewed-on: #14
2026-08-22 10:26:45 +00:00
f6c0fbcfeb fix(gameplay): use universe-generated AI companions
Some checks failed
CI / validate (pull_request) Waiting to run
CI / validate (push) Failing after 34m2s
Co-authored-by: multica-agent <github@multica.ai>
2026-08-22 14:50:14 +05:00
0b53ad0701 chore: refresh Gitea hooks
Some checks failed
CI / validate (push) Failing after 12s
2026-08-22 10:58:32 +05:00
719cc738db chore: refresh Gitea hooks 2026-08-22 10:58:26 +05:00
e430b4e773 Merge pull request 'DUNGEONS-16: use universe-generated AI companions' (#13) from agent/codex-chatgpt/1cffe813 into main
Reviewed-on: #13
2026-08-21 16:11:33 +00:00
9dd856ff8c fix(gameplay): use AI-generated world companions
Some checks failed
CI / validate (push) Successful in 1h3m54s
CI / validate (pull_request) Failing after 14m14s
Co-authored-by: multica-agent <github@multica.ai>
2026-08-21 20:38:12 +05:00
56bddafbe8 Merge pull request 'DUNGEONS-16: make campaigns immediately playable' (#12) from agent/codex-chatgpt/1cffe813 into main
Some checks failed
CI / validate (push) Has been cancelled
Reviewed-on: #12
2026-08-21 14:50:56 +00:00
f7ca158fe4 fix(gameplay): start campaigns in playable state
Some checks failed
CI / validate (pull_request) Has been cancelled
CI / validate (push) Successful in 32m54s
Co-authored-by: multica-agent <github@multica.ai>
2026-08-21 19:41:50 +05:00
19693c78c4 Merge pull request 'DUNGEONS-15: keep coauthor questions in the user's language' (#10) from agent/codex-chatgpt/1d9b2488 into main
Some checks failed
CI / validate (push) Has been cancelled
Reviewed-on: #10
2026-08-21 13:40:57 +00:00
2898efa1db fix(web): keep coauthor questions in user language
Some checks failed
CI / validate (push) Failing after 13m17s
CI / validate (pull_request) Failing after 13m51s
Co-authored-by: multica-agent <github@multica.ai>
2026-08-21 12:12:10 +05:00
fb4b06711d Merge pull request 'DUNGEONS-12: add profile management' (#9) from agent/codex-chatgpt/24f3d9f6 into main
Some checks failed
CI / validate (push) Failing after 10m19s
Reviewed-on: #9
2026-08-20 15:18:20 +00:00
41f4633dca feat(web): add profile management
Some checks failed
CI / validate (push) Successful in 19m25s
CI / validate (pull_request) Failing after 9m54s
Co-authored-by: multica-agent <github@multica.ai>
2026-08-20 19:24:50 +05:00
fc205c232f Merge pull request 'DUNGEONS-11: keep auth pages styled reliably' (#8) from agent/codex-chatgpt/d97b9d5e into main
Some checks failed
CI / validate (push) Failing after 59s
Reviewed-on: #8
2026-08-20 13:51:41 +00:00
744749bc54 fix(web): keep auth styles globally available
All checks were successful
CI / validate (push) Successful in 17m4s
CI / validate (pull_request) Successful in 16m32s
Co-authored-by: multica-agent <github@multica.ai>
2026-08-20 18:28:12 +05:00
c1e6b4b61f Style dashboard world cards and responsive layout
Some checks failed
CI / validate (push) Failing after 34m57s
2026-08-20 14:53:11 +05:00
139c0d4fd2 Merge pull request 'DUNGEONS-9: fix black screen caused by asset failures' (#7) from agent/codex-chatgpt/75fff14e into main
Some checks failed
CI / validate (push) Has been cancelled
Reviewed-on: #7
2026-08-20 09:11:13 +00:00
8ce9314887 fix(web): serve Nitro assets reliably
Some checks failed
CI / validate (push) Failing after 8m18s
CI / validate (pull_request) Successful in 29m51s
Co-authored-by: multica-agent <github@multica.ai>
2026-08-20 13:38:32 +05:00
40100cccab Merge pull request 'DUNGEONS-8: test Coolify PR webhook (recreated #2)' (#6) from test/coolify-webhook-3 into main
Some checks failed
CI / validate (push) Has been cancelled
Reviewed-on: #6
2026-08-19 14:50:41 +00:00
a079598f9c test(coolify): trigger preview deployment webhook
Some checks failed
CI / validate (pull_request) Has been cancelled
CI / validate (push) Has been cancelled
Co-authored-by: multica-agent <github@multica.ai>
2026-08-19 19:44:34 +05:00
263bee12dd Merge pull request 'DUNGEONS-8: test Coolify PR webhook (recreated)' (#5) from test/coolify-webhook-2 into main
Some checks failed
CI / validate (push) Has been cancelled
Reviewed-on: #5
2026-08-19 14:42:43 +00:00
de815cb9c1 test(coolify): trigger preview deployment webhook
Some checks failed
CI / validate (pull_request) Has been cancelled
CI / validate (push) Successful in 14m54s
Co-authored-by: multica-agent <github@multica.ai>
2026-08-19 19:21:18 +05:00
bf48924644 Merge pull request 'DUNGEONS-8: test Coolify PR webhook' (#4) from test/coolify-webhook into main
Some checks failed
CI / validate (push) Has been cancelled
Reviewed-on: #4
2026-08-19 14:19:40 +00:00
e90dccd8bb test(coolify): trigger preview deployment webhook
All checks were successful
CI / validate (push) Successful in 15m15s
CI / validate (pull_request) Successful in 16m0s
Co-authored-by: multica-agent <github@multica.ai>
2026-08-19 19:15:46 +05:00
9c08cabcf7 Merge pull request 'DUNGEONS-6: Fix Nitro public assets in Coolify image' (#3) from perfect_python500/dungeons-6-coolify-assets into main
All checks were successful
CI / validate (push) Successful in 14m28s
Reviewed-on: #3
2026-08-19 13:28:05 +00:00
8651a9aad1 Fix Nitro public assets in Coolify image
All checks were successful
CI / validate (push) Successful in 20m23s
CI / validate (pull_request) Successful in 17m14s
Co-authored-by: multica-agent <github@multica.ai>
2026-08-19 18:19:59 +05:00
860a5383b0 Merge pull request 'Fix Dockerfile healthcheck for Coolify PORT override' (#2) from agent/opencode-deepseek/1fa9d179 into main
All checks were successful
CI / validate (push) Successful in 23m18s
Reviewed-on: #2
2026-08-19 12:06:45 +00:00
62f01f1db3 Fix healthcheck to respect runtime PORT
Some checks failed
CI / validate (push) Successful in 26m55s
CI / validate (pull_request) Failing after 1m15s
Co-authored-by: multica-agent <github@multica.ai>
2026-08-19 17:00:39 +05:00
8f42a6664b Merge pull request 'Add Dockerfile for containerized build and run' (#1) from agent/opencode-deepseek/1fa9d179 into main
All checks were successful
CI / validate (push) Successful in 14m54s
Reviewed-on: #1
2026-08-19 09:47:20 +00:00
95fb688910 Add Dockerfile to build and run the project in Docker
Some checks failed
CI / validate (push) Successful in 16m14s
CI / validate (pull_request) Failing after 15s
Co-authored-by: multica-agent <github@multica.ai>
2026-08-19 14:43:50 +05:00
33ff879e90 Require email account authentication
All checks were successful
CI / validate (push) Successful in 14m23s
2026-08-18 20:26:24 +05:00
45afb302b7 Add closed alpha product plan
All checks were successful
CI / validate (push) Successful in 16m33s
2026-08-18 14:48:24 +05:00
65 changed files with 6022 additions and 288 deletions

View File

@@ -20,7 +20,6 @@ DEEPSEEK_API_ENDPOINT=https://api.deepseek.com/chat/completions
REDIS_URL=
AI_JOB_POLL_INTERVAL_MS=1000
# Alpha controls
INVITE_ALLOWLIST=founder@example.com
# Alpha controls (the matching hard safety ceiling is enforced in migration 0013)
DAILY_AI_TOKEN_LIMIT=250000
NUXT_PUBLIC_APP_URL=http://localhost:3000

79
Dockerfile Normal file
View File

@@ -0,0 +1,79 @@
# syntax=docker/dockerfile:1
#
# Dungeons & Ground — production image.
#
# Build: docker build -t dungeons-ground .
# Run: docker run --env-file .env -p 3000:3000 dungeons-ground
#
# Required env vars at runtime (see .env.example):
# NUXT_PUBLIC_SUPABASE_URL, SUPABASE_URL, NUXT_PUBLIC_SUPABASE_ANON_KEY,
# SUPABASE_SERVICE_ROLE_KEY, plus the AI provider key (OPENROUTER_API_KEY
# or DEEPSEEK_API_KEY). REDIS_URL is optional.
# The Supabase schema is NOT created by the container; install
# supabase/bootstrap.sql (or run `pnpm db:ensure` with SUPABASE_DB_URL set)
# in your Supabase project before starting.
FROM node:18.20.8-slim AS base
ENV PNPM_HOME=/pnpm
ENV PATH=$PNPM_HOME:$PATH
RUN corepack enable && corepack prepare pnpm@10.15.0 --activate
WORKDIR /app
# ---------- Dependencies ----------
FROM base AS deps
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
COPY apps/web/package.json apps/web/package.json
COPY apps/worker/package.json apps/worker/package.json
COPY packages/shared/package.json packages/shared/package.json
COPY packages/game-engine/package.json packages/game-engine/package.json
RUN pnpm install --frozen-lockfile
# ---------- Build ----------
FROM deps AS build
COPY . .
# The web build explicitly loads ../../.env (--dotenv); an empty file keeps
# the build hermetic — all real values come from container env at runtime.
RUN touch .env && pnpm --filter @dng/web build
# ---------- Runtime ----------
FROM base AS runtime
ENV NODE_ENV=production
ENV HOST=0.0.0.0
ENV PORT=3000
WORKDIR /app
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
COPY apps/web/package.json apps/web/package.json
COPY apps/worker/package.json apps/worker/package.json
COPY packages/shared/package.json packages/shared/package.json
COPY packages/game-engine/package.json packages/game-engine/package.json
COPY --from=deps /app/node_modules node_modules
COPY --from=build /app/apps/web/.output apps/web/.output
COPY --from=build /app/apps/worker apps/worker
COPY --from=build /app/packages/shared packages/shared
COPY --from=build /app/packages/game-engine packages/game-engine
EXPOSE 3000
# Coolify (and other hosts) override PORT at runtime; probe that port, not a
# hardcoded one.
HEALTHCHECK --interval=30s --timeout=5s --start-period=30s --retries=3 \
CMD node -e "fetch('http://127.0.0.1:' + (process.env.PORT || 3000) + '/').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"
# The Nuxt web server reads its config through Nuxt runtimeConfig, which is
# baked at build time and overridden at runtime only by NUXT_-prefixed env
# vars; the worker reads plain names (SUPABASE_URL, OPENROUTER_API_KEY, ...)
# directly. The entrypoint maps the plain .env.example names onto the NUXT_
# runtime overrides so `docker run --env-file .env` works for both processes.
# The web server runs in the foreground (PID 1); the AI worker runs alongside
# it in the background. Stop the container with `docker stop` — both die with
# it.
CMD ["sh", "-c", "export NUXT_SUPABASE_URL=\"${NUXT_SUPABASE_URL:-${SUPABASE_URL:-}}\" NUXT_SUPABASE_SERVICE_ROLE_KEY=\"${NUXT_SUPABASE_SERVICE_ROLE_KEY:-${SUPABASE_SERVICE_ROLE_KEY:-}}\" NUXT_AI_PROVIDER=\"${NUXT_AI_PROVIDER:-${AI_PROVIDER:-}}\" NUXT_OPENROUTER_API_KEY=\"${NUXT_OPENROUTER_API_KEY:-${OPENROUTER_API_KEY:-}}\" NUXT_OPENROUTER_MODEL=\"${NUXT_OPENROUTER_MODEL:-${OPENROUTER_MODEL:-}}\" NUXT_OPENROUTER_API_ENDPOINT=\"${NUXT_OPENROUTER_API_ENDPOINT:-${OPENROUTER_API_ENDPOINT:-}}\" NUXT_DEEPSEEK_API_KEY=\"${NUXT_DEEPSEEK_API_KEY:-${DEEPSEEK_API_KEY:-}}\" NUXT_DEEPSEEK_MODEL=\"${NUXT_DEEPSEEK_MODEL:-${DEEPSEEK_MODEL:-}}\" NUXT_DEEPSEEK_API_ENDPOINT=\"${NUXT_DEEPSEEK_API_ENDPOINT:-${DEEPSEEK_API_ENDPOINT:-}}\" NUXT_REDIS_URL=\"${NUXT_REDIS_URL:-${REDIS_URL:-}}\"; pnpm --filter @dng/worker start & exec node apps/web/.output/server/index.mjs"]

193
PLAN.md Normal file
View File

@@ -0,0 +1,193 @@
# Dungeons & Ground — план закрытой webальфы
## 1. Цель продукта
Dungeons & Ground — англоязычная веб‑платформа для текстовых TTRPGкампаний любого жанра, где:
- AIсоавтор помогает за несколько сообщений создать собственную вселенную.
- AI Game Master ведёт историю, NPC и последствия.
- Реальные игроки участвуют асинхронно в общем пошаговом чате.
- Постоянные AIгерои дополняют партию, а при необходимости временно заменяют отсутствующих игроков.
- Сервер, а не языковая модель, рассчитывает проверки, броски, HP и урон.
- Кампания сохраняет мир, отношения, инвентарь, события и долгосрочную память.
Главный критерий готовности альфы: 24 человека создают мир, персонажей и проходят минимум 2030 минут связной кампании, причём AI не забывает ключевые события и не изменяет игровые значения произвольно.
В отличие от Friends & Fables, уже совмещающего AIведущего, worldbuilding, память, карты, бой и мультиплеер, первая версия D&G сосредоточится на простом создании мира и партии, которая продолжает игру даже при отсутствии части людей. [Возможности Friends & Fables](https://fables.gg/), [описание их системы памяти](https://help.fables.gg/articles/2838157-memories).
## 2. Функциональность альфы
### Пользовательский путь
1. Регистрация обязательного аккаунта по имени, email и паролю; вход по email/password и восстановление пароля через письмо.
2. Создание приватной вселенной через чат с AIсоавтором.
3. AI задаёт 35 уточняющих вопросов о жанре, атмосфере, конфликте и желаемой роли игроков.
4. Создаётся редактируемый стартовый набор:
- premise, genre, tone и content boundaries;
- стартовая локация;
- 3 значимых NPC;
- 2 фракции;
- 3 постоянных AIгероя, созданных специально для этой вселенной;
- сюжетная завязка;
- скрытая угроза или цель;
- начальная сцена.
5. Владелец подтверждает мир, создаёт кампанию и приглашает игроков ссылкой.
6. Каждый игрок создаёт персонажа вручную или с помощью AI.
7. Созданные вместе со вселенной постоянные AIгерои автоматически входят в стартовую партию; владелец может добавлять других и определяет, чьи персонажи могут временно переходить под управление AI.
8. Игроки отправляют действия в текущем раунде.
9. AIDM обрабатывает раунд, когда:
- ответили все активные реальные игроки; или
- владелец нажал `Continue without waiting`.
10. AIгерои и замены совершают действия после людей, затем AIDM публикует единый результат раунда.
### Облегчённая игровая система
Использовать классические характеристики `STR`, `DEX`, `CON`, `INT`, `WIS`, `CHA` во всех жанрах, но позволять AI адаптировать названия архетипов, экипировки и способностей под fantasy, scifi, horror и другие сеттинги.
Первая версия движка покрывает:
- d20 ability и skill checks;
- proficiency bonus;
- HP, Defense/AC и initiative;
- attack roll, damage и healing;
- advantage/disadvantage;
- простые статусы и ограниченные ресурсы;
- серверный генератор случайных чисел с сохранением формулы и результата;
- ручную корректировку состояния владельцем кампании с записью в аудит.
Полные классы, заклинания, сетка боя и редкие правила 5e не входят в альфу. Основа — SRD 5.2.1 под CC BY 4.0 с обязательной атрибуцией; закрытые названия и сеттинги D&D не использовать. [Официальные условия SRD 5.2.1](https://www.dndbeyond.com/srd).
### Память и состояние мира
Хранить отдельно:
- неизменяемый журнал действий и результатов;
- текущую сцену и активные сущности;
- структурированное состояние персонажей, NPC, фракций и заданий;
- отношения между персонажами;
- важные воспоминания с привязкой к персонажам, локациям и тегам;
- краткое резюме истории, обновляемое каждые три завершённых раунда.
В контекст модели передавать только системные инструкции, текущую сцену, последние два раунда, участвующие сущности, активные цели и релевантные воспоминания. Полная история никогда не отправляется автоматически.
## 3. Техническая архитектура
### Стек
- Monorepo на TypeScript.
- Nuxt.js для SSRинтерфейса и Nitro API.
- Отдельный TypeScript worker для генерации миров и обработки игровых раундов.
- Supabase: PostgreSQL, Auth, Row Level Security и Realtime.
- BullMQ + managed Redis для очереди AIзадач.
- Railway: отдельные deployments для Nuxt/Nitro и worker.
- OpenRouter provider adapter с моделью `deepseek/deepseek-v4-flash`.
- Zod для общей валидации API, AIответов и игровых команд.
- Vitest, Nuxt Test Utils и Playwright для тестов.
Desktopверсия позднее создаётся через Tauri и использует тот же API и webинтерфейс.
### Основные сущности и интерфейсы
- `User`, `Invite`, `World`, `WorldEntity`, `Campaign`, `CampaignMember`.
- `Character`, `CharacterController`, `Relationship`.
- `Round`, `PlayerIntent`, `GameEvent`, `DiceRoll`.
- `SceneState`, `QuestState`, `Memory`, `StorySummary`.
- `AiJob`, `AiUsage`, `AuditEntry`.
Публичные серверные операции:
- создание и редактирование мира через coauthor session;
- подтверждение сгенерированного мира;
- создание кампании и приглашение участника;
- создание/назначение персонажа;
- отправка или изменение действия текущего раунда;
- отметка игрока как готового;
- принудительное закрытие раунда владельцем;
- получение истории, состояния персонажа и активной сцены;
- ручная корректировка состояния владельцем.
### Обработка раунда
1. Nitro сохраняет `PlayerIntent` и готовность игрока.
2. Транзакция блокирует раунд и создаёт ровно одну задачу после выполнения условия закрытия.
3. Worker собирает минимальный контекст.
4. AI определяет намерения, нужные проверки и действия AIгероев через типизированные tool calls.
5. Rules engine выполняет все броски и изменения механического состояния.
6. Результаты возвращаются AI для финальной narration.
7. Предложенные события и изменения проходят Zodвалидацию и проверку разрешённых переходов.
8. Narration, события, память и новое состояние сохраняются атомарно.
9. Supabase Realtime обновляет интерфейсы участников.
При timeout или ошибке OpenRouter задача повторяется с idempotency key. После исчерпания повторов раунд остаётся открываемым повторно владельцем; частичные изменения не сохраняются. OpenRouter поддерживает tool calling и JSON Schema outputs, которые следует использовать для типизированных AIопераций. [Tool calling](https://openrouter.ai/docs/guides/features/tool-calling), [structured outputs](https://openrouter.ai/docs/guides/features/structured-outputs).
## 4. Дорожная карта на 810 недель
### Недели 12 — фундамент
- Monorepo, Nuxt/Nitro, worker, CI и окружения.
- Supabase email/password Auth, email confirmation/recovery, RLS и базовая схема данных.
- Англоязычный UI: login, dashboard, world list.
- OpenRouter adapter, учёт токенов, дневные квоты и безопасное хранение ключа.
- Первый вертикальный тест: действие игрока → worker → ответ AI.
### Недели 34 — вселенные и персонажи
- Coauthor chat и структурированная генерация стартового набора.
- Preview/edit/confirm для мира.
- Создание кампании и персонажей.
- Приглашения, lobby, роли owner/player и AIгерои.
- 13+ фильтрация входных prompts и выходов модели.
### Недели 56 — игровой цикл
- Раунды, готовность участников и принудительное продолжение владельцем.
- Realtimeобновления.
- Rules engine и журнал бросков.
- AIDM orchestration, AIгерои и временная замена отсутствующих игроков.
- Защита от двойной обработки и конфликтующих действий.
### Недели 78 — память и стабильность
- Game events, relationships, memories и story summaries.
- Контекстный retrieval без векторной базы: сущности, локации, теги и PostgreSQL fulltext search.
- Retry, rate limits, usage dashboard и аудит изменений.
- E2Eсценарий полной партии и набор AIрегрессионных тестов.
### Недели 910 — закрытая альфа
- UXполировка, responsive web и onboarding.
- Наблюдаемость: ошибки, latency, стоимость раунда, размер контекста.
- Тестирование командой и приглашёнными пользователями.
- Исправление критических проблем и подготовка формы обратной связи.
## 5. Проверка и критерии приёмки
Обязательные сценарии:
- Создать fantasy, scifi и horrorмиры через coauthor chat.
- Отклонить или исправить неполную/невалидную AIструктуру.
- Два пользователя вступают в одну кампанию и видят одинаковое состояние.
- Раунд ждёт всех активных людей.
- Владелец продолжает без отсутствующего игрока, после чего его персонажа корректно подхватывает AI.
- Постоянный AIгерой сохраняет характер и отношения между сценами.
- Броски воспроизводимо отображают формулу, модификатор и итог.
- LLM не может напрямую изменить HP, инвентарь или результат броска.
- Повторный запрос, reconnect или retry не создаёт второй narration.
- OpenRouter timeout не повреждает состояние кампании.
- Пользователь не видит чужие приватные миры и кампании.
- 13+ фильтр блокирует запрещённый explicitконтент.
- После 1520 раундов AI корректно вспоминает ключевое событие из начала игры.
- Средняя стоимость и задержка раунда укладываются в установленные перед альфой квоты.
Перед приглашением внешних тестеров провести минимум 20 сценарных AIпрогонов с заранее ожидаемыми фактами, правилами и последствиями.
## 6. Зафиксированные ограничения
- Название `Dungeons & Ground` считается финальным, но перед публичным релизом обязательны проверка товарных знаков, домена и визуального сходства.
- Первая версия: web, English-first, обязательные email-аккаунты, private worlds и приглашения в кампании.
- Контент: 13+, без explicitматериалов.
- OpenRouter оплачивает проект; пользователи не вводят собственные ключи.
- Оплаты нет — используются дневные, пользовательские и кампанийные лимиты.
- MVP полностью текстовый.
- Не входят: публичный каталог, marketplace, карты, изображения, TTS, видеогенерация, полноценный 5e combat, нативный desktop/mobile и пользовательские ruleset.
- После альфы приоритет определяется метриками: удержание кампаний, завершённые раунды, стоимость AIхода, частота ручных исправлений и качество памяти.

View File

@@ -14,14 +14,18 @@ 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.
To verify the complete hosted-Supabase multiplayer path (two temporary guests, invite, AI character draft, persistent AI companion, shared readiness, AI resolution, and the next round), keep `dev:all` running and execute `pnpm smoke:multiplayer` in another terminal.
To verify the complete hosted-Supabase party path (two temporary email accounts, invite, AI character draft, persistent AI companions, shared readiness, atomic AI resolution, memory state, usage telemetry, and the next round), keep `dev:all` running and execute `pnpm e2e:full-party` in another terminal. `pnpm smoke:multiplayer` is retained as an alias for the same scenario.
Before starting either process, the root commands check the Supabase schema. If it is missing and `SUPABASE_DB_URL` is configured, they transactionally create all D&G tables, functions, triggers, indexes, and policies from `supabase/bootstrap.sql`. Copy the **Session pooler** URI from the Supabase Dashboard **Connect** panel into the root `.env` and replace the password placeholder with the URL-encoded database password. The database URL is server-only and must never use a `NUXT_PUBLIC_` prefix.
Existing campaigns are upgraded automatically to ruleset version 2 by `pnpm db:ensure`. The migration preserves every round, narration, current HP, inventory, and status; adds the SRD 5.2.1 character rules profile; and records one `migrate_ruleset` audit entry per campaign. Migrated characters retain compatibility with their previous proficiency hints, while newly created characters use explicit server-owned skill, expertise, and saving-throw proficiencies.
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 UI can be built without credentials. A running 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.
Campaign context is deliberately bounded: the worker sends the current scene, the last two resolved rounds, active characters and goals, relevant world entities and relationships, the latest three-round summary, and up to eight memories selected by PostgreSQL full-text/entity/tag relevance. It never sends the full campaign transcript automatically. AI requests are protected by per-minute, daily-user, daily-campaign, and daily-token quotas; successful provider calls record tokens, estimated cost, and latency for the dashboard.
Both legacy JWT service-role keys and current `sb_secret_…` Supabase keys are supported. Keep either form server-only; never expose it through a `NUXT_PUBLIC_` variable.
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.
@@ -41,13 +45,13 @@ Use Node.js 18.20.5 or newer. The lockfile pins the web toolchain to the Node 18
- 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.
- AI can propose checks and events, but cannot directly set dice outcomes, proficiency, temporary HP, death saves, resources, or other mechanical values.
- SRD-derived work must retain the attribution in `LEGAL.md`.
## Production services
1. Copy the Supabase **Session pooler** connection URI from **Connect** into `SUPABASE_DB_URL`. `pnpm dev:all` now detects an empty project and creates the complete schema automatically. `pnpm db:ensure` runs the same guarded bootstrap without starting the app. Manual SQL Editor installation remains available through `supabase/bootstrap.sql`.
2. In Supabase Auth settings, enable **Allow anonymous sign-ins**. In Auth URL Configuration, set the Site URL to `http://localhost:3000` and add `http://localhost:3000/auth/callback` as a Redirect URL. Email magic-link accounts remain optional and must be added to `public.allowlist`. Before external testing, enable CAPTCHA and review Supabase's anonymous sign-in rate limits so disposable guests cannot be used to evade AI quotas.
2. In Supabase Auth settings, enable the **Email** provider and disable **Allow anonymous sign-ins**. In Auth URL Configuration, set the Site URL to `http://localhost:3000`; add `http://localhost:3000/auth/callback` and `http://localhost:3000/auth/reset-password` as Redirect URLs. Configure SMTP before external testing so confirmation and recovery email is delivered reliably. CAPTCHA and Auth rate limits remain recommended.
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`).
@@ -55,10 +59,18 @@ If the worker reports `POST /rest/v1/rpc/claim_ai_job 404` and `/rest/v1/profile
## Weeks 34 flow
1. Click **Enter the Alpha** to create a persistent guest session without email. Do not sign out or clear site storage unless you are prepared to lose that guest account. An allowlisted email magic link is still available as an alternative.
1. Click **Enter the Alpha** to open registration. Every account requires a display name, email, password, and 13+ confirmation. Existing users sign in with email/password; forgotten passwords use the email recovery flow.
2. Create a world in the dynamic coauthor chat. Unfinished conversations and generated drafts appear on the dashboard and resume after a reload.
3. Review every starting-world field, including the owner-only hidden threat, then confirm it.
4. Create a human character manually or ask the coauthor for an editable draft. Owners can add persistent AI companions the same way.
5. The owner creates an expiring private invite link. Guest or allowlisted-email users join through `/join/:token`.
4. During world creation, the coauthor also creates three editable, universe-specific AI companions. A new campaign starts immediately with those companions and an owner-controlled hero. Additional players create a human character manually or ask the coauthor for an editable draft; owners can add more AI companions the same way.
5. The owner creates an expiring private invite link. Signed-in email users join through `/join/:token`.
6. Players save actions and mark them ready. The final ready action queues the round automatically; the owner can also continue without waiting.
7. The worker resolves server-owned rolls and state, publishes narration, and opens the next round. Visible campaign tabs synchronize every two seconds and immediately when the tab regains focus.
## Weeks 78 stability flow
1. Every resolved round appends immutable game events and may atomically project scene, quest, relationship, character, and tagged-memory state.
2. A compact story summary is generated and committed with every third round. If the provider or transaction fails, no partial narration or state is saved; the leased job retries with the same round idempotency key.
3. The campaign screen exposes the current summary, important memories, and active goals. The dashboard shows 30-day tokens, cost, latency, and the current daily request allowance.
4. Campaign owners may correct HP, inventory, or statuses through the audited character-state API; the reason and before/after values are retained in `audit_entries`.
5. `pnpm test` includes twenty long-memory regression scenarios. `pnpm e2e:full-party` verifies the hosted two-player/AI-companion flow end to end.

View File

@@ -1 +1,24 @@
: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}}
/* Keep account pages usable even when a route-level component stylesheet is delayed or missed. */
.auth-page{min-height:100vh;display:grid;place-content:center;justify-items:center;gap:28px;padding:42px 20px}
.auth-page .auth-card{width:min(480px,calc(100vw - 40px));padding:clamp(28px,5vw,48px);border:1px solid var(--line);background:linear-gradient(145deg,#11110f,#090909);box-shadow:0 35px 100px rgba(0,0,0,.48)}
.auth-page .auth-card header small{font:600 8px var(--mono);letter-spacing:.18em;color:var(--acid)}
.auth-page .auth-card header h1{margin:14px 0 12px;font:600 clamp(32px,6vw,52px)/1 var(--display);letter-spacing:-.06em}
.auth-page .auth-card header p{margin:0 0 30px;color:var(--muted);font-size:12px;line-height:1.7}
.auth-page .auth-card footer{margin-top:25px;padding-top:22px;border-top:1px solid var(--line);text-align:center;color:var(--muted);font-size:11px}
.auth-page .security-note{font:500 7px var(--mono);letter-spacing:.13em;color:var(--muted)}
.auth-page .security-note i{display:inline-block;width:5px;height:5px;margin-right:8px;border-radius:50%;background:var(--acid)}
.auth-page .auth-form{display:grid;gap:15px}
.auth-page .auth-form label{display:grid;gap:8px;font:600 8px var(--mono);letter-spacing:.11em;color:#b5b5ae}
.auth-page .auth-form input{box-sizing:border-box;width:100%;min-height:48px;padding:0 14px;border:1px solid #373732;background:#0a0a09;color:var(--ink);font:12px var(--body);outline:none}
.auth-page .auth-form input:focus{border-color:var(--acid)}
.auth-page .auth-form button{min-height:50px;border:0;background:var(--acid);color:#080808;font:700 9px var(--mono);letter-spacing:.12em}
.auth-page .auth-form button:disabled{opacity:.45;cursor:wait}
.auth-page .form-error,.auth-page .form-success{margin:0;padding:12px 14px;border:1px solid #6b372e;background:#221310;color:#ffab98;font:500 9px/1.55 var(--mono)}
.auth-page .form-success{border-color:#526425;background:#151a0d;color:var(--acid)}
.auth-page .form-link{color:var(--acid);text-decoration:none;font-weight:600}
.auth-page .field-row{display:flex;align-items:center;justify-content:space-between}
.auth-page .field-row a{color:var(--muted);font:500 8px var(--mono)}
.auth-page .auth-form .consent{grid-template-columns:18px 1fr!important;align-items:start!important;font:400 10px/1.5 var(--body)!important;letter-spacing:0!important;color:var(--muted)!important}
.auth-page .auth-form .consent input{width:16px!important;min-height:16px!important;margin:1px 0 0;padding:0!important;accent-color:var(--acid)}

View File

@@ -1,35 +1,136 @@
<script setup lang="ts">
defineProps<{ section?: string }>()
const { session, restore, signOut } = useDngAuth()
const emit = defineEmits<{
campaignAction: [payload: { action: 'rename' | 'delete'; campaignId: string }]
}>()
const { session, restore } = useDngAuth()
const route = useRoute()
const router = useRouter()
const contextMenu = ref<HTMLElement | null>(null)
const contextOpen = ref(false)
const contextX = ref(0)
const contextY = ref(0)
const contextCampaignId = ref('')
const contextCampaignTitle = ref('')
onMounted(() => void restore())
const initials = computed(() => {
const email = session.value?.user.email ?? ''
return email.slice(0, 2).toUpperCase() || 'G'
const displayName = session.value?.user.user_metadata?.display_name
const source = typeof displayName === 'string' && displayName.trim() ? displayName : session.value?.user.email ?? ''
return source.trim().split(/\s+/).map(part => part[0]).join('').slice(0, 2).toUpperCase() || 'D&G'
})
const avatarUrl = computed(() => {
const value = session.value?.user.user_metadata?.avatar_url
return typeof value === 'string' && value ? value : ''
})
async function leave() {
if (session.value?.user.is_anonymous && !window.confirm('This guest account cannot be recovered after sign out. Leave anyway?')) return
await signOut()
await navigateTo('/')
function closeContextMenu() {
contextOpen.value = false
contextCampaignId.value = ''
contextCampaignTitle.value = ''
}
async function openContextMenu(event: MouseEvent) {
event.preventDefault()
const campaign = event.target instanceof Element
? event.target.closest<HTMLElement>('[data-context-campaign]')
: null
contextCampaignId.value = campaign?.dataset.contextCampaign ?? ''
contextCampaignTitle.value = campaign?.dataset.contextLabel ?? ''
contextX.value = event.clientX
contextY.value = event.clientY
contextOpen.value = true
await nextTick()
const bounds = contextMenu.value?.getBoundingClientRect()
if (!bounds) return
contextX.value = Math.max(12, Math.min(contextX.value, window.innerWidth - bounds.width - 12))
contextY.value = Math.max(12, Math.min(contextY.value, window.innerHeight - bounds.height - 12))
}
function runCampaignAction(action: 'rename' | 'delete') {
const campaignId = contextCampaignId.value
closeContextMenu()
if (campaignId) emit('campaignAction', { action, campaignId })
}
async function openAccountSection(section: 'identity' | 'settings') {
closeContextMenu()
await navigateTo({ path: '/profile', hash: `#${section}` })
}
function goBack() {
closeContextMenu()
if (window.history.length > 1) router.back()
else void navigateTo('/dashboard')
}
function closeFromPointer(event: PointerEvent) {
if (contextOpen.value && event.target instanceof Node && !contextMenu.value?.contains(event.target)) closeContextMenu()
}
function closeFromKeyboard(event: KeyboardEvent) {
if (event.key === 'Escape') closeContextMenu()
}
onMounted(() => {
document.addEventListener('pointerdown', closeFromPointer)
document.addEventListener('keydown', closeFromKeyboard)
window.addEventListener('resize', closeContextMenu)
window.addEventListener('scroll', closeContextMenu, true)
})
onBeforeUnmount(() => {
document.removeEventListener('pointerdown', closeFromPointer)
document.removeEventListener('keydown', closeFromKeyboard)
window.removeEventListener('resize', closeContextMenu)
window.removeEventListener('scroll', closeContextMenu, true)
})
watch(() => route.fullPath, closeContextMenu)
</script>
<template>
<div class="shell">
<div class="shell" @contextmenu="openContextMenu">
<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>
<button v-if="session" class="avatar" :title="session.user.is_anonymous ? 'Guest session — sign out' : `Sign out ${session.user.email ?? ''}`" @click="leave">{{ initials }}</button>
<NuxtLink v-if="session" to="/profile" class="avatar" :title="`Open profile for ${session.user.email ?? ''}`" aria-label="Open profile">
<span>{{ initials }}</span><img v-if="avatarUrl" :src="avatarUrl" alt="">
</NuxtLink>
<div v-else class="avatar">D&G</div>
</div>
</header>
<main><slot /></main>
<Teleport to="body">
<nav
v-if="contextOpen"
ref="contextMenu"
class="context-menu"
:style="{ left: `${contextX}px`, top: `${contextY}px` }"
aria-label="Site actions"
role="menu"
@contextmenu.prevent
>
<header>
<small>{{ contextCampaignId ? 'UNIVERSE CONTROL' : 'D&G COMMANDS' }}</small>
<strong>{{ contextCampaignTitle || 'QUICK ACCESS' }}</strong>
</header>
<div v-if="contextCampaignId" class="context-group">
<button role="menuitem" @click="runCampaignAction('rename')"><span></span> RENAME UNIVERSE</button>
<button class="danger" role="menuitem" @click="runCampaignAction('delete')"><span>×</span> DELETE UNIVERSE</button>
</div>
<div class="context-group">
<button role="menuitem" @click="openAccountSection('identity')"><span></span> EDIT PROFILE</button>
<button role="menuitem" @click="goBack"><span></span> PREVIOUS PAGE</button>
<button role="menuitem" @click="openAccountSection('settings')"><span></span> SETTINGS</button>
</div>
<footer><i /> PRIVATE ALPHA / SECURE</footer>
</nav>
</Teleport>
</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;padding:0;border:0;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}}
.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{position:relative;overflow:hidden;display:grid;place-items:center;width:36px;height:36px;padding:0;border:0;border-radius:50%;background:var(--acid);color:#0a0a0a;text-decoration:none;font:700 10px var(--mono)}.avatar img{position:absolute;inset:0;width:100%;height:100%;object-fit:cover}.avatar span{position:relative}.context-menu{position:fixed;z-index:2000;width:min(286px,calc(100vw - 24px));border:1px solid #3c3c37;background:rgba(10,10,9,.98);color:var(--ink);box-shadow:0 24px 70px rgba(0,0,0,.68);backdrop-filter:blur(18px)}.context-menu header{display:grid;gap:7px;padding:17px 18px 15px;border-bottom:1px solid var(--line);background:linear-gradient(120deg,rgba(217,247,95,.08),transparent 70%)}.context-menu header small{color:var(--acid);font:600 7px var(--mono);letter-spacing:.17em}.context-menu header strong{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font:600 12px var(--display);letter-spacing:-.02em}.context-group{padding:6px;border-bottom:1px solid var(--line)}.context-group button{width:100%;min-height:40px;display:grid;grid-template-columns:28px 1fr;align-items:center;padding:0 10px;border:0;background:transparent;color:#c4c4bd;text-align:left;font:600 8px var(--mono);letter-spacing:.11em}.context-group button span{color:var(--acid);font:500 15px var(--mono)}.context-group button:hover,.context-group button:focus-visible{background:#171715;color:var(--ink);filter:none}.context-group button.danger{color:#e5aaa0}.context-group button.danger span{color:#ff8875}.context-menu footer{padding:11px 15px;color:#6e6e68;font:500 6px var(--mono);letter-spacing:.14em}.context-menu footer i{display:inline-block;width:5px;height:5px;margin-right:7px;border-radius:50%;background:var(--acid);box-shadow:0 0 8px var(--acid)}@media(max-width:700px){.topbar{grid-template-columns:1fr auto}.topbar-center{display:none}}
</style>

View File

@@ -0,0 +1,28 @@
<script setup lang="ts">
defineProps<{
eyebrow: string
title: string
subtitle: string
}>()
</script>
<template>
<main class="auth-page noise">
<div class="brand"><AppMark /></div>
<section class="auth-card">
<header>
<small>{{ eyebrow }}</small>
<h1>{{ title }}</h1>
<p>{{ subtitle }}</p>
</header>
<slot />
<footer><slot name="footer" /></footer>
</section>
<p class="security-note"><i /> PRIVATE WORLDS · SECURE SUPABASE ACCOUNT</p>
</main>
</template>
<style scoped>
.auth-page{min-height:100vh;display:grid;place-content:center;justify-items:center;gap:28px;padding:42px 20px}.auth-card{width:min(480px,calc(100vw - 40px));padding:clamp(28px,5vw,48px);border:1px solid var(--line);background:linear-gradient(145deg,#11110f,#090909);box-shadow:0 35px 100px rgba(0,0,0,.48)}header small{font:600 8px var(--mono);letter-spacing:.18em;color:var(--acid)}header h1{margin:14px 0 12px;font:600 clamp(32px,6vw,52px)/1 var(--display);letter-spacing:-.06em}header p{margin:0 0 30px;color:var(--muted);font-size:12px;line-height:1.7}footer{margin-top:25px;padding-top:22px;border-top:1px solid var(--line);text-align:center;color:var(--muted);font-size:11px}.security-note{font:500 7px var(--mono);letter-spacing:.13em;color:var(--muted)}.security-note i{display:inline-block;width:5px;height:5px;margin-right:8px;border-radius:50%;background:var(--acid)}
:deep(.auth-form){display:grid;gap:15px}:deep(.auth-form label){display:grid;gap:8px;font:600 8px var(--mono);letter-spacing:.11em;color:#b5b5ae}:deep(.auth-form input){box-sizing:border-box;width:100%;min-height:48px;padding:0 14px;border:1px solid #373732;background:#0a0a09;color:var(--ink);font:12px var(--body);outline:none}:deep(.auth-form input:focus){border-color:var(--acid)}:deep(.auth-form button){min-height:50px;border:0;background:var(--acid);color:#080808;font:700 9px var(--mono);letter-spacing:.12em}:deep(.auth-form button:disabled){opacity:.45;cursor:wait}:deep(.form-error),:deep(.form-success){margin:0;padding:12px 14px;border:1px solid #6b372e;background:#221310;color:#ffab98;font:500 9px/1.55 var(--mono)}:deep(.form-success){border-color:#526425;background:#151a0d;color:var(--acid)}:deep(.form-link){color:var(--acid);text-decoration:none;font-weight:600}:deep(.field-row){display:flex;align-items:center;justify-content:space-between}:deep(.field-row a){color:var(--muted);font:500 8px var(--mono)}
</style>

View File

@@ -1,8 +1,16 @@
<script setup lang="ts">
import type { WorldStarter } from '@dng/shared'
import type { CharacterDraft, WorldStarter } from '@dng/shared'
const draft = defineModel<WorldStarter>({ required: true })
defineEmits<{ revise: []; confirm: [] }>()
function updateInventory(companion: CharacterDraft, event: Event) {
companion.inventory = (event.target as HTMLInputElement).value
.split(',')
.map(item => item.trim())
.filter(Boolean)
.slice(0, 12)
}
</script>
<template>
@@ -21,6 +29,7 @@ defineEmits<{ revise: []; confirm: [] }>()
<section class="wide private-truth"><label>PRIVATE GM TRUTH</label><textarea v-model="draft.hiddenThreat" rows="4" /><small>This hidden threat or goal is visible only to the owner and the Groundkeeper.</small></section>
<section class="wide"><label>KEY PEOPLE</label><div class="entities"><article v-for="npc in draft.npcs" :key="npc.id"><small>NPC</small><input v-model="npc.name"><textarea v-model="npc.summary" rows="3" /></article></div></section>
<section class="wide"><label>FACTIONS</label><div class="entities two"><article v-for="faction in draft.factions" :key="faction.id"><small>FACTION</small><input v-model="faction.name"><textarea v-model="faction.summary" rows="3" /></article></div></section>
<section class="wide"><label>AI COMPANIONS</label><p class="section-note">These universe-specific heroes join the campaign automatically.</p><div class="entities companion-grid"><article v-for="(companion, index) in draft.companions" :key="index" class="companion-card"><small>AI HERO</small><input v-model="companion.name" maxlength="80" aria-label="Companion name"><textarea v-model="companion.concept" maxlength="600" rows="4" aria-label="Companion concept" /><div class="companion-stats"><label>HP<input v-model.number="companion.hp" type="number" min="1" :max="companion.maxHp"></label><label>MAX<input v-model.number="companion.maxHp" type="number" min="1" max="100"></label><label>AC<input v-model.number="companion.defense" type="number" min="1" max="40"></label><label>PROF<input v-model.number="companion.proficiency" type="number" min="1" max="10"></label><label v-for="ability in (['str','dex','con','int','wis','cha'] as const)" :key="ability">{{ ability }}<input v-model.number="companion.abilities[ability]" type="number" min="1" max="30"></label></div><label class="inventory-label">INVENTORY<input :value="companion.inventory.join(', ')" maxlength="800" @input="updateInventory(companion, $event)"></label><details><summary>VOICE & PERSONALITY</summary><input v-model="companion.persona.voice" maxlength="240" placeholder="Voice"><input v-model="companion.persona.motivation" maxlength="300" placeholder="Motivation"><input v-model="companion.persona.flaw" maxlength="300" placeholder="Flaw"><input v-model="companion.persona.bond" maxlength="300" placeholder="Bond"></details></article></div></section>
<section><label>OPENING SCENE</label><textarea v-model="draft.openingScene" rows="6" /></section>
<section><label>CONTENT BOUNDARIES</label><div v-for="(_, index) in draft.contentBoundaries" :key="index" class="boundary"><input v-model="draft.contentBoundaries[index]"><button type="button" aria-label="Remove boundary" @click="draft.contentBoundaries.splice(index, 1)">×</button></div><button class="add" type="button" @click="draft.contentBoundaries.push('')"> ADD BOUNDARY</button></section>
</div>
@@ -29,6 +38,6 @@ defineEmits<{ revise: []; confirm: [] }>()
</template>
<style scoped>
.preview{max-width:1300px;width:100%;padding:clamp(40px,5vw,76px)}header{display:flex;justify-content:space-between;align-items:end;gap:20px}.kicker,label{font:600 8px var(--mono);letter-spacing:.15em;color:var(--acid)}header>div{flex:1}header 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}.revise,.add{background:transparent;color:var(--muted);border:1px solid var(--line);padding:12px;font:500 8px var(--mono)}.edit-note{color:var(--muted);font-size:11px}.grid{display:grid;grid-template-columns:1fr 1fr;border-top:1px solid var(--line);border-left:1px solid var(--line);margin-top:28px}.grid>section{padding:26px;border-right:1px solid var(--line);border-bottom:1px solid var(--line)}.wide{grid-column:1/-1}input,textarea{box-sizing:border-box;width:100%;margin-top:12px;padding:13px;resize:vertical;background:#10100f;border:1px solid var(--line);color:var(--ink);font:12px/1.55 var(--body)}.entities{display:grid;grid-template-columns:repeat(3,1fr);gap:12px;margin-top:18px}.entities.two{grid-template-columns:repeat(2,1fr)}.entities article{padding:16px;border:1px solid var(--line);background:#0c0c0b}.entities small{font:500 7px var(--mono);color:var(--acid)}.boundary{display:flex}.boundary input{margin-top:8px}.boundary button{margin-top:8px;width:42px;border:1px solid var(--line);background:#151513;color:var(--muted)}.add{margin-top:12px}footer{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}footer p{margin:0;display:flex;flex-direction:column;font:600 8px var(--mono);color:var(--acid)}footer p span{margin-top:5px;color:var(--muted);font-weight:400}footer>button{min-height:48px;border:0;background:var(--acid);color:#080808;padding:0 24px;font:600 9px var(--mono)}@media(max-width:760px){.preview{padding:36px 18px}header{align-items:stretch;flex-direction:column}.grid{grid-template-columns:1fr}.wide{grid-column:auto}.entities,.entities.two{grid-template-columns:1fr}footer{align-items:stretch;flex-direction:column;gap:14px}}
.preview{max-width:1300px;width:100%;padding:clamp(40px,5vw,76px)}header{display:flex;justify-content:space-between;align-items:end;gap:20px}.kicker,label{font:600 8px var(--mono);letter-spacing:.15em;color:var(--acid)}header>div{flex:1}header 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}.revise,.add{background:transparent;color:var(--muted);border:1px solid var(--line);padding:12px;font:500 8px var(--mono)}.edit-note,.section-note{color:var(--muted);font-size:11px}.section-note{margin:9px 0 0}.grid{display:grid;grid-template-columns:1fr 1fr;border-top:1px solid var(--line);border-left:1px solid var(--line);margin-top:28px}.grid>section{padding:26px;border-right:1px solid var(--line);border-bottom:1px solid var(--line)}.wide{grid-column:1/-1}input,textarea{box-sizing:border-box;width:100%;margin-top:12px;padding:13px;resize:vertical;background:#10100f;border:1px solid var(--line);color:var(--ink);font:12px/1.55 var(--body)}.entities{display:grid;grid-template-columns:repeat(3,1fr);gap:12px;margin-top:18px}.entities.two{grid-template-columns:repeat(2,1fr)}.entities article{padding:16px;border:1px solid var(--line);background:#0c0c0b}.entities small{font:500 7px var(--mono);color:var(--acid)}.companion-stats{display:grid;grid-template-columns:repeat(5,1fr);gap:5px;margin-top:12px}.companion-stats label{color:var(--muted);font-size:6px}.companion-stats input{margin-top:5px;padding:7px;text-align:center}.inventory-label{display:block;margin-top:12px;color:var(--muted)}.inventory-label input{margin-top:6px}.companion-card details{margin-top:12px;border-top:1px solid var(--line);padding-top:10px}.companion-card summary{cursor:pointer;color:var(--muted);font:600 7px var(--mono);letter-spacing:.1em}.companion-card details input{margin-top:7px}.boundary{display:flex}.boundary input{margin-top:8px}.boundary button{margin-top:8px;width:42px;border:1px solid var(--line);background:#151513;color:var(--muted)}.add{margin-top:12px}footer{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}footer p{margin:0;display:flex;flex-direction:column;font:600 8px var(--mono);color:var(--acid)}footer p span{margin-top:5px;color:var(--muted);font-weight:400}footer>button{min-height:48px;border:0;background:var(--acid);color:#080808;padding:0 24px;font:600 9px var(--mono)}@media(max-width:760px){.preview{padding:36px 18px}header{align-items:stretch;flex-direction:column}.grid{grid-template-columns:1fr}.wide{grid-column:auto}.entities,.entities.two{grid-template-columns:1fr}.companion-stats{grid-template-columns:repeat(2,1fr)}footer{align-items:stretch;flex-direction:column;gap:14px}}
.private-truth{background:linear-gradient(90deg,rgba(207,255,70,.035),transparent)}.private-truth small{display:block;margin-top:9px;color:var(--muted);font-size:9px}
</style>

View File

@@ -16,6 +16,11 @@ const defaultWorld: WorldStarter = {
{ 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: [] },
],
companions: [
{ name: 'Rook-7', concept: 'A security intelligence in a weathered rescue frame.', 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'], skillProficiencies: ['athletics', 'intimidation', 'perception', 'survival'], skillExpertise: [], savingThrowProficiencies: ['str', 'con'], persona: { voice: 'Direct and protective.', motivation: 'Keep the crew alive long enough to learn the truth.', flaw: 'Treats uncertainty as a threat.', bond: 'The Orison crew gave Rook a second purpose.' } },
{ name: 'Sable Thread', concept: 'A probabilistic navigator who reads possible futures as tangled routes.', abilities: { str: 8, dex: 14, con: 10, int: 16, wis: 13, cha: 11 }, hp: 10, maxHp: 10, defense: 13, proficiency: 2, inventory: ['Route prism', 'Vacuum line'], skillProficiencies: ['arcana', 'investigation', 'perception', 'stealth'], skillExpertise: ['investigation'], savingThrowProficiencies: ['dex', 'int'], persona: { voice: 'Careful, elliptical, and precise.', motivation: 'Find the future in which everyone returns.', flaw: 'Can hesitate when too many paths look viable.', bond: 'Trusts the party to choose what prediction cannot.' } },
{ name: 'Morrow Coil', concept: 'A former relay technician rebuilt around experimental signal hardware.', abilities: { str: 11, dex: 12, con: 13, int: 14, wis: 10, cha: 13 }, hp: 12, maxHp: 12, defense: 12, proficiency: 2, inventory: ['Signal probe', 'Insulated field coat'], skillProficiencies: ['arcana', 'history', 'investigation', 'persuasion'], skillExpertise: [], savingThrowProficiencies: ['int', 'cha'], persona: { voice: 'Warm humor under technical jargon.', motivation: 'Prove the relay can be understood instead of destroyed.', flaw: 'Cannot leave a broken machine alone.', bond: 'Believes this party is the last honest crew in the sector.' } },
],
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.”',
@@ -36,7 +41,7 @@ export function useDemo() {
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' },
{ type: 'roll', author: 'SYSTEM CHECK', body: 'Intelligence check · 1d20 + 1 = 15', meta: 'SUCCESS · DC 14' },
])
const currentAction = useState('current-action', () => '')
const ready = useState('ready', () => false)

View File

@@ -19,6 +19,13 @@ interface AuthResponse {
user: DngAuthUser
}
interface SignUpResponse {
access_token?: string | null
refresh_token?: string | null
expires_in?: number | null
user: DngAuthUser
}
const STORAGE_KEY = 'dng-auth-session'
export function useDngAuth() {
@@ -48,6 +55,10 @@ export function useDngAuth() {
}
}
function isEmailAccount(user: DngAuthUser | undefined): boolean {
return Boolean(user?.email && !user.is_anonymous)
}
function parseStoredSession(value: string): DngSession | null {
try {
const saved = JSON.parse(value) as Partial<DngSession>
@@ -97,7 +108,11 @@ export function useDngAuth() {
}
async function restore() {
if (!import.meta.client || hydrated.value) return session.value
if (!import.meta.client) return session.value
if (hydrated.value) {
if (session.value && !isEmailAccount(session.value.user)) persist(null)
return session.value
}
let restoredFromStorage = false
const hash = new URLSearchParams(window.location.hash.replace(/^#/, ''))
@@ -133,37 +148,134 @@ export function useDngAuth() {
await refresh()
}
}
if (session.value && !isEmailAccount(session.value.user)) persist(null)
hydrated.value = true
return session.value
}
async function requestMagicLink(email: string) {
async function signUp(displayName: string, email: string, password: string) {
const redirectTo = `${window.location.origin}/auth/callback`
await $fetch(`${config.public.supabaseUrl}/auth/v1/otp?redirect_to=${encodeURIComponent(redirectTo)}`, {
method: 'POST',
headers: authHeaders(),
body: { email: email.trim().toLowerCase(), create_user: true },
})
}
async function signInAnonymously() {
const response = await $fetch<AuthResponse>(`${config.public.supabaseUrl}/auth/v1/signup`, {
const response = await $fetch<SignUpResponse>(`${config.public.supabaseUrl}/auth/v1/signup?redirect_to=${encodeURIComponent(redirectTo)}`, {
method: 'POST',
headers: authHeaders(),
body: {
data: { display_name: 'Guest Adventurer' },
gotrue_meta_security: {},
email: email.trim().toLowerCase(),
password,
data: { display_name: displayName.trim() },
},
})
if (response.access_token && response.refresh_token && response.expires_in) {
const next = fromResponse({
access_token: response.access_token,
refresh_token: response.refresh_token,
expires_in: response.expires_in,
user: response.user,
})
persist(next)
hydrated.value = true
return { session: next, needsEmailConfirmation: false }
}
return { session: null, needsEmailConfirmation: true }
}
async function signIn(email: string, password: string) {
const response = await $fetch<AuthResponse>(`${config.public.supabaseUrl}/auth/v1/token?grant_type=password`, {
method: 'POST',
headers: authHeaders(),
body: { email: email.trim().toLowerCase(), password },
})
const next = fromResponse(response)
if (!isEmailAccount(next.user)) throw new Error('An email account is required.')
persist(next)
hydrated.value = true
return next
}
async function requestPasswordReset(email: string) {
const redirectTo = `${window.location.origin}/auth/reset-password`
await $fetch(`${config.public.supabaseUrl}/auth/v1/recover?redirect_to=${encodeURIComponent(redirectTo)}`, {
method: 'POST',
headers: authHeaders(),
body: { email: email.trim().toLowerCase() },
})
}
async function updatePassword(password: string) {
const token = await accessToken()
const user = normalizeUser(await $fetch<DngAuthUser>(`${config.public.supabaseUrl}/auth/v1/user`, {
method: 'PUT',
headers: authHeaders(token),
body: { password },
}))
if (session.value) persist({ ...session.value, user })
return user
}
async function updateUserMetadata(data: Record<string, unknown>) {
const token = await accessToken()
const user = normalizeUser(await $fetch<DngAuthUser>(`${config.public.supabaseUrl}/auth/v1/user`, {
method: 'PUT',
headers: authHeaders(token),
body: { data },
}))
if (session.value) persist({ ...session.value, user })
return user
}
async function avatarStorageRequest(path: string, options: RequestInit) {
const token = await accessToken()
const baseUrl = String(config.public.supabaseUrl).replace(/\/$/, '')
const response = await fetch(`${baseUrl}/storage/v1/${path}`, {
...options,
headers: {
apikey: config.public.supabaseAnonKey,
Authorization: `Bearer ${token}`,
...options.headers,
},
})
if (!response.ok) {
const payload = await response.json().catch(() => null) as { message?: string; error?: string } | null
throw new Error(payload?.message || payload?.error || `Profile picture request failed (${response.status}).`)
}
}
async function uploadAvatar(file: File) {
if (!['image/jpeg', 'image/png', 'image/webp'].includes(file.type)) {
throw new Error('Choose a JPG, PNG, or WebP image.')
}
if (file.size > 2 * 1024 * 1024) throw new Error('Profile pictures must be 2 MB or smaller.')
await restore()
if (!session.value) throw new Error('Sign in to update your profile picture.')
const path = `${session.value.user.id}/avatar`
const body = new FormData()
body.append('cacheControl', '3600')
body.append('', file)
await avatarStorageRequest(`object/profile-avatars/${path}`, {
method: 'POST',
headers: { 'x-upsert': 'true' },
body,
})
return path
}
async function removeAvatar() {
await restore()
if (!session.value) throw new Error('Sign in to update your profile picture.')
const path = `${session.value.user.id}/avatar`
await avatarStorageRequest('object/profile-avatars', {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ prefixes: [path] }),
})
}
async function accessToken() {
await restore()
if (!session.value) throw new Error('Enter the alpha to continue.')
if (!isEmailAccount(session.value.user)) {
persist(null)
throw new Error('Sign in with an email account to continue.')
}
if (session.value.expiresAt <= Date.now() + 60_000) await refresh()
if (!session.value) throw new Error('Your session expired. Sign in again.')
return session.value.accessToken
@@ -184,5 +296,20 @@ export function useDngAuth() {
hydrated.value = true
}
return { session, hydrated, restore, refresh, requestMagicLink, signInAnonymously, accessToken, signOut, invalidate }
return {
session,
hydrated,
restore,
refresh,
signUp,
signIn,
requestPasswordReset,
updatePassword,
updateUserMetadata,
uploadAvatar,
removeAvatar,
accessToken,
signOut,
invalidate,
}
}

View File

@@ -3,6 +3,13 @@ export default defineNuxtConfig({
devtools: { enabled: false },
css: ['~/assets/css/main.css'],
modules: [],
nitro: {
// Nitro 2.10 resolves file-backed assets relative to its split runtime
// chunk, which makes every /_nuxt request fail outside the Docker-only
// symlink workaround. Embedding them keeps every production launcher
// (preview, Docker, and managed hosts) on the same reliable code path.
serveStatic: 'inline',
},
runtimeConfig: {
aiProvider: process.env.AI_PROVIDER ?? 'openrouter',
openrouterApiKey: process.env.OPENROUTER_API_KEY,

View File

@@ -19,8 +19,8 @@ onMounted(async () => {
<template>
<div class="callback noise">
<AppMark />
<p v-if="!error">VERIFYING YOUR SIGNAL</p>
<template v-else><p class="error">{{ error }}</p><NuxtLink to="/">REQUEST A NEW LINK</NuxtLink></template>
<p v-if="!error">VERIFYING YOUR EMAIL</p>
<template v-else><p class="error">{{ error }}</p><NuxtLink to="/auth/sign-in">BACK TO SIGN IN</NuxtLink></template>
</div>
</template>

View File

@@ -0,0 +1,33 @@
<script setup lang="ts">
const { requestPasswordReset } = useDngAuth()
const email = ref('')
const busy = ref(false)
const error = ref('')
const sent = ref(false)
async function submit() {
busy.value = true
error.value = ''
try {
await requestPasswordReset(email.value)
sent.value = true
} catch (cause) {
const value = cause as { data?: { msg?: string; message?: string }; message?: string }
error.value = value.data?.msg ?? value.data?.message ?? value.message ?? 'Could not send the reset email.'
} finally {
busy.value = false
}
}
</script>
<template>
<AuthCard eyebrow="ACCOUNT RECOVERY" title="RESET THE SIGNAL." subtitle="Enter your account email. If it exists, Supabase will send a secure password reset link.">
<p v-if="sent" class="form-success" role="status">CHECK YOUR EMAIL. The recovery link can be used once and expires automatically.</p>
<form v-else class="auth-form" @submit.prevent="submit">
<label>EMAIL<input v-model="email" name="email" type="email" autocomplete="email" required placeholder="you@example.com"></label>
<p v-if="error" class="form-error" role="alert">{{ error }}</p>
<button :disabled="busy">{{ busy ? 'SENDING…' : 'SEND RESET LINK →' }}</button>
</form>
<template #footer><NuxtLink class="form-link" to="/auth/sign-in"> BACK TO SIGN IN</NuxtLink></template>
</AuthCard>
</template>

View File

@@ -0,0 +1,54 @@
<script setup lang="ts">
const auth = useDngAuth()
const password = ref('')
const confirmation = ref('')
const busy = ref(false)
const ready = ref(false)
const error = ref('')
onMounted(async () => {
try {
const session = await auth.restore()
if (!session) throw new Error('This recovery link is invalid or expired.')
ready.value = true
} catch (cause) {
error.value = cause instanceof Error ? cause.message : 'Could not verify the recovery link.'
}
})
async function submit() {
error.value = ''
if (password.value.length < 8) {
error.value = 'Password must contain at least 8 characters.'
return
}
if (password.value !== confirmation.value) {
error.value = 'Passwords do not match.'
return
}
busy.value = true
try {
await auth.updatePassword(password.value)
await navigateTo('/dashboard', { replace: true })
} catch (cause) {
const value = cause as { data?: { msg?: string; message?: string }; message?: string }
error.value = value.data?.msg ?? value.data?.message ?? value.message ?? 'Could not update the password.'
} finally {
busy.value = false
}
}
</script>
<template>
<AuthCard eyebrow="SECURE RECOVERY" title="CHOOSE A NEW PASSWORD." subtitle="Set a new password for your Dungeons & Ground account.">
<p v-if="!ready && !error" class="form-success">VERIFYING RECOVERY LINK</p>
<form v-if="ready" class="auth-form" @submit.prevent="submit">
<label>NEW PASSWORD<input v-model="password" name="password" type="password" autocomplete="new-password" minlength="8" required placeholder="At least 8 characters"></label>
<label>CONFIRM PASSWORD<input v-model="confirmation" name="password-confirmation" type="password" autocomplete="new-password" minlength="8" required placeholder="Repeat your password"></label>
<p v-if="error" class="form-error" role="alert">{{ error }}</p>
<button :disabled="busy">{{ busy ? 'UPDATING…' : 'UPDATE PASSWORD →' }}</button>
</form>
<p v-else-if="error" class="form-error" role="alert">{{ error }}</p>
<template #footer><NuxtLink class="form-link" to="/auth/sign-in">BACK TO SIGN IN</NuxtLink></template>
</AuthCard>
</template>

View File

@@ -0,0 +1,45 @@
<script setup lang="ts">
const auth = useDngAuth()
const email = ref('')
const password = ref('')
const busy = ref(false)
const error = ref('')
function messageFrom(cause: unknown) {
const value = cause as { data?: { msg?: string; message?: string; error_description?: string }; message?: string }
return value.data?.msg ?? value.data?.message ?? value.data?.error_description ?? value.message ?? 'Sign-in failed. Check your email and password.'
}
async function submit() {
busy.value = true
error.value = ''
try {
await auth.signIn(email.value, password.value)
const requested = localStorage.getItem('dng-post-auth-redirect')
localStorage.removeItem('dng-post-auth-redirect')
const destination = requested?.startsWith('/') && !requested.startsWith('//') ? requested : '/dashboard'
await navigateTo(destination, { replace: true })
} catch (cause) {
error.value = messageFrom(cause)
} finally {
busy.value = false
}
}
onMounted(async () => {
await auth.restore()
if (auth.session.value) await navigateTo('/dashboard', { replace: true })
})
</script>
<template>
<AuthCard eyebrow="ACCOUNT ACCESS" title="WELCOME BACK." subtitle="Sign in to continue your private worlds and shared campaigns.">
<form class="auth-form" @submit.prevent="submit">
<label>EMAIL<input v-model="email" name="email" type="email" autocomplete="email" required placeholder="you@example.com"></label>
<label><span class="field-row"><span>PASSWORD</span><NuxtLink to="/auth/forgot-password">FORGOT?</NuxtLink></span><input v-model="password" name="password" type="password" autocomplete="current-password" required placeholder="Your password"></label>
<p v-if="error" class="form-error" role="alert">{{ error }}</p>
<button :disabled="busy">{{ busy ? 'SIGNING IN…' : 'SIGN IN →' }}</button>
</form>
<template #footer>New to D&G? <NuxtLink class="form-link" to="/auth/sign-up">CREATE ACCOUNT</NuxtLink></template>
</AuthCard>
</template>

View File

@@ -0,0 +1,80 @@
<script setup lang="ts">
const auth = useDngAuth()
const displayName = ref('')
const email = ref('')
const password = ref('')
const confirmation = ref('')
const ageConfirmed = ref(false)
const busy = ref(false)
const error = ref('')
const confirmationSent = ref(false)
function messageFrom(cause: unknown) {
const value = cause as { data?: { msg?: string; message?: string; error_description?: string }; message?: string }
return value.data?.msg ?? value.data?.message ?? value.data?.error_description ?? value.message ?? 'Registration failed. Try again.'
}
async function finishSignIn() {
const requested = localStorage.getItem('dng-post-auth-redirect')
localStorage.removeItem('dng-post-auth-redirect')
const destination = requested?.startsWith('/') && !requested.startsWith('//') ? requested : '/dashboard'
await navigateTo(destination, { replace: true })
}
async function submit() {
error.value = ''
if (displayName.value.trim().length < 2) {
error.value = 'Enter a name with at least 2 characters.'
return
}
if (password.value.length < 8) {
error.value = 'Password must contain at least 8 characters.'
return
}
if (password.value !== confirmation.value) {
error.value = 'Passwords do not match.'
return
}
if (!ageConfirmed.value) {
error.value = 'You must confirm that you are at least 13 years old.'
return
}
busy.value = true
try {
const result = await auth.signUp(displayName.value, email.value, password.value)
if (result.session) await finishSignIn()
else confirmationSent.value = true
} catch (cause) {
error.value = messageFrom(cause)
} finally {
busy.value = false
}
}
onMounted(async () => {
await auth.restore()
if (auth.session.value) await navigateTo('/dashboard', { replace: true })
})
</script>
<template>
<AuthCard eyebrow="CREATE YOUR ACCOUNT" title="ENTER THE ALPHA." subtitle="Your email keeps your worlds, characters, and campaign access recoverable across devices.">
<div v-if="confirmationSent" class="form-success" role="status">
CHECK YOUR EMAIL. We sent a confirmation link to {{ email }}. Open it to activate your account.
</div>
<form v-else class="auth-form" @submit.prevent="submit">
<label>DISPLAY NAME<input v-model="displayName" name="name" autocomplete="name" maxlength="80" required placeholder="How your party sees you"></label>
<label>EMAIL<input v-model="email" name="email" type="email" autocomplete="email" required placeholder="you@example.com"></label>
<label>PASSWORD<input v-model="password" name="password" type="password" autocomplete="new-password" minlength="8" required placeholder="At least 8 characters"></label>
<label>CONFIRM PASSWORD<input v-model="confirmation" name="password-confirmation" type="password" autocomplete="new-password" minlength="8" required placeholder="Repeat your password"></label>
<label class="consent"><input v-model="ageConfirmed" type="checkbox" required><span>I am at least 13 years old and accept the 13+ content boundary.</span></label>
<p v-if="error" class="form-error" role="alert">{{ error }}</p>
<button :disabled="busy">{{ busy ? 'CREATING ACCOUNT…' : 'CREATE ACCOUNT →' }}</button>
</form>
<template #footer>Already have an account? <NuxtLink class="form-link" to="/auth/sign-in">SIGN IN</NuxtLink></template>
</AuthCard>
</template>
<style scoped>
.consent{grid-template-columns:18px 1fr!important;align-items:start!important;font:400 10px/1.5 var(--body)!important;letter-spacing:0!important;color:var(--muted)!important}.consent input{width:16px!important;min-height:16px!important;margin:1px 0 0;padding:0!important;accent-color:var(--acid)}
</style>

File diff suppressed because one or more lines are too long

View File

@@ -5,6 +5,11 @@ const forceOpen = ref(false)
const errorMessage = ref('')
const submittedActionIndex = ref<number | null>(null)
function abilityLabel(score: number) {
const modifier = Math.floor((Number(score) - 10) / 2)
return modifier >= 0 ? `+${modifier}` : String(modifier)
}
async function submitAction() {
if (!currentAction.value.trim() || submitting.value || ready.value) return
submitting.value = true
@@ -67,8 +72,8 @@ async function continueRound() {
</article>
<div class="character-sheet">
<small>ACTIVE CHARACTER</small><h2>{{ characters[0]?.name }}</h2><p>{{ characters[0]?.concept }}</p>
<div class="vitals"><span><b>{{ characters[0]?.hp }}</b>/{{ characters[0]?.maxHp }} HP</span><span><b>{{ characters[0]?.defense }}</b> DEF</span></div>
<div class="abilities"><span v-for="(score,key) in characters[0]?.abilities" :key="key"><small>{{ key }}</small><b>{{ score }}</b></span></div>
<div class="vitals"><span><b>{{ characters[0]?.hp }}</b>/{{ characters[0]?.maxHp }} HP</span><span><b>{{ characters[0]?.defense }}</b> AC</span></div>
<div class="abilities"><span v-for="(score,key) in characters[0]?.abilities" :key="key"><small>{{ key }} {{ abilityLabel(score) }}</small><b>{{ score }}</b></span></div>
<details><summary>INVENTORY</summary><ul><li v-for="item in characters[0]?.inventory" :key="item">{{ item }}</li></ul></details>
</div>
</aside>

View File

@@ -13,14 +13,21 @@ interface DraftSession {
generated_world?: { title?: string; premise?: string } | null
updated_at: string
}
interface UsageSummary {
quota: { usedToday: number; dailyRequestLimit: number; dailyTokenLimit: number }
totals: { requests: number; inputTokens: number; outputTokens: number; costUsd: number; averageLatencyMs: number }
}
const { api } = useDngApi()
const auth = useDngAuth()
const filter = ref<'all' | 'active' | 'drafts'>('all')
const campaigns = ref<CampaignRow[]>([])
const drafts = ref<DraftSession[]>([])
const usage = ref<UsageSummary | null>(null)
const loading = ref(true)
const error = ref('')
const renamingCampaignId = ref<string | null>(null)
const newName = ref('')
const visibleCampaigns = computed(() => filter.value === 'active'
? campaigns.value.filter(campaign => campaign.status === 'active')
@@ -41,15 +48,49 @@ function draftSummary(draft: DraftSession) {
|| 'Continue your conversation with the coauthor.'
}
function compactNumber(value: number) {
return new Intl.NumberFormat('en', { notation: 'compact', maximumFractionDigits: 1 }).format(value)
}
function openRename(campaign: CampaignRow) {
renamingCampaignId.value = campaign.id
newName.value = campaign.title
}
function handleCampaignAction(payload: { action: 'rename' | 'delete'; campaignId: string }) {
const campaign = campaigns.value.find(item => item.id === payload.campaignId)
if (!campaign) return
if (payload.action === 'rename') openRename(campaign)
else void requestDelete(campaign)
}
async function confirmRename() {
if (!renamingCampaignId.value || !newName.value.trim()) return
try {
await api(`/api/v1/campaigns/${renamingCampaignId.value}`, { method: 'PATCH', body: { title: newName.value } })
campaigns.value = campaigns.value.map(c => c.id === renamingCampaignId.value ? { ...c, title: newName.value.trim(), updated_at: new Date().toISOString() } : c)
} catch { /* handled silently */ } finally { renamingCampaignId.value = null; newName.value = '' }
}
async function requestDelete(campaign: CampaignRow) {
if (!confirm(`Delete "${campaign.title}"? This cannot be undone.`)) return
try {
await api(`/api/v1/campaigns/${campaign.id}`, { method: 'DELETE' })
campaigns.value = campaigns.value.filter(c => c.id !== campaign.id)
} catch { /* handled silently — refresh on next mount */ }
}
onMounted(async () => {
try {
await auth.restore()
const [campaignResult, draftResult] = await Promise.all([
const [campaignResult, draftResult, usageResult] = await Promise.all([
api<{ campaigns: CampaignRow[] }>('/api/v1/campaigns'),
api<{ sessions: DraftSession[] }>('/api/v1/coauthor/sessions'),
api<UsageSummary>('/api/v1/usage').catch(() => null),
])
campaigns.value = campaignResult.campaigns
drafts.value = draftResult.sessions
usage.value = usageResult
} catch (cause) {
const value = cause as { data?: { statusMessage?: string }; message?: string }
error.value = value.data?.statusMessage ?? value.message ?? 'Could not load your campaigns.'
@@ -60,13 +101,20 @@ onMounted(async () => {
</script>
<template>
<AppShell section="COMMAND DECK">
<AppShell section="COMMAND DECK" @campaign-action="handleCampaignAction">
<div class="dash-wrap noise">
<section class="dash-head">
<div><p class="kicker">WELCOME BACK, {{ displayName.toUpperCase() }}</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>
<section v-if="usage" class="usage-deck" aria-label="AI usage dashboard">
<div><small>AI REQUESTS / TODAY</small><b>{{ usage.quota.usedToday }}<em>/ {{ usage.quota.dailyRequestLimit }}</em></b></div>
<div><small>TOKENS / 30 DAYS</small><b>{{ compactNumber(usage.totals.inputTokens + usage.totals.outputTokens) }}</b></div>
<div><small>EST. COST / 30 DAYS</small><b>${{ usage.totals.costUsd.toFixed(3) }}</b></div>
<div><small>AVG. AI LATENCY</small><b>{{ (usage.totals.averageLatencyMs / 1000).toFixed(1) }}s</b></div>
</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>{{ campaigns.length }} CAMPAIGNS · {{ drafts.length }} DRAFTS</span>
@@ -74,8 +122,15 @@ onMounted(async () => {
<section class="world-grid">
<p v-if="loading" class="load-state">RECEIVING PRIVATE CAMPAIGNS</p>
<p v-else-if="error" class="load-state error">{{ error }} <NuxtLink to="/">SIGN IN AGAIN</NuxtLink></p>
<NuxtLink v-for="campaign in visibleCampaigns" :key="campaign.id" :to="`/campaign/${campaign.id}`" class="world-card active-world">
<p v-else-if="error" class="load-state error">{{ error }} <NuxtLink to="/auth/sign-in">SIGN IN AGAIN</NuxtLink></p>
<NuxtLink
v-for="campaign in visibleCampaigns"
:key="campaign.id"
:to="`/campaign/${campaign.id}`"
class="world-card active-world"
:data-context-campaign="campaign.id"
:data-context-label="campaign.title"
>
<div class="world-art"><div class="eclipse" /><span>{{ campaign.status.toUpperCase() }} CAMPAIGN</span></div>
<div class="world-info"><small>PRIVATE MULTIPLAYER</small><h2>{{ campaign.title }}</h2><p>{{ campaign.current_scene }}</p><div><b>CONTINUE STORY</b><span>{{ new Date(campaign.updated_at).toLocaleDateString() }}</span></div></div>
</NuxtLink>
@@ -90,9 +145,68 @@ onMounted(async () => {
<section class="system-strip"><span><i /> AI COAUTHOR READY</span><span>SERVER DICE <b>ONLINE</b></span><span>PRIVATE BY DEFAULT</span></section>
</div>
<Teleport to="body">
<dialog v-if="renamingCampaignId" class="rename-dialog" open @close.stop="">
<form @submit.prevent="confirmRename" class="rename-panel">
<small>RENAME UNIVERSE</small>
<input v-model="newName" required maxlength="200" autofocus placeholder="Universe name" aria-label="New universe name" />
<div class="rename-actions">
<button type="button" class="ghost-button" @click="renamingCampaignId = null; newName = ''">CANCEL</button>
<button class="acid-button" type="submit">SAVE</button>
</div>
</form>
</dialog>
</Teleport>
</AppShell>
</template>
<style scoped>
.draft-world .world-art{background:radial-gradient(circle at 50% 60%,#3f4720 0 2%,#1c2011 9%,#080808 58%)}.draft-world .world-art span{background:transparent;color:var(--acid);border:1px solid var(--acid-dim)}
.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}
.usage-deck{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));margin-top:34px;border:1px solid var(--line);background:#0d0d0c}.usage-deck>div{display:grid;gap:10px;padding:18px 20px;border-right:1px solid var(--line)}.usage-deck>div:last-child{border-right:0}.usage-deck small{font:500 7px var(--mono);letter-spacing:.14em;color:var(--muted)}.usage-deck b{font:600 20px var(--mono);color:var(--acid)}.usage-deck em{margin-left:4px;color:var(--muted);font:500 9px var(--mono);font-style:normal}
.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;margin-right:18px;background:none;border:0;color:var(--muted);font:500 9px var(--mono);text-transform:uppercase;letter-spacing:.14em}
.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:repeat(2,minmax(0,1fr));gap:18px}
.load-state{grid-column:1/-1;padding:34px;border:1px solid var(--line);font:500 9px var(--mono);color:var(--muted)}
.load-state.error{color:#ff9d85}
.load-state a{color:var(--acid)}
.world-card{min-height:420px;border:1px solid var(--line);color:var(--ink);text-decoration:none;background:#0e0e0d;transition:border-color .25s ease,transform .25s ease}
.world-card:hover{border-color:#65655e;transform:translateY(-3px)}
.active-world{display:grid;grid-template-columns:.75fr 1.25fr}
.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}
.draft-world .world-art{background:radial-gradient(circle at 50% 60%,#3f4720 0 2%,#1c2011 9%,#080808 58%)}
.draft-world .world-art span{background:transparent;color:var(--acid);border:1px solid var(--acid-dim)}
.world-info{padding:42px;display:flex;flex-direction:column;min-width:0}
.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;overflow-wrap:anywhere}
.world-info p,.new-card p{color:var(--muted);font-size:13px;line-height:1.7;display:-webkit-box;-webkit-line-clamp:5;-webkit-box-orient:vertical;overflow:hidden}
.world-info div{margin-top:auto;padding-top:26px;border-top:1px solid var(--line);display:flex;justify-content:space-between;gap:16px;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)}
.rename-dialog{position:fixed;inset:0;margin:auto;z-index:1000;width:min(440px,calc(100vw - 40px));padding:0;border:1px solid var(--line);background:#0a0a0a;color:var(--ink);box-shadow:0 60px 120px rgba(0,0,0,.7);font-family:inherit;display:grid}
.rename-dialog::backdrop{background:rgba(0,0,0,.55)}
.rename-panel{padding:32px;display:grid;gap:18px}
.rename-panel small{font:600 8px var(--mono);letter-spacing:.18em;color:var(--acid)}
.rename-panel input{box-sizing:border-box;width:100%;min-height:48px;padding:0 14px;border:1px solid var(--line);background:#0e0e0d;color:var(--ink);font:12px var(--body);outline:none}
.rename-panel input:focus{border-color:var(--acid)}
.rename-actions{display:flex;justify-content:flex-end;gap:10px}
@media(max-width:1050px){.world-grid{grid-template-columns:1fr}}
@media(max-width:850px){.dash-head{align-items:start;flex-direction:column}.usage-deck{grid-template-columns:repeat(2,1fr)}.usage-deck>div:nth-child(2){border-right:0}.usage-deck>div:nth-child(-n+2){border-bottom:1px solid var(--line)}.active-world{grid-template-columns:1fr}.world-art{min-height:280px}.system-strip{gap:16px;flex-wrap:wrap}}
@media(max-width:520px){.dash-head h1{font-size:clamp(34px,12vw,52px)}.create-button{width:100%;justify-content:center}.filter-row>span{display:none}.world-info,.new-card{padding:28px}.active-world{min-height:580px}.world-info div{align-items:flex-start;flex-direction:column}.system-strip{flex-direction:column}}
</style>

File diff suppressed because one or more lines are too long

View File

@@ -28,11 +28,12 @@ onMounted(async () => {
<div class="join noise">
<AppMark />
<div v-if="state === 'joining'" class="card"><small>PARTY INVITE</small><h1>JOINING CAMPAIGN</h1><p>Verifying your seat at the table.</p></div>
<div v-else-if="state === 'signin'" class="card"><small>PARTY INVITE</small><h1>ENTER THE ALPHA FIRST</h1><p>Your invite is saved. Guest access needs no email; after entering, this campaign will open automatically.</p><NuxtLink to="/">ENTER THE ALPHA </NuxtLink></div>
<div v-else-if="state === 'signin'" class="card"><small>PARTY INVITE</small><h1>ACCOUNT REQUIRED</h1><p>Your invite is saved. Sign in or create an email account, and this campaign will open automatically.</p><div class="auth-actions"><NuxtLink to="/auth/sign-in">SIGN IN →</NuxtLink><NuxtLink to="/auth/sign-up">CREATE ACCOUNT</NuxtLink></div></div>
<div v-else class="card"><small>INVITE ERROR</small><h1>THE DOOR STAYED SHUT</h1><p>{{ message }}</p><NuxtLink to="/dashboard">BACK TO DASHBOARD</NuxtLink></div>
</div>
</template>
<style scoped>
.join{min-height:100vh;display:grid;place-content:center;justify-items:center;gap:30px;padding:24px}.card{width:min(560px,calc(100vw - 40px));padding:42px;border:1px solid var(--line);background:#0d0d0c}.card small{font:600 8px var(--mono);letter-spacing:.16em;color:var(--acid)}.card h1{font:600 clamp(26px,5vw,45px)/1.05 var(--display);letter-spacing:-.05em}.card p{color:var(--muted);line-height:1.7}.card a{display:inline-flex;margin-top:20px;padding:15px 18px;background:var(--acid);color:#080808;text-decoration:none;font:600 9px var(--mono)}
.auth-actions{display:flex;gap:10px;flex-wrap:wrap}.auth-actions a:last-child{border:1px solid var(--line);background:transparent;color:var(--ink)}
</style>

View File

@@ -0,0 +1,74 @@
<script setup lang="ts">
interface ProfileView {
id: string
display_name: string
description: string
avatar_url: string | null
created_at: string
}
interface SharedCampaign {
id: string
title: string
status: string
role: string
joined_at: string
}
const route = useRoute()
const { api } = useDngApi()
const auth = useDngAuth()
const profile = ref<ProfileView | null>(null)
const campaigns = ref<SharedCampaign[]>([])
const loading = ref(true)
const error = ref('')
const profileId = computed(() => String(route.params.id ?? ''))
const returnCampaign = computed(() => typeof route.query.campaign === 'string' ? route.query.campaign : '')
const initials = computed(() => (profile.value?.display_name || 'Adventurer')
.split(/\s+/).map(part => part[0]).join('').slice(0, 2).toUpperCase())
function messageFrom(cause: unknown) {
const value = cause as { data?: { statusMessage?: string }; message?: string }
return value.data?.statusMessage ?? value.message ?? 'This player profile is not available.'
}
onMounted(async () => {
try {
await auth.restore()
const result = await api<{ profile: ProfileView; sharedCampaigns: SharedCampaign[] }>(`/api/v1/profiles/${profileId.value}`)
profile.value = result.profile
campaigns.value = result.sharedCampaigns
} catch (cause) {
error.value = messageFrom(cause)
} finally {
loading.value = false
}
})
</script>
<template>
<AppShell section="PLAYER RECORD">
<div v-if="loading" class="public-state noise"><i /><p>LOCATING PLAYER RECORD</p></div>
<div v-else-if="!profile" class="public-state noise"><small>PRIVATE RECORD</small><h1>PLAYER NOT FOUND.</h1><p>{{ error }}</p><NuxtLink :to="returnCampaign ? `/campaign/${returnCampaign}` : '/dashboard'">GO BACK</NuxtLink></div>
<div v-else class="public-profile noise">
<nav><NuxtLink :to="returnCampaign ? `/campaign/${returnCampaign}` : '/dashboard'"> {{ returnCampaign ? 'BACK TO CAMPAIGN' : 'BACK TO DASHBOARD' }}</NuxtLink><NuxtLink v-if="profile.id === auth.session.value?.user.id" to="/profile">MANAGE PROFILE </NuxtLink></nav>
<main>
<div class="public-avatar"><span>{{ initials }}</span><img v-if="profile.avatar_url" :src="profile.avatar_url" :alt="`${profile.display_name}'s profile picture`"><i>VERIFIED<br>PARTY MEMBER</i></div>
<section class="public-copy">
<p class="eyebrow">PLAYER PROFILE / DUNGEONS & GROUND</p>
<h1>{{ profile.display_name }}<span>.</span></h1>
<blockquote>{{ profile.description || 'This adventurer has not written a profile description yet.' }}</blockquote>
<dl>
<div><dt>REGISTERED</dt><dd>{{ new Date(profile.created_at).toLocaleDateString(undefined, { dateStyle: 'long' }) }}</dd></div>
<div><dt>SHARED UNIVERSES</dt><dd>{{ campaigns.length }}</dd></div>
</dl>
<div v-if="campaigns.length" class="shared-worlds"><small>YOUR SHARED UNIVERSES</small><NuxtLink v-for="campaign in campaigns" :key="campaign.id" :to="`/campaign/${campaign.id}`"><span><b>{{ campaign.title }}</b><em>{{ campaign.role }} · joined {{ new Date(campaign.joined_at).toLocaleDateString() }}</em></span><i></i></NuxtLink></div>
</section>
</main>
</div>
</AppShell>
</template>
<style scoped>
.public-profile{min-height:calc(100vh - 76px);padding:32px clamp(20px,6vw,88px) 80px}.public-profile nav{display:flex;justify-content:space-between;gap:20px;padding:0 0 28px;border-bottom:1px solid var(--line)}.public-profile nav a,.public-state a{color:var(--muted);text-decoration:none;font:600 8px var(--mono);letter-spacing:.1em}.public-profile main{display:grid;grid-template-columns:minmax(300px,42%) minmax(0,1fr);min-height:650px;border:1px solid var(--line);border-top:0;background:#0e0e0d}.public-avatar{position:relative;overflow:hidden;display:grid;place-items:center;min-height:650px;border-right:1px solid var(--line);background:radial-gradient(circle at 50% 42%,#4e5724,#17170f 33%,#080808 70%);color:var(--acid);font:600 clamp(50px,8vw,110px) var(--display)}.public-avatar::after{content:"";position:absolute;inset:28px;border:1px solid rgba(217,247,95,.2)}.public-avatar img{position:absolute;inset:0;width:100%;height:100%;object-fit:cover}.public-avatar i{position:absolute;z-index:2;left:50px;bottom:45px;color:var(--ink);font:500 8px/1.6 var(--mono);letter-spacing:.16em;font-style:normal}.public-copy{padding:clamp(35px,6vw,80px);align-self:center}.eyebrow{color:var(--acid);font:500 8px var(--mono);letter-spacing:.18em}.public-copy h1,.public-state h1{margin:18px 0 28px;font:600 clamp(42px,6vw,78px)/1.04 var(--display);letter-spacing:-.07em;overflow-wrap:anywhere}.public-copy h1 span{color:var(--acid)}blockquote{margin:0;padding-left:22px;border-left:2px solid var(--acid);color:#c9c9c2;font:400 clamp(14px,2vw,19px)/1.8 var(--body)}dl{display:grid;grid-template-columns:1fr 1fr;margin:42px 0;border-top:1px solid var(--line);border-bottom:1px solid var(--line)}dl div{padding:18px 0}dl div+div{padding-left:24px;border-left:1px solid var(--line)}dt{color:var(--muted);font:500 7px var(--mono);letter-spacing:.13em}dd{margin:7px 0 0;font-size:11px}.shared-worlds>small{color:var(--acid);font:500 7px var(--mono);letter-spacing:.14em}.shared-worlds>a{display:flex;align-items:center;justify-content:space-between;gap:20px;padding:16px 0;border-bottom:1px solid var(--line);color:var(--ink);text-decoration:none}.shared-worlds span{display:flex;flex-direction:column;gap:6px}.shared-worlds b{font:600 11px var(--display)}.shared-worlds em{color:var(--muted);font:500 7px var(--mono);text-transform:uppercase;font-style:normal}.shared-worlds>a>i{color:var(--acid);font-style:normal}.public-state{min-height:calc(100vh - 76px);display:grid;place-content:center;justify-items:center;text-align:center;padding:30px}.public-state>i{width:24px;height:24px;border:2px solid var(--line);border-top-color:var(--acid);border-radius:50%;animation:spin .8s linear infinite}.public-state small{color:var(--acid);font:500 8px var(--mono);letter-spacing:.15em}.public-state p{color:var(--muted)}@keyframes spin{to{transform:rotate(360deg)}}@media(max-width:820px){.public-profile main{grid-template-columns:1fr}.public-avatar{min-height:380px;border-right:0;border-bottom:1px solid var(--line)}.public-avatar i{left:28px;bottom:26px}.public-copy{padding:34px 26px}}@media(max-width:480px){dl{grid-template-columns:1fr}dl div+div{padding-left:0;border-left:0;border-top:1px solid var(--line)}}
</style>

File diff suppressed because one or more lines are too long

View File

@@ -16,6 +16,8 @@ interface StoredSession {
confirmed_world_id?: string | null
}
const questionLimit = 4
const { api } = useDngApi()
const route = useRoute()
const stage = ref<Stage>('seed')
@@ -33,7 +35,14 @@ const messages = ref<Message[]>([
])
const displayedQuestion = computed(() => currentQuestion.value ? { key: currentQuestion.value.id, label: currentQuestion.value.label, options: currentQuestion.value.options } : null)
const progress = computed(() => stage.value === 'preview' || stage.value === 'confirming' ? 100 : stage.value === 'generating' ? 85 : stage.value === 'seed' ? 10 : 20 + Math.round((Math.min(answerCount.value, 5) / 5) * 55))
const conversationMessages = computed(() => {
if (!currentQuestion.value) return messages.value
const currentIndex = messages.value.findLastIndex(message =>
message.role === 'coauthor' && message.body === currentQuestion.value?.label,
)
return currentIndex < 0 ? messages.value : messages.value.filter((_, index) => index !== currentIndex)
})
const progress = computed(() => stage.value === 'preview' || stage.value === 'confirming' ? 100 : stage.value === 'generating' ? 85 : stage.value === 'seed' ? 10 : 20 + Math.round((Math.min(answerCount.value, questionLimit) / questionLimit) * 55))
function addMessage(role: Message['role'], body: string, label?: string) {
messages.value.push({ id: `${Date.now()}-${messages.value.length}`, role, body, label })
@@ -117,6 +126,7 @@ async function resume(id: string) {
async function answerQuestion(key: string, value: string) {
if (stage.value !== 'questions' || currentQuestion.value?.id !== key || busy.value || !sessionId.value) return
const answeredQuestion = currentQuestion.value
busy.value = true
pendingAnswer.value = { key, value }
try {
@@ -124,9 +134,10 @@ async function answerQuestion(key: string, value: string) {
method: 'POST', body: { content: value },
})
answers[key] = value
addMessage('coauthor', answeredQuestion.label, `COAUTHOR · QUESTION ${Math.min(answerCount.value + 1, questionLimit)} OF ${questionLimit}`)
addMessage('player', value)
currentQuestion.value = null
answerCount.value = Math.min(answerCount.value + 1, 5)
answerCount.value = Math.min(answerCount.value + 1, questionLimit)
pendingAnswer.value = null
retryAction.value = 'respond'
await requestNextQuestion()
@@ -140,14 +151,13 @@ async function answerQuestion(key: string, value: string) {
async function requestNextQuestion() {
if (!sessionId.value) return
const result = await api<RespondResult>(`/api/v1/coauthor/sessions/${sessionId.value}/respond`, { method: 'POST' })
if (result.readyToGenerate || answerCount.value >= 5) {
if (result.readyToGenerate || answerCount.value >= questionLimit) {
currentQuestion.value = null
await generate()
return
}
if (!result.question) throw new Error('The coauthor did not return its next question.')
currentQuestion.value = result.question
addMessage('coauthor', result.question.label, `COAUTHOR · QUESTION ${Math.min(answerCount.value + 1, 5)} OF UP TO 5`)
}
function errorText(error: unknown) {
@@ -247,7 +257,7 @@ onMounted(() => {
<main v-if="stage !== 'preview' && stage !== 'confirming'" class="coauthor">
<p class="kicker">COAUTHOR / SESSION 01</p>
<h1>BUILD THE<br>IMPOSSIBLE<span>.</span></h1>
<CoauthorConversation :messages="messages" :question="displayedQuestion" :selected-answer="currentQuestion ? answers[currentQuestion.id] : undefined" :busy="stage === 'generating' || busy" @answer="answerQuestion" @retry="retry" />
<CoauthorConversation :messages="conversationMessages" :question="displayedQuestion" :selected-answer="currentQuestion ? answers[currentQuestion.id] : undefined" :busy="stage === 'generating' || busy" @answer="answerQuestion" @retry="retry" />
<form v-if="stage === 'seed'" class="seed" @submit.prevent="begin">
<textarea v-model="seed" aria-label="World idea" rows="3" maxlength="1200" autofocus />
<button :disabled="!seed.trim() || busy">{{ busy ? 'SAVING…' : 'BEGIN' }} <span></span></button>

View File

@@ -0,0 +1,17 @@
import { deleteStageTwoCampaign, requireCampaignAccess, stageTwoApiError, stageTwoUuid } from '~/server/utils/stage-two-supabase'
import { requireStageTwoUser } from '~/server/utils/stage-two-supabase'
export default defineEventHandler(async (event) => {
try {
const user = await requireStageTwoUser(event)
const campaignId = stageTwoUuid(event.context.params?.id ?? '')
const { campaign, owner } = await requireCampaignAccess(campaignId, user.id, true)
if (!owner) throw createError({ statusCode: 403, statusMessage: 'Only the campaign owner can delete it.' })
const worldId = stageTwoUuid(campaign.world_id, 'world id')
await deleteStageTwoCampaign(campaignId, worldId)
return { success: true }
} catch (error) {
stageTwoApiError(error)
}
})

View File

@@ -1,4 +1,5 @@
import { requireCampaignAccess, requireStageTwoUser, stageTwoApiError, stageTwoDatabase, stageTwoUuid } from '~/server/utils/stage-two-supabase'
import { visibleProfile, type ProfileRecord } from '~/server/utils/profile'
export default defineEventHandler(async (event) => {
try {
@@ -15,7 +16,7 @@ export default defineEventHandler(async (event) => {
`campaign_members?select=id,user_id,role,active,ai_takeover_allowed,joined_at&campaign_id=eq.${campaignId}&order=joined_at.asc`,
),
stageTwoDatabase<Array<Record<string, unknown>>>(
`characters?select=id,campaign_id,user_id,name,concept,controller,abilities,hp,max_hp,defense,proficiency,inventory,statuses,persona,created_at&campaign_id=eq.${campaignId}&order=created_at.asc`,
`characters?select=id,campaign_id,user_id,name,concept,controller,abilities,hp,max_hp,defense,proficiency,inventory,statuses,persona,rules_state,created_at&campaign_id=eq.${campaignId}&order=created_at.asc`,
),
stageTwoDatabase<Array<Record<string, unknown>>>(
`rounds?select=id,campaign_id,number,status,forced_by,narration,next_prompt,queued_at,resolved_at,error,created_at&campaign_id=eq.${campaignId}&status=eq.open&order=number.desc&limit=1`,
@@ -30,15 +31,16 @@ export default defineEventHandler(async (event) => {
const round = openRounds[0] ?? fallbackRounds[0] ?? null
const userIds = [...new Set(members.map(member => String(member.user_id)))]
const profiles = userIds.length
? await stageTwoDatabase<Array<{ id: string; display_name: string }>>(
`profiles?select=id,display_name&id=in.(${userIds.join(',')})`,
? await stageTwoDatabase<ProfileRecord[]>(
`profiles?select=id,display_name,description,avatar_path,created_at,updated_at&id=in.(${userIds.join(',')})`,
)
: []
const profileById = new Map(profiles.map(profile => [profile.id, profile]))
const supabaseUrl = useRuntimeConfig().supabaseUrl
const profileById = new Map(profiles.map(profile => [profile.id, visibleProfile(profile, supabaseUrl)]))
const visibleMembers = members.map(member => ({ ...member, profile: profileById.get(String(member.user_id)) ?? null }))
const visibleRounds = rounds.slice().reverse()
const visibleRoundIds = visibleRounds.map(item => String(item.id))
const [intents, events, diceRolls] = await Promise.all([
const [intents, events, diceRolls, sceneStates, questStates, relationships, memories, storySummaries, auditEntries] = await Promise.all([
round ? stageTwoDatabase<Array<Record<string, unknown>>>(
`player_intents?select=id,round_id,member_id,character_id,action,ready,created_at,updated_at&round_id=eq.${String(round.id)}&order=created_at.asc`,
) : Promise.resolve([]),
@@ -48,8 +50,42 @@ export default defineEventHandler(async (event) => {
visibleRoundIds.length ? stageTwoDatabase<Array<Record<string, unknown>>>(
`dice_rolls?select=id,round_id,actor_id,target_id,check_kind,formula,rolls,kept,modifier,total,difficulty,success,created_at&round_id=in.(${visibleRoundIds.join(',')})&order=created_at.asc`,
) : Promise.resolve([]),
stageTwoDatabase<Array<Record<string, unknown>>>(
`scene_states?select=campaign_id,location_id,summary,active_entity_ids,tags,through_round,updated_at&campaign_id=eq.${campaignId}&limit=1`,
),
stageTwoDatabase<Array<Record<string, unknown>>>(
`quest_states?select=campaign_id,quest_entity_id,status,summary,tags,through_round,updated_at&campaign_id=eq.${campaignId}&order=updated_at.desc`,
),
stageTwoDatabase<Array<Record<string, unknown>>>(
`relationships?select=source_entity_id,target_entity_id,score,notes&campaign_id=eq.${campaignId}&order=score.desc&limit=30`,
),
stageTwoDatabase<Array<Record<string, unknown>>>(
`memories?select=id,round_id,summary,importance,tags,entity_ids,created_at&campaign_id=eq.${campaignId}&order=importance.desc,created_at.desc&limit=20`,
),
stageTwoDatabase<Array<Record<string, unknown>>>(
`story_summaries?select=id,through_round,summary,created_at&campaign_id=eq.${campaignId}&order=through_round.desc&limit=1`,
),
access.owner ? stageTwoDatabase<Array<Record<string, unknown>>>(
`audit_entries?select=id,actor_id,action,entity_type,entity_id,before_state,after_state,created_at&campaign_id=eq.${campaignId}&order=created_at.desc&limit=50`,
) : Promise.resolve([]),
])
return { campaign, world: worlds[0] ?? null, members: visibleMembers, characters, round, rounds: visibleRounds, intents, events: events.slice().reverse(), diceRolls }
return {
campaign,
world: worlds[0] ?? null,
members: visibleMembers,
characters,
round,
rounds: visibleRounds,
intents,
events: events.slice().reverse(),
diceRolls,
sceneState: sceneStates[0] ?? null,
activeGoals: questStates.filter(quest => quest.status === 'active'),
relationships,
memories,
storySummary: storySummaries[0] ?? null,
auditEntries,
}
} catch (error) {
stageTwoApiError(error)
}

View File

@@ -0,0 +1,27 @@
import { z } from 'zod'
import { requireCampaignAccess, requireStageTwoSafeText, stageTwoApiError, stageTwoDatabase, stageTwoUuid } from '~/server/utils/stage-two-supabase'
import { requireStageTwoUser } from '~/server/utils/stage-two-supabase'
const PatchBodySchema = z.object({
title: z.string().trim().min(1).max(200),
}).strict()
export default defineEventHandler(async (event) => {
try {
const user = await requireStageTwoUser(event)
const campaignId = stageTwoUuid(event.context.params?.id ?? '')
await requireCampaignAccess(campaignId, user.id, true)
const body = PatchBodySchema.parse(await readBody(event))
requireStageTwoSafeText(body.title)
const campaigns = await stageTwoDatabase<Array<Record<string, unknown>>>(`campaigns?id=eq.${campaignId}&limit=1`, {
method: 'PATCH',
body: JSON.stringify({ title: body.title, updated_at: new Date().toISOString() }),
prefer: 'return=representation',
})
if (!campaigns[0]) throw createError({ statusCode: 404, statusMessage: 'Campaign not found.' })
return { campaign: campaigns[0] }
} catch (error) {
stageTwoApiError(error)
}
})

View File

@@ -1,4 +1,4 @@
import { AbilityScoresSchema } from '@dng/shared'
import { AbilityKeySchema, AbilityScoresSchema, SkillKeySchema } from '@dng/shared'
import { z } from 'zod'
import { requireCampaignAccess, requireStageTwoSafeText, requireStageTwoUser, stageTwoApiError, stageTwoDatabase, stageTwoRpc, stageTwoUuid } from '~/server/utils/stage-two-supabase'
@@ -13,8 +13,15 @@ const BodySchema = z.object({
proficiency: z.number().int().min(1).max(10).default(2),
inventory: z.array(z.string().trim().min(1).max(120)).max(50).default([]),
statuses: z.array(z.string().trim().min(1).max(80)).max(12).default([]),
skillProficiencies: z.array(SkillKeySchema).max(8).default([]),
skillExpertise: z.array(SkillKeySchema).max(4).default([]),
savingThrowProficiencies: z.array(AbilityKeySchema).max(3).default([]),
persona: z.record(z.string(), z.unknown()).default({}),
}).strict().refine(value => value.hp <= value.maxHp, { message: 'hp must not exceed maxHp', path: ['hp'] })
}).strict()
.refine(value => value.hp <= value.maxHp, { message: 'hp must not exceed maxHp', path: ['hp'] })
.refine(value => value.skillExpertise.every(skill => value.skillProficiencies.includes(skill)), {
message: 'expertise requires skill proficiency', path: ['skillExpertise'],
})
export default defineEventHandler(async (event) => {
try {
@@ -26,7 +33,7 @@ export default defineEventHandler(async (event) => {
throw createError({ statusCode: 403, statusMessage: 'Only the campaign owner can add AI heroes.' })
}
requireStageTwoSafeText([body.name, body.concept, ...body.inventory, ...body.statuses, JSON.stringify(body.persona)].join('\n'))
const characterId = await stageTwoRpc<string>('stage_four_create_character', {
const characterId = await stageTwoRpc<string>('stage_five_create_character', {
p_campaign_id: campaignId,
p_actor_id: user.id,
p_controller: body.controller,
@@ -40,6 +47,21 @@ export default defineEventHandler(async (event) => {
p_inventory: body.inventory,
p_statuses: body.statuses,
p_persona: body.persona,
p_rules_state: {
version: 2,
level: 1,
skillProficiencies: body.skillProficiencies,
skillExpertise: body.skillExpertise,
savingThrowProficiencies: body.savingThrowProficiencies,
temporaryHp: 0,
deathSaves: { successes: 0, failures: 0 },
exhaustion: 0,
damageResistances: [],
damageVulnerabilities: [],
damageImmunities: [],
resources: { hitDice: { current: 1, max: 1, recovery: 'longRest' } },
legacyProficiencyFallback: false,
},
})
const rows = await stageTwoDatabase<Array<Record<string, unknown>>>(
`characters?select=*&id=eq.${characterId}&campaign_id=eq.${campaignId}&limit=1`,

View File

@@ -0,0 +1,40 @@
import { z } from 'zod'
import {
requireCampaignAccess,
requireStageTwoSafeText,
requireStageTwoUser,
stageTwoApiError,
stageTwoRpc,
stageTwoUuid,
} from '~/server/utils/stage-two-supabase'
const BodySchema = z.object({
hp: z.number().int().min(0).optional(),
inventory: z.array(z.string().trim().min(1).max(120)).max(50).optional(),
statuses: z.array(z.string().trim().min(1).max(80)).max(12).optional(),
reason: z.string().trim().min(3).max(500),
}).strict().refine(body => body.hp !== undefined || body.inventory !== undefined || body.statuses !== undefined, {
message: 'At least one state field is required.',
})
export default defineEventHandler(async (event) => {
try {
const user = await requireStageTwoUser(event)
const campaignId = stageTwoUuid(getRouterParam(event, 'id'), 'campaign id')
const characterId = stageTwoUuid(getRouterParam(event, 'characterId'), 'character id')
await requireCampaignAccess(campaignId, user.id, true)
const body = BodySchema.parse(await readBody(event))
requireStageTwoSafeText([body.reason, ...(body.inventory ?? []), ...(body.statuses ?? [])].join('\n'))
const { reason, ...patch } = body
const state = await stageTwoRpc<Record<string, unknown>>('stage_six_adjust_character_state', {
p_campaign_id: campaignId,
p_owner_id: user.id,
p_character_id: characterId,
p_patch: patch,
p_reason: reason,
})
return { characterId, state }
} catch (error) {
stageTwoApiError(error)
}
})

View File

@@ -5,6 +5,8 @@ import {
requireCampaignAccess,
requireStageTwoSafeText,
requireStageTwoUser,
stageSixConsumeAiQuota,
stageSixRecordAiUsage,
stageTwoApiError,
stageTwoDatabase,
stageTwoUuid,
@@ -34,7 +36,7 @@ export default defineEventHandler(async (event) => {
if (!world) throw createError({ statusCode: 404, statusMessage: 'Campaign world not found.' })
const completion = buildJsonCompletion(useRuntimeConfig(), {
system: `You create an original, editable Dungeons & Ground character draft for a private 13+ TTRPG campaign. Adapt archetype, equipment and voice to the supplied genre, but always use STR, DEX, CON, INT, WIS and CHA. Keep an alpha hero grounded: ability scores 716, max HP 820, Defense 1016, proficiency 2. Give 25 useful starting items and a distinctive persona. Never use protected franchise characters. Return only the requested JSON.`,
system: `You create an original, editable Dungeons & Ground character draft for a private 13+ SRD 5.2.1 TTRPG campaign. Adapt archetype, equipment and voice to the supplied genre, but always use STR, DEX, CON, INT, WIS and CHA. Keep an alpha hero grounded: ability scores 716, max HP 820, Armor Class (AC) 1016, proficiency 2. Choose exactly four fitting skill proficiencies and two saving throw proficiencies; expertise may contain at most one of the chosen skills. Give 25 useful starting items and a distinctive persona. Never use protected franchise characters. Return only the requested JSON.`,
messages: [{
role: 'user',
content: JSON.stringify({
@@ -48,8 +50,10 @@ export default defineEventHandler(async (event) => {
jsonSchema: CharacterDraftSchema.toJSONSchema(),
})
await stageSixConsumeAiQuota(user.id, campaignId, 'character-draft')
const controller = new AbortController()
const timeout = setTimeout(() => controller.abort(), 30_000)
const startedAt = Date.now()
let response: Response
try {
response = await fetch(completion.endpoint, {
@@ -62,10 +66,14 @@ export default defineEventHandler(async (event) => {
clearTimeout(timeout)
}
if (!response.ok) throw createError({ statusCode: 502, statusMessage: 'The character coauthor is temporarily unavailable.' })
const payload = await response.json()
await stageSixRecordAiUsage({
userId: user.id, campaignId, requestKind: 'character-draft', completion, payload, latencyMs: Date.now() - startedAt,
}).catch(error => console.error('[web] could not record character draft usage:', error))
let character
try {
character = CharacterDraftSchema.parse(parseJsonCompletion(await response.json()))
character = CharacterDraftSchema.parse(parseJsonCompletion(payload))
} catch {
throw createError({ statusCode: 502, statusMessage: 'The coauthor returned an invalid character draft.' })
}

View File

@@ -1,4 +1,4 @@
import { requireCampaignAccess, requireStageTwoUser, stageTwoApiError, stageTwoDatabase, stageTwoRpc, stageTwoUuid } from '~/server/utils/stage-two-supabase'
import { requireCampaignAccess, requireStageTwoUser, stageSixConsumeAiQuota, stageTwoApiError, stageTwoDatabase, stageTwoRpc, stageTwoUuid } from '~/server/utils/stage-two-supabase'
export default defineEventHandler(async (event) => {
try {
@@ -11,6 +11,7 @@ export default defineEventHandler(async (event) => {
)
if (!rounds[0]) throw createError({ statusCode: 404, statusMessage: 'Round not found.' })
if (rounds[0].status !== 'failed') throw createError({ statusCode: 409, statusMessage: 'Only a failed round can be retried.' })
await stageSixConsumeAiQuota(user.id, campaignId, 'retry-round')
const jobId = await stageTwoRpc<string>('stage_two_retry_failed_round', {
p_round_id: roundId,
p_owner_id: user.id,

View File

@@ -8,7 +8,7 @@ export default defineEventHandler(async (event) => {
)
const filters = [`owner_id.eq.${user.id}`, ...memberships.map(item => `id.eq.${item.campaign_id}`)]
const campaigns = await stageTwoDatabase<Array<Record<string, unknown>>>(
`campaigns?select=id,world_id,owner_id,title,current_scene,next_prompt,status,created_at,updated_at&or=(${filters.join(',')})&order=updated_at.desc`,
`campaigns?select=id,world_id,owner_id,title,current_scene,next_prompt,status,ruleset_version,created_at,updated_at&or=(${filters.join(',')})&order=updated_at.desc`,
)
return { campaigns }
} catch (error) {

View File

@@ -1,17 +1,9 @@
import { z } from 'zod'
import { buildJsonCompletion, parseJsonCompletion } from '~/server/utils/ai-provider'
import { requireStageTwoSafeText, requireStageTwoUser, stageTwoApiError, stageTwoDatabase, stageTwoRpc, stageTwoUuid } from '~/server/utils/stage-two-supabase'
import { CoauthorQuestionSchema, StoredCoauthorQuestionSchema } from '~/server/utils/coauthor-question'
import { requireStageTwoSafeText, requireStageTwoUser, stageSixConsumeAiQuota, stageSixRecordAiUsage, stageTwoApiError, stageTwoDatabase, stageTwoRpc, stageTwoUuid } from '~/server/utils/stage-two-supabase'
const MessageSchema = z.object({ role: z.enum(['user', 'assistant']), content: z.string().min(1).max(5000) })
const QuestionSchema = z.object({
question: z.string().trim().min(5).max(300),
options: z.array(z.string().trim().min(1).max(100)).length(3),
})
const StoredQuestionSchema = z.object({
id: z.string().trim().min(1).max(80),
label: z.string().trim().min(5).max(300),
options: z.array(z.string().trim().min(1).max(100)).length(3),
})
export default defineEventHandler(async (event) => {
try {
@@ -27,29 +19,48 @@ export default defineEventHandler(async (event) => {
const answerCount = Math.max(0, messages.filter(message => message.role === 'user').length - 1)
if (answerCount >= 4) return { readyToGenerate: true }
if (session.current_question) {
return { readyToGenerate: false, question: StoredQuestionSchema.parse(session.current_question) }
const storedQuestion = StoredCoauthorQuestionSchema.safeParse(session.current_question)
if (storedQuestion.success) return { readyToGenerate: false, question: storedQuestion.data }
}
const completion = buildJsonCompletion(useRuntimeConfig(), {
system: `You are the Dungeons & Ground coauthor. Based only on this conversation, ask one focused question that makes the original TTRPG world more playable. This is clarification ${answerCount + 1} of 4. Do not repeat a topic already answered. Adapt to the requested genre. Provide exactly three concise, mutually distinct answer options. Keep everything suitable for ages 13+.`,
messages,
schemaName: 'coauthor_question',
jsonSchema: QuestionSchema.toJSONSchema(),
})
const controller = new AbortController()
const timeout = setTimeout(() => controller.abort(), 30_000)
let response: Response
try {
response = await fetch(completion.endpoint, {
method: 'POST', headers: completion.headers, body: JSON.stringify(completion.body), signal: controller.signal,
let question: z.infer<typeof CoauthorQuestionSchema> | undefined
for (let attempt = 0; attempt < 2 && !question; attempt += 1) {
const correction = attempt
? ' The previous response was malformed. Ensure every option is a natural-language answer, not punctuation, a JSON key, or a copy of the question.'
: ''
const completion = buildJsonCompletion(useRuntimeConfig(), {
system: `You are the Dungeons & Ground coauthor. Based only on this conversation, ask one focused question that makes the original TTRPG world more playable. This is clarification ${answerCount + 1} of 4. Do not repeat a topic already answered. Adapt to the requested genre. Use the language of the first user message for the question and every answer option unless the user explicitly requests another language. Provide exactly three concise, mutually distinct natural-language answers to the question. Never use punctuation or JSON field names as an option, and never repeat the question as an option. Keep everything suitable for ages 13+.${correction}`,
messages,
schemaName: 'coauthor_question',
jsonSchema: CoauthorQuestionSchema.toJSONSchema(),
})
} finally {
clearTimeout(timeout)
await stageSixConsumeAiQuota(user.id, null, 'coauthor-question')
const controller = new AbortController()
const timeout = setTimeout(() => controller.abort(), 30_000)
const startedAt = Date.now()
let response: Response
try {
response = await fetch(completion.endpoint, {
method: 'POST', headers: completion.headers, body: JSON.stringify(completion.body), signal: controller.signal,
})
} finally {
clearTimeout(timeout)
}
if (!response.ok) throw createError({ statusCode: 502, statusMessage: 'The coauthor is temporarily unavailable.' })
const payload = await response.json()
await stageSixRecordAiUsage({
userId: user.id, campaignId: null, requestKind: 'coauthor-question', completion, payload, latencyMs: Date.now() - startedAt,
}).catch(error => console.error('[web] could not record coauthor question usage:', error))
try {
const parsed = CoauthorQuestionSchema.safeParse(parseJsonCompletion(payload))
if (parsed.success) question = parsed.data
} catch {
// Retry once when the provider ignores structured-output requirements.
}
}
if (!response.ok) throw createError({ statusCode: 502, statusMessage: 'The coauthor is temporarily unavailable.' })
const question = QuestionSchema.parse(parseJsonCompletion(await response.json()))
if (!question) throw createError({ statusCode: 502, statusMessage: 'The coauthor returned invalid answer options. Try the question again.' })
requireStageTwoSafeText([question.question, ...question.options].join('\n'))
const storedQuestion = StoredQuestionSchema.parse({
const storedQuestion = StoredCoauthorQuestionSchema.parse({
id: `question-${answerCount + 1}`,
label: question.question,
options: question.options,

View File

@@ -0,0 +1,37 @@
import { z } from 'zod'
import { requireStageTwoSafeText, requireStageTwoUser, stageTwoApiError, stageTwoDatabase } from '~/server/utils/stage-two-supabase'
import { visibleProfile, type ProfileRecord } from '~/server/utils/profile'
const ProfileUpdateSchema = z.object({
displayName: z.string().trim().min(1).max(80).optional(),
description: z.string().trim().max(500).optional(),
avatarPath: z.string().nullable().optional(),
}).strict().refine(value => Object.keys(value).length > 0, 'At least one profile field is required.')
export default defineEventHandler(async (event) => {
try {
const user = await requireStageTwoUser(event)
const update = ProfileUpdateSchema.parse(await readBody(event))
const expectedAvatarPath = `${user.id}/avatar`
if (update.avatarPath !== undefined && update.avatarPath !== null && update.avatarPath !== expectedAvatarPath) {
throw createError({ statusCode: 400, statusMessage: 'Invalid profile picture path.' })
}
if (update.displayName !== undefined) requireStageTwoSafeText(update.displayName)
if (update.description !== undefined) requireStageTwoSafeText(update.description)
const changes: Record<string, unknown> = { updated_at: new Date().toISOString() }
if (update.displayName !== undefined) changes.display_name = update.displayName
if (update.description !== undefined) changes.description = update.description
if (update.avatarPath !== undefined) changes.avatar_path = update.avatarPath
const profiles = await stageTwoDatabase<ProfileRecord[]>(`profiles?id=eq.${user.id}`, {
method: 'PATCH',
body: JSON.stringify(changes),
prefer: 'return=representation',
})
if (!profiles[0]) throw createError({ statusCode: 404, statusMessage: 'Profile not found.' })
return { profile: visibleProfile(profiles[0], useRuntimeConfig().supabaseUrl) }
} catch (error) {
stageTwoApiError(error)
}
})

View File

@@ -0,0 +1,65 @@
import { requireStageTwoUser, stageTwoApiError, stageTwoDatabase, stageTwoUuid } from '~/server/utils/stage-two-supabase'
import { visibleProfile, type ProfileRecord } from '~/server/utils/profile'
interface MembershipRow {
campaign_id: string
role: string
joined_at: string
}
export default defineEventHandler(async (event) => {
try {
const user = await requireStageTwoUser(event)
const profileId = stageTwoUuid(getRouterParam(event, 'id'), 'profile id')
const viewerMemberships = await stageTwoDatabase<Array<{ campaign_id: string }>>(
`campaign_members?select=campaign_id&user_id=eq.${user.id}&active=eq.true`,
)
const viewerCampaignIds = [...new Set(viewerMemberships.map(member => member.campaign_id))]
let sharedMemberships: MembershipRow[]
if (profileId === user.id) {
sharedMemberships = await stageTwoDatabase<MembershipRow[]>(
`campaign_members?select=campaign_id,role,joined_at&user_id=eq.${profileId}&active=eq.true&order=joined_at.desc`,
)
} else if (viewerCampaignIds.length) {
sharedMemberships = await stageTwoDatabase<MembershipRow[]>(
`campaign_members?select=campaign_id,role,joined_at&user_id=eq.${profileId}&active=eq.true&campaign_id=in.(${viewerCampaignIds.join(',')})&order=joined_at.desc`,
)
} else {
sharedMemberships = []
}
if (profileId !== user.id && !sharedMemberships.length) {
throw createError({ statusCode: 404, statusMessage: 'Profile not found in your campaigns.' })
}
const profiles = await stageTwoDatabase<ProfileRecord[]>(
`profiles?select=id,display_name,description,avatar_path,created_at,updated_at&id=eq.${profileId}&limit=1`,
)
if (!profiles[0]) throw createError({ statusCode: 404, statusMessage: 'Profile not found.' })
const campaignIds = sharedMemberships.map(member => member.campaign_id)
const campaigns = campaignIds.length
? await stageTwoDatabase<Array<{ id: string; title: string; status: string }>>(
`campaigns?select=id,title,status&id=in.(${campaignIds.join(',')})`,
)
: []
const campaignById = new Map(campaigns.map(campaign => [campaign.id, campaign]))
const sharedCampaigns = sharedMemberships.flatMap((membership) => {
const campaign = campaignById.get(membership.campaign_id)
return campaign ? [{
id: campaign.id,
title: campaign.title,
status: campaign.status,
role: membership.role,
joined_at: membership.joined_at,
}] : []
})
return {
profile: visibleProfile(profiles[0], useRuntimeConfig().supabaseUrl),
sharedCampaigns,
}
} catch (error) {
stageTwoApiError(error)
}
})

View File

@@ -0,0 +1,61 @@
import { requireStageTwoUser, stageTwoApiError, stageTwoDatabase } from '~/server/utils/stage-two-supabase'
interface UsageRow {
campaign_id: string | null
request_kind: string
input_tokens: number
output_tokens: number
cost_usd: number | string
latency_ms: number | null
created_at: string
}
export default defineEventHandler(async (event) => {
try {
const user = await requireStageTwoUser(event)
const since = new Date()
since.setUTCDate(since.getUTCDate() - 30)
const today = new Date()
today.setUTCHours(0, 0, 0, 0)
const [usage, quotaEvents] = await Promise.all([
stageTwoDatabase<UsageRow[]>(
`ai_usage?select=campaign_id,request_kind,input_tokens,output_tokens,cost_usd,latency_ms,created_at&user_id=eq.${user.id}&created_at=gte.${encodeURIComponent(since.toISOString())}&order=created_at.desc&limit=1000`,
),
stageTwoDatabase<Array<{ created_at: string }>>(
`ai_quota_events?select=created_at&user_id=eq.${user.id}&created_at=gte.${encodeURIComponent(today.toISOString())}`,
),
])
const campaignIds = [...new Set(usage.flatMap(row => row.campaign_id ? [row.campaign_id] : []))]
const campaigns = campaignIds.length
? await stageTwoDatabase<Array<{ id: string; title: string }>>(`campaigns?select=id,title&id=in.(${campaignIds.join(',')})`)
: []
const campaignNames = new Map(campaigns.map(campaign => [campaign.id, campaign.title]))
const summarize = (rows: UsageRow[]) => {
const latencies = rows.flatMap(row => row.latency_ms === null ? [] : [Number(row.latency_ms)])
return {
requests: rows.length,
inputTokens: rows.reduce((sum, row) => sum + Number(row.input_tokens), 0),
outputTokens: rows.reduce((sum, row) => sum + Number(row.output_tokens), 0),
costUsd: rows.reduce((sum, row) => sum + Number(row.cost_usd), 0),
averageLatencyMs: latencies.length ? Math.round(latencies.reduce((sum, value) => sum + value, 0) / latencies.length) : 0,
}
}
const grouped = new Map<string, UsageRow[]>()
for (const row of usage) {
const key = row.campaign_id ?? 'coauthor'
grouped.set(key, [...(grouped.get(key) ?? []), row])
}
return {
periodDays: 30,
quota: { usedToday: quotaEvents.length, dailyRequestLimit: 60, dailyTokenLimit: 250000 },
totals: summarize(usage),
groups: [...grouped].map(([id, rows]) => ({
id,
label: id === 'coauthor' ? 'World coauthor' : campaignNames.get(id) ?? 'Private campaign',
...summarize(rows),
})).sort((left, right) => right.costUsd - left.costUsd),
}
} catch (error) {
stageTwoApiError(error)
}
})

View File

@@ -1,9 +1,9 @@
import { CreateWorldRequestSchema, WorldStarterSchema, moderate13Plus } from '@dng/shared'
import { buildJsonCompletion, parseJsonCompletion } from '../../utils/ai-provider'
import { requireStageTwoUser } from '../../utils/stage-two-supabase'
import { requireStageTwoUser, stageSixConsumeAiQuota, stageSixRecordAiUsage } from '../../utils/stage-two-supabase'
export default defineEventHandler(async event => {
await requireStageTwoUser(event)
const user = await requireStageTwoUser(event)
const raw = await readBody(event)
const prompt = typeof raw?.prompt === 'string' ? raw.prompt : ''
const answers = raw?.answers && typeof raw.answers === 'object' ? raw.answers : {}
@@ -15,7 +15,7 @@ export default defineEventHandler(async event => {
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.',
system: 'Create an original 13+ TTRPG starting world. Return only valid JSON matching the supplied WorldStarter structure, with exactly 3 NPCs, 2 factions, and 3 distinct persistent AI companion heroes designed specifically for this universe. Give every companion complete playable mechanics, useful genre-appropriate equipment, and a distinctive persona. Do not use protected franchises.',
messages: request.messages,
schemaName: 'world_starter',
jsonSchema: WorldStarterSchema.toJSONSchema(),
@@ -24,15 +24,21 @@ export default defineEventHandler(async event => {
throw createError({ statusCode: 500, statusMessage: error instanceof Error ? error.message : 'Invalid AI provider configuration.' })
}
await stageSixConsumeAiQuota(user.id, null, 'world-preview')
const startedAt = Date.now()
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.' })
const payload = await response.json()
await stageSixRecordAiUsage({
userId: user.id, campaignId: null, requestKind: 'world-preview', completion, payload, latencyMs: Date.now() - startedAt,
}).catch(error => console.error('[web] could not record world preview usage:', error))
let world
try {
world = WorldStarterSchema.parse(parseJsonCompletion(await response.json()))
world = WorldStarterSchema.parse(parseJsonCompletion(payload))
} catch {
throw createError({ statusCode: 502, statusMessage: 'The coauthor returned an invalid world structure.' })
}

View File

@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest'
import { buildJsonCompletion, parseJsonCompletion, resolveAiProvider } from './ai-provider'
import { buildJsonCompletion, parseAiUsage, parseJsonCompletion, resolveAiProvider } from './ai-provider'
const request = { system: 'Return JSON.', messages: [{ role: 'user' as const, content: 'Create a world' }], schemaName: 'world', jsonSchema: { type: 'object' } }
@@ -22,4 +22,11 @@ describe('AI provider adapter', () => {
expect(() => parseJsonCompletion({ choices: [] })).toThrow()
expect(parseJsonCompletion({ choices: [{ message: { content: '{"ok":true}' } }] })).toEqual({ ok: true })
})
it('normalizes provider usage telemetry', () => {
expect(parseAiUsage({
choices: [{ message: { content: '{}' } }],
usage: { prompt_tokens: 120, completion_tokens: 30, cost: 0.0042 },
})).toEqual({ inputTokens: 120, outputTokens: 30, costUsd: 0.0042 })
})
})

View File

@@ -5,6 +5,14 @@ const CompletionResponseSchema = z.object({
choices: z.array(z.object({
message: z.object({ content: z.string().min(1) }),
})).min(1),
usage: z.object({
prompt_tokens: z.number().nonnegative().nullish(),
completion_tokens: z.number().nonnegative().nullish(),
input_tokens: z.number().nonnegative().nullish(),
output_tokens: z.number().nonnegative().nullish(),
cost: z.number().nonnegative().nullish(),
total_cost: z.number().nonnegative().nullish(),
}).optional(),
})
export type AiProvider = z.infer<typeof ProviderSchema>
@@ -26,10 +34,18 @@ export interface JsonCompletionRequest {
jsonSchema: Record<string, unknown>
}
interface JsonCompletion {
export interface JsonCompletion {
endpoint: string
headers: Record<string, string>
body: Record<string, unknown>
provider: AiProvider
model: string
}
export interface AiUsageSnapshot {
inputTokens: number
outputTokens: number
costUsd: number
}
const nonEmptyString = (value: unknown, name: string) => {
@@ -73,6 +89,8 @@ export function buildJsonCompletion(config: AiRuntimeConfig, request: JsonComple
if (provider.provider === 'deepseek') {
return {
endpoint: provider.endpoint,
provider: provider.provider,
model: provider.model,
headers: { Authorization: `Bearer ${provider.apiKey}`, 'Content-Type': 'application/json' },
body: {
...common,
@@ -88,6 +106,8 @@ export function buildJsonCompletion(config: AiRuntimeConfig, request: JsonComple
return {
endpoint: provider.endpoint,
provider: provider.provider,
model: provider.model,
headers: { Authorization: `Bearer ${provider.apiKey}`, 'Content-Type': 'application/json', 'X-Title': 'Dungeons & Ground' },
body: {
...common,
@@ -101,3 +121,12 @@ export function parseJsonCompletion(payload: unknown): unknown {
const completion = CompletionResponseSchema.parse(payload)
return JSON.parse(completion.choices[0]!.message.content)
}
export function parseAiUsage(payload: unknown): AiUsageSnapshot {
const completion = CompletionResponseSchema.parse(payload)
return {
inputTokens: Math.max(0, Math.trunc(completion.usage?.prompt_tokens ?? completion.usage?.input_tokens ?? 0)),
outputTokens: Math.max(0, Math.trunc(completion.usage?.completion_tokens ?? completion.usage?.output_tokens ?? 0)),
costUsd: Math.max(0, completion.usage?.cost ?? completion.usage?.total_cost ?? 0),
}
}

View File

@@ -0,0 +1,30 @@
import { describe, expect, it } from 'vitest'
import { CoauthorQuestionSchema, StoredCoauthorQuestionSchema } from './coauthor-question'
describe('coauthor question validation', () => {
it('accepts three useful, distinct answers', () => {
expect(CoauthorQuestionSchema.parse({
question: 'How will the party enter the sealed archive?',
options: ['Negotiate with its keeper', 'Search for a maintenance route', 'Force the main gate'],
})).toMatchObject({ options: ['Negotiate with its keeper', 'Search for a maintenance route', 'Force the main gate'] })
expect(CoauthorQuestionSchema.toJSONSchema()).toMatchObject({
type: 'object',
properties: { options: { type: 'array', minItems: 3, maxItems: 3 } },
})
})
it('rejects malformed JSON fragments and a repeated question', () => {
expect(() => CoauthorQuestionSchema.parse({
question: 'Как персонажи собираются решить проблему отсутствия наличных денег?',
options: [':', 'question', 'Как персонажи собираются решить проблему отсутствия наличных денег?'],
})).toThrow()
})
it('applies the same quality checks to a stored question', () => {
expect(() => StoredCoauthorQuestionSchema.parse({
id: 'question-2',
label: 'What makes the signal dangerous?',
options: ['The signal is alive', 'The signal is alive', 'Nobody knows yet'],
})).toThrow()
})
})

View File

@@ -0,0 +1,63 @@
import { z } from 'zod'
const reservedOptionValues = new Set([
'answer',
'answers',
'label',
'option',
'options',
'question',
])
function normalized(value: string) {
return value.trim().toLocaleLowerCase().replace(/\s+/g, ' ')
}
export const CoauthorQuestionSchema = z.object({
question: z.string().trim().min(5).max(300),
options: z.array(z.string().trim().min(2).max(100)).length(3),
}).superRefine((value, context) => {
const question = normalized(value.question)
const seen = new Set<string>()
value.options.forEach((option, index) => {
const candidate = normalized(option)
if (!/[\p{L}\p{N}]/u.test(candidate) || reservedOptionValues.has(candidate)) {
context.addIssue({
code: 'custom',
message: 'Each option must be a meaningful answer.',
path: ['options', index],
})
}
if (seen.has(candidate)) {
context.addIssue({
code: 'custom',
message: 'Answer options must be distinct.',
path: ['options', index],
})
}
if (candidate === question || (candidate.length >= 20 && question.includes(candidate))) {
context.addIssue({
code: 'custom',
message: 'An answer option must not repeat the question.',
path: ['options', index],
})
}
seen.add(candidate)
})
})
export const StoredCoauthorQuestionSchema = z.object({
id: z.string().trim().min(1).max(80),
label: z.string().trim().min(5).max(300),
options: CoauthorQuestionSchema.shape.options,
}).superRefine((value, context) => {
const result = CoauthorQuestionSchema.safeParse({ question: value.label, options: value.options })
for (const issue of result.error?.issues ?? []) {
context.addIssue({
code: 'custom',
message: issue.message,
path: issue.path[0] === 'question' ? ['label', ...issue.path.slice(1)] : issue.path,
})
}
})

View File

@@ -0,0 +1,29 @@
import { describe, expect, it } from 'vitest'
import { profileAvatarUrl, visibleProfile } from './profile'
const profile = {
id: '00000000-0000-0000-0000-000000000001',
display_name: 'Aster Vale',
description: 'Keeper of impossible maps.',
avatar_path: '00000000-0000-0000-0000-000000000001/avatar',
created_at: '2026-08-20T10:00:00.000Z',
updated_at: '2026-08-20T11:00:00.000Z',
}
describe('profile presentation', () => {
it('builds a cache-busted public avatar URL', () => {
expect(profileAvatarUrl(profile, 'https://example.supabase.co/')).toBe(
'https://example.supabase.co/storage/v1/object/public/profile-avatars/00000000-0000-0000-0000-000000000001/avatar?v=2026-08-20T11%3A00%3A00.000Z',
)
})
it('does not expose the storage path in a visible profile', () => {
const visible = visibleProfile(profile, 'https://example.supabase.co')
expect(visible).not.toHaveProperty('avatar_path')
expect(visible.avatar_url).toContain('/profile-avatars/')
})
it('returns no URL when the profile has no avatar', () => {
expect(profileAvatarUrl({ ...profile, avatar_path: null }, 'https://example.supabase.co')).toBeNull()
})
})

View File

@@ -0,0 +1,26 @@
export interface ProfileRecord {
id: string
display_name: string
description: string
avatar_path: string | null
created_at: string
updated_at: string
}
export function profileAvatarUrl(profile: Pick<ProfileRecord, 'avatar_path' | 'updated_at'>, supabaseUrl: string): string | null {
if (!profile.avatar_path) return null
const encodedPath = profile.avatar_path.split('/').map(encodeURIComponent).join('/')
const version = encodeURIComponent(profile.updated_at)
return `${supabaseUrl.replace(/\/$/, '')}/storage/v1/object/public/profile-avatars/${encodedPath}?v=${version}`
}
export function visibleProfile(profile: ProfileRecord, supabaseUrl: string) {
return {
id: profile.id,
display_name: profile.display_name,
description: profile.description,
avatar_url: profileAvatarUrl(profile, supabaseUrl),
created_at: profile.created_at,
updated_at: profile.updated_at,
}
}

View File

@@ -1,5 +1,5 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { stageTwoDatabase } from './stage-two-supabase'
import { deleteStageTwoCampaign, stageTwoDatabase } from './stage-two-supabase'
describe('stage-two Supabase client', () => {
afterEach(() => vi.unstubAllGlobals())
@@ -48,4 +48,64 @@ describe('stage-two Supabase client', () => {
Authorization: 'Bearer header.payload.signature',
})
})
it('deletes a campaign before deleting its orphaned world', async () => {
vi.stubGlobal('useRuntimeConfig', () => ({
supabaseUrl: 'https://example.supabase.co',
supabaseServiceRoleKey: 'service-role-key',
public: { supabaseAnonKey: 'anon-key' },
}))
const fetchMock = vi.fn(async (url: URL, init?: RequestInit) => {
if (url.pathname === '/rest/v1/ai_usage') return new Response(null, { status: 204 })
if (url.pathname === '/rest/v1/campaigns' && init?.method === 'DELETE') {
return Response.json([{ id: '11111111-1111-4111-8111-111111111111' }])
}
if (url.pathname === '/rest/v1/campaigns') return Response.json([])
if (url.pathname === '/rest/v1/worlds') return new Response(null, { status: 204 })
return new Response(null, { status: 404 })
})
vi.stubGlobal('fetch', fetchMock)
await deleteStageTwoCampaign(
'11111111-1111-4111-8111-111111111111',
'22222222-2222-4222-8222-222222222222',
)
expect(fetchMock.mock.calls.map(([url, init]) => [
(url as URL).pathname,
(url as URL).search,
init?.method ?? 'GET',
])).toEqual([
['/rest/v1/ai_usage', '?campaign_id=eq.11111111-1111-4111-8111-111111111111', 'PATCH'],
['/rest/v1/campaigns', '?select=id&id=eq.11111111-1111-4111-8111-111111111111', 'DELETE'],
['/rest/v1/campaigns', '?select=id&world_id=eq.22222222-2222-4222-8222-222222222222&limit=1', 'GET'],
['/rest/v1/worlds', '?id=eq.22222222-2222-4222-8222-222222222222', 'DELETE'],
])
})
it('keeps a world that is still used by another campaign', async () => {
vi.stubGlobal('useRuntimeConfig', () => ({
supabaseUrl: 'https://example.supabase.co',
supabaseServiceRoleKey: 'service-role-key',
public: { supabaseAnonKey: 'anon-key' },
}))
const fetchMock = vi.fn(async (url: URL, init?: RequestInit) => {
if (url.pathname === '/rest/v1/ai_usage') return new Response(null, { status: 204 })
if (url.pathname === '/rest/v1/campaigns' && init?.method === 'DELETE') {
return Response.json([{ id: '11111111-1111-4111-8111-111111111111' }])
}
if (url.pathname === '/rest/v1/campaigns') {
return Response.json([{ id: '33333333-3333-4333-8333-333333333333' }])
}
return new Response(null, { status: 404 })
})
vi.stubGlobal('fetch', fetchMock)
await deleteStageTwoCampaign(
'11111111-1111-4111-8111-111111111111',
'22222222-2222-4222-8222-222222222222',
)
expect(fetchMock).toHaveBeenCalledTimes(3)
})
})

View File

@@ -1,13 +1,19 @@
import { z } from 'zod'
import { randomUUID } from 'node:crypto'
import type { H3Event } from 'h3'
import { moderate13Plus } from '@dng/shared'
import { parseAiUsage, type JsonCompletion } from './ai-provider'
const UuidSchema = z.string().uuid()
const AuthEmailSchema = z.preprocess(
value => value === null || value === '' ? undefined : value,
z.string().email().optional(),
)
const AuthUserSchema = z.object({ id: UuidSchema, email: AuthEmailSchema })
const AuthUserSchema = z.object({
id: UuidSchema,
email: AuthEmailSchema,
is_anonymous: z.boolean().optional().default(false),
})
interface RequestOptions extends RequestInit {
prefer?: string
@@ -83,6 +89,41 @@ export function stageTwoRpc<T>(name: string, body: Record<string, unknown>): Pro
return stageTwoDatabase<T>(`rpc/${name}`, { method: 'POST', body: JSON.stringify(body) })
}
export function stageSixConsumeAiQuota(userId: string, campaignId: string | null, requestKind: string) {
return stageTwoRpc<{ userRemaining: number; campaignRemaining: number | null; tokenRemaining: number }>('stage_six_consume_ai_quota', {
p_user_id: userId,
p_campaign_id: campaignId,
p_request_kind: requestKind,
})
}
export async function stageSixRecordAiUsage(input: {
userId: string
campaignId: string | null
requestKind: string
completion: JsonCompletion
payload: unknown
latencyMs: number
}): Promise<void> {
const usage = parseAiUsage(input.payload)
await stageTwoDatabase('ai_usage', {
method: 'POST',
prefer: 'return=minimal',
body: JSON.stringify({
usage_key: `${input.requestKind}:${randomUUID()}`,
user_id: input.userId,
campaign_id: input.campaignId,
provider: input.completion.provider,
request_kind: input.requestKind,
model: input.completion.model,
input_tokens: usage.inputTokens,
output_tokens: usage.outputTokens,
cost_usd: usage.costUsd,
latency_ms: Math.max(0, Math.trunc(input.latencyMs)),
}),
})
}
export async function requireStageTwoUser(event: H3Event): Promise<StageTwoUser> {
const authorization = getHeader(event, 'authorization')
if (!authorization?.startsWith('Bearer ') || authorization.length <= 7) {
@@ -95,6 +136,9 @@ export async function requireStageTwoUser(event: H3Event): Promise<StageTwoUser>
if (!response.ok) throw createError({ statusCode: 401, statusMessage: 'The access token is invalid or expired.' })
const parsed = AuthUserSchema.safeParse(await response.json())
if (!parsed.success) throw createError({ statusCode: 401, statusMessage: 'Supabase returned an invalid user.' })
if (parsed.data.is_anonymous || !parsed.data.email) {
throw createError({ statusCode: 403, statusMessage: 'A verified email account is required.' })
}
const profiles = await stageTwoDatabase<Array<{ id: string }>>(`profiles?select=id&id=eq.${parsed.data.id}&limit=1`)
if (!profiles[0]) throw createError({ statusCode: 403, statusMessage: 'This account is not enabled for the alpha.' })
@@ -116,6 +160,32 @@ export async function requireCampaignAccess(campaignId: string, userId: string,
return { campaign, owner: false, memberId: members[0].id }
}
export async function deleteStageTwoCampaign(campaignId: string, worldId: string): Promise<void> {
// Usage is retained for account history, but its restrictive foreign key
// must no longer point at the campaign being removed.
await stageTwoDatabase(`ai_usage?campaign_id=eq.${campaignId}`, {
method: 'PATCH',
body: JSON.stringify({ campaign_id: null }),
})
// Campaign-owned rows use ON DELETE CASCADE. Deleting the parent lets
// Postgres remove them in dependency-safe order in a single statement.
const deleted = await stageTwoDatabase<Array<{ id: string }>>(
`campaigns?select=id&id=eq.${campaignId}`,
{ method: 'DELETE', prefer: 'return=representation' },
)
if (!deleted[0]) throw createError({ statusCode: 404, statusMessage: 'Campaign not found.' })
const otherCampaigns = await stageTwoDatabase<Array<{ id: string }>>(
`campaigns?select=id&world_id=eq.${worldId}&limit=1`,
)
if (!otherCampaigns[0]) {
// World entities cascade from the world; confirmed coauthor sessions are
// retained and automatically clear their world reference.
await stageTwoDatabase(`worlds?id=eq.${worldId}`, { method: 'DELETE' })
}
}
export function stageTwoApiError(error: unknown): never {
if (error && typeof error === 'object' && 'statusCode' in error) throw error
if (error instanceof StageTwoDatabaseError) {
@@ -126,7 +196,8 @@ export function stageTwoApiError(error: unknown): never {
})
}
const conflict = /duplicate key|already exists|not claimable/i.test(error.message)
throw createError({ statusCode: conflict ? 409 : error.status >= 500 ? 502 : 400, statusMessage: error.message })
const quota = /AI (?:rate limit|.*quota)/i.test(error.message)
throw createError({ statusCode: quota ? 429 : conflict ? 409 : error.status >= 500 ? 502 : 400, statusMessage: error.message })
}
if (error instanceof z.ZodError) {
throw createError({ statusCode: 400, statusMessage: error.issues.map(issue => issue.message).join('; ') })

View File

@@ -8,6 +8,20 @@ const worldFixture = () => ({
startingLocation: { id: 'location', kind: 'location' as const, name: 'Gate', summary: 'A station beyond known space.', tags: [] as string[], secrets: [] as string[] },
npcs: [1, 2, 3].map(index => ({ id: `npc-${index}`, kind: 'npc' as const, name: `NPC ${index}`, summary: 'A useful person with their own agenda.', tags: [], secrets: [] })),
factions: [1, 2].map(index => ({ id: `faction-${index}`, kind: 'faction' as const, name: `Faction ${index}`, summary: 'An organization pursuing a hidden objective.', tags: [], secrets: [] })),
companions: [1, 2, 3].map(index => ({
name: `Companion ${index}`,
concept: `A universe-specific companion with role ${index}.`,
abilities: { str: 10, dex: 12, con: 11, int: 13, wis: 12, cha: 10 },
hp: 11,
maxHp: 11,
defense: 12,
proficiency: 2,
inventory: ['Field kit'],
skillProficiencies: ['arcana', 'investigation', 'perception', 'survival'],
skillExpertise: ['investigation'],
savingThrowProficiencies: ['int', 'wis'],
persona: { voice: 'Distinctive and clear.', motivation: 'Help the party.', flaw: 'Has a private agenda.', bond: 'Believes in the party.' },
})),
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.',
@@ -34,7 +48,10 @@ describe('worker AI providers', () => {
})
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' })
await expect(generateWorld([{ role: 'user', content: 'Create a science fiction frontier.' }])).resolves.toMatchObject({
title: 'Test Reach',
companions: [{ name: 'Companion 1' }, { name: 'Companion 2' }, { name: 'Companion 3' }],
})
expect(fetchMock).toHaveBeenCalledWith('https://api.deepseek.com/chat/completions', expect.any(Object))
})

View File

@@ -12,6 +12,20 @@ interface AiProvider {
providerOptions?: Record<string, unknown>
}
export interface AiUsageMeasurement {
operation: string
provider: AiProvider['name']
model: string
inputTokens: number
outputTokens: number
costUsd: number
latencyMs: number
}
interface AiRequestOptions {
onUsage?: (measurement: AiUsageMeasurement) => void | Promise<void>
}
function getProvider(): AiProvider {
const provider = (process.env.AI_PROVIDER ?? 'openrouter').toLowerCase()
if (provider === 'deepseek') {
@@ -46,10 +60,11 @@ function assert13Plus(...texts: string[]): void {
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> {
async function structuredRequest<T>(name: string, schema: JsonSchema, messages: Array<{ role: string; content: string }>, parse: (value: unknown) => T, options: AiRequestOptions = {}): Promise<T> {
const provider = getProvider()
const controller = new AbortController()
const timeout = setTimeout(() => controller.abort(), 45_000)
const startedAt = Date.now()
try {
const response = await fetch(provider.endpoint, {
method: 'POST',
@@ -77,8 +92,21 @@ async function structuredRequest<T>(name: string, schema: JsonSchema, messages:
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 payload = await response.json() as {
choices?: Array<{ message?: { content?: string } }>
usage?: { prompt_tokens?: number | null; completion_tokens?: number | null; input_tokens?: number | null; output_tokens?: number | null; cost?: number | null; total_cost?: number | null }
}
const content = payload.choices?.[0]?.message?.content
const usage = payload.usage
await options.onUsage?.({
operation: name,
provider: provider.name,
model: provider.model,
inputTokens: Math.max(0, Math.trunc(usage?.prompt_tokens ?? usage?.input_tokens ?? 0)),
outputTokens: Math.max(0, Math.trunc(usage?.completion_tokens ?? usage?.output_tokens ?? 0)),
costUsd: Math.max(0, Number(usage?.cost ?? usage?.total_cost ?? 0)),
latencyMs: Math.max(0, Date.now() - startedAt),
})
if (!content) throw new Error('AI provider returned an empty response')
return parse(JSON.parse(content))
} finally {
@@ -86,41 +114,41 @@ async function structuredRequest<T>(name: string, schema: JsonSchema, messages:
}
}
export async function generateWorld(messages: Array<{ role: 'user' | 'assistant'; content: string }>): Promise<WorldStarter> {
export async function generateWorld(messages: Array<{ role: 'user' | 'assistant'; content: string }>, options: AiRequestOptions = {}): 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.' },
{ role: 'system', content: 'You are the Dungeons & Ground world coauthor. Create an original 13+ private SRD 5.2.1 TTRPG setting in any requested genre. Never use protected settings or characters. Return exactly three NPCs, two factions, and three distinct persistent AI companion heroes created specifically for this universe. Give each companion complete playable mechanics, exactly four fitting skill proficiencies, exactly two saving throw proficiencies, no more than one expertise chosen from their proficient skills, useful genre-appropriate equipment, and a persona that produces interesting party choices without overriding the human player. Make the opening immediately playable.' },
...messages,
], value => WorldStarterSchema.parse(value))
], value => WorldStarterSchema.parse(value), options)
// Moderate the entire typed object, including entity summaries, secrets and
// boundaries—not just the headline fields shown in the first preview.
assert13Plus(JSON.stringify(world))
return world
}
export async function planRound(input: RoundContext): Promise<RoundPlan> {
export async function planRound(input: RoundContext, options: AiRequestOptions = {}): Promise<RoundPlan> {
const plan = await structuredRequest('round_plan', RoundPlanSchema.toJSONSchema(), [
{ role: 'system', content: 'You plan one asynchronous TTRPG round. Human intents are authoritative and happen first. Then provide at most one action for each supplied aiCharacter, in the supplied order; never create AI actions for human-controlled characters. 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: 'system', content: 'You plan one asynchronous SRD 5.2.1 TTRPG round. Human intents are authoritative and happen first. Then provide at most one action for each supplied aiCharacter, in the supplied order; never create AI actions for human-controlled characters. Request a d20 test only when the outcome is uncertain: ability, skill, savingThrow, deathSavingThrow, attack, or initiative. Skill checks require a skill and DC; saving throws require an ability and DC; attacks require an active targetId and use that target\'s server-owned Armor Class. Character rules profiles—not you—own proficiency, expertise, temporary HP, death saves, exhaustion, resistances and limited resources. Request a deathSavingThrow only for an unstable actor at 0 HP. Request separate damage or healing dice only after an applicable action; every damage event needs its SRD damageType. A resource event may consume only a named resource present on the character. A rest event uses item short or long. Never invent dice results and never directly mutate mechanical state. The server applies conditions, resistance, vulnerability, immunity, critical damage, HP and randomness. Use only supplied entity IDs for world-state events: relationship changes require distinct actorId and targetId plus a delta from -20 to 20; quest changes target a supplied quest entity and use item hidden, active, completed, or failed; a scene transition is a narrative event with item scene and may target a supplied location. Include only changes caused by this round.' },
{ role: 'user', content: JSON.stringify(input) },
], value => RoundPlanSchema.parse(value))
], value => RoundPlanSchema.parse(value), options)
assert13Plus(JSON.stringify(plan.aiActions), JSON.stringify(plan.proposedEvents))
return plan
}
export async function narrateRound(input: { context: RoundContext; actionSequence: Array<{ phase: 'human' | 'ai'; characterId: string; action: string }>; rolls: DiceRoll[]; permittedEvents: RoundPlan['proposedEvents'] }): Promise<RoundResolution> {
export async function narrateRound(input: { context: RoundContext; actionSequence: Array<{ phase: 'human' | 'ai'; characterId: string; action: string }>; rolls: DiceRoll[]; permittedEvents: RoundPlan['proposedEvents'] }, options: AiRequestOptions = {}): Promise<RoundResolution> {
const resolution = await structuredRequest('round_resolution', RoundResolutionSchema.toJSONSchema(), [
{ role: 'system', content: 'Narrate the resolved TTRPG round in the exact supplied actionSequence order: all human actions first, then AI/delegated hero actions. Use the authoritative dice results without changing 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))
], value => RoundResolutionSchema.parse(value), options)
assert13Plus(JSON.stringify(resolution))
return resolution
}
export async function summarizeStory(input: { previousSummary: string | null; rounds: Array<{ number: number; narration: string }> }): Promise<StorySummary> {
export async function summarizeStory(input: { previousSummary: string | null; rounds: Array<{ number: number; narration: string }> }, options: AiRequestOptions = {}): Promise<StorySummary> {
const result = await structuredRequest('story_summary', StorySummarySchema.toJSONSchema(), [
{ role: 'system', content: 'Update a durable TTRPG campaign summary. Preserve established facts, goals, relationships and unresolved consequences. Use only the supplied previous summary and round narrations. Be concise and do not invent details.' },
{ role: 'user', content: JSON.stringify(input) },
], value => StorySummarySchema.parse(value))
], value => StorySummarySchema.parse(value), options)
assert13Plus(result.summary)
return result
}

View File

@@ -1,10 +1,10 @@
import './env'
import { Worker } from 'bullmq'
import IORedis from 'ioredis'
import { applyMechanicalEvents, bindMechanicalEventsToRolls, resolveCheck } from '@dng/game-engine'
import { applyMechanicalEvents, bindMechanicalEventsToRolls, resolveChecks } from '@dng/game-engine'
import { CharacterSchema, PlayerIntentSchema, makeId } from '@dng/shared'
import { generateWorld, narrateRound, planRound, summarizeStory } from './ai'
import { buildActionSequence, buildRoundContext, sanitizeRoundMemory, shouldUpdateStorySummary, validateAndOrderRoundPlan } from './orchestration'
import { generateWorld, narrateRound, planRound, summarizeStory, type AiUsageMeasurement } from './ai'
import { buildActionSequence, buildRetrievalText, buildRoundContext, sanitizeRoundMemory, shouldUpdateStorySummary, validateAndOrderRoundPlan } from './orchestration'
const redisUrl = process.env.REDIS_URL
const supabaseUrl = process.env.SUPABASE_URL
@@ -28,6 +28,12 @@ if (!supabaseUrl || !serviceKey) {
job_type: JobType
entity_id: string
}
interface RetrievedContext {
memories?: Array<{ summary: string; importance: number; tags: string[]; entityIds: string[] }>
entities?: Array<{ id: string; kind: 'location' | 'npc' | 'faction' | 'quest'; name: string; summary: string; tags: string[] }>
relationships?: Array<{ sourceEntityId: string; targetEntityId: string; score: number; notes: string }>
activeGoals?: Array<{ questEntityId: string; name: string; status: 'hidden' | 'active' | 'completed' | 'failed'; summary: string; tags: string[] }>
}
class SupabaseRequestError extends Error {
constructor(
@@ -67,38 +73,42 @@ if (!supabaseUrl || !serviceKey) {
return databaseRequest<T[]>(`${table}?${new URLSearchParams(query)}`)
}
async function persistStorySummary(jobId: string, campaignId: string, throughRound: number, summary: string): Promise<void> {
let lastError: unknown
for (let attempt = 1; attempt <= 3; attempt += 1) {
function usageRecorder(job: JobPayload, userId: string | null, campaignId: string | null) {
return async (measurement: AiUsageMeasurement): Promise<void> => {
try {
await databaseRequest('rpc/stage_two_upsert_story_summary', {
await databaseRequest('ai_usage?on_conflict=usage_key', {
method: 'POST',
headers: { Prefer: 'resolution=merge-duplicates,return=minimal' },
body: JSON.stringify({
p_job_id: jobId,
p_campaign_id: campaignId,
p_through_round: throughRound,
p_summary: summary,
usage_key: `${job.id}:${measurement.operation}:${makeId('usage')}`,
user_id: userId,
campaign_id: campaignId,
job_id: job.id,
provider: measurement.provider,
request_kind: measurement.operation,
model: measurement.model,
input_tokens: measurement.inputTokens,
output_tokens: measurement.outputTokens,
cost_usd: measurement.costUsd,
latency_ms: measurement.latencyMs,
}),
})
return
} catch (error) {
lastError = error
if (attempt < 3) await new Promise(resolve => setTimeout(resolve, attempt * 250))
// Usage telemetry must never trigger a second paid model call. Quota
// reservations are persisted before the job is queued, so a temporary
// telemetry failure cannot bypass rate limits.
console.error(`[worker] could not record ${measurement.operation} usage for job ${job.id}:`, error)
}
}
throw lastError
}
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)
const [session] = await selectRows<{ owner_id: string; messages: Array<{ role: 'user' | 'assistant'; content: string }> }>('coauthor_sessions', {
select: 'owner_id,messages', id: `eq.${job.entityId}`, limit: '1',
})
if (!session) throw new Error('Coauthor session not found')
const messages = job.messages ?? session.messages
const world = await generateWorld(messages, { onUsage: usageRecorder(job, session.owner_id, null) })
await databaseRequest(`coauthor_sessions?id=eq.${encodeURIComponent(job.entityId)}`, {
method: 'PATCH',
body: JSON.stringify({ status: 'ready', generated_world: world, updated_at: new Date().toISOString() }),
@@ -118,12 +128,9 @@ if (!supabaseUrl || !serviceKey) {
if (!round) throw new Error('Round not found')
if (round.status === 'resolved') return { duplicate: true }
const [rawCharacters, rawIntents, rawMemories, rawRecentRounds, rawStorySummaries] = await Promise.all([
const [rawCharacters, rawIntents, rawRecentRounds, rawStorySummaries] = 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; importance: number; tags: string[]; entity_ids: string[] }>('memories', {
select: 'summary,importance,tags,entity_ids', campaign_id: `eq.${round.campaign_id}`, order: 'importance.desc,created_at.desc', limit: '24',
}),
selectRows<{ number: number; narration: string; next_prompt: string | null }>('rounds', {
select: 'number,narration,next_prompt', campaign_id: `eq.${round.campaign_id}`, status: 'eq.resolved', order: 'number.desc', limit: '2',
}),
@@ -135,42 +142,49 @@ if (!supabaseUrl || !serviceKey) {
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, persona: row.persona,
defense: row.defense, proficiency: row.proficiency, inventory: row.inventory, statuses: row.statuses,
persona: row.persona, rules: row.rules_state,
}))
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 retrieval = await databaseRequest<RetrievedContext>('rpc/stage_six_retrieve_context', {
method: 'POST',
body: JSON.stringify({
p_campaign_id: round.campaign_id,
p_search_text: buildRetrievalText({ scene, intents, characters }),
p_limit: 8,
}),
})
const context = buildRoundContext({
scene,
storySummary: rawStorySummaries[0]?.summary ?? null,
recentRounds: rawRecentRounds.map(previous => ({ number: previous.number, narration: previous.narration, nextPrompt: previous.next_prompt })),
intents,
characters,
memories: rawMemories.map(memory => ({
summary: memory.summary, importance: memory.importance, tags: memory.tags, entityIds: memory.entity_ids,
})),
})
const plan = validateAndOrderRoundPlan(await planRound(context), context)
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)
memories: retrieval.memories ?? [],
entities: retrieval.entities ?? [],
relationships: retrieval.relationships ?? [],
activeGoals: retrieval.activeGoals ?? [],
})
const recordUsage = usageRecorder(job, String(round.campaigns.owner_id), String(round.campaign_id))
const plan = validateAndOrderRoundPlan(await planRound(context, { onUsage: recordUsage }), context)
const rolls = resolveChecks(plan.checks, characters)
const permittedEvents = bindMechanicalEventsToRolls(plan.proposedEvents, rolls)
const resolution = await narrateRound({ context, actionSequence: buildActionSequence(context, plan), rolls, permittedEvents })
const resolution = await narrateRound({ context, actionSequence: buildActionSequence(context, plan), rolls, permittedEvents }, { onUsage: recordUsage })
const safeEvents = resolution.events.filter(event => permittedEvents.some(permitted => JSON.stringify(permitted) === JSON.stringify(event)))
const applied = applyMechanicalEvents(characters, safeEvents)
const applied = applyMechanicalEvents(characters, safeEvents, rolls)
const shouldSummarize = shouldUpdateStorySummary(Number(round.number))
const summary = shouldSummarize
? await summarizeStory({
previousSummary: context.storySummary,
rounds: [...context.recentRounds.map(previous => ({ number: previous.number, narration: previous.narration })), { number: Number(round.number), narration: resolution.narration }],
})
}, { onUsage: recordUsage })
: null
await databaseRequest(job.claimToken ? 'rpc/commit_claimed_round_resolution' : 'rpc/commit_round_resolution', {
await databaseRequest(job.claimToken ? 'rpc/commit_claimed_stage_six_round_resolution' : 'rpc/commit_stage_six_round_resolution', {
method: 'POST',
body: JSON.stringify({
p_round_id: round.id,
@@ -178,19 +192,22 @@ if (!supabaseUrl || !serviceKey) {
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: sanitizeRoundMemory(resolution.memory),
p_character_states: applied.characters.map(character => ({
id: character.id,
hp: character.hp,
inventory: character.inventory,
statuses: character.statuses,
rulesState: character.rules,
})),
p_memory: sanitizeRoundMemory(resolution.memory, new Set([
...context.activeCharacters.map(character => character.id),
...context.entities.map(entity => entity.id),
])),
p_story_summary: summary?.summary ?? null,
p_idempotency_key: job.id,
...(job.claimToken ? { p_worker_id: job.claimToken } : {}),
}),
})
if (summary) {
try {
await persistStorySummary(job.id, round.campaign_id, Number(round.number), summary.summary)
} catch (error) {
console.error(`[worker] round ${round.id} committed but story summary persistence failed:`, error)
}
}
return { narration: resolution.narration, rolls: rolls.length }
}

View File

@@ -0,0 +1,64 @@
import { describe, expect, it } from 'vitest'
import type { Character, PlayerIntent } from '@dng/shared'
import { buildRoundContext } from './orchestration'
const hero: Character = {
id: 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa',
name: 'Mara Venn',
concept: 'A careful investigator who remembers promises.',
controller: 'human',
userId: 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb',
abilities: { str: 10, dex: 12, con: 10, int: 14, wis: 13, cha: 9 },
hp: 10,
maxHp: 10,
defense: 12,
proficiency: 2,
inventory: [],
statuses: [],
}
const scenarios = Array.from({ length: 20 }, (_, index) => {
const number = index + 1
const tag = `clue-${number.toString().padStart(2, '0')}`
return {
name: `memory regression ${number.toString().padStart(2, '0')}`,
tag,
fact: `In the opening round, Mara promised the keeper to preserve ${tag}.`,
action: `Mara invokes ${tag} and asks the keeper to honor their opening-round agreement.`,
}
})
describe('20-scenario AI memory context regression suite', () => {
it.each(scenarios)('$name retrieves the expected early fact after twenty rounds', ({ tag, fact, action }) => {
const intent: PlayerIntent = {
id: 'cccccccc-cccc-4ccc-8ccc-cccccccccccc',
roundId: 'dddddddd-dddd-4ddd-8ddd-dddddddddddd',
memberId: 'eeeeeeee-eeee-4eee-8eee-eeeeeeeeeeee',
characterId: hero.id,
action,
ready: true,
createdAt: '2026-01-01T00:00:00.000Z',
updatedAt: '2026-01-01T00:00:00.000Z',
}
const context = buildRoundContext({
scene: `Round 21 returns to the keeper's chamber near ${tag}.`,
storySummary: 'The party survived twenty rounds without replaying the full transcript.',
recentRounds: Array.from({ length: 20 }, (_, round) => ({ number: round + 1, narration: `Resolved round ${round + 1}.` })),
intents: [intent],
characters: [hero],
memories: [
{ summary: fact, importance: 5, tags: [tag], entityIds: [] },
...Array.from({ length: 24 }, (_, decoy) => ({
summary: `Unrelated later observation ${decoy + 1}.`,
importance: (decoy % 4) + 1,
tags: [`decoy-${decoy + 1}`],
entityIds: [],
})),
],
})
expect(context.recentRounds.map(round => round.number)).toEqual([19, 20])
expect(context.memories.map(memory => memory.summary)).toContain(fact)
expect(context.memories).toHaveLength(8)
})
})

View File

@@ -1,6 +1,6 @@
import { describe, expect, it } from 'vitest'
import type { Character, PlayerIntent, RoundPlan } from '@dng/shared'
import { buildActionSequence, buildRoundContext, sanitizeRoundMemory, shouldUpdateStorySummary, validateAndOrderRoundPlan } from './orchestration'
import { buildActionSequence, buildRetrievalText, buildRoundContext, sanitizeRoundMemory, shouldUpdateStorySummary, validateAndOrderRoundPlan } from './orchestration'
const character = (id: string, controller: Character['controller']): Character => ({
id, controller, name: id, concept: 'A campaign hero', userId: controller === 'human' ? 'user' : null,
@@ -41,16 +41,64 @@ describe('round orchestration', () => {
}, context)).toThrow('Unknown check actor outsider')
})
it('requires complete SRD check inputs before rolling', () => {
const context = buildRoundContext({
scene: 'Scene', recentRounds: [], intents: [],
characters: [character('hero', 'human'), character('foe', 'ai')], memories: [],
})
const basePlan = { aiActions: [], proposedEvents: [], relevantMemoryQueries: [] }
expect(() => validateAndOrderRoundPlan({
...basePlan, checks: [{ actorId: 'hero', kind: 'skill', mode: 'normal', reason: 'Search' }],
}, context)).toThrow('Skill checks require a skill')
expect(() => validateAndOrderRoundPlan({
...basePlan, checks: [{ actorId: 'hero', kind: 'attack', mode: 'normal', reason: 'Strike' }],
}, context)).toThrow('Attack rolls require a target')
})
it('schedules durable story summaries every third completed round', () => {
expect([1, 2, 3, 4, 5, 6].filter(shouldUpdateStorySummary)).toEqual([3, 6])
})
it('drops model-authored memory labels that PostgreSQL cannot cast to uuid[]', () => {
const knownId = '9ea0ef06-34bf-4cf3-8f2f-af1a65180d0e'
expect(sanitizeRoundMemory({
summary: 'The party learned who controls the gate.',
importance: 4,
tags: ['gate'],
entityIds: ['the-gatekeeper', '9ea0ef06-34bf-4cf3-8f2f-af1a65180d0e', '9ea0ef06-34bf-4cf3-8f2f-af1a65180d0e'],
})?.entityIds).toEqual(['9ea0ef06-34bf-4cf3-8f2f-af1a65180d0e'])
entityIds: ['the-gatekeeper', knownId, knownId, '8ea0ef06-34bf-4cf3-8f2f-af1a65180d0e'],
}, new Set([knownId]))?.entityIds).toEqual([knownId])
})
it('builds a bounded retrieval query from only current-round context', () => {
const text = buildRetrievalText({
scene: 'The sealed moon gate is humming.',
intents: [intent('ready', 'hero', '2026-01-01T00:00:00.000Z'), { ...intent('draft', 'hero', '2026-01-01T00:00:01.000Z'), ready: false }],
characters: [character('hero', 'human')],
})
expect(text).toContain('sealed moon gate')
expect(text).toContain('Action ready')
expect(text).not.toContain('Action draft')
})
it('accepts typed relationship, quest, and scene projections only for known entities', () => {
const questId = '11111111-1111-4111-8111-111111111111'
const locationId = '22222222-2222-4222-8222-222222222222'
const context = buildRoundContext({
scene: 'Scene', recentRounds: [], intents: [], characters: [character('hero', 'human')], memories: [],
entities: [
{ id: questId, kind: 'quest', name: 'Open the gate', summary: 'Find the key.', tags: ['gate'] },
{ id: locationId, kind: 'location', name: 'Moon Gate', summary: 'A sealed arch.', tags: ['gate'] },
],
})
const validated = validateAndOrderRoundPlan({
checks: [], aiActions: [], relevantMemoryQueries: [],
proposedEvents: [
{ type: 'relationship', actorId: 'hero', targetId: questId, value: 3, item: null, damageType: null, description: 'The hero becomes invested.' },
{ type: 'quest', actorId: 'hero', targetId: questId, value: null, item: 'active', damageType: null, description: 'The gate must be opened.' },
{ type: 'narrative', actorId: null, targetId: locationId, value: null, item: 'scene', damageType: null, description: 'The party reaches the Moon Gate.' },
],
}, context)
expect(validated.proposedEvents).toHaveLength(3)
})
})

View File

@@ -2,7 +2,10 @@ import {
RoundContextSchema,
RoundPlanSchema,
type Character,
type ContextEntity,
type PlayerIntent,
type QuestState,
type RelationshipState,
type RoundContext,
type RoundPlan,
type RoundResolution,
@@ -17,6 +20,17 @@ export interface RoundContextInput {
intents: PlayerIntent[]
characters: Character[]
memories: Array<{ summary: string; importance?: number; tags?: string[]; entityIds?: string[] }>
entities?: ContextEntity[]
relationships?: RelationshipState[]
activeGoals?: QuestState[]
}
export function buildRetrievalText(input: Pick<RoundContextInput, 'scene' | 'intents' | 'characters'>): string {
return [
input.scene,
...input.intents.filter(intent => intent.ready).map(intent => intent.action),
...input.characters.flatMap(character => [character.name, character.concept]),
].join(' ').replace(/\s+/g, ' ').trim().slice(0, 4_000)
}
export function buildRoundContext(input: RoundContextInput): RoundContext {
@@ -51,6 +65,9 @@ export function buildRoundContext(input: RoundContextInput): RoundContext {
humanIntents,
aiCharacters,
activeCharacters: input.characters,
entities: input.entities ?? [],
relationships: input.relationships ?? [],
activeGoals: input.activeGoals ?? [],
memories: rankedMemories,
})
}
@@ -58,19 +75,45 @@ export function buildRoundContext(input: RoundContextInput): RoundContext {
export function validateAndOrderRoundPlan(planInput: unknown, context: RoundContext): RoundPlan {
const plan = RoundPlanSchema.parse(planInput)
const characterIds = new Set(context.activeCharacters.map(character => character.id))
const entityIds = new Set(context.entities.map(entity => entity.id))
const worldStateIds = new Set([...characterIds, ...entityIds])
const entityKinds = new Map(context.entities.map(entity => [entity.id, entity.kind]))
const aiOrder = new Map(context.aiCharacters.map((character, index) => [character.id, index]))
const seenAiActors = new Set<string>()
for (const check of plan.checks) {
if (!characterIds.has(check.actorId)) throw new Error(`Unknown check actor ${check.actorId}`)
if (check.targetId && !characterIds.has(check.targetId)) throw new Error(`Unknown check target ${check.targetId}`)
if (check.kind === 'skill' && !check.skill) throw new Error('Skill checks require a skill')
if (['ability', 'savingThrow'].includes(check.kind) && !check.ability) throw new Error(`${check.kind} checks require an ability`)
if (['ability', 'skill', 'savingThrow'].includes(check.kind) && check.difficulty === undefined) {
throw new Error(`${check.kind} checks require a Difficulty Class`)
}
if (check.kind === 'deathSavingThrow' && check.targetId) throw new Error('Death saving throws cannot target another character')
if (check.kind === 'attack' && !check.targetId) throw new Error('Attack rolls require a target')
if (['damage', 'healing'].includes(check.kind) && !check.targetId) throw new Error(`${check.kind} rolls require a target`)
}
for (const event of plan.proposedEvents) {
if (event.actorId && !characterIds.has(event.actorId)) throw new Error(`Unknown event actor ${event.actorId}`)
if (event.targetId && !characterIds.has(event.targetId)) throw new Error(`Unknown event target ${event.targetId}`)
if (['damage', 'healing', 'inventory', 'status'].includes(event.type) && !event.targetId) {
const mechanical = ['damage', 'healing', 'inventory', 'status', 'temporaryHp', 'resource', 'rest'].includes(event.type)
if (event.actorId && !(mechanical ? characterIds : worldStateIds).has(event.actorId)) throw new Error(`Unknown event actor ${event.actorId}`)
if (event.targetId && !(mechanical ? characterIds : worldStateIds).has(event.targetId)) throw new Error(`Unknown event target ${event.targetId}`)
if (['damage', 'healing', 'inventory', 'status', 'temporaryHp', 'resource', 'rest'].includes(event.type) && !event.targetId) {
throw new Error(`${event.type} event requires a target`)
}
if (event.type === 'damage' && !event.damageType) throw new Error('damage events require a damage type')
if (event.type === 'resource' && !event.item) throw new Error('resource events require a resource name')
if (event.type === 'rest' && !['short', 'long'].includes(String(event.item))) throw new Error('rest events require short or long')
if (event.type === 'relationship') {
if (!event.actorId || !event.targetId || event.actorId === event.targetId) throw new Error('relationship events require two distinct known entities')
if (event.value === null || event.value < -20 || event.value > 20) throw new Error('relationship changes must be between -20 and 20')
}
if (event.type === 'quest') {
if (!event.targetId || entityKinds.get(event.targetId) !== 'quest') throw new Error('quest events require a known quest entity')
if (!['hidden', 'active', 'completed', 'failed'].includes(String(event.item))) throw new Error('quest events require a valid quest status')
}
if (event.type === 'narrative' && event.item === 'scene' && event.targetId && entityKinds.get(event.targetId) !== 'location') {
throw new Error('scene events may only target a known location')
}
}
const aiActions = plan.aiActions
@@ -101,10 +144,12 @@ export function shouldUpdateStorySummary(roundNumber: number): boolean {
* are useful prose but cannot be cast to that column and would roll back an
* otherwise valid round. Preserve only storage-compatible references.
*/
export function sanitizeRoundMemory(memory: RoundResolution['memory']): RoundResolution['memory'] {
export function sanitizeRoundMemory(memory: RoundResolution['memory'], allowedEntityIds?: ReadonlySet<string>): RoundResolution['memory'] {
if (!memory) return null
return {
...memory,
entityIds: [...new Set(memory.entityIds.filter(id => postgresUuidPattern.test(id)))],
entityIds: [...new Set(memory.entityIds.filter(id =>
postgresUuidPattern.test(id) && (!allowedEntityIds || allowedEntityIds.has(id)),
))],
}
}

View File

@@ -23,7 +23,8 @@
"test:unit": "vitest run",
"test:watch": "vitest",
"typecheck": "pnpm -r --if-present typecheck",
"smoke:multiplayer": "node scripts/smoke-multiplayer.mjs"
"smoke:multiplayer": "node scripts/smoke-multiplayer.mjs",
"e2e:full-party": "node scripts/smoke-multiplayer.mjs"
},
"dependencies": {
"postgres": "3.4.7"

View File

@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest'
import { abilityModifier, applyMechanicalEvents, bindMechanicalEventsToRolls, resolveCheck, shouldCloseRound, type RandomSource } from './index'
import { abilityModifier, applyMechanicalEvents, bindMechanicalEventsToRolls, proficiencyBonus, resolveCheck, resolveChecks, shouldCloseRound, type RandomSource } from './index'
import type { Character } from '@dng/shared'
const fixed: RandomSource = { integer: () => 12 }
@@ -18,7 +18,7 @@ describe('game engine', () => {
it('keeps dice server-owned and auditable', () => {
const roll = resolveCheck({ actorId: 'hero', kind: 'ability', ability: 'dex', difficulty: 15, mode: 'normal', reason: 'Leap' }, hero, fixed)
expect(roll.rolls).toEqual([12])
expect(roll.total).toBe(17)
expect(roll.total).toBe(15)
expect(roll.success).toBe(true)
expect(roll.id).toMatch(/^[0-9a-f]{8}-(?:[0-9a-f]{4}-){3}[0-9a-f]{12}$/i)
})
@@ -30,10 +30,60 @@ describe('game engine', () => {
hero,
{ integer: () => values.shift()! },
)
expect(roll.formula).toBe('2d20kl1+5')
expect(roll.formula).toBe('2d20kl1+3')
expect(roll.kept).toEqual([4])
})
it('applies proficiency only to proficient skills and saving throws', () => {
const perception = resolveCheck({
actorId: 'hero', kind: 'skill', skill: 'perception', proficient: true,
difficulty: 12, mode: 'normal', reason: 'Spot an ambush',
}, hero, fixed)
const constitutionSave = resolveCheck({
actorId: 'hero', kind: 'savingThrow', ability: 'con', proficient: false,
difficulty: 12, mode: 'normal', reason: 'Endure poison',
}, hero, fixed)
expect(perception.modifier).toBe(1)
expect(constitutionSave.modifier).toBe(1)
})
it('uses server-owned Armor Class and natural attack outcomes', () => {
const target: Character = { ...hero, id: 'target', defense: 30 }
const naturalTwenty = resolveCheck({
actorId: 'hero', targetId: 'target', kind: 'attack', ability: 'str',
difficulty: 1, mode: 'normal', reason: 'Sword strike',
}, hero, { integer: () => 20 }, target)
const naturalOne = resolveCheck({
actorId: 'hero', targetId: 'target', kind: 'attack', ability: 'str',
difficulty: 1, mode: 'normal', reason: 'Sword strike',
}, hero, { integer: () => 1 }, { ...target, defense: 1 })
expect(naturalTwenty.difficulty).toBe(30)
expect(naturalTwenty.success).toBe(true)
expect(naturalOne.difficulty).toBe(1)
expect(naturalOne.success).toBe(false)
})
it('skips damage on a miss and doubles only critical damage dice', () => {
const target: Character = { ...hero, id: 'target', defense: 14 }
const checks = [
{ actorId: 'hero', targetId: 'target', kind: 'attack' as const, ability: 'str' as const, mode: 'normal' as const, reason: 'Sword strike' },
{ actorId: 'hero', targetId: 'target', kind: 'damage' as const, dice: '1d6+2', mode: 'normal' as const, reason: 'Sword damage' },
]
const missed = resolveChecks(checks, [hero, target], { integer: () => 1 })
expect(missed).toHaveLength(1)
expect(missed[0]?.checkKind).toBe('attack')
const values = [20, 4, 5]
const critical = resolveChecks(checks, [hero, target], { integer: () => values.shift()! })
expect(critical).toHaveLength(2)
expect(critical[1]?.formula).toBe('2d6+2')
expect(critical[1]?.rolls).toEqual([4, 5])
expect(critical[1]?.total).toBe(11)
})
it('replaces model-proposed damage with the authoritative roll total', () => {
const damage = resolveCheck(
{ actorId: 'hero', targetId: 'target', kind: 'damage', dice: '1d6+2', mode: 'normal', reason: 'Strike' },
@@ -41,13 +91,13 @@ describe('game engine', () => {
{ integer: () => 4 },
)
const events = bindMechanicalEventsToRolls([
{ type: 'damage', actorId: 'hero', targetId: 'target', value: 999, item: null, description: 'Strike' },
{ type: 'damage', actorId: 'hero', targetId: 'target', value: 999, item: null, damageType: 'slashing', description: 'Strike' },
], [damage])
expect(events[0]?.value).toBe(6)
})
it('clamps mechanical state', () => {
const result = applyMechanicalEvents([hero], [{ type: 'damage', actorId: null, targetId: 'hero', value: 99, item: null, description: 'Catastrophic hit' }])
const result = applyMechanicalEvents([hero], [{ type: 'damage', actorId: null, targetId: 'hero', value: 99, item: null, damageType: 'force', description: 'Catastrophic hit' }])
expect(result.characters[0]?.hp).toBe(0)
expect(result.audit).toHaveLength(1)
})
@@ -57,4 +107,73 @@ describe('game engine', () => {
expect(shouldCloseRound(['a', 'b'], ['a', 'b'])).toBe(true)
expect(shouldCloseRound(['a', 'b'], [], true)).toBe(true)
})
it('derives proficiency and expertise from the server-owned rules profile', () => {
const trained: Character = {
...hero,
rules: {
version: 2, level: 9, skillProficiencies: ['perception'], skillExpertise: ['perception'],
savingThrowProficiencies: ['con'], temporaryHp: 0, deathSaves: { successes: 0, failures: 0 },
exhaustion: 1, damageResistances: [], damageVulnerabilities: [], damageImmunities: [],
resources: {}, legacyProficiencyFallback: false,
},
}
const roll = resolveCheck({
actorId: 'hero', kind: 'skill', skill: 'perception', proficient: false,
difficulty: 10, mode: 'normal', reason: 'Notice the rune',
}, trained, fixed)
expect(proficiencyBonus(9)).toBe(4)
// WIS -1 + expertise 8 - exhaustion 2.
expect(roll.modifier).toBe(5)
})
it('applies resistance, temporary HP, zero-HP state and healing recovery', () => {
const guarded: Character = {
...hero,
hp: 4,
rules: {
version: 2, level: 1, skillProficiencies: [], skillExpertise: [], savingThrowProficiencies: [],
temporaryHp: 2, deathSaves: { successes: 0, failures: 0 }, exhaustion: 0,
damageResistances: ['fire'], damageVulnerabilities: [], damageImmunities: [],
resources: {}, legacyProficiencyFallback: false,
},
}
const damaged = applyMechanicalEvents([guarded], [{
type: 'damage', actorId: null, targetId: 'hero', value: 12, item: null,
damageType: 'fire', description: 'Flames engulf the hero',
}]).characters[0]!
expect(damaged.rules?.temporaryHp).toBe(0)
expect(damaged.hp).toBe(0)
expect(damaged.statuses).toContain('unconscious')
const healed = applyMechanicalEvents([damaged], [{
type: 'healing', actorId: null, targetId: 'hero', value: 4, item: null,
damageType: null, description: 'An ally restores the hero',
}]).characters[0]!
expect(healed.hp).toBe(4)
expect(healed.statuses).not.toContain('unconscious')
})
it('tracks natural death-save outcomes without model-authored state changes', () => {
const dying: Character = {
...hero,
hp: 0,
statuses: ['unconscious'],
rules: {
version: 2, level: 1, skillProficiencies: [], skillExpertise: [], savingThrowProficiencies: [],
temporaryHp: 0, deathSaves: { successes: 0, failures: 0 }, exhaustion: 0,
damageResistances: [], damageVulnerabilities: [], damageImmunities: [],
resources: {}, legacyProficiencyFallback: false,
},
}
const deathRoll = resolveCheck({
actorId: 'hero', kind: 'deathSavingThrow', mode: 'normal', reason: 'Cling to life',
}, dying, { integer: () => 20 })
const recovered = applyMechanicalEvents([dying], [], [deathRoll]).characters[0]!
expect(recovered.hp).toBe(1)
expect(recovered.statuses).not.toContain('unconscious')
expect(recovered.rules?.deathSaves).toEqual({ successes: 0, failures: 0 })
})
})

View File

@@ -1,5 +1,5 @@
import { randomInt, randomUUID } from 'node:crypto'
import type { AbilityKey, Character, CheckRequest, DiceRoll, ProposedEvent } from '@dng/shared'
import type { AbilityKey, Character, CharacterRules, CheckRequest, DamageType, DiceRoll, ProposedEvent, SkillKey } from '@dng/shared'
export interface RandomSource {
integer(min: number, max: number): number
@@ -16,6 +16,11 @@ export function abilityModifier(score: number): number {
return Math.floor((score - 10) / 2)
}
export function proficiencyBonus(level: number): number {
if (!Number.isInteger(level) || level < 1 || level > 20) throw new Error('Character level must be between 1 and 20')
return 2 + Math.floor((level - 1) / 4)
}
export function rollDice(formula: string, random: RandomSource = secureRandom) {
const match = /^(\d+)d(\d+)(?:([+-])(\d+))?$/.exec(formula)
if (!match) throw new Error(`Unsupported dice formula: ${formula}`)
@@ -51,13 +56,95 @@ export function bindMechanicalEventsToRolls(events: ProposedEvent[], rolls: Dice
})
}
const skillAbilities: Record<SkillKey, AbilityKey> = {
acrobatics: 'dex',
animalHandling: 'wis',
arcana: 'int',
athletics: 'str',
deception: 'cha',
history: 'int',
insight: 'wis',
intimidation: 'cha',
investigation: 'int',
medicine: 'wis',
nature: 'int',
perception: 'wis',
performance: 'cha',
persuasion: 'cha',
religion: 'int',
sleightOfHand: 'dex',
stealth: 'dex',
survival: 'wis',
}
function abilityForCheck(request: CheckRequest): AbilityKey {
if (request.kind === 'skill' && request.skill) return skillAbilities[request.skill]
if (request.ability) return request.ability
if (request.kind === 'initiative') return 'dex'
return 'str'
}
export function resolveCheck(request: CheckRequest, actor: Character, random: RandomSource = secureRandom): DiceRoll {
function usesProficiency(request: CheckRequest): boolean {
// The alpha treats ordinary weapon attacks as proficient unless the planner
// explicitly marks an improvised or unfamiliar attack otherwise.
if (request.kind === 'attack') return request.proficient !== false
return request.proficient === true
}
function proficiencyMultiplier(request: CheckRequest, actor: Character): number {
const rules = actor.rules
if (!rules) return usesProficiency(request) ? 1 : 0
if (request.kind === 'skill' && request.skill) {
if (rules.skillExpertise.includes(request.skill)) return 2
if (rules.skillProficiencies.includes(request.skill)) return 1
return rules.legacyProficiencyFallback && request.proficient === true ? 1 : 0
}
if (request.kind === 'savingThrow' && request.ability) {
if (rules.savingThrowProficiencies.includes(request.ability)) return 1
return rules.legacyProficiencyFallback && request.proficient === true ? 1 : 0
}
if (request.kind === 'attack') return request.proficient === false ? 0 : 1
return rules.legacyProficiencyFallback && request.proficient === true ? 1 : 0
}
function normalizedStatuses(character: Character): Set<string> {
return new Set(character.statuses.map(status => status.trim().toLowerCase()))
}
function effectiveRollMode(request: CheckRequest, actor: Character, target?: Character): CheckRequest['mode'] {
let advantage = request.mode === 'advantage' ? 1 : 0
let disadvantage = request.mode === 'disadvantage' ? 1 : 0
const actorStatuses = normalizedStatuses(actor)
const targetStatuses = target ? normalizedStatuses(target) : new Set<string>()
if (['ability', 'skill', 'attack'].includes(request.kind) && actorStatuses.has('poisoned')) disadvantage += 1
if (request.kind === 'attack') {
if (actorStatuses.has('blinded') || actorStatuses.has('restrained') || actorStatuses.has('prone')) disadvantage += 1
if (actorStatuses.has('invisible')) advantage += 1
if (targetStatuses.has('blinded') || targetStatuses.has('restrained') || targetStatuses.has('paralyzed')
|| targetStatuses.has('petrified') || targetStatuses.has('stunned') || targetStatuses.has('unconscious')) advantage += 1
if (targetStatuses.has('invisible')) disadvantage += 1
}
if (request.kind === 'savingThrow' && request.ability === 'dex' && actorStatuses.has('restrained')) disadvantage += 1
if (advantage && disadvantage) return 'normal'
if (advantage) return 'advantage'
if (disadvantage) return 'disadvantage'
return 'normal'
}
function automaticallyFailedSave(request: CheckRequest, actor: Character): boolean {
if (request.kind !== 'savingThrow' || !request.ability || !['str', 'dex'].includes(request.ability)) return false
const statuses = normalizedStatuses(actor)
return ['paralyzed', 'petrified', 'stunned', 'unconscious'].some(status => statuses.has(status))
}
export function resolveCheck(
request: CheckRequest,
actor: Character,
random: RandomSource = secureRandom,
target?: Character,
): DiceRoll {
if (request.kind === 'damage' || request.kind === 'healing') {
const rolled = rollDice(request.dice ?? '1d6', random)
return {
@@ -78,56 +165,243 @@ export function resolveCheck(request: CheckRequest, actor: Character, random: Ra
}
}
if (request.kind === 'deathSavingThrow') {
const statuses = normalizedStatuses(actor)
if (actor.hp !== 0 || statuses.has('stable') || statuses.has('dead')) {
throw new Error('Death saving throws require an unstable character at 0 HP')
}
const result = random.integer(1, 20)
return {
id: randomUUID(), checkKind: request.kind, formula: '1d20', rolls: [result], kept: [result],
modifier: 0, total: result, difficulty: 10, success: result >= 10,
actorId: actor.id, targetId: null, createdAt: new Date().toISOString(),
}
}
const ability = abilityForCheck(request)
const diceCount = request.mode === 'normal' ? 1 : 2
const mode = effectiveRollMode(request, actor, target)
const diceCount = mode === 'normal' ? 1 : 2
const rolls = Array.from({ length: diceCount }, () => random.integer(1, 20))
const keptValue = request.mode === 'advantage' ? Math.max(...rolls) : request.mode === 'disadvantage' ? Math.min(...rolls) : rolls[0]!
const modifier = abilityModifier(actor.abilities[ability]) + actor.proficiency
const keptValue = mode === 'advantage' ? Math.max(...rolls) : mode === 'disadvantage' ? Math.min(...rolls) : rolls[0]!
const proficiency = actor.rules ? proficiencyBonus(actor.rules.level) : actor.proficiency
const exhaustionPenalty = actor.rules ? actor.rules.exhaustion * 2 : 0
const modifier = abilityModifier(actor.abilities[ability]) + proficiency * proficiencyMultiplier(request, actor) - exhaustionPenalty
const total = keptValue + modifier
const difficulty = request.difficulty ?? null
if (request.kind === 'attack' && (!request.targetId || !target || target.id !== request.targetId)) {
throw new Error('Attack rolls require the active target character')
}
const difficulty = request.kind === 'attack' ? target!.defense : request.difficulty ?? null
const automaticAttackOutcome = request.kind === 'attack'
? keptValue === 20 ? true : keptValue === 1 ? false : null
: null
const automaticSaveOutcome = automaticallyFailedSave(request, actor) ? false : null
return {
id: randomUUID(),
checkKind: request.kind,
formula: request.mode === 'normal'
formula: mode === 'normal'
? `1d20${modifier >= 0 ? '+' : ''}${modifier}`
: `2d20${request.mode === 'advantage' ? 'kh1' : 'kl1'}${modifier >= 0 ? '+' : ''}${modifier}`,
: `2d20${mode === 'advantage' ? 'kh1' : 'kl1'}${modifier >= 0 ? '+' : ''}${modifier}`,
rolls,
kept: [keptValue],
modifier,
total,
difficulty,
success: difficulty === null ? null : total >= difficulty,
success: difficulty === null ? null : automaticAttackOutcome ?? automaticSaveOutcome ?? total >= difficulty,
actorId: actor.id,
targetId: request.targetId ?? null,
createdAt: new Date().toISOString(),
}
}
function criticalDamageFormula(formula: string): string {
const match = /^(\d+)d(\d+)(?:([+-])(\d+))?$/.exec(formula)
if (!match) throw new Error(`Unsupported dice formula: ${formula}`)
const modifier = match[3] ? `${match[3]}${match[4]}` : ''
return `${Number(match[1]) * 2}d${match[2]}${modifier}`
}
/**
* Resolves an ordered round plan while enforcing attack-to-damage rules.
* A missed attack never rolls damage; a natural 20 doubles only the damage
* dice and not the flat modifier.
*/
export function resolveChecks(
requests: CheckRequest[],
characters: Character[],
random: RandomSource = secureRandom,
): DiceRoll[] {
const rolls: DiceRoll[] = []
for (const originalRequest of requests) {
const actor = characters.find(character => character.id === originalRequest.actorId)
if (!actor) throw new Error(`Unknown check actor ${originalRequest.actorId}`)
const target = originalRequest.targetId
? characters.find(character => character.id === originalRequest.targetId)
: undefined
let request = originalRequest
if (request.kind === 'damage') {
const attack = [...rolls].reverse().find(roll =>
roll.checkKind === 'attack'
&& roll.actorId === request.actorId
&& roll.targetId === request.targetId,
)
if (attack?.success === false) continue
if (attack?.kept[0] === 20) {
request = { ...request, dice: criticalDamageFormula(request.dice ?? '1d6') }
}
}
rolls.push(resolveCheck(request, actor, random, target))
}
return rolls
}
export interface AppliedState {
characters: Character[]
audit: Array<{ event: ProposedEvent; before: unknown; after: unknown }>
}
export function applyMechanicalEvents(characters: Character[], events: ProposedEvent[]): AppliedState {
const next = characters.map(character => ({ ...character, inventory: [...character.inventory], statuses: [...character.statuses] }))
function cloneRules(rules: CharacterRules | undefined): CharacterRules | undefined {
return rules ? structuredClone(rules) : undefined
}
function addStatus(character: Character, status: string): void {
if (!character.statuses.some(current => current.toLowerCase() === status.toLowerCase())) character.statuses.push(status)
}
function removeStatus(character: Character, status: string): void {
character.statuses = character.statuses.filter(current => current.toLowerCase() !== status.toLowerCase())
}
function adjustedDamage(value: number, damageType: DamageType | null | undefined, rules: CharacterRules | undefined): number {
let damage = Math.max(0, value)
if (!damageType || !rules) return damage
if (rules.damageImmunities.includes(damageType)) return 0
if (rules.damageResistances.includes(damageType)) damage = Math.floor(damage / 2)
if (rules.damageVulnerabilities.includes(damageType)) damage *= 2
return damage
}
function applyDeathSavingThrows(characters: Character[], rolls: DiceRoll[]): void {
for (const roll of rolls.filter(candidate => candidate.checkKind === 'deathSavingThrow')) {
const character = characters.find(candidate => candidate.id === roll.actorId)
if (!character?.rules || character.hp !== 0 || normalizedStatuses(character).has('dead')) continue
const natural = roll.kept[0]
if (natural === 20) {
character.hp = 1
character.rules.deathSaves = { successes: 0, failures: 0 }
removeStatus(character, 'unconscious')
removeStatus(character, 'stable')
continue
}
if (roll.success) character.rules.deathSaves.successes = Math.min(3, character.rules.deathSaves.successes + 1)
else character.rules.deathSaves.failures = Math.min(3, character.rules.deathSaves.failures + (natural === 1 ? 2 : 1))
if (character.rules.deathSaves.successes >= 3) {
character.rules.deathSaves = { successes: 0, failures: 0 }
addStatus(character, 'stable')
}
if (character.rules.deathSaves.failures >= 3) {
removeStatus(character, 'unconscious')
removeStatus(character, 'stable')
addStatus(character, 'dead')
}
}
}
function applyRest(character: Character, rest: string): void {
if (!character.rules || normalizedStatuses(character).has('dead')) return
const longRest = rest.toLowerCase() === 'long'
for (const resource of Object.values(character.rules.resources)) {
if (longRest ? resource.recovery !== 'none' : resource.recovery === 'shortRest') resource.current = resource.max
}
if (!longRest || character.hp < 1) return
character.hp = character.maxHp
character.rules.temporaryHp = 0
character.rules.exhaustion = Math.max(0, character.rules.exhaustion - 1)
character.rules.deathSaves = { successes: 0, failures: 0 }
}
export function applyMechanicalEvents(characters: Character[], events: ProposedEvent[], rolls: DiceRoll[] = []): AppliedState {
const next = characters.map(character => ({
...character,
inventory: [...character.inventory],
statuses: [...character.statuses],
rules: cloneRules(character.rules),
}))
const audit: AppliedState['audit'] = []
applyDeathSavingThrows(next, rolls)
for (const event of events) {
if (!['damage', 'healing', 'inventory', 'status'].includes(event.type)) continue
if (!['damage', 'healing', 'inventory', 'status', 'temporaryHp', 'resource', 'rest'].includes(event.type)) continue
const target = next.find(character => character.id === event.targetId)
if (!target) throw new Error(`Unknown event target: ${event.targetId}`)
const before = structuredClone(target)
if (event.type === 'damage') target.hp = Math.max(0, target.hp - Math.max(0, event.value ?? 0))
if (event.type === 'healing') target.hp = Math.min(target.maxHp, target.hp + Math.max(0, event.value ?? 0))
if (event.type === 'damage') {
const damage = adjustedDamage(event.value ?? 0, event.damageType, target.rules)
const priorHp = target.hp
const absorbed = Math.min(target.rules?.temporaryHp ?? 0, damage)
if (target.rules) target.rules.temporaryHp -= absorbed
const hpDamage = damage - absorbed
const remainingDamageAtZero = Math.max(0, hpDamage - priorHp)
const criticalAtZero = priorHp === 0 && rolls.some(roll =>
roll.checkKind === 'attack'
&& roll.actorId === event.actorId
&& roll.targetId === event.targetId
&& roll.kept[0] === 20,
)
target.hp = Math.max(0, priorHp - hpDamage)
if (target.hp === 0 && !normalizedStatuses(target).has('dead')) {
if (remainingDamageAtZero >= target.maxHp) {
removeStatus(target, 'unconscious')
removeStatus(target, 'stable')
addStatus(target, 'dead')
} else {
addStatus(target, 'unconscious')
if (priorHp === 0 && target.rules) {
removeStatus(target, 'stable')
target.rules.deathSaves.failures = Math.min(3, target.rules.deathSaves.failures + (criticalAtZero ? 2 : 1))
if (target.rules.deathSaves.failures >= 3) {
removeStatus(target, 'unconscious')
addStatus(target, 'dead')
}
}
}
}
}
if (event.type === 'healing' && !normalizedStatuses(target).has('dead')) {
target.hp = Math.min(target.maxHp, target.hp + Math.max(0, event.value ?? 0))
if (target.hp > 0) {
removeStatus(target, 'unconscious')
removeStatus(target, 'stable')
if (target.rules) target.rules.deathSaves = { successes: 0, failures: 0 }
}
}
if (event.type === 'inventory' && event.item) {
if ((event.value ?? 1) >= 0 && !target.inventory.includes(event.item)) target.inventory.push(event.item)
if ((event.value ?? 1) < 0) target.inventory = target.inventory.filter(item => item !== event.item)
}
if (event.type === 'status' && event.item) {
if ((event.value ?? 1) >= 0 && !target.statuses.includes(event.item)) target.statuses.push(event.item)
if ((event.value ?? 1) < 0) target.statuses = target.statuses.filter(status => status !== event.item)
if (event.item.toLowerCase() === 'exhaustion' && target.rules) {
target.rules.exhaustion = Math.max(0, Math.min(6, target.rules.exhaustion + (event.value ?? 1)))
if (target.rules.exhaustion >= 6) addStatus(target, 'dead')
} else {
if ((event.value ?? 1) >= 0) addStatus(target, event.item)
if ((event.value ?? 1) < 0) removeStatus(target, event.item)
}
}
if (event.type === 'temporaryHp' && target.rules) {
target.rules.temporaryHp = Math.max(target.rules.temporaryHp, Math.max(0, event.value ?? 0))
}
if (event.type === 'resource' && event.item && target.rules?.resources[event.item]) {
const resource = target.rules.resources[event.item]!
resource.current = Math.max(0, Math.min(resource.max, resource.current + (event.value ?? -1)))
}
if (event.type === 'rest' && event.item) applyRest(target, event.item)
audit.push({ event, before, after: structuredClone(target) })
}

View File

@@ -10,6 +10,9 @@ const draft = {
defense: 13,
proficiency: 2,
inventory: ['Signal lens', 'Field toolkit'],
skillProficiencies: ['arcana', 'history', 'investigation', 'perception'],
skillExpertise: ['investigation'],
savingThrowProficiencies: ['int', 'wis'],
persona: {
voice: 'Precise, with dry humor.',
motivation: 'Prove the relay is speaking from the future.',

View File

@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest'
import { PlayerIntentSchema } from './index'
import { CharacterRulesSchema, CheckRequestSchema, PlayerIntentSchema } from './index'
describe('shared database contracts', () => {
it('accepts PostgREST timestamptz values with an explicit UTC offset', () => {
@@ -16,4 +16,33 @@ describe('shared database contracts', () => {
expect(intent.ready).toBe(true)
})
it('accepts typed SRD d20 tests', () => {
expect(CheckRequestSchema.parse({
actorId: 'hero', kind: 'skill', skill: 'perception', proficient: true,
difficulty: 15, mode: 'advantage', reason: 'Search the trapped hall',
})).toMatchObject({ kind: 'skill', skill: 'perception', proficient: true })
expect(CheckRequestSchema.parse({
actorId: 'hero', kind: 'savingThrow', ability: 'wis', proficient: false,
difficulty: 13, mode: 'normal', reason: 'Resist the whisper',
})).toMatchObject({ kind: 'savingThrow', ability: 'wis' })
})
it('validates versioned SRD character mechanics', () => {
const rules = CharacterRulesSchema.parse({
version: 2,
level: 5,
skillProficiencies: ['perception'],
skillExpertise: ['perception'],
savingThrowProficiencies: ['wis'],
resources: { focus: { current: 2, max: 3, recovery: 'shortRest' } },
})
expect(rules.temporaryHp).toBe(0)
expect(rules.deathSaves).toEqual({ successes: 0, failures: 0 })
expect(() => CharacterRulesSchema.parse({
...rules,
skillExpertise: ['stealth'],
})).toThrow('expertise requires skill proficiency')
})
})

View File

@@ -10,6 +10,70 @@ export const abilityKeys = ['str', 'dex', 'con', 'int', 'wis', 'cha'] as const
export const AbilityKeySchema = z.enum(abilityKeys)
export type AbilityKey = z.infer<typeof AbilityKeySchema>
export const skillKeys = [
'acrobatics', 'animalHandling', 'arcana', 'athletics', 'deception', 'history',
'insight', 'intimidation', 'investigation', 'medicine', 'nature', 'perception',
'performance', 'persuasion', 'religion', 'sleightOfHand', 'stealth', 'survival',
] as const
export const SkillKeySchema = z.enum(skillKeys)
export type SkillKey = z.infer<typeof SkillKeySchema>
export const damageTypeKeys = [
'acid', 'bludgeoning', 'cold', 'fire', 'force', 'lightning', 'necrotic',
'piercing', 'poison', 'psychic', 'radiant', 'slashing', 'thunder',
] as const
export const DamageTypeSchema = z.enum(damageTypeKeys)
export type DamageType = z.infer<typeof DamageTypeSchema>
export const conditionKeys = [
'blinded', 'charmed', 'deafened', 'frightened', 'grappled', 'incapacitated',
'invisible', 'paralyzed', 'petrified', 'poisoned', 'prone', 'restrained',
'stunned', 'unconscious', 'stable', 'dead',
] as const
export const ConditionKeySchema = z.enum(conditionKeys)
export type ConditionKey = z.infer<typeof ConditionKeySchema>
export const CharacterResourceSchema = z.object({
current: z.number().int().min(0),
max: z.number().int().min(0),
recovery: z.enum(['none', 'shortRest', 'longRest']).default('longRest'),
}).strict().refine(value => value.current <= value.max, {
message: 'resource current value must not exceed its maximum',
path: ['current'],
})
/**
* Versioned SRD mechanics that older campaigns did not persist. The legacy
* fallback is explicit so migrated games keep their previous proficiency
* behavior without allowing it in newly-authored rules profiles.
*/
export const CharacterRulesSchema = z.object({
version: z.literal(2),
level: z.number().int().min(1).max(20).default(1),
skillProficiencies: z.array(SkillKeySchema).max(18).default([]),
skillExpertise: z.array(SkillKeySchema).max(18).default([]),
savingThrowProficiencies: z.array(AbilityKeySchema).max(6).default([]),
temporaryHp: z.number().int().min(0).max(999).default(0),
deathSaves: z.object({
successes: z.number().int().min(0).max(3),
failures: z.number().int().min(0).max(3),
}).strict().default({ successes: 0, failures: 0 }),
exhaustion: z.number().int().min(0).max(6).default(0),
damageResistances: z.array(DamageTypeSchema).max(13).default([]),
damageVulnerabilities: z.array(DamageTypeSchema).max(13).default([]),
damageImmunities: z.array(DamageTypeSchema).max(13).default([]),
resources: z.record(z.string().trim().min(1).max(80), CharacterResourceSchema).default({}),
legacyProficiencyFallback: z.boolean().default(false),
}).strict().superRefine((value, context) => {
const trained = new Set(value.skillProficiencies)
for (const skill of value.skillExpertise) {
if (!trained.has(skill)) {
context.addIssue({ code: 'custom', message: 'expertise requires skill proficiency', path: ['skillExpertise'] })
}
}
})
export type CharacterRules = z.infer<typeof CharacterRulesSchema>
export const AbilityScoresSchema = z.object({
str: z.number().int().min(1).max(30),
dex: z.number().int().min(1).max(30),
@@ -30,21 +94,6 @@ export const WorldEntitySchema = z.object({
})
export type WorldEntity = z.infer<typeof WorldEntitySchema>
export const WorldStarterSchema = z.object({
title: z.string().min(3).max(100),
genre: z.string().min(2).max(80),
tone: z.string().min(2).max(160),
premise: z.string().min(20).max(1800),
contentBoundaries: z.array(z.string().max(120)).min(1).max(8),
startingLocation: WorldEntitySchema.extend({ kind: z.literal('location') }),
npcs: z.array(WorldEntitySchema.extend({ kind: z.literal('npc') })).length(3),
factions: z.array(WorldEntitySchema.extend({ kind: z.literal('faction') })).length(2),
hook: z.string().min(20).max(1200),
hiddenThreat: z.string().min(10).max(1000),
openingScene: z.string().min(40).max(2400),
})
export type WorldStarter = z.infer<typeof WorldStarterSchema>
export const CharacterPersonaSchema = z.object({
voice: z.string().trim().min(1).max(240),
motivation: z.string().trim().min(1).max(300),
@@ -62,6 +111,9 @@ export const CharacterDraftSchema = z.object({
defense: z.number().int().min(1).max(40),
proficiency: z.number().int().min(1).max(10),
inventory: z.array(z.string().trim().min(1).max(120)).max(12),
skillProficiencies: z.array(SkillKeySchema).max(8).default([]),
skillExpertise: z.array(SkillKeySchema).max(4).default([]),
savingThrowProficiencies: z.array(AbilityKeySchema).max(3).default([]),
persona: CharacterPersonaSchema,
}).strict().refine(value => value.hp <= value.maxHp, {
message: 'hp must not exceed maxHp',
@@ -69,6 +121,22 @@ export const CharacterDraftSchema = z.object({
})
export type CharacterDraft = z.infer<typeof CharacterDraftSchema>
export const WorldStarterSchema = z.object({
title: z.string().min(3).max(100),
genre: z.string().min(2).max(80),
tone: z.string().min(2).max(160),
premise: z.string().min(20).max(1800),
contentBoundaries: z.array(z.string().max(120)).min(1).max(8),
startingLocation: WorldEntitySchema.extend({ kind: z.literal('location') }),
npcs: z.array(WorldEntitySchema.extend({ kind: z.literal('npc') })).length(3),
factions: z.array(WorldEntitySchema.extend({ kind: z.literal('faction') })).length(2),
companions: z.array(CharacterDraftSchema).length(3),
hook: z.string().min(20).max(1200),
hiddenThreat: z.string().min(10).max(1000),
openingScene: z.string().min(40).max(2400),
})
export type WorldStarter = z.infer<typeof WorldStarterSchema>
export const CharacterSchema = z.object({
id: z.string().min(1),
name: z.string().min(1).max(80),
@@ -83,6 +151,7 @@ export const CharacterSchema = z.object({
inventory: z.array(z.string().max(120)).max(50).default([]),
statuses: z.array(z.string().max(80)).max(12).default([]),
persona: z.record(z.string(), z.unknown()).optional(),
rules: CharacterRulesSchema.optional(),
})
export type Character = z.infer<typeof CharacterSchema>
@@ -98,6 +167,32 @@ export const PlayerIntentSchema = z.object({
})
export type PlayerIntent = z.infer<typeof PlayerIntentSchema>
export const ContextEntitySchema = WorldEntitySchema.pick({
id: true,
kind: true,
name: true,
summary: true,
tags: true,
})
export type ContextEntity = z.infer<typeof ContextEntitySchema>
export const RelationshipStateSchema = z.object({
sourceEntityId: z.string().min(1),
targetEntityId: z.string().min(1),
score: z.number().int().min(-100).max(100),
notes: z.string().max(1200),
})
export type RelationshipState = z.infer<typeof RelationshipStateSchema>
export const QuestStateSchema = z.object({
questEntityId: z.string().min(1),
name: z.string().min(1).max(120),
status: z.enum(['hidden', 'active', 'completed', 'failed']),
summary: z.string().min(1).max(1200),
tags: z.array(z.string().max(40)).max(12).default([]),
})
export type QuestState = z.infer<typeof QuestStateSchema>
export const RoundContextSchema = z.object({
scene: z.string().max(6000),
storySummary: z.string().max(6000).nullable(),
@@ -109,6 +204,9 @@ export const RoundContextSchema = z.object({
humanIntents: z.array(PlayerIntentSchema).max(8),
aiCharacters: z.array(CharacterSchema).max(8),
activeCharacters: z.array(CharacterSchema).max(16),
entities: z.array(ContextEntitySchema).max(12).default([]),
relationships: z.array(RelationshipStateSchema).max(20).default([]),
activeGoals: z.array(QuestStateSchema).max(8).default([]),
memories: z.array(z.object({
summary: z.string().min(1).max(1200),
importance: z.number().int().min(1).max(5).optional(),
@@ -120,12 +218,15 @@ export type RoundContext = z.infer<typeof RoundContextSchema>
export const CheckRequestSchema = z.object({
actorId: z.string().min(1),
kind: z.enum(['ability', 'attack', 'initiative', 'damage', 'healing']),
kind: z.enum(['ability', 'skill', 'savingThrow', 'deathSavingThrow', 'attack', 'initiative', 'damage', 'healing']),
ability: AbilityKeySchema.optional(),
skill: SkillKeySchema.optional(),
proficient: z.boolean().optional(),
difficulty: z.number().int().min(1).max(40).optional(),
targetId: z.string().optional(),
mode: z.enum(['normal', 'advantage', 'disadvantage']).default('normal'),
dice: z.string().regex(/^\d+d\d+(?:[+-]\d+)?$/).optional(),
damageType: DamageTypeSchema.optional(),
reason: z.string().min(1).max(500),
})
export type CheckRequest = z.infer<typeof CheckRequestSchema>
@@ -147,11 +248,12 @@ export const DiceRollSchema = z.object({
export type DiceRoll = z.infer<typeof DiceRollSchema>
export const ProposedEventSchema = z.object({
type: z.enum(['narrative', 'relationship', 'quest', 'inventory', 'status', 'damage', 'healing']),
type: z.enum(['narrative', 'relationship', 'quest', 'inventory', 'status', 'damage', 'healing', 'temporaryHp', 'resource', 'rest']),
actorId: z.string().nullable().default(null),
targetId: z.string().nullable().default(null),
value: z.number().int().nullable().default(null),
item: z.string().max(120).nullable().default(null),
damageType: DamageTypeSchema.nullable().default(null),
description: z.string().min(1).max(1000),
})
export type ProposedEvent = z.infer<typeof ProposedEventSchema>

View File

@@ -38,14 +38,30 @@ async function jsonRequest(url, options = {}) {
return payload
}
async function createGuest(label) {
const session = await jsonRequest(`${supabaseUrl}/auth/v1/signup`, {
function serviceHeaders() {
return {
apikey: serviceKey,
...(serviceKey.split('.').length === 3 ? { Authorization: `Bearer ${serviceKey}` } : {}),
'Content-Type': 'application/json',
}
}
async function createEmailAccount(label) {
const email = `smoke-${randomUUID()}@example.com`
const password = `Smoke-${randomUUID()}!aA1`
const user = await jsonRequest(`${supabaseUrl}/auth/v1/admin/users`, {
method: 'POST',
headers: serviceHeaders(),
body: JSON.stringify({ email, password, email_confirm: true, user_metadata: { display_name: label } }),
})
const session = await jsonRequest(`${supabaseUrl}/auth/v1/token?grant_type=password`, {
method: 'POST',
headers: { apikey: anonKey, 'Content-Type': 'application/json' },
body: JSON.stringify({ data: { display_name: label }, gotrue_meta_security: {} }),
body: JSON.stringify({ email, password }),
})
if (!session?.access_token || !session?.user?.id) throw new Error('[smoke] Supabase returned an incomplete anonymous session.')
return { token: session.access_token, id: session.user.id }
if (!session?.access_token || !session?.user?.id) throw new Error('[smoke] Supabase returned an incomplete email session.')
if (session.user.id !== user.id) throw new Error('[smoke] Email login returned the wrong user.')
return { token: session.access_token, id: session.user.id, email }
}
function appRequest(path, token, options = {}) {
@@ -56,15 +72,10 @@ function appRequest(path, token, options = {}) {
}
async function servicePatch(path, body) {
const serviceAuthorization = serviceKey.split('.').length === 3
? { Authorization: `Bearer ${serviceKey}` }
: {}
await jsonRequest(`${supabaseUrl}/rest/v1/${path}`, {
method: 'PATCH',
headers: {
apikey: serviceKey,
...serviceAuthorization,
'Content-Type': 'application/json',
...serviceHeaders(),
Prefer: 'return=representation',
},
body: JSON.stringify(body),
@@ -72,10 +83,10 @@ async function servicePatch(path, body) {
}
const suffix = Date.now().toString(36).toUpperCase()
console.log('[smoke] Creating two isolated guest sessions…')
console.log('[smoke] Creating two confirmed email accounts…')
const [owner, player] = await Promise.all([
createGuest(`Smoke Owner ${suffix}`),
createGuest(`Smoke Player ${suffix}`),
createEmailAccount(`Smoke Owner ${suffix}`),
createEmailAccount(`Smoke Player ${suffix}`),
])
console.log('[smoke] Creating a private world and campaign through the public API…')
@@ -101,6 +112,17 @@ const world = {
entity('faction', 'The Reclaimers', 'Salvagers who want the station stripped for parts.'),
entity('faction', 'The Seed Vault', 'An automated network protecting the last viable specimens.'),
],
companions: [1, 2, 3].map(index => ({
name: `Garden Companion ${index}`,
concept: `A universe-specific caretaker hero assigned to garden sector ${index}.`,
abilities: { str: 10, dex: 12, con: 11, int: 13, wis: 12, cha: 10 },
hp: 11,
maxHp: 11,
defense: 12,
proficiency: 2,
inventory: ['Garden survey kit'],
persona: { voice: 'Calm and observant.', motivation: 'Protect the living garden.', flaw: 'Trusts the station too much.', bond: 'Treats the party as new growth.' },
})),
hook: 'The airlock opens only after both visitors speak names the station already knows.',
hiddenThreat: 'The garden predicts visitors by growing imperfect biological copies of them.',
openingScene: 'The inner airlock opens on warm rain and rows of silver trees. A voice welcomes both salvagers by name, then asks why one of them has returned without the other.',
@@ -112,6 +134,12 @@ const { worldId } = await appRequest(`/api/v1/coauthor/sessions/${sessionId}/con
const { campaignId } = await appRequest('/api/v1/campaigns', owner.token, {
method: 'POST', body: JSON.stringify({ worldId, title: `Smoke Party ${suffix}` }),
})
const starterParty = await appRequest(`/api/v1/campaigns/${campaignId}`, owner.token)
const ownerCharacter = starterParty.characters.find(character => character.user_id === owner.id && character.controller === 'human')
const automaticCompanions = starterParty.characters.filter(character => character.controller === 'ai')
if (!ownerCharacter || automaticCompanions.length !== world.companions.length || automaticCompanions.some(character => character.name === 'Echo') || starterParty.round?.status !== 'open') {
throw new Error('[smoke] A new campaign did not start with its actionable owner hero, generated world companions, and an open round.')
}
console.log('[smoke] Joining the second player through an invite…')
const { token: inviteToken } = await appRequest(`/api/v1/campaigns/${campaignId}/invites`, owner.token, {
@@ -126,14 +154,11 @@ const characterBody = (name, concept) => JSON.stringify({
name, concept, controller: 'human', abilities: { str: 10, dex: 10, con: 10, int: 12, wis: 11, cha: 9 },
hp: 12, maxHp: 12, defense: 12, proficiency: 2, inventory: [], statuses: [], persona: {},
})
const ownerCharacter = await appRequest(`/api/v1/campaigns/${campaignId}/characters`, owner.token, {
method: 'POST', body: characterBody('Iris Vale', 'A methodical station engineer.'),
})
const playerCharacter = await appRequest(`/api/v1/campaigns/${campaignId}/characters`, player.token, {
method: 'POST', body: characterBody('Rowan Pike', 'A curious xenobotanist.'),
})
console.log('[smoke] Drafting and adding a persistent AI companion…')
console.log('[smoke] Verifying AI drafting while keeping the automatic companion…')
const suggested = await appRequest(`/api/v1/campaigns/${campaignId}/characters/suggest`, owner.token, {
method: 'POST',
body: JSON.stringify({ controller: 'ai', concept: 'A damaged garden caretaker who values living things over orders.' }),
@@ -141,20 +166,16 @@ const suggested = await appRequest(`/api/v1/campaigns/${campaignId}/characters/s
if (!suggested?.character?.name || !suggested.character?.persona?.motivation) {
throw new Error('[smoke] Character coauthor returned an incomplete draft.')
}
await appRequest(`/api/v1/campaigns/${campaignId}/characters`, owner.token, {
method: 'POST',
body: JSON.stringify({ ...suggested.character, controller: 'ai', statuses: [] }),
})
const initial = await appRequest(`/api/v1/campaigns/${campaignId}`, owner.token)
if (initial.members.length !== 2) throw new Error(`[smoke] Expected 2 members, received ${initial.members.length}.`)
if (initial.characters.length !== 3 || initial.round?.status !== 'open') throw new Error('[smoke] Multiplayer lobby did not expose two humans, one AI companion, and an open round.')
if (initial.characters.length !== 5 || initial.round?.status !== 'open') throw new Error('[smoke] Multiplayer lobby did not expose two humans, three generated AI companions, and an open round.')
const roundId = initial.round.id
console.log('[smoke] Verifying that the round waits for both humans…')
await appRequest(`/api/v1/campaigns/${campaignId}/rounds/${roundId}/intent`, owner.token, {
method: 'PUT',
body: JSON.stringify({ characterId: ownerCharacter.character.id, action: 'Iris checks the airlock telemetry for a safe path.', ready: true }),
body: JSON.stringify({ characterId: ownerCharacter.id, action: 'The owner checks the airlock telemetry for a safe path.', ready: true }),
})
await new Promise(resolve => setTimeout(resolve, 1_500))
const waiting = await appRequest(`/api/v1/campaigns/${campaignId}`, player.token)
@@ -182,5 +203,12 @@ while (Date.now() < deadline) {
}
if (!completed) throw new Error('[smoke] Multiplayer round did not resolve within 120 seconds.')
if (!completed.rounds.find(round => round.id === roundId)?.narration) throw new Error('[smoke] Resolved round has no narration.')
if (!completed.sceneState || !Array.isArray(completed.memories) || !Array.isArray(completed.relationships) || !Array.isArray(completed.activeGoals)) {
throw new Error('[smoke] Campaign memory and world-state projections are missing from the shared campaign response.')
}
const usage = await appRequest('/api/v1/usage', owner.token)
if (!usage?.totals || usage.totals.requests < 3 || usage.quota.usedToday < 2) {
throw new Error('[smoke] AI usage telemetry or quota reservations were not recorded for the full-party flow.')
}
console.log(`[smoke] PASS — 2 players, AI character drafting, persistent companion, shared readiness, worker resolution, and next round all work (campaign ${campaignId}).`)
console.log(`[smoke] PASS — full party, AI drafting, persistent companions, shared readiness, atomic resolution, memory state, usage telemetry, and the next round all work (campaign ${campaignId}).`)

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,78 @@
-- D&G now uses recoverable email/password accounts. Keep the legacy function
-- names so existing auth.users triggers are upgraded in place.
create or replace function public.create_profile_for_allowlisted_user()
returns trigger
language plpgsql
security definer
set search_path = ''
as $$
declare
v_display_name text;
begin
if coalesce(new.is_anonymous, false) or nullif(btrim(new.email), '') is null then
raise exception using
errcode = '42501',
message = 'A Dungeons & Ground account requires an email address.';
end if;
v_display_name := coalesce(
nullif(btrim(new.raw_user_meta_data ->> 'display_name'), ''),
split_part(new.email, '@', 1),
'Adventurer'
);
insert into public.profiles(id, display_name)
values (new.id, left(v_display_name, 80))
on conflict (id) do update
set display_name = excluded.display_name;
return new;
end;
$$;
revoke all on function public.create_profile_for_allowlisted_user() from public, anon, authenticated;
create or replace function public.enforce_alpha_email_allowlist()
returns trigger
language plpgsql
security definer
set search_path = ''
as $$
begin
if coalesce(new.is_anonymous, false) or nullif(btrim(new.email), '') is null then
raise exception using
errcode = '42501',
message = 'A Dungeons & Ground account requires an email address.';
end if;
return new;
end;
$$;
revoke all on function public.enforce_alpha_email_allowlist() from public, anon, authenticated;
-- Installations may contain confirmed email users created before the D&G
-- schema. Give every non-anonymous email account a profile without touching
-- existing campaign ownership or display names.
insert into public.profiles(id, display_name)
select
auth_user.id,
left(coalesce(
nullif(btrim(auth_user.raw_user_meta_data ->> 'display_name'), ''),
split_part(auth_user.email, '@', 1),
'Adventurer'
), 80)
from auth.users auth_user
where not coalesce(auth_user.is_anonymous, false)
and nullif(btrim(auth_user.email), '') is not null
on conflict (id) do nothing;
create or replace function public.dng_schema_version()
returns integer language sql stable security definer set search_path = '' as $$
select 8;
$$;
revoke all on function public.dng_schema_version() from public, anon, authenticated;
grant execute on function public.dng_schema_version() to service_role;
notify pgrst, 'reload schema';

View File

@@ -0,0 +1,61 @@
alter table public.profiles
add column description text not null default '' check (char_length(description) <= 500),
add column avatar_path text check (avatar_path is null or avatar_path = id::text || '/avatar'),
add column updated_at timestamptz not null default now();
-- Avatars are public profile media, but only the owning authenticated user may
-- create, replace, or remove the one deterministic object in their folder.
insert into storage.buckets(id, name, public, file_size_limit, allowed_mime_types)
values (
'profile-avatars',
'profile-avatars',
true,
2097152,
array['image/jpeg', 'image/png', 'image/webp']
)
on conflict (id) do update
set public = excluded.public,
file_size_limit = excluded.file_size_limit,
allowed_mime_types = excluded.allowed_mime_types;
create policy dng_profile_avatar_insert
on storage.objects for insert to authenticated
with check (
bucket_id = 'profile-avatars'
and name = (select auth.uid())::text || '/avatar'
);
create policy dng_profile_avatar_select_own
on storage.objects for select to authenticated
using (
bucket_id = 'profile-avatars'
and name = (select auth.uid())::text || '/avatar'
);
create policy dng_profile_avatar_update
on storage.objects for update to authenticated
using (
bucket_id = 'profile-avatars'
and name = (select auth.uid())::text || '/avatar'
)
with check (
bucket_id = 'profile-avatars'
and name = (select auth.uid())::text || '/avatar'
);
create policy dng_profile_avatar_delete
on storage.objects for delete to authenticated
using (
bucket_id = 'profile-avatars'
and name = (select auth.uid())::text || '/avatar'
);
create or replace function public.dng_schema_version()
returns integer language sql stable security definer set search_path = '' as $$
select 9;
$$;
revoke all on function public.dng_schema_version() from public, anon, authenticated;
grant execute on function public.dng_schema_version() to service_role;
notify pgrst, 'reload schema';

View File

@@ -0,0 +1,159 @@
-- Make a newly launched campaign playable immediately: the owner receives a
-- human-controlled starter, and only actionable members hold the round open.
create or replace function public.stage_two_create_campaign(
p_world_id uuid,
p_owner_id uuid,
p_title text
) returns uuid language plpgsql security definer set search_path = '' as $$
declare
v_world public.worlds%rowtype;
v_campaign_id uuid;
v_owner_name text;
begin
if p_title is null or char_length(btrim(p_title)) not between 3 and 100 then
raise exception 'campaign title must be between 3 and 100 characters';
end if;
select * into v_world from public.worlds
where id = p_world_id and owner_id = p_owner_id and status = 'confirmed' for share;
if not found then raise exception 'confirmed world not found'; end if;
select nullif(btrim(display_name), '') into v_owner_name
from public.profiles where id = p_owner_id;
v_owner_name := left(coalesce(v_owner_name, 'Wayfinder'), 80);
insert into public.campaigns(world_id, owner_id, title, current_scene, next_prompt, status)
values (p_world_id, p_owner_id, btrim(p_title), coalesce(v_world.opening_scene, v_world.premise), 'What do you do?', 'active')
returning id into v_campaign_id;
insert into public.campaign_members(campaign_id, user_id, role)
values (v_campaign_id, p_owner_id, 'owner');
insert into public.characters(
campaign_id, user_id, name, concept, controller, abilities, hp, max_hp,
defense, proficiency, inventory, statuses, persona
) values (
v_campaign_id,
p_owner_id,
v_owner_name,
left('An adaptable protagonist ready to confront the opening mystery of ' || v_world.title || '.', 600),
'human'::public.character_controller,
'{"str":10,"dex":12,"con":12,"int":11,"wis":13,"cha":10}'::jsonb,
12,
12,
12,
2,
'["Field kit","Personal keepsake"]'::jsonb,
'[]'::jsonb,
jsonb_build_object(
'voice', 'Defined by the player.',
'motivation', 'Discover what the opening scene is hiding.',
'flaw', 'Still learning what this world demands.',
'bond', 'Protect the party through the first danger.'
)
);
insert into public.rounds(campaign_id, number) values (v_campaign_id, 1);
return v_campaign_id;
end;
$$;
-- Members who have not created a character cannot submit an intent and must not
-- hold the round open. Only active members with a human-controlled character
-- participate in the readiness barrier.
create or replace function public.stage_two_submit_intent(
p_round_id uuid,
p_user_id uuid,
p_character_id uuid,
p_action text,
p_ready boolean default false
) returns jsonb language plpgsql security definer set search_path = '' as $$
declare
v_round public.rounds%rowtype;
v_member public.campaign_members%rowtype;
v_intent_id uuid;
v_job_id uuid;
begin
if p_action is null or char_length(btrim(p_action)) not between 1 and 2000 then
raise exception 'action must be between 1 and 2000 characters';
end if;
select * into v_round from public.rounds where id = p_round_id for update;
if not found or v_round.status <> 'open' then raise exception 'round is not open'; end if;
select * into v_member from public.campaign_members
where campaign_id = v_round.campaign_id and user_id = p_user_id and active;
if not found then raise exception 'active campaign membership not found'; end if;
if not exists (
select 1 from public.characters
where id = p_character_id and campaign_id = v_round.campaign_id
and user_id = p_user_id and controller = 'human'
) then raise exception 'controlled character not found'; end if;
insert into public.player_intents(round_id, member_id, character_id, action, ready)
values (p_round_id, v_member.id, p_character_id, btrim(p_action), coalesce(p_ready, false))
on conflict (round_id, member_id) do update
set character_id = excluded.character_id, action = excluded.action,
ready = excluded.ready, updated_at = now()
returning id into v_intent_id;
if coalesce(p_ready, false) and not exists (
select 1
from public.campaign_members member
join public.characters character
on character.campaign_id = member.campaign_id
and character.user_id = member.user_id
and character.controller = 'human'::public.character_controller
where member.campaign_id = v_round.campaign_id
and member.active
and not exists (
select 1 from public.player_intents intent
where intent.round_id = p_round_id and intent.member_id = member.id and intent.ready
)
) then
v_job_id := public.enqueue_round_resolution(p_round_id, null);
end if;
return jsonb_build_object('intentId', v_intent_id, 'jobId', v_job_id);
end;
$$;
-- Repair campaigns created before starter parties were automatic.
insert into public.characters(
campaign_id, user_id, name, concept, controller, abilities, hp, max_hp,
defense, proficiency, inventory, statuses, persona
)
select
campaign.id,
campaign.owner_id,
left(coalesce(nullif(btrim(profile.display_name), ''), 'Wayfinder'), 80),
left('An adaptable protagonist ready to confront the opening mystery of ' || world.title || '.', 600),
'human'::public.character_controller,
'{"str":10,"dex":12,"con":12,"int":11,"wis":13,"cha":10}'::jsonb,
12,
12,
12,
2,
'["Field kit","Personal keepsake"]'::jsonb,
'[]'::jsonb,
jsonb_build_object(
'voice', 'Defined by the player.',
'motivation', 'Discover what the opening scene is hiding.',
'flaw', 'Still learning what this world demands.',
'bond', 'Protect the party through the first danger.'
)
from public.campaigns campaign
join public.worlds world on world.id = campaign.world_id
left join public.profiles profile on profile.id = campaign.owner_id
where campaign.status = 'active'
and not exists (
select 1 from public.characters character
where character.campaign_id = campaign.id
and character.user_id = campaign.owner_id
);
create or replace function public.dng_schema_version()
returns integer language sql stable security definer set search_path = '' as $$
select 10;
$$;
revoke all on function public.dng_schema_version() from public, anon, authenticated;
grant execute on function public.dng_schema_version() to service_role;
notify pgrst, 'reload schema';

View File

@@ -0,0 +1,287 @@
-- Persist the AI companions created with each universe and use that party when
-- a campaign starts. This replaces the temporary hard-coded Echo companion.
alter table public.worlds
add column if not exists starter_companions jsonb not null default '[]'::jsonb;
alter table public.worlds
drop constraint if exists worlds_starter_companions_array;
alter table public.worlds
add constraint worlds_starter_companions_array check (
jsonb_typeof(starter_companions) = 'array'
and jsonb_array_length(starter_companions) <= 3
);
-- Ready drafts created before companions were part of WorldStarter inherit
-- three universe-specific people that the coauthor already generated.
update public.coauthor_sessions session
set generated_world = jsonb_set(
session.generated_world,
'{companions}',
coalesce((
select jsonb_agg(
jsonb_build_object(
'name', npc.value->>'name',
'concept', left(npc.value->>'summary', 600),
'abilities', case npc.position
when 1 then '{"str":10,"dex":14,"con":11,"int":13,"wis":12,"cha":10}'::jsonb
when 2 then '{"str":13,"dex":10,"con":14,"int":10,"wis":12,"cha":11}'::jsonb
else '{"str":9,"dex":12,"con":11,"int":14,"wis":13,"cha":12}'::jsonb
end,
'hp', 10 + npc.position,
'maxHp', 10 + npc.position,
'defense', 11 + npc.position,
'proficiency', 2,
'inventory', jsonb_build_array('Universe field kit', 'Travel supplies'),
'persona', jsonb_build_object(
'voice', left(npc.value->>'summary', 240),
'motivation', left('Pursue the goal behind: ' || (npc.value->>'summary'), 300),
'flaw', 'Their personal agenda can complicate the party''s plans.',
'bond', left('They belong to ' || (session.generated_world->>'title') || ' and choose to stand with the party.', 300)
)
) order by npc.position
)
from jsonb_array_elements(coalesce(session.generated_world->'npcs', '[]'::jsonb))
with ordinality as npc(value, position)
where npc.position <= 3
), '[]'::jsonb),
true
),
updated_at = now()
where session.generated_world is not null
and jsonb_typeof(session.generated_world->'npcs') = 'array'
and coalesce(jsonb_array_length(session.generated_world->'companions'), 0) = 0;
-- Confirmed universes do not retain the original generation JSON. Build their
-- starter companions from the AI-created NPC entities already stored for them.
update public.worlds world
set starter_companions = coalesce((
select jsonb_agg(
jsonb_build_object(
'name', npc.name,
'concept', left(npc.summary, 600),
'abilities', case npc.position
when 1 then '{"str":10,"dex":14,"con":11,"int":13,"wis":12,"cha":10}'::jsonb
when 2 then '{"str":13,"dex":10,"con":14,"int":10,"wis":12,"cha":11}'::jsonb
else '{"str":9,"dex":12,"con":11,"int":14,"wis":13,"cha":12}'::jsonb
end,
'hp', 10 + npc.position,
'maxHp', 10 + npc.position,
'defense', 11 + npc.position,
'proficiency', 2,
'inventory', jsonb_build_array('Universe field kit', 'Travel supplies'),
'persona', jsonb_build_object(
'voice', left(npc.summary, 240),
'motivation', left('Pursue the goal behind: ' || npc.summary, 300),
'flaw', 'Their personal agenda can complicate the party''s plans.',
'bond', left('They belong to ' || world.title || ' and choose to stand with the party.', 300)
)
) order by npc.position
)
from (
select entity.name, entity.summary,
(row_number() over (order by entity.created_at, entity.id))::integer as position
from public.world_entities entity
where entity.world_id = world.id and entity.kind = 'npc'
order by entity.created_at, entity.id
limit 3
) npc
), '[]'::jsonb)
where jsonb_array_length(world.starter_companions) = 0;
create or replace function public.stage_two_confirm_world(
p_session_id uuid,
p_owner_id uuid
) returns uuid language plpgsql security definer set search_path = '' as $$
declare
v_session public.coauthor_sessions%rowtype;
v_world jsonb;
v_world_id uuid;
v_entity jsonb;
begin
select * into v_session from public.coauthor_sessions
where id = p_session_id and owner_id = p_owner_id for update;
if not found then raise exception 'coauthor session not found'; end if;
if v_session.status = 'confirmed' and v_session.confirmed_world_id is not null then
return v_session.confirmed_world_id;
end if;
if v_session.status <> 'ready' or v_session.generated_world is null then
raise exception 'coauthor session is not ready to confirm';
end if;
v_world := v_session.generated_world;
if coalesce(v_world->>'title', '') = ''
or coalesce(v_world->>'openingScene', '') = ''
or jsonb_typeof(v_world->'companions') <> 'array'
or jsonb_array_length(v_world->'companions') <> 3 then
raise exception 'generated world is incomplete';
end if;
insert into public.worlds(
owner_id, title, genre, tone, premise, content_boundaries,
hidden_threat, hook, opening_scene, starter_companions, status
) values (
p_owner_id, v_world->>'title', v_world->>'genre', v_world->>'tone', v_world->>'premise',
coalesce(v_world->'contentBoundaries', '[]'::jsonb), v_world->>'hiddenThreat',
v_world->>'hook', v_world->>'openingScene', v_world->'companions', 'confirmed'
) returning id into v_world_id;
v_entity := v_world->'startingLocation';
insert into public.world_entities(world_id, kind, name, summary, tags, secrets)
values (v_world_id, 'location', v_entity->>'name', v_entity->>'summary',
array(select jsonb_array_elements_text(coalesce(v_entity->'tags', '[]'::jsonb))),
coalesce(v_entity->'secrets', '[]'::jsonb));
for v_entity in select * from jsonb_array_elements(v_world->'npcs') loop
insert into public.world_entities(world_id, kind, name, summary, tags, secrets)
values (v_world_id, 'npc', v_entity->>'name', v_entity->>'summary',
array(select jsonb_array_elements_text(coalesce(v_entity->'tags', '[]'::jsonb))),
coalesce(v_entity->'secrets', '[]'::jsonb));
end loop;
for v_entity in select * from jsonb_array_elements(v_world->'factions') loop
insert into public.world_entities(world_id, kind, name, summary, tags, secrets)
values (v_world_id, 'faction', v_entity->>'name', v_entity->>'summary',
array(select jsonb_array_elements_text(coalesce(v_entity->'tags', '[]'::jsonb))),
coalesce(v_entity->'secrets', '[]'::jsonb));
end loop;
insert into public.world_entities(world_id, kind, name, summary, tags, secrets)
values (v_world_id, 'quest', 'Opening Hook', v_world->>'hook', array['opening'], '[]'::jsonb);
update public.coauthor_sessions
set status = 'confirmed', confirmed_world_id = v_world_id, updated_at = now()
where id = p_session_id;
return v_world_id;
end;
$$;
create or replace function public.stage_two_create_campaign(
p_world_id uuid,
p_owner_id uuid,
p_title text
) returns uuid language plpgsql security definer set search_path = '' as $$
declare
v_world public.worlds%rowtype;
v_campaign_id uuid;
v_owner_name text;
v_companion jsonb;
begin
if p_title is null or char_length(btrim(p_title)) not between 3 and 100 then
raise exception 'campaign title must be between 3 and 100 characters';
end if;
select * into v_world from public.worlds
where id = p_world_id and owner_id = p_owner_id and status = 'confirmed' for share;
if not found then raise exception 'confirmed world not found'; end if;
if jsonb_typeof(v_world.starter_companions) <> 'array'
or jsonb_array_length(v_world.starter_companions) = 0 then
raise exception 'confirmed world has no AI companions';
end if;
select nullif(btrim(display_name), '') into v_owner_name
from public.profiles where id = p_owner_id;
v_owner_name := left(coalesce(v_owner_name, 'Wayfinder'), 80);
insert into public.campaigns(world_id, owner_id, title, current_scene, next_prompt, status)
values (p_world_id, p_owner_id, btrim(p_title), coalesce(v_world.opening_scene, v_world.premise), 'What do you do?', 'active')
returning id into v_campaign_id;
insert into public.campaign_members(campaign_id, user_id, role)
values (v_campaign_id, p_owner_id, 'owner');
insert into public.characters(
campaign_id, user_id, name, concept, controller, abilities, hp, max_hp,
defense, proficiency, inventory, statuses, persona
) values (
v_campaign_id,
p_owner_id,
v_owner_name,
left('An adaptable protagonist ready to confront the opening mystery of ' || v_world.title || '.', 600),
'human'::public.character_controller,
'{"str":10,"dex":12,"con":12,"int":11,"wis":13,"cha":10}'::jsonb,
12,
12,
12,
2,
'["Field kit","Personal keepsake"]'::jsonb,
'[]'::jsonb,
jsonb_build_object(
'voice', 'Defined by the player.',
'motivation', 'Discover what the opening scene is hiding.',
'flaw', 'Still learning what this world demands.',
'bond', 'Protect the party through the first danger.'
)
);
for v_companion in select * from jsonb_array_elements(v_world.starter_companions) loop
insert into public.characters(
campaign_id, user_id, name, concept, controller, abilities, hp, max_hp,
defense, proficiency, inventory, statuses, persona
) values (
v_campaign_id,
null,
v_companion->>'name',
v_companion->>'concept',
'ai'::public.character_controller,
v_companion->'abilities',
(v_companion->>'hp')::integer,
(v_companion->>'maxHp')::integer,
(v_companion->>'defense')::integer,
(v_companion->>'proficiency')::integer,
v_companion->'inventory',
'[]'::jsonb,
v_companion->'persona'
);
end loop;
insert into public.rounds(campaign_id, number) values (v_campaign_id, 1);
return v_campaign_id;
end;
$$;
-- Remove only the exact temporary Echo inserted by schema v10, preserving any
-- independently authored character that happens to share the name.
delete from public.characters character
where character.user_id is null
and character.controller = 'ai'::public.character_controller
and character.name = 'Echo'
and character.inventory = '["Survey kit","Emergency supplies"]'::jsonb
and character.persona->>'voice' = 'Observant, concise, and quietly curious.'
and character.persona->>'motivation' = 'Help the party understand this unfamiliar world.'
and character.concept like 'A persistent AI companion shaped by the %';
-- Existing active campaigns receive the companion roster belonging to their
-- universe. Name matching keeps this migration idempotent around manual heroes.
insert into public.characters(
campaign_id, user_id, name, concept, controller, abilities, hp, max_hp,
defense, proficiency, inventory, statuses, persona
)
select
campaign.id,
null,
companion.value->>'name',
companion.value->>'concept',
'ai'::public.character_controller,
companion.value->'abilities',
(companion.value->>'hp')::integer,
(companion.value->>'maxHp')::integer,
(companion.value->>'defense')::integer,
(companion.value->>'proficiency')::integer,
companion.value->'inventory',
'[]'::jsonb,
companion.value->'persona'
from public.campaigns campaign
join public.worlds world on world.id = campaign.world_id
cross join lateral jsonb_array_elements(world.starter_companions) companion(value)
where campaign.status = 'active'
and not exists (
select 1 from public.characters character
where character.campaign_id = campaign.id
and character.controller = 'ai'::public.character_controller
and lower(character.name) = lower(companion.value->>'name')
);
create or replace function public.dng_schema_version()
returns integer language sql stable security definer set search_path = '' as $$
select 11;
$$;
revoke all on function public.dng_schema_version() from public, anon, authenticated;
grant execute on function public.dng_schema_version() to service_role;
notify pgrst, 'reload schema';

View File

@@ -0,0 +1,352 @@
-- Introduce a versioned SRD 5.2.1 rules profile without rebuilding campaigns.
-- Existing narrative, rounds, HP, inventory and statuses remain untouched.
alter table public.campaigns
add column if not exists ruleset_version smallint;
insert into public.audit_entries(
campaign_id, actor_id, action, entity_type, entity_id, before_state, after_state
)
select
campaign.id,
campaign.owner_id,
'migrate_ruleset',
'campaign',
campaign.id,
jsonb_build_object('rulesetVersion', coalesce(campaign.ruleset_version, 1)),
jsonb_build_object(
'rulesetVersion', 2,
'ruleset', 'SRD 5.2.1 alpha',
'preserved', jsonb_build_array('rounds', 'history', 'hp', 'inventory', 'statuses')
)
from public.campaigns campaign
where coalesce(campaign.ruleset_version, 1) < 2;
update public.campaigns
set ruleset_version = 2
where ruleset_version is null or ruleset_version < 2;
alter table public.campaigns
alter column ruleset_version set default 2,
alter column ruleset_version set not null;
alter table public.campaigns
drop constraint if exists campaigns_ruleset_version_supported;
alter table public.campaigns
add constraint campaigns_ruleset_version_supported check (ruleset_version = 2);
alter table public.characters
add column if not exists rules_state jsonb;
-- Compatibility is deliberate: migrated heroes retain the old planner hint
-- only until their profile is explicitly edited. Newly-authored profiles use
-- server-owned skill/save lists and set this flag to false.
update public.characters character
set rules_state = jsonb_build_object(
'version', 2,
'level', case
when character.proficiency <= 2 then 1
when character.proficiency = 3 then 5
when character.proficiency = 4 then 9
when character.proficiency = 5 then 13
else 17
end,
'skillProficiencies', '[]'::jsonb,
'skillExpertise', '[]'::jsonb,
'savingThrowProficiencies', '[]'::jsonb,
'temporaryHp', 0,
'deathSaves', jsonb_build_object('successes', 0, 'failures', 0),
'exhaustion', 0,
'damageResistances', '[]'::jsonb,
'damageVulnerabilities', '[]'::jsonb,
'damageImmunities', '[]'::jsonb,
'resources', jsonb_build_object(
'hitDice', jsonb_build_object('current', 1, 'max', 1, 'recovery', 'longRest')
),
'legacyProficiencyFallback', true
)
where character.rules_state is null;
alter table public.characters
alter column rules_state set default '{
"version":2,
"level":1,
"skillProficiencies":[],
"skillExpertise":[],
"savingThrowProficiencies":[],
"temporaryHp":0,
"deathSaves":{"successes":0,"failures":0},
"exhaustion":0,
"damageResistances":[],
"damageVulnerabilities":[],
"damageImmunities":[],
"resources":{"hitDice":{"current":1,"max":1,"recovery":"longRest"}},
"legacyProficiencyFallback":true
}'::jsonb,
alter column rules_state set not null;
alter table public.characters
drop constraint if exists characters_rules_state_v2;
alter table public.characters
add constraint characters_rules_state_v2 check (
jsonb_typeof(rules_state) = 'object'
and rules_state->>'version' = '2'
and (rules_state->>'level')::integer between 1 and 20
and jsonb_typeof(rules_state->'skillProficiencies') = 'array'
and jsonb_typeof(rules_state->'skillExpertise') = 'array'
and jsonb_typeof(rules_state->'savingThrowProficiencies') = 'array'
and (rules_state->>'temporaryHp')::integer between 0 and 999
and jsonb_typeof(rules_state->'deathSaves') = 'object'
and (rules_state->'deathSaves'->>'successes')::integer between 0 and 3
and (rules_state->'deathSaves'->>'failures')::integer between 0 and 3
and (rules_state->>'exhaustion')::integer between 0 and 6
and jsonb_typeof(rules_state->'damageResistances') = 'array'
and jsonb_typeof(rules_state->'damageVulnerabilities') = 'array'
and jsonb_typeof(rules_state->'damageImmunities') = 'array'
and jsonb_typeof(rules_state->'resources') = 'object'
and jsonb_typeof(rules_state->'legacyProficiencyFallback') = 'boolean'
);
-- Older world drafts gain deterministic proficiency data so a newly-started
-- campaign is fully on rules v2 even when its universe predates this migration.
update public.worlds world
set starter_companions = coalesce((
select jsonb_agg(
companion.value || case companion.position
when 1 then '{"skillProficiencies":["acrobatics","perception","stealth","survival"],"skillExpertise":[],"savingThrowProficiencies":["dex","wis"]}'::jsonb
when 2 then '{"skillProficiencies":["athletics","intimidation","perception","survival"],"skillExpertise":[],"savingThrowProficiencies":["str","con"]}'::jsonb
else '{"skillProficiencies":["arcana","history","investigation","perception"],"skillExpertise":["investigation"],"savingThrowProficiencies":["int","wis"]}'::jsonb
end
order by companion.position
)
from jsonb_array_elements(world.starter_companions) with ordinality as companion(value, position)
), '[]'::jsonb)
where jsonb_typeof(world.starter_companions) = 'array';
update public.coauthor_sessions session
set generated_world = jsonb_set(
session.generated_world,
'{companions}',
coalesce((
select jsonb_agg(
companion.value || case companion.position
when 1 then '{"skillProficiencies":["acrobatics","perception","stealth","survival"],"skillExpertise":[],"savingThrowProficiencies":["dex","wis"]}'::jsonb
when 2 then '{"skillProficiencies":["athletics","intimidation","perception","survival"],"skillExpertise":[],"savingThrowProficiencies":["str","con"]}'::jsonb
else '{"skillProficiencies":["arcana","history","investigation","perception"],"skillExpertise":["investigation"],"savingThrowProficiencies":["int","wis"]}'::jsonb
end
order by companion.position
)
from jsonb_array_elements(session.generated_world->'companions') with ordinality as companion(value, position)
), '[]'::jsonb),
true
)
where session.generated_world is not null
and jsonb_typeof(session.generated_world->'companions') = 'array';
create or replace function public.stage_two_create_campaign(
p_world_id uuid,
p_owner_id uuid,
p_title text
) returns uuid language plpgsql security definer set search_path = '' as $$
declare
v_world public.worlds%rowtype;
v_campaign_id uuid;
v_owner_name text;
v_companion jsonb;
begin
if p_title is null or char_length(btrim(p_title)) not between 3 and 100 then
raise exception 'campaign title must be between 3 and 100 characters';
end if;
select * into v_world from public.worlds
where id = p_world_id and owner_id = p_owner_id and status = 'confirmed' for share;
if not found then raise exception 'confirmed world not found'; end if;
if jsonb_typeof(v_world.starter_companions) <> 'array'
or jsonb_array_length(v_world.starter_companions) = 0 then
raise exception 'confirmed world has no AI companions';
end if;
select nullif(btrim(display_name), '') into v_owner_name
from public.profiles where id = p_owner_id;
v_owner_name := left(coalesce(v_owner_name, 'Wayfinder'), 80);
insert into public.campaigns(world_id, owner_id, title, current_scene, next_prompt, status, ruleset_version)
values (p_world_id, p_owner_id, btrim(p_title), coalesce(v_world.opening_scene, v_world.premise), 'What do you do?', 'active', 2)
returning id into v_campaign_id;
insert into public.campaign_members(campaign_id, user_id, role)
values (v_campaign_id, p_owner_id, 'owner');
insert into public.characters(
campaign_id, user_id, name, concept, controller, abilities, hp, max_hp,
defense, proficiency, inventory, statuses, persona, rules_state
) values (
v_campaign_id, p_owner_id, v_owner_name,
left('An adaptable protagonist ready to confront the opening mystery of ' || v_world.title || '.', 600),
'human'::public.character_controller,
'{"str":10,"dex":12,"con":12,"int":11,"wis":13,"cha":10}'::jsonb,
12, 12, 12, 2,
'["Field kit","Personal keepsake"]'::jsonb,
'[]'::jsonb,
jsonb_build_object(
'voice', 'Defined by the player.',
'motivation', 'Discover what the opening scene is hiding.',
'flaw', 'Still learning what this world demands.',
'bond', 'Protect the party through the first danger.'
),
'{
"version":2,"level":1,
"skillProficiencies":["investigation","perception","persuasion","survival"],
"skillExpertise":[],"savingThrowProficiencies":["dex","wis"],
"temporaryHp":0,"deathSaves":{"successes":0,"failures":0},"exhaustion":0,
"damageResistances":[],"damageVulnerabilities":[],"damageImmunities":[],
"resources":{"hitDice":{"current":1,"max":1,"recovery":"longRest"}},
"legacyProficiencyFallback":false
}'::jsonb
);
for v_companion in select * from jsonb_array_elements(v_world.starter_companions) loop
insert into public.characters(
campaign_id, user_id, name, concept, controller, abilities, hp, max_hp,
defense, proficiency, inventory, statuses, persona, rules_state
) values (
v_campaign_id, null, v_companion->>'name', v_companion->>'concept',
'ai'::public.character_controller, v_companion->'abilities',
(v_companion->>'hp')::integer, (v_companion->>'maxHp')::integer,
(v_companion->>'defense')::integer, (v_companion->>'proficiency')::integer,
v_companion->'inventory', '[]'::jsonb, v_companion->'persona',
jsonb_build_object(
'version', 2, 'level', 1,
'skillProficiencies', coalesce(v_companion->'skillProficiencies', '[]'::jsonb),
'skillExpertise', coalesce(v_companion->'skillExpertise', '[]'::jsonb),
'savingThrowProficiencies', coalesce(v_companion->'savingThrowProficiencies', '[]'::jsonb),
'temporaryHp', 0, 'deathSaves', jsonb_build_object('successes', 0, 'failures', 0),
'exhaustion', 0, 'damageResistances', '[]'::jsonb,
'damageVulnerabilities', '[]'::jsonb, 'damageImmunities', '[]'::jsonb,
'resources', jsonb_build_object('hitDice', jsonb_build_object('current', 1, 'max', 1, 'recovery', 'longRest')),
'legacyProficiencyFallback', false
)
);
end loop;
insert into public.rounds(campaign_id, number) values (v_campaign_id, 1);
return v_campaign_id;
end;
$$;
create or replace function public.stage_five_create_character(
p_campaign_id uuid,
p_actor_id uuid,
p_controller text,
p_name text,
p_concept text,
p_abilities jsonb,
p_hp integer,
p_max_hp integer,
p_defense integer,
p_proficiency integer,
p_inventory jsonb,
p_statuses jsonb,
p_persona jsonb,
p_rules_state jsonb
) returns uuid language plpgsql security definer set search_path = '' as $$
declare
v_character_id uuid;
begin
if coalesce(jsonb_typeof(p_rules_state), '') <> 'object' or p_rules_state->>'version' <> '2' then
raise exception 'a version 2 rules profile is required';
end if;
v_character_id := public.stage_four_create_character(
p_campaign_id, p_actor_id, p_controller, p_name, p_concept, p_abilities,
p_hp, p_max_hp, p_defense, p_proficiency, p_inventory, p_statuses, p_persona
);
update public.characters set rules_state = p_rules_state where id = v_character_id;
return v_character_id;
end;
$$;
create or replace function public.apply_srd_character_rules(
p_round_id uuid,
p_character_states jsonb
) returns void language plpgsql security definer set search_path = '' as $$
declare
v_campaign_id uuid;
v_state jsonb;
v_character_id uuid;
v_seen uuid[] := '{}';
begin
select campaign_id into v_campaign_id from public.rounds where id = p_round_id;
if not found then raise exception 'round not found'; end if;
if coalesce(jsonb_typeof(p_character_states), '') <> 'array' then
raise exception 'character states must be an array';
end if;
for v_state in select * from jsonb_array_elements(p_character_states) loop
v_character_id := (v_state->>'id')::uuid;
if v_character_id = any(v_seen) then raise exception 'duplicate character state for %', v_character_id; end if;
v_seen := array_append(v_seen, v_character_id);
if coalesce(jsonb_typeof(v_state->'rulesState'), '') <> 'object'
or v_state->'rulesState'->>'version' <> '2' then
raise exception 'character % has an invalid rules profile', v_character_id;
end if;
update public.characters
set rules_state = v_state->'rulesState'
where id = v_character_id and campaign_id = v_campaign_id;
if not found then raise exception 'character % is outside the round campaign', v_character_id; end if;
end loop;
end;
$$;
create or replace function public.commit_srd_round_resolution(
p_round_id uuid,
p_narration text,
p_next_prompt text,
p_rolls jsonb,
p_events jsonb,
p_character_states jsonb,
p_memory jsonb,
p_idempotency_key text
) returns void language plpgsql security definer set search_path = '' as $$
begin
if exists (select 1 from public.rounds where id = p_round_id and status = 'resolved') then return; end if;
perform public.commit_round_resolution(
p_round_id, p_narration, p_next_prompt, p_rolls, p_events,
p_character_states, p_memory, p_idempotency_key
);
perform public.apply_srd_character_rules(p_round_id, p_character_states);
end;
$$;
create or replace function public.commit_claimed_srd_round_resolution(
p_round_id uuid,
p_narration text,
p_next_prompt text,
p_rolls jsonb,
p_events jsonb,
p_character_states jsonb,
p_memory jsonb,
p_idempotency_key text,
p_worker_id text
) returns void language plpgsql security definer set search_path = '' as $$
begin
perform public.commit_claimed_round_resolution(
p_round_id, p_narration, p_next_prompt, p_rolls, p_events,
p_character_states, p_memory, p_idempotency_key, p_worker_id
);
perform public.apply_srd_character_rules(p_round_id, p_character_states);
end;
$$;
create or replace function public.dng_schema_version()
returns integer language sql stable security definer set search_path = '' as $$
select 12;
$$;
revoke all on function public.stage_five_create_character(uuid, uuid, text, text, text, jsonb, integer, integer, integer, integer, jsonb, jsonb, jsonb, jsonb) from public, anon, authenticated;
revoke all on function public.apply_srd_character_rules(uuid, jsonb) from public, anon, authenticated;
revoke all on function public.commit_srd_round_resolution(uuid, text, text, jsonb, jsonb, jsonb, jsonb, text) from public, anon, authenticated;
revoke all on function public.commit_claimed_srd_round_resolution(uuid, text, text, jsonb, jsonb, jsonb, jsonb, text, text) from public, anon, authenticated;
revoke all on function public.dng_schema_version() from public, anon, authenticated;
grant execute on function public.stage_five_create_character(uuid, uuid, text, text, text, jsonb, integer, integer, integer, integer, jsonb, jsonb, jsonb, jsonb) to service_role;
grant execute on function public.commit_srd_round_resolution(uuid, text, text, jsonb, jsonb, jsonb, jsonb, text) to service_role;
grant execute on function public.commit_claimed_srd_round_resolution(uuid, text, text, jsonb, jsonb, jsonb, jsonb, text, text) to service_role;
grant execute on function public.dng_schema_version() to service_role;
notify pgrst, 'reload schema';

View File

@@ -0,0 +1,543 @@
-- Weeks 78: durable world projections, PostgreSQL context retrieval, quotas,
-- usage telemetry, atomic summaries, and owner-audited corrections.
alter table public.ai_usage add column if not exists usage_key text;
alter table public.ai_usage add column if not exists provider text not null default 'openrouter';
alter table public.ai_usage add column if not exists request_kind text not null default 'unknown';
update public.ai_usage set usage_key = id::text where usage_key is null;
alter table public.ai_usage alter column usage_key set not null;
create unique index if not exists ai_usage_usage_key_unique on public.ai_usage(usage_key);
create index if not exists ai_usage_user_created_idx on public.ai_usage(user_id, created_at desc);
create index if not exists ai_usage_campaign_created_idx on public.ai_usage(campaign_id, created_at desc);
create table if not exists public.ai_quota_events (
id uuid primary key default gen_random_uuid(),
user_id uuid not null references public.profiles(id) on delete cascade,
campaign_id uuid references public.campaigns(id) on delete cascade,
request_kind text not null check (char_length(request_kind) between 1 and 80),
created_at timestamptz not null default now()
);
create index if not exists ai_quota_events_user_created_idx on public.ai_quota_events(user_id, created_at desc);
create index if not exists ai_quota_events_campaign_created_idx on public.ai_quota_events(campaign_id, created_at desc);
alter table public.ai_quota_events enable row level security;
drop policy if exists quota_events_self_read on public.ai_quota_events;
create policy quota_events_self_read on public.ai_quota_events for select using (user_id = auth.uid());
create table if not exists public.scene_states (
campaign_id uuid primary key references public.campaigns(id) on delete cascade,
location_id uuid references public.world_entities(id) on delete set null,
summary text not null check (char_length(summary) between 1 and 6000),
active_entity_ids uuid[] not null default '{}',
tags text[] not null default '{}',
through_round integer not null default 0 check (through_round >= 0),
updated_at timestamptz not null default now()
);
create table if not exists public.quest_states (
campaign_id uuid not null references public.campaigns(id) on delete cascade,
quest_entity_id uuid not null references public.world_entities(id) on delete cascade,
status text not null default 'hidden' check (status in ('hidden', 'active', 'completed', 'failed')),
summary text not null check (char_length(summary) between 1 and 1200),
tags text[] not null default '{}',
through_round integer not null default 0 check (through_round >= 0),
updated_at timestamptz not null default now(),
primary key (campaign_id, quest_entity_id)
);
create index if not exists quest_states_campaign_status_idx on public.quest_states(campaign_id, status, updated_at desc);
create or replace function public.stage_six_ensure_opening_quest()
returns trigger language plpgsql security definer set search_path = '' as $$
begin
if nullif(btrim(new.hook), '') is not null and not exists (
select 1 from public.world_entities where world_id = new.id and kind = 'quest'
) then
insert into public.world_entities(world_id, kind, name, summary, tags, secrets)
values (new.id, 'quest', 'Opening objective', left(btrim(new.hook), 1200), array['opening', 'active'], '[]'::jsonb);
end if;
return new;
end;
$$;
drop trigger if exists stage_six_world_opening_quest on public.worlds;
create trigger stage_six_world_opening_quest
after insert or update of hook on public.worlds
for each row execute function public.stage_six_ensure_opening_quest();
insert into public.world_entities(world_id, kind, name, summary, tags, secrets)
select world.id, 'quest', 'Opening objective', left(btrim(world.hook), 1200), array['opening', 'active'], '[]'::jsonb
from public.worlds world
where nullif(btrim(world.hook), '') is not null
and not exists (select 1 from public.world_entities entity where entity.world_id = world.id and entity.kind = 'quest');
alter table public.scene_states enable row level security;
alter table public.quest_states enable row level security;
drop policy if exists scene_states_member_read on public.scene_states;
drop policy if exists quest_states_member_read on public.quest_states;
create policy scene_states_member_read on public.scene_states for select using (
public.is_campaign_member(campaign_id) or public.is_campaign_owner(campaign_id)
);
create policy quest_states_member_read on public.quest_states for select using (
public.is_campaign_member(campaign_id) or public.is_campaign_owner(campaign_id)
);
insert into public.scene_states(campaign_id, summary)
select id, current_scene from public.campaigns
on conflict (campaign_id) do nothing;
insert into public.quest_states(campaign_id, quest_entity_id, status, summary, tags)
select campaign.id, entity.id, case when 'opening' = any(entity.tags) then 'active' else 'hidden' end, entity.summary, entity.tags
from public.campaigns campaign
join public.world_entities entity on entity.world_id = campaign.world_id and entity.kind = 'quest'
on conflict (campaign_id, quest_entity_id) do nothing;
create or replace function public.stage_six_initialize_campaign_state()
returns trigger language plpgsql security definer set search_path = '' as $$
begin
insert into public.scene_states(campaign_id, summary)
values (new.id, new.current_scene)
on conflict (campaign_id) do nothing;
insert into public.quest_states(campaign_id, quest_entity_id, status, summary, tags)
select new.id, entity.id, case when 'opening' = any(entity.tags) then 'active' else 'hidden' end, entity.summary, entity.tags
from public.world_entities entity
where entity.world_id = new.world_id and entity.kind = 'quest'
on conflict (campaign_id, quest_entity_id) do nothing;
return new;
end;
$$;
drop trigger if exists stage_six_campaign_state on public.campaigns;
create trigger stage_six_campaign_state
after insert on public.campaigns
for each row execute function public.stage_six_initialize_campaign_state();
create or replace function public.stage_six_consume_ai_quota(
p_user_id uuid,
p_campaign_id uuid,
p_request_kind text
) returns jsonb language plpgsql security definer set search_path = '' as $$
declare
v_user_minute integer;
v_user_day integer;
v_campaign_day integer := 0;
v_tokens_day bigint;
begin
if p_user_id is null or not exists (select 1 from public.profiles where id = p_user_id) then
raise exception 'AI quota requires a valid user';
end if;
if p_campaign_id is not null and not exists (
select 1 from public.campaigns where id = p_campaign_id
and (owner_id = p_user_id or exists (
select 1 from public.campaign_members where campaign_id = p_campaign_id and user_id = p_user_id and active
))
) then
raise exception 'AI quota campaign access denied';
end if;
if p_request_kind is null or char_length(btrim(p_request_kind)) not between 1 and 80 then
raise exception 'AI request kind is required';
end if;
perform pg_catalog.pg_advisory_xact_lock(pg_catalog.hashtextextended('ai-quota:' || p_user_id::text, 0));
select count(*) into v_user_minute from public.ai_quota_events
where user_id = p_user_id and created_at >= now() - interval '1 minute';
select count(*) into v_user_day from public.ai_quota_events
where user_id = p_user_id and created_at >= date_trunc('day', now());
select coalesce(sum(input_tokens + output_tokens), 0) into v_tokens_day from public.ai_usage
where user_id = p_user_id and created_at >= date_trunc('day', now());
if p_campaign_id is not null then
select count(*) into v_campaign_day from public.ai_quota_events
where campaign_id = p_campaign_id and created_at >= date_trunc('day', now());
end if;
if v_user_minute >= 10 then raise exception 'AI rate limit reached; try again in a minute'; end if;
if v_user_day >= 60 then raise exception 'daily user AI quota reached'; end if;
if p_campaign_id is not null and v_campaign_day >= 40 then raise exception 'daily campaign AI quota reached'; end if;
if v_tokens_day >= 250000 then raise exception 'daily AI token quota reached'; end if;
insert into public.ai_quota_events(user_id, campaign_id, request_kind)
values (p_user_id, p_campaign_id, btrim(p_request_kind));
return jsonb_build_object(
'userRemaining', 59 - v_user_day,
'campaignRemaining', case when p_campaign_id is null then null else 39 - v_campaign_day end,
'tokenRemaining', greatest(0, 250000 - v_tokens_day)
);
end;
$$;
create or replace function public.enqueue_round_resolution(
p_round_id uuid,
p_forced_by uuid default null
) returns uuid language plpgsql security definer set search_path = '' as $$
declare
v_round public.rounds%rowtype;
v_job_id uuid;
v_owner_id uuid;
begin
select * into v_round from public.rounds where id = p_round_id for update;
if not found then raise exception 'round not found'; end if;
select owner_id into v_owner_id from public.campaigns where id = v_round.campaign_id;
select id into v_job_id from public.ai_jobs
where job_type = 'resolve-round' and entity_id = p_round_id;
if v_round.status in ('queued', 'resolving', 'resolved') then
if v_job_id is null and v_round.status <> 'resolved' then raise exception 'round status and job outbox are inconsistent'; end if;
return v_job_id;
end if;
if v_round.status <> 'open' then raise exception 'round cannot be queued from status %', v_round.status; end if;
if p_forced_by is not null then
if p_forced_by <> v_owner_id then raise exception 'only the campaign owner can force a round'; end if;
elsif exists (
select 1 from public.campaign_members member
join public.characters character on character.campaign_id = member.campaign_id
and character.user_id = member.user_id and character.controller = 'human'::public.character_controller
where member.campaign_id = v_round.campaign_id and member.active
and not exists (
select 1 from public.player_intents intent
where intent.round_id = p_round_id and intent.member_id = member.id and intent.ready
)
) then
raise exception 'not all active players are ready';
end if;
perform public.stage_six_consume_ai_quota(v_owner_id, v_round.campaign_id, 'resolve-round');
v_job_id := gen_random_uuid();
insert into public.ai_jobs(id, job_type, entity_id, idempotency_key, status)
values (v_job_id, 'resolve-round', p_round_id, v_job_id::text, 'queued');
update public.rounds set status = 'queued', queued_at = now(), forced_by = p_forced_by, error = null
where id = p_round_id;
return v_job_id;
end;
$$;
create or replace function public.stage_two_enqueue_world_generation(
p_session_id uuid,
p_owner_id uuid
) returns uuid language plpgsql security definer set search_path = '' as $$
declare
v_session public.coauthor_sessions%rowtype;
v_job public.ai_jobs%rowtype;
v_existing boolean := false;
begin
select * into v_session from public.coauthor_sessions
where id = p_session_id and owner_id = p_owner_id for update;
if not found then raise exception 'coauthor session not found'; end if;
if v_session.status = 'confirmed' then raise exception 'coauthor session is already confirmed'; end if;
if jsonb_array_length(v_session.messages) < 1 then raise exception 'at least one message is required'; end if;
select * into v_job from public.ai_jobs
where job_type = 'generate-world' and entity_id = p_session_id for update;
v_existing := found;
if v_existing and v_job.status in ('queued', 'running') then return v_job.id; end if;
perform public.stage_six_consume_ai_quota(p_owner_id, null, 'generate-world');
if v_existing then
update public.ai_jobs set status = 'queued', attempts = 0, claimed_by = null,
lease_expires_at = null, available_at = now(), error = null, updated_at = now()
where id = v_job.id;
else
v_job.id := gen_random_uuid();
insert into public.ai_jobs(id, job_type, entity_id, idempotency_key, status)
values (v_job.id, 'generate-world', p_session_id, v_job.id::text, 'queued');
end if;
update public.coauthor_sessions set status = 'generating', generated_world = null, updated_at = now()
where id = p_session_id;
return v_job.id;
end;
$$;
create or replace function public.stage_six_retrieve_context(
p_campaign_id uuid,
p_search_text text,
p_limit integer default 8
) returns jsonb language plpgsql stable security definer set search_path = '' as $$
declare
v_world_id uuid;
v_query tsquery;
v_limit integer := greatest(1, least(coalesce(p_limit, 8), 12));
v_entities jsonb;
v_entity_ids uuid[];
begin
select world_id into v_world_id from public.campaigns where id = p_campaign_id;
if not found then raise exception 'campaign not found'; end if;
v_query := plainto_tsquery('english', left(coalesce(p_search_text, ''), 4000));
select coalesce(jsonb_agg(item.payload order by item.relevance desc, item.name), '[]'::jsonb),
coalesce(array_agg(item.id order by item.relevance desc, item.name), '{}')
into v_entities, v_entity_ids
from (
select entity.id, entity.name,
jsonb_build_object('id', entity.id, 'kind', entity.kind, 'name', entity.name, 'summary', entity.summary, 'tags', entity.tags) as payload,
case when numnode(v_query) > 0 then ts_rank_cd(entity.search_document, v_query) else 0 end
+ case when exists (select 1 from unnest(entity.tags) tag where position(lower(tag) in lower(coalesce(p_search_text, ''))) > 0) then 2 else 0 end
+ case when quest.status = 'active' then 3 else 0 end
+ case when scene.location_id = entity.id or entity.id = any(scene.active_entity_ids) then 4 else 0 end as relevance
from public.world_entities entity
left join public.quest_states quest on quest.campaign_id = p_campaign_id and quest.quest_entity_id = entity.id
left join public.scene_states scene on scene.campaign_id = p_campaign_id
where entity.world_id = v_world_id
order by relevance desc, entity.created_at
limit v_limit
) item;
return jsonb_build_object(
'entities', v_entities,
'memories', coalesce((
select jsonb_agg(memory_item.payload order by memory_item.relevance desc, memory_item.created_at desc)
from (
select memory.created_at,
jsonb_build_object('summary', memory.summary, 'importance', memory.importance, 'tags', memory.tags, 'entityIds', memory.entity_ids) as payload,
memory.importance
+ case when numnode(v_query) > 0 then ts_rank_cd(memory.search_document, v_query) * 10 else 0 end
+ case when memory.entity_ids && v_entity_ids then 5 else 0 end
+ case when exists (select 1 from unnest(memory.tags) tag where position(lower(tag) in lower(coalesce(p_search_text, ''))) > 0) then 4 else 0 end as relevance
from public.memories memory
where memory.campaign_id = p_campaign_id
order by relevance desc, memory.created_at desc
limit v_limit
) memory_item
), '[]'::jsonb),
'relationships', coalesce((
select jsonb_agg(jsonb_build_object(
'sourceEntityId', item.source_entity_id,
'targetEntityId', item.target_entity_id,
'score', item.score,
'notes', item.notes
) order by abs(item.score) desc)
from (
select relationship.* from public.relationships relationship
where relationship.campaign_id = p_campaign_id
and (
relationship.source_entity_id = any(v_entity_ids)
or relationship.target_entity_id = any(v_entity_ids)
or exists (
select 1 from public.characters character
where character.campaign_id = p_campaign_id
and character.id in (relationship.source_entity_id, relationship.target_entity_id)
)
)
order by abs(relationship.score) desc
limit 20
) item
), '[]'::jsonb),
'activeGoals', coalesce((
select jsonb_agg(jsonb_build_object(
'questEntityId', item.quest_entity_id,
'name', item.name,
'status', item.status,
'summary', item.summary,
'tags', item.tags
) order by item.updated_at desc)
from (
select quest.*, entity.name from public.quest_states quest
join public.world_entities entity on entity.id = quest.quest_entity_id
where quest.campaign_id = p_campaign_id and quest.status = 'active'
order by quest.updated_at desc
limit 8
) item
), '[]'::jsonb)
);
end;
$$;
create or replace function public.stage_six_apply_round_projections(
p_round_id uuid,
p_events jsonb
) returns void language plpgsql security definer set search_path = '' as $$
declare
v_round public.rounds%rowtype;
v_event jsonb;
v_actor uuid;
v_target uuid;
v_before jsonb;
v_after jsonb;
v_scene_projected boolean := false;
begin
select * into v_round from public.rounds where id = p_round_id;
if not found then raise exception 'round not found'; end if;
for v_event in select * from jsonb_array_elements(coalesce(p_events, '[]'::jsonb)) loop
v_actor := nullif(v_event->>'actorId', '')::uuid;
v_target := nullif(v_event->>'targetId', '')::uuid;
if v_event->>'type' = 'relationship' then
if v_actor is null or v_target is null or v_actor = v_target
or (v_event->>'value')::integer not between -20 and 20 then
raise exception 'invalid relationship projection';
end if;
select to_jsonb(relationship) into v_before from public.relationships relationship
where campaign_id = v_round.campaign_id and source_entity_id = v_actor and target_entity_id = v_target;
insert into public.relationships(campaign_id, source_entity_id, target_entity_id, score, notes)
values (v_round.campaign_id, v_actor, v_target, (v_event->>'value')::integer, v_event->>'description')
on conflict (campaign_id, source_entity_id, target_entity_id) do update
set score = greatest(-100, least(100, public.relationships.score + excluded.score)), notes = excluded.notes;
select to_jsonb(relationship) into v_after from public.relationships relationship
where campaign_id = v_round.campaign_id and source_entity_id = v_actor and target_entity_id = v_target;
insert into public.audit_entries(campaign_id, action, entity_type, entity_id, before_state, after_state)
values (v_round.campaign_id, 'project_relationship', 'relationship', v_target, v_before, v_after);
elsif v_event->>'type' = 'quest' then
if v_target is null or v_event->>'item' not in ('hidden', 'active', 'completed', 'failed') then
raise exception 'invalid quest projection';
end if;
select to_jsonb(quest) into v_before from public.quest_states quest
where campaign_id = v_round.campaign_id and quest_entity_id = v_target;
update public.quest_states set status = v_event->>'item', summary = v_event->>'description',
through_round = v_round.number, updated_at = now()
where campaign_id = v_round.campaign_id and quest_entity_id = v_target;
if not found then raise exception 'quest is outside the round campaign'; end if;
select to_jsonb(quest) into v_after from public.quest_states quest
where campaign_id = v_round.campaign_id and quest_entity_id = v_target;
insert into public.audit_entries(campaign_id, action, entity_type, entity_id, before_state, after_state)
values (v_round.campaign_id, 'project_quest', 'quest', v_target, v_before, v_after);
elsif v_event->>'type' = 'narrative' and v_event->>'item' = 'scene' then
v_scene_projected := true;
if v_target is not null and not exists (
select 1 from public.world_entities entity join public.campaigns campaign on campaign.world_id = entity.world_id
where campaign.id = v_round.campaign_id and entity.id = v_target and entity.kind = 'location'
) then raise exception 'scene location is outside the campaign world'; end if;
insert into public.scene_states(campaign_id, location_id, summary, active_entity_ids, through_round, updated_at)
values (
v_round.campaign_id, v_target, v_event->>'description',
array_remove(array[v_actor, v_target], null), v_round.number, now()
)
on conflict (campaign_id) do update set location_id = excluded.location_id,
summary = excluded.summary, active_entity_ids = excluded.active_entity_ids,
through_round = excluded.through_round, updated_at = excluded.updated_at;
update public.campaigns set current_scene = v_event->>'description', updated_at = now()
where id = v_round.campaign_id;
end if;
end loop;
if not v_scene_projected and nullif(btrim(v_round.narration), '') is not null then
insert into public.scene_states(campaign_id, summary, through_round, updated_at)
values (v_round.campaign_id, v_round.narration, v_round.number, now())
on conflict (campaign_id) do update set summary = excluded.summary,
through_round = excluded.through_round, updated_at = excluded.updated_at;
end if;
end;
$$;
create or replace function public.commit_stage_six_round_resolution(
p_round_id uuid,
p_narration text,
p_next_prompt text,
p_rolls jsonb,
p_events jsonb,
p_character_states jsonb,
p_memory jsonb,
p_story_summary text,
p_idempotency_key text
) returns void language plpgsql security definer set search_path = '' as $$
declare v_round public.rounds%rowtype;
begin
select * into v_round from public.rounds where id = p_round_id for update;
if not found then raise exception 'round not found'; end if;
if v_round.status = 'resolved' then return; end if;
perform public.commit_srd_round_resolution(
p_round_id, p_narration, p_next_prompt, p_rolls, p_events,
p_character_states, p_memory, p_idempotency_key
);
perform public.stage_six_apply_round_projections(p_round_id, p_events);
if p_story_summary is not null then
if v_round.number % 3 <> 0 or char_length(btrim(p_story_summary)) not between 20 and 6000 then
raise exception 'invalid scheduled story summary';
end if;
insert into public.story_summaries(campaign_id, through_round, summary)
values (v_round.campaign_id, v_round.number, btrim(p_story_summary))
on conflict (campaign_id, through_round) do update set summary = excluded.summary;
end if;
end;
$$;
create or replace function public.commit_claimed_stage_six_round_resolution(
p_round_id uuid,
p_narration text,
p_next_prompt text,
p_rolls jsonb,
p_events jsonb,
p_character_states jsonb,
p_memory jsonb,
p_story_summary text,
p_idempotency_key text,
p_worker_id text
) returns void language plpgsql security definer set search_path = '' as $$
declare v_round public.rounds%rowtype;
begin
select * into v_round from public.rounds where id = p_round_id for update;
if not found then raise exception 'round not found'; end if;
if v_round.status = 'resolved' then return; end if;
perform public.commit_claimed_srd_round_resolution(
p_round_id, p_narration, p_next_prompt, p_rolls, p_events,
p_character_states, p_memory, p_idempotency_key, p_worker_id
);
perform public.stage_six_apply_round_projections(p_round_id, p_events);
if p_story_summary is not null then
if v_round.number % 3 <> 0 or char_length(btrim(p_story_summary)) not between 20 and 6000 then
raise exception 'invalid scheduled story summary';
end if;
insert into public.story_summaries(campaign_id, through_round, summary)
values (v_round.campaign_id, v_round.number, btrim(p_story_summary))
on conflict (campaign_id, through_round) do update set summary = excluded.summary;
end if;
end;
$$;
create or replace function public.stage_six_adjust_character_state(
p_campaign_id uuid,
p_owner_id uuid,
p_character_id uuid,
p_patch jsonb,
p_reason text
) returns jsonb language plpgsql security definer set search_path = '' as $$
declare
v_character public.characters%rowtype;
v_before jsonb;
v_after jsonb;
v_hp integer;
begin
if not exists (select 1 from public.campaigns where id = p_campaign_id and owner_id = p_owner_id) then
raise exception 'only the campaign owner can adjust state';
end if;
if p_reason is null or char_length(btrim(p_reason)) not between 3 and 500 then raise exception 'an audit reason is required'; end if;
if coalesce(jsonb_typeof(p_patch), '') <> 'object' or p_patch - array['hp', 'inventory', 'statuses']::text[] <> '{}'::jsonb then
raise exception 'only hp, inventory, and statuses may be adjusted';
end if;
select * into v_character from public.characters
where id = p_character_id and campaign_id = p_campaign_id for update;
if not found then raise exception 'character not found'; end if;
v_before := jsonb_build_object('hp', v_character.hp, 'inventory', v_character.inventory, 'statuses', v_character.statuses);
v_hp := coalesce((p_patch->>'hp')::integer, v_character.hp);
if v_hp < 0 or v_hp > v_character.max_hp then raise exception 'hp must be between zero and max hp'; end if;
if p_patch ? 'inventory' and jsonb_typeof(p_patch->'inventory') <> 'array' then raise exception 'inventory must be an array'; end if;
if p_patch ? 'statuses' and jsonb_typeof(p_patch->'statuses') <> 'array' then raise exception 'statuses must be an array'; end if;
update public.characters set hp = v_hp,
inventory = coalesce(p_patch->'inventory', inventory),
statuses = coalesce(p_patch->'statuses', statuses)
where id = p_character_id
returning jsonb_build_object('hp', hp, 'inventory', inventory, 'statuses', statuses) into v_after;
insert into public.audit_entries(campaign_id, actor_id, action, entity_type, entity_id, before_state, after_state)
values (p_campaign_id, p_owner_id, 'manual_character_adjustment: ' || btrim(p_reason), 'character', p_character_id, v_before, v_after);
return v_after;
end;
$$;
create or replace function public.dng_schema_version()
returns integer language sql stable security definer set search_path = '' as $$
select 13;
$$;
revoke all on function public.stage_six_consume_ai_quota(uuid, uuid, text) from public, anon, authenticated;
revoke all on function public.stage_six_retrieve_context(uuid, text, integer) from public, anon, authenticated;
revoke all on function public.stage_six_apply_round_projections(uuid, jsonb) from public, anon, authenticated;
revoke all on function public.commit_stage_six_round_resolution(uuid, text, text, jsonb, jsonb, jsonb, jsonb, text, text) from public, anon, authenticated;
revoke all on function public.commit_claimed_stage_six_round_resolution(uuid, text, text, jsonb, jsonb, jsonb, jsonb, text, text, text) from public, anon, authenticated;
revoke all on function public.stage_six_adjust_character_state(uuid, uuid, uuid, jsonb, text) from public, anon, authenticated;
revoke all on function public.dng_schema_version() from public, anon, authenticated;
grant execute on function public.stage_six_consume_ai_quota(uuid, uuid, text) to service_role;
grant execute on function public.stage_six_retrieve_context(uuid, text, integer) to service_role;
grant execute on function public.commit_stage_six_round_resolution(uuid, text, text, jsonb, jsonb, jsonb, jsonb, text, text) to service_role;
grant execute on function public.commit_claimed_stage_six_round_resolution(uuid, text, text, jsonb, jsonb, jsonb, jsonb, text, text, text) to service_role;
grant execute on function public.stage_six_adjust_character_state(uuid, uuid, uuid, jsonb, text) to service_role;
grant execute on function public.dng_schema_version() to service_role;
notify pgrst, 'reload schema';