Compare commits
9 Commits
agent/code
...
66fe3ee3be
| Author | SHA1 | Date | |
|---|---|---|---|
| 66fe3ee3be | |||
| 031d094867 | |||
| 4cd0b7b390 | |||
| 68b4fe9f33 | |||
| fdf02446f5 | |||
| 034966f9ef | |||
| 0a38ffdaf0 | |||
| 6c39188027 | |||
| 89fd1fd984 |
@@ -20,6 +20,6 @@ DEEPSEEK_API_ENDPOINT=https://api.deepseek.com/chat/completions
|
|||||||
REDIS_URL=
|
REDIS_URL=
|
||||||
AI_JOB_POLL_INTERVAL_MS=1000
|
AI_JOB_POLL_INTERVAL_MS=1000
|
||||||
|
|
||||||
# Alpha controls
|
# Alpha controls (the matching hard safety ceiling is enforced in migration 0013)
|
||||||
DAILY_AI_TOKEN_LIMIT=250000
|
DAILY_AI_TOKEN_LIMIT=250000
|
||||||
NUXT_PUBLIC_APP_URL=http://localhost:3000
|
NUXT_PUBLIC_APP_URL=http://localhost:3000
|
||||||
|
|||||||
16
README.md
16
README.md
@@ -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.
|
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 email accounts, 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.
|
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 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.
|
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.
|
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.
|
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,7 +45,7 @@ Use Node.js 18.20.5 or newer. The lockfile pins the web toolchain to the Node 18
|
|||||||
- Worlds are private by default.
|
- Worlds are private by default.
|
||||||
- Content is limited to 13+; explicit sexual content and sexual content involving minors are rejected.
|
- Content is limited to 13+; explicit sexual content and sexual content involving minors are rejected.
|
||||||
- AI responses are parsed against strict schemas.
|
- 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`.
|
- SRD-derived work must retain the attribution in `LEGAL.md`.
|
||||||
|
|
||||||
## Production services
|
## Production services
|
||||||
@@ -62,3 +66,11 @@ If the worker reports `POST /rest/v1/rpc/claim_ai_job 404` and `/rest/v1/profile
|
|||||||
5. The owner creates an expiring private invite link. Signed-in email users join through `/join/:token`.
|
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.
|
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.
|
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 7–8 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.
|
||||||
|
|||||||
@@ -1,6 +1,17 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
defineProps<{ section?: string }>()
|
defineProps<{ section?: string }>()
|
||||||
|
const emit = defineEmits<{
|
||||||
|
campaignAction: [payload: { action: 'rename' | 'delete'; campaignId: string }]
|
||||||
|
}>()
|
||||||
const { session, restore } = useDngAuth()
|
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())
|
onMounted(() => void restore())
|
||||||
|
|
||||||
const initials = computed(() => {
|
const initials = computed(() => {
|
||||||
@@ -12,10 +23,73 @@ const avatarUrl = computed(() => {
|
|||||||
const value = session.value?.user.user_metadata?.avatar_url
|
const value = session.value?.user.user_metadata?.avatar_url
|
||||||
return typeof value === 'string' && value ? value : ''
|
return typeof value === 'string' && value ? value : ''
|
||||||
})
|
})
|
||||||
|
|
||||||
|
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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="shell">
|
<div class="shell" @contextmenu="openContextMenu">
|
||||||
<header class="topbar">
|
<header class="topbar">
|
||||||
<AppMark />
|
<AppMark />
|
||||||
<div class="topbar-center"><span class="status-dot" /> PRIVATE ALPHA <b v-if="section">/ {{ section }}</b></div>
|
<div class="topbar-center"><span class="status-dot" /> PRIVATE ALPHA <b v-if="section">/ {{ section }}</b></div>
|
||||||
@@ -28,9 +102,35 @@ const avatarUrl = computed(() => {
|
|||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
<main><slot /></main>
|
<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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<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{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}@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>
|
</style>
|
||||||
|
|||||||
@@ -17,9 +17,9 @@ const defaultWorld: WorldStarter = {
|
|||||||
{ id: 'fac_quiet', kind: 'faction', name: 'The Quiet Choir', summary: 'Pilgrims who believe signals can remember the dead.', tags: ['mystic'], secrets: [] },
|
{ id: 'fac_quiet', kind: 'faction', name: 'The Quiet Choir', summary: 'Pilgrims who believe signals can remember the dead.', tags: ['mystic'], secrets: [] },
|
||||||
],
|
],
|
||||||
companions: [
|
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'], 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: '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'], 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: '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'], 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.' } },
|
{ 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.',
|
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.',
|
hiddenThreat: 'A causality fracture is teaching the relay to choose which future becomes real.',
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -13,14 +13,21 @@ interface DraftSession {
|
|||||||
generated_world?: { title?: string; premise?: string } | null
|
generated_world?: { title?: string; premise?: string } | null
|
||||||
updated_at: string
|
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 { api } = useDngApi()
|
||||||
const auth = useDngAuth()
|
const auth = useDngAuth()
|
||||||
const filter = ref<'all' | 'active' | 'drafts'>('all')
|
const filter = ref<'all' | 'active' | 'drafts'>('all')
|
||||||
const campaigns = ref<CampaignRow[]>([])
|
const campaigns = ref<CampaignRow[]>([])
|
||||||
const drafts = ref<DraftSession[]>([])
|
const drafts = ref<DraftSession[]>([])
|
||||||
|
const usage = ref<UsageSummary | null>(null)
|
||||||
const loading = ref(true)
|
const loading = ref(true)
|
||||||
const error = ref('')
|
const error = ref('')
|
||||||
|
const renamingCampaignId = ref<string | null>(null)
|
||||||
|
const newName = ref('')
|
||||||
|
|
||||||
const visibleCampaigns = computed(() => filter.value === 'active'
|
const visibleCampaigns = computed(() => filter.value === 'active'
|
||||||
? campaigns.value.filter(campaign => campaign.status === 'active')
|
? campaigns.value.filter(campaign => campaign.status === 'active')
|
||||||
@@ -41,15 +48,49 @@ function draftSummary(draft: DraftSession) {
|
|||||||
|| 'Continue your conversation with the coauthor.'
|
|| '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 () => {
|
onMounted(async () => {
|
||||||
try {
|
try {
|
||||||
await auth.restore()
|
await auth.restore()
|
||||||
const [campaignResult, draftResult] = await Promise.all([
|
const [campaignResult, draftResult, usageResult] = await Promise.all([
|
||||||
api<{ campaigns: CampaignRow[] }>('/api/v1/campaigns'),
|
api<{ campaigns: CampaignRow[] }>('/api/v1/campaigns'),
|
||||||
api<{ sessions: DraftSession[] }>('/api/v1/coauthor/sessions'),
|
api<{ sessions: DraftSession[] }>('/api/v1/coauthor/sessions'),
|
||||||
|
api<UsageSummary>('/api/v1/usage').catch(() => null),
|
||||||
])
|
])
|
||||||
campaigns.value = campaignResult.campaigns
|
campaigns.value = campaignResult.campaigns
|
||||||
drafts.value = draftResult.sessions
|
drafts.value = draftResult.sessions
|
||||||
|
usage.value = usageResult
|
||||||
} catch (cause) {
|
} catch (cause) {
|
||||||
const value = cause as { data?: { statusMessage?: string }; message?: string }
|
const value = cause as { data?: { statusMessage?: string }; message?: string }
|
||||||
error.value = value.data?.statusMessage ?? value.message ?? 'Could not load your campaigns.'
|
error.value = value.data?.statusMessage ?? value.message ?? 'Could not load your campaigns.'
|
||||||
@@ -60,13 +101,20 @@ onMounted(async () => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<AppShell section="COMMAND DECK">
|
<AppShell section="COMMAND DECK" @campaign-action="handleCampaignAction">
|
||||||
<div class="dash-wrap noise">
|
<div class="dash-wrap noise">
|
||||||
<section class="dash-head">
|
<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>
|
<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>
|
<NuxtLink to="/worlds/new" class="create-button"><span>+</span> CREATE A UNIVERSE</NuxtLink>
|
||||||
</section>
|
</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">
|
<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>
|
<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>
|
<span>{{ campaigns.length }} CAMPAIGNS · {{ drafts.length }} DRAFTS</span>
|
||||||
@@ -75,7 +123,14 @@ onMounted(async () => {
|
|||||||
<section class="world-grid">
|
<section class="world-grid">
|
||||||
<p v-if="loading" class="load-state">RECEIVING PRIVATE CAMPAIGNS…</p>
|
<p v-if="loading" class="load-state">RECEIVING PRIVATE CAMPAIGNS…</p>
|
||||||
<p v-else-if="error" class="load-state error">{{ error }} <NuxtLink to="/auth/sign-in">SIGN IN AGAIN</NuxtLink></p>
|
<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">
|
<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-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>
|
<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>
|
</NuxtLink>
|
||||||
@@ -90,6 +145,19 @@ 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>
|
<section class="system-strip"><span><i /> AI COAUTHOR READY</span><span>SERVER DICE <b>ONLINE</b></span><span>PRIVATE BY DEFAULT</span></section>
|
||||||
</div>
|
</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>
|
</AppShell>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -102,6 +170,7 @@ onMounted(async () => {
|
|||||||
.dash-head p{color:var(--muted)}
|
.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{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}
|
.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{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{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 button.active{color:var(--ink);border-bottom:2px solid var(--acid)}
|
||||||
@@ -130,7 +199,14 @@ onMounted(async () => {
|
|||||||
.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{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 i{display:inline-block;width:6px;height:6px;background:var(--acid);border-radius:50%;margin-right:8px}
|
||||||
.system-strip b{color:var(--acid)}
|
.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:1050px){.world-grid{grid-template-columns:1fr}}
|
||||||
@media(max-width:850px){.dash-head{align-items:start;flex-direction:column}.active-world{grid-template-columns:1fr}.world-art{min-height:280px}.system-strip{gap:16px;flex-wrap:wrap}}
|
@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}}
|
@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>
|
</style>
|
||||||
|
|||||||
@@ -204,14 +204,14 @@ onBeforeUnmount(clearPreview)
|
|||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
<div class="profile-forms">
|
<div class="profile-forms">
|
||||||
<form class="profile-panel" @submit.prevent="saveProfile">
|
<form id="identity" class="profile-panel" @submit.prevent="saveProfile">
|
||||||
<header><span>01</span><div><small>PUBLIC IDENTITY</small><h2>NAME & DESCRIPTION</h2></div></header>
|
<header><span>01</span><div><small>PUBLIC IDENTITY</small><h2>NAME & DESCRIPTION</h2></div></header>
|
||||||
<label>DISPLAY NAME<input v-model="displayName" required minlength="1" maxlength="80" autocomplete="name" placeholder="How your party knows you"></label>
|
<label>DISPLAY NAME<input v-model="displayName" required minlength="1" maxlength="80" autocomplete="name" placeholder="How your party knows you"></label>
|
||||||
<label>PROFILE DESCRIPTION<textarea v-model="description" maxlength="500" rows="7" placeholder="Tell your party who you are, what you enjoy playing, or what kind of stories you seek." /><span>{{ description.length }} / 500</span></label>
|
<label>PROFILE DESCRIPTION<textarea v-model="description" maxlength="500" rows="7" placeholder="Tell your party who you are, what you enjoy playing, or what kind of stories you seek." /><span>{{ description.length }} / 500</span></label>
|
||||||
<button class="acid-button" :disabled="saving || !displayName.trim()">{{ saving ? 'SAVING PROFILE…' : avatarFile ? 'SAVE PROFILE & PICTURE →' : 'SAVE PROFILE →' }}</button>
|
<button class="acid-button" :disabled="saving || !displayName.trim()">{{ saving ? 'SAVING PROFILE…' : avatarFile ? 'SAVE PROFILE & PICTURE →' : 'SAVE PROFILE →' }}</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<form class="profile-panel" @submit.prevent="changePassword">
|
<form id="settings" class="profile-panel" @submit.prevent="changePassword">
|
||||||
<header><span>02</span><div><small>SECURITY</small><h2>PASSWORD MANAGER</h2></div></header>
|
<header><span>02</span><div><small>SECURITY</small><h2>PASSWORD MANAGER</h2></div></header>
|
||||||
<p class="panel-copy">Choose a new password for this account. Your current signed-in session authorizes the change.</p>
|
<p class="panel-copy">Choose a new password for this account. Your current signed-in session authorizes the change.</p>
|
||||||
<div class="password-grid"><label>NEW PASSWORD<input v-model="password" type="password" minlength="8" autocomplete="new-password" placeholder="At least 8 characters"></label><label>CONFIRM PASSWORD<input v-model="passwordConfirmation" type="password" minlength="8" autocomplete="new-password" placeholder="Repeat new password"></label></div>
|
<div class="password-grid"><label>NEW PASSWORD<input v-model="password" type="password" minlength="8" autocomplete="new-password" placeholder="At least 8 characters"></label><label>CONFIRM PASSWORD<input v-model="passwordConfirmation" type="password" minlength="8" autocomplete="new-password" placeholder="Repeat new password"></label></div>
|
||||||
|
|||||||
17
apps/web/server/api/v1/campaigns/[id].delete.ts
Normal file
17
apps/web/server/api/v1/campaigns/[id].delete.ts
Normal 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)
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -16,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`,
|
`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>>>(
|
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>>>(
|
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`,
|
`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`,
|
||||||
@@ -40,7 +40,7 @@ export default defineEventHandler(async (event) => {
|
|||||||
const visibleMembers = members.map(member => ({ ...member, profile: profileById.get(String(member.user_id)) ?? null }))
|
const visibleMembers = members.map(member => ({ ...member, profile: profileById.get(String(member.user_id)) ?? null }))
|
||||||
const visibleRounds = rounds.slice().reverse()
|
const visibleRounds = rounds.slice().reverse()
|
||||||
const visibleRoundIds = visibleRounds.map(item => String(item.id))
|
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>>>(
|
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`,
|
`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([]),
|
) : Promise.resolve([]),
|
||||||
@@ -50,8 +50,42 @@ export default defineEventHandler(async (event) => {
|
|||||||
visibleRoundIds.length ? stageTwoDatabase<Array<Record<string, unknown>>>(
|
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`,
|
`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([]),
|
) : 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) {
|
} catch (error) {
|
||||||
stageTwoApiError(error)
|
stageTwoApiError(error)
|
||||||
}
|
}
|
||||||
|
|||||||
27
apps/web/server/api/v1/campaigns/[id].patch.ts
Normal file
27
apps/web/server/api/v1/campaigns/[id].patch.ts
Normal 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)
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { AbilityScoresSchema } from '@dng/shared'
|
import { AbilityKeySchema, AbilityScoresSchema, SkillKeySchema } from '@dng/shared'
|
||||||
import { z } from 'zod'
|
import { z } from 'zod'
|
||||||
import { requireCampaignAccess, requireStageTwoSafeText, requireStageTwoUser, stageTwoApiError, stageTwoDatabase, stageTwoRpc, stageTwoUuid } from '~/server/utils/stage-two-supabase'
|
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),
|
proficiency: z.number().int().min(1).max(10).default(2),
|
||||||
inventory: z.array(z.string().trim().min(1).max(120)).max(50).default([]),
|
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([]),
|
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({}),
|
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) => {
|
export default defineEventHandler(async (event) => {
|
||||||
try {
|
try {
|
||||||
@@ -26,7 +33,7 @@ export default defineEventHandler(async (event) => {
|
|||||||
throw createError({ statusCode: 403, statusMessage: 'Only the campaign owner can add AI heroes.' })
|
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'))
|
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_campaign_id: campaignId,
|
||||||
p_actor_id: user.id,
|
p_actor_id: user.id,
|
||||||
p_controller: body.controller,
|
p_controller: body.controller,
|
||||||
@@ -40,6 +47,21 @@ export default defineEventHandler(async (event) => {
|
|||||||
p_inventory: body.inventory,
|
p_inventory: body.inventory,
|
||||||
p_statuses: body.statuses,
|
p_statuses: body.statuses,
|
||||||
p_persona: body.persona,
|
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>>>(
|
const rows = await stageTwoDatabase<Array<Record<string, unknown>>>(
|
||||||
`characters?select=*&id=eq.${characterId}&campaign_id=eq.${campaignId}&limit=1`,
|
`characters?select=*&id=eq.${characterId}&campaign_id=eq.${campaignId}&limit=1`,
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -5,6 +5,8 @@ import {
|
|||||||
requireCampaignAccess,
|
requireCampaignAccess,
|
||||||
requireStageTwoSafeText,
|
requireStageTwoSafeText,
|
||||||
requireStageTwoUser,
|
requireStageTwoUser,
|
||||||
|
stageSixConsumeAiQuota,
|
||||||
|
stageSixRecordAiUsage,
|
||||||
stageTwoApiError,
|
stageTwoApiError,
|
||||||
stageTwoDatabase,
|
stageTwoDatabase,
|
||||||
stageTwoUuid,
|
stageTwoUuid,
|
||||||
@@ -34,7 +36,7 @@ export default defineEventHandler(async (event) => {
|
|||||||
if (!world) throw createError({ statusCode: 404, statusMessage: 'Campaign world not found.' })
|
if (!world) throw createError({ statusCode: 404, statusMessage: 'Campaign world not found.' })
|
||||||
|
|
||||||
const completion = buildJsonCompletion(useRuntimeConfig(), {
|
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 7–16, max HP 8–20, Armor Class (AC) 10–16, proficiency 2. Give 2–5 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 7–16, max HP 8–20, Armor Class (AC) 10–16, proficiency 2. Choose exactly four fitting skill proficiencies and two saving throw proficiencies; expertise may contain at most one of the chosen skills. Give 2–5 useful starting items and a distinctive persona. Never use protected franchise characters. Return only the requested JSON.`,
|
||||||
messages: [{
|
messages: [{
|
||||||
role: 'user',
|
role: 'user',
|
||||||
content: JSON.stringify({
|
content: JSON.stringify({
|
||||||
@@ -48,8 +50,10 @@ export default defineEventHandler(async (event) => {
|
|||||||
jsonSchema: CharacterDraftSchema.toJSONSchema(),
|
jsonSchema: CharacterDraftSchema.toJSONSchema(),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
await stageSixConsumeAiQuota(user.id, campaignId, 'character-draft')
|
||||||
const controller = new AbortController()
|
const controller = new AbortController()
|
||||||
const timeout = setTimeout(() => controller.abort(), 30_000)
|
const timeout = setTimeout(() => controller.abort(), 30_000)
|
||||||
|
const startedAt = Date.now()
|
||||||
let response: Response
|
let response: Response
|
||||||
try {
|
try {
|
||||||
response = await fetch(completion.endpoint, {
|
response = await fetch(completion.endpoint, {
|
||||||
@@ -62,10 +66,14 @@ export default defineEventHandler(async (event) => {
|
|||||||
clearTimeout(timeout)
|
clearTimeout(timeout)
|
||||||
}
|
}
|
||||||
if (!response.ok) throw createError({ statusCode: 502, statusMessage: 'The character coauthor is temporarily unavailable.' })
|
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
|
let character
|
||||||
try {
|
try {
|
||||||
character = CharacterDraftSchema.parse(parseJsonCompletion(await response.json()))
|
character = CharacterDraftSchema.parse(parseJsonCompletion(payload))
|
||||||
} catch {
|
} catch {
|
||||||
throw createError({ statusCode: 502, statusMessage: 'The coauthor returned an invalid character draft.' })
|
throw createError({ statusCode: 502, statusMessage: 'The coauthor returned an invalid character draft.' })
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) => {
|
export default defineEventHandler(async (event) => {
|
||||||
try {
|
try {
|
||||||
@@ -11,6 +11,7 @@ export default defineEventHandler(async (event) => {
|
|||||||
)
|
)
|
||||||
if (!rounds[0]) throw createError({ statusCode: 404, statusMessage: 'Round not found.' })
|
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.' })
|
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', {
|
const jobId = await stageTwoRpc<string>('stage_two_retry_failed_round', {
|
||||||
p_round_id: roundId,
|
p_round_id: roundId,
|
||||||
p_owner_id: user.id,
|
p_owner_id: user.id,
|
||||||
|
|||||||
@@ -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 filters = [`owner_id.eq.${user.id}`, ...memberships.map(item => `id.eq.${item.campaign_id}`)]
|
||||||
const campaigns = await stageTwoDatabase<Array<Record<string, unknown>>>(
|
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 }
|
return { campaigns }
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { z } from 'zod'
|
import { z } from 'zod'
|
||||||
import { buildJsonCompletion, parseJsonCompletion } from '~/server/utils/ai-provider'
|
import { buildJsonCompletion, parseJsonCompletion } from '~/server/utils/ai-provider'
|
||||||
import { CoauthorQuestionSchema, StoredCoauthorQuestionSchema } from '~/server/utils/coauthor-question'
|
import { CoauthorQuestionSchema, StoredCoauthorQuestionSchema } from '~/server/utils/coauthor-question'
|
||||||
import { requireStageTwoSafeText, requireStageTwoUser, stageTwoApiError, stageTwoDatabase, stageTwoRpc, stageTwoUuid } from '~/server/utils/stage-two-supabase'
|
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 MessageSchema = z.object({ role: z.enum(['user', 'assistant']), content: z.string().min(1).max(5000) })
|
||||||
|
|
||||||
@@ -34,8 +34,10 @@ export default defineEventHandler(async (event) => {
|
|||||||
schemaName: 'coauthor_question',
|
schemaName: 'coauthor_question',
|
||||||
jsonSchema: CoauthorQuestionSchema.toJSONSchema(),
|
jsonSchema: CoauthorQuestionSchema.toJSONSchema(),
|
||||||
})
|
})
|
||||||
|
await stageSixConsumeAiQuota(user.id, null, 'coauthor-question')
|
||||||
const controller = new AbortController()
|
const controller = new AbortController()
|
||||||
const timeout = setTimeout(() => controller.abort(), 30_000)
|
const timeout = setTimeout(() => controller.abort(), 30_000)
|
||||||
|
const startedAt = Date.now()
|
||||||
let response: Response
|
let response: Response
|
||||||
try {
|
try {
|
||||||
response = await fetch(completion.endpoint, {
|
response = await fetch(completion.endpoint, {
|
||||||
@@ -45,8 +47,12 @@ export default defineEventHandler(async (event) => {
|
|||||||
clearTimeout(timeout)
|
clearTimeout(timeout)
|
||||||
}
|
}
|
||||||
if (!response.ok) throw createError({ statusCode: 502, statusMessage: 'The coauthor is temporarily unavailable.' })
|
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 {
|
try {
|
||||||
const parsed = CoauthorQuestionSchema.safeParse(parseJsonCompletion(await response.json()))
|
const parsed = CoauthorQuestionSchema.safeParse(parseJsonCompletion(payload))
|
||||||
if (parsed.success) question = parsed.data
|
if (parsed.success) question = parsed.data
|
||||||
} catch {
|
} catch {
|
||||||
// Retry once when the provider ignores structured-output requirements.
|
// Retry once when the provider ignores structured-output requirements.
|
||||||
|
|||||||
61
apps/web/server/api/v1/usage.get.ts
Normal file
61
apps/web/server/api/v1/usage.get.ts
Normal 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)
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -1,9 +1,9 @@
|
|||||||
import { CreateWorldRequestSchema, WorldStarterSchema, moderate13Plus } from '@dng/shared'
|
import { CreateWorldRequestSchema, WorldStarterSchema, moderate13Plus } from '@dng/shared'
|
||||||
import { buildJsonCompletion, parseJsonCompletion } from '../../utils/ai-provider'
|
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 => {
|
export default defineEventHandler(async event => {
|
||||||
await requireStageTwoUser(event)
|
const user = await requireStageTwoUser(event)
|
||||||
const raw = await readBody(event)
|
const raw = await readBody(event)
|
||||||
const prompt = typeof raw?.prompt === 'string' ? raw.prompt : ''
|
const prompt = typeof raw?.prompt === 'string' ? raw.prompt : ''
|
||||||
const answers = raw?.answers && typeof raw.answers === 'object' ? raw.answers : {}
|
const answers = raw?.answers && typeof raw.answers === 'object' ? raw.answers : {}
|
||||||
@@ -24,15 +24,21 @@ export default defineEventHandler(async event => {
|
|||||||
throw createError({ statusCode: 500, statusMessage: error instanceof Error ? error.message : 'Invalid AI provider configuration.' })
|
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, {
|
const response = await fetch(completion.endpoint, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: completion.headers,
|
headers: completion.headers,
|
||||||
body: JSON.stringify(completion.body),
|
body: JSON.stringify(completion.body),
|
||||||
})
|
})
|
||||||
if (!response.ok) throw createError({ statusCode: 502, statusMessage: 'The coauthor is temporarily unavailable.' })
|
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
|
let world
|
||||||
try {
|
try {
|
||||||
world = WorldStarterSchema.parse(parseJsonCompletion(await response.json()))
|
world = WorldStarterSchema.parse(parseJsonCompletion(payload))
|
||||||
} catch {
|
} catch {
|
||||||
throw createError({ statusCode: 502, statusMessage: 'The coauthor returned an invalid world structure.' })
|
throw createError({ statusCode: 502, statusMessage: 'The coauthor returned an invalid world structure.' })
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { describe, expect, it } from 'vitest'
|
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' } }
|
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: [] })).toThrow()
|
||||||
expect(parseJsonCompletion({ choices: [{ message: { content: '{"ok":true}' } }] })).toEqual({ ok: true })
|
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 })
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -5,6 +5,14 @@ const CompletionResponseSchema = z.object({
|
|||||||
choices: z.array(z.object({
|
choices: z.array(z.object({
|
||||||
message: z.object({ content: z.string().min(1) }),
|
message: z.object({ content: z.string().min(1) }),
|
||||||
})).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>
|
export type AiProvider = z.infer<typeof ProviderSchema>
|
||||||
@@ -26,10 +34,18 @@ export interface JsonCompletionRequest {
|
|||||||
jsonSchema: Record<string, unknown>
|
jsonSchema: Record<string, unknown>
|
||||||
}
|
}
|
||||||
|
|
||||||
interface JsonCompletion {
|
export interface JsonCompletion {
|
||||||
endpoint: string
|
endpoint: string
|
||||||
headers: Record<string, string>
|
headers: Record<string, string>
|
||||||
body: Record<string, unknown>
|
body: Record<string, unknown>
|
||||||
|
provider: AiProvider
|
||||||
|
model: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AiUsageSnapshot {
|
||||||
|
inputTokens: number
|
||||||
|
outputTokens: number
|
||||||
|
costUsd: number
|
||||||
}
|
}
|
||||||
|
|
||||||
const nonEmptyString = (value: unknown, name: string) => {
|
const nonEmptyString = (value: unknown, name: string) => {
|
||||||
@@ -73,6 +89,8 @@ export function buildJsonCompletion(config: AiRuntimeConfig, request: JsonComple
|
|||||||
if (provider.provider === 'deepseek') {
|
if (provider.provider === 'deepseek') {
|
||||||
return {
|
return {
|
||||||
endpoint: provider.endpoint,
|
endpoint: provider.endpoint,
|
||||||
|
provider: provider.provider,
|
||||||
|
model: provider.model,
|
||||||
headers: { Authorization: `Bearer ${provider.apiKey}`, 'Content-Type': 'application/json' },
|
headers: { Authorization: `Bearer ${provider.apiKey}`, 'Content-Type': 'application/json' },
|
||||||
body: {
|
body: {
|
||||||
...common,
|
...common,
|
||||||
@@ -88,6 +106,8 @@ export function buildJsonCompletion(config: AiRuntimeConfig, request: JsonComple
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
endpoint: provider.endpoint,
|
endpoint: provider.endpoint,
|
||||||
|
provider: provider.provider,
|
||||||
|
model: provider.model,
|
||||||
headers: { Authorization: `Bearer ${provider.apiKey}`, 'Content-Type': 'application/json', 'X-Title': 'Dungeons & Ground' },
|
headers: { Authorization: `Bearer ${provider.apiKey}`, 'Content-Type': 'application/json', 'X-Title': 'Dungeons & Ground' },
|
||||||
body: {
|
body: {
|
||||||
...common,
|
...common,
|
||||||
@@ -101,3 +121,12 @@ export function parseJsonCompletion(payload: unknown): unknown {
|
|||||||
const completion = CompletionResponseSchema.parse(payload)
|
const completion = CompletionResponseSchema.parse(payload)
|
||||||
return JSON.parse(completion.choices[0]!.message.content)
|
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),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
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', () => {
|
describe('stage-two Supabase client', () => {
|
||||||
afterEach(() => vi.unstubAllGlobals())
|
afterEach(() => vi.unstubAllGlobals())
|
||||||
@@ -48,4 +48,64 @@ describe('stage-two Supabase client', () => {
|
|||||||
Authorization: 'Bearer header.payload.signature',
|
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)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import { z } from 'zod'
|
import { z } from 'zod'
|
||||||
|
import { randomUUID } from 'node:crypto'
|
||||||
import type { H3Event } from 'h3'
|
import type { H3Event } from 'h3'
|
||||||
import { moderate13Plus } from '@dng/shared'
|
import { moderate13Plus } from '@dng/shared'
|
||||||
|
import { parseAiUsage, type JsonCompletion } from './ai-provider'
|
||||||
|
|
||||||
const UuidSchema = z.string().uuid()
|
const UuidSchema = z.string().uuid()
|
||||||
const AuthEmailSchema = z.preprocess(
|
const AuthEmailSchema = z.preprocess(
|
||||||
@@ -87,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) })
|
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> {
|
export async function requireStageTwoUser(event: H3Event): Promise<StageTwoUser> {
|
||||||
const authorization = getHeader(event, 'authorization')
|
const authorization = getHeader(event, 'authorization')
|
||||||
if (!authorization?.startsWith('Bearer ') || authorization.length <= 7) {
|
if (!authorization?.startsWith('Bearer ') || authorization.length <= 7) {
|
||||||
@@ -123,6 +160,32 @@ export async function requireCampaignAccess(campaignId: string, userId: string,
|
|||||||
return { campaign, owner: false, memberId: members[0].id }
|
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 {
|
export function stageTwoApiError(error: unknown): never {
|
||||||
if (error && typeof error === 'object' && 'statusCode' in error) throw error
|
if (error && typeof error === 'object' && 'statusCode' in error) throw error
|
||||||
if (error instanceof StageTwoDatabaseError) {
|
if (error instanceof StageTwoDatabaseError) {
|
||||||
@@ -133,7 +196,8 @@ export function stageTwoApiError(error: unknown): never {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
const conflict = /duplicate key|already exists|not claimable/i.test(error.message)
|
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) {
|
if (error instanceof z.ZodError) {
|
||||||
throw createError({ statusCode: 400, statusMessage: error.issues.map(issue => issue.message).join('; ') })
|
throw createError({ statusCode: 400, statusMessage: error.issues.map(issue => issue.message).join('; ') })
|
||||||
|
|||||||
@@ -17,6 +17,9 @@ const worldFixture = () => ({
|
|||||||
defense: 12,
|
defense: 12,
|
||||||
proficiency: 2,
|
proficiency: 2,
|
||||||
inventory: ['Field kit'],
|
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.' },
|
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.',
|
hook: 'A sufficiently long hook that immediately gives the party something to investigate.',
|
||||||
|
|||||||
@@ -12,6 +12,20 @@ interface AiProvider {
|
|||||||
providerOptions?: Record<string, unknown>
|
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 {
|
function getProvider(): AiProvider {
|
||||||
const provider = (process.env.AI_PROVIDER ?? 'openrouter').toLowerCase()
|
const provider = (process.env.AI_PROVIDER ?? 'openrouter').toLowerCase()
|
||||||
if (provider === 'deepseek') {
|
if (provider === 'deepseek') {
|
||||||
@@ -46,10 +60,11 @@ function assert13Plus(...texts: string[]): void {
|
|||||||
if (!result.allowed) throw new Error(`Content policy rejected: ${result.categories.join(', ')}`)
|
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 provider = getProvider()
|
||||||
const controller = new AbortController()
|
const controller = new AbortController()
|
||||||
const timeout = setTimeout(() => controller.abort(), 45_000)
|
const timeout = setTimeout(() => controller.abort(), 45_000)
|
||||||
|
const startedAt = Date.now()
|
||||||
try {
|
try {
|
||||||
const response = await fetch(provider.endpoint, {
|
const response = await fetch(provider.endpoint, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@@ -77,8 +92,21 @@ async function structuredRequest<T>(name: string, schema: JsonSchema, messages:
|
|||||||
signal: controller.signal,
|
signal: controller.signal,
|
||||||
})
|
})
|
||||||
if (!response.ok) throw new Error(`AI provider returned ${response.status}`)
|
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 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')
|
if (!content) throw new Error('AI provider returned an empty response')
|
||||||
return parse(JSON.parse(content))
|
return parse(JSON.parse(content))
|
||||||
} finally {
|
} 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))
|
assert13Plus(...messages.map(message => message.content))
|
||||||
const world = await structuredRequest('world_starter', WorldStarterSchema.toJSONSchema(), [
|
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, two factions, and three distinct persistent AI companion heroes created specifically for this universe. Give each companion complete playable mechanics, useful genre-appropriate equipment, and a persona that produces interesting party choices without overriding the human player. 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,
|
...messages,
|
||||||
], value => WorldStarterSchema.parse(value))
|
], value => WorldStarterSchema.parse(value), options)
|
||||||
// Moderate the entire typed object, including entity summaries, secrets and
|
// Moderate the entire typed object, including entity summaries, secrets and
|
||||||
// boundaries—not just the headline fields shown in the first preview.
|
// boundaries—not just the headline fields shown in the first preview.
|
||||||
assert13Plus(JSON.stringify(world))
|
assert13Plus(JSON.stringify(world))
|
||||||
return 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(), [
|
const plan = await structuredRequest('round_plan', RoundPlanSchema.toJSONSchema(), [
|
||||||
{ 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, 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. Set proficient true for a skill or saving throw only when the character concept supports it; ordinary equipped-weapon attacks default to proficient. Request separate damage or healing dice only after an applicable action. Never invent dice results and never directly mutate mechanical state. The server owns rules, AC, 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) },
|
{ role: 'user', content: JSON.stringify(input) },
|
||||||
], value => RoundPlanSchema.parse(value))
|
], value => RoundPlanSchema.parse(value), options)
|
||||||
assert13Plus(JSON.stringify(plan.aiActions), JSON.stringify(plan.proposedEvents))
|
assert13Plus(JSON.stringify(plan.aiActions), JSON.stringify(plan.proposedEvents))
|
||||||
return plan
|
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(), [
|
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: '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) },
|
{ role: 'user', content: JSON.stringify(input) },
|
||||||
], value => RoundResolutionSchema.parse(value))
|
], value => RoundResolutionSchema.parse(value), options)
|
||||||
assert13Plus(JSON.stringify(resolution))
|
assert13Plus(JSON.stringify(resolution))
|
||||||
return 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(), [
|
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: '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) },
|
{ role: 'user', content: JSON.stringify(input) },
|
||||||
], value => StorySummarySchema.parse(value))
|
], value => StorySummarySchema.parse(value), options)
|
||||||
assert13Plus(result.summary)
|
assert13Plus(result.summary)
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,8 +3,8 @@ import { Worker } from 'bullmq'
|
|||||||
import IORedis from 'ioredis'
|
import IORedis from 'ioredis'
|
||||||
import { applyMechanicalEvents, bindMechanicalEventsToRolls, resolveChecks } from '@dng/game-engine'
|
import { applyMechanicalEvents, bindMechanicalEventsToRolls, resolveChecks } from '@dng/game-engine'
|
||||||
import { CharacterSchema, PlayerIntentSchema, makeId } from '@dng/shared'
|
import { CharacterSchema, PlayerIntentSchema, makeId } from '@dng/shared'
|
||||||
import { generateWorld, narrateRound, planRound, summarizeStory } from './ai'
|
import { generateWorld, narrateRound, planRound, summarizeStory, type AiUsageMeasurement } from './ai'
|
||||||
import { buildActionSequence, buildRoundContext, sanitizeRoundMemory, shouldUpdateStorySummary, validateAndOrderRoundPlan } from './orchestration'
|
import { buildActionSequence, buildRetrievalText, buildRoundContext, sanitizeRoundMemory, shouldUpdateStorySummary, validateAndOrderRoundPlan } from './orchestration'
|
||||||
|
|
||||||
const redisUrl = process.env.REDIS_URL
|
const redisUrl = process.env.REDIS_URL
|
||||||
const supabaseUrl = process.env.SUPABASE_URL
|
const supabaseUrl = process.env.SUPABASE_URL
|
||||||
@@ -28,6 +28,12 @@ if (!supabaseUrl || !serviceKey) {
|
|||||||
job_type: JobType
|
job_type: JobType
|
||||||
entity_id: string
|
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 {
|
class SupabaseRequestError extends Error {
|
||||||
constructor(
|
constructor(
|
||||||
@@ -67,38 +73,42 @@ if (!supabaseUrl || !serviceKey) {
|
|||||||
return databaseRequest<T[]>(`${table}?${new URLSearchParams(query)}`)
|
return databaseRequest<T[]>(`${table}?${new URLSearchParams(query)}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
async function persistStorySummary(jobId: string, campaignId: string, throughRound: number, summary: string): Promise<void> {
|
function usageRecorder(job: JobPayload, userId: string | null, campaignId: string | null) {
|
||||||
let lastError: unknown
|
return async (measurement: AiUsageMeasurement): Promise<void> => {
|
||||||
for (let attempt = 1; attempt <= 3; attempt += 1) {
|
|
||||||
try {
|
try {
|
||||||
await databaseRequest('rpc/stage_two_upsert_story_summary', {
|
await databaseRequest('ai_usage?on_conflict=usage_key', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
|
headers: { Prefer: 'resolution=merge-duplicates,return=minimal' },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
p_job_id: jobId,
|
usage_key: `${job.id}:${measurement.operation}:${makeId('usage')}`,
|
||||||
p_campaign_id: campaignId,
|
user_id: userId,
|
||||||
p_through_round: throughRound,
|
campaign_id: campaignId,
|
||||||
p_summary: summary,
|
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) {
|
} catch (error) {
|
||||||
lastError = error
|
// Usage telemetry must never trigger a second paid model call. Quota
|
||||||
if (attempt < 3) await new Promise(resolve => setTimeout(resolve, attempt * 250))
|
// 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) {
|
async function processWorld(job: JobPayload) {
|
||||||
let messages = job.messages
|
const [session] = await selectRows<{ owner_id: string; messages: Array<{ role: 'user' | 'assistant'; content: string }> }>('coauthor_sessions', {
|
||||||
if (!messages) {
|
select: 'owner_id,messages', id: `eq.${job.entityId}`, limit: '1',
|
||||||
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')
|
||||||
})
|
const messages = job.messages ?? session.messages
|
||||||
if (!session) throw new Error('Coauthor session not found')
|
const world = await generateWorld(messages, { onUsage: usageRecorder(job, session.owner_id, null) })
|
||||||
messages = session.messages
|
|
||||||
}
|
|
||||||
const world = await generateWorld(messages)
|
|
||||||
await databaseRequest(`coauthor_sessions?id=eq.${encodeURIComponent(job.entityId)}`, {
|
await databaseRequest(`coauthor_sessions?id=eq.${encodeURIComponent(job.entityId)}`, {
|
||||||
method: 'PATCH',
|
method: 'PATCH',
|
||||||
body: JSON.stringify({ status: 'ready', generated_world: world, updated_at: new Date().toISOString() }),
|
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) throw new Error('Round not found')
|
||||||
if (round.status === 'resolved') return { duplicate: true }
|
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>>('characters', { select: '*', campaign_id: `eq.${round.campaign_id}` }),
|
||||||
selectRows<Record<string, any>>('player_intents', { select: '*', round_id: `eq.${round.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', {
|
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',
|
select: 'number,narration,next_prompt', campaign_id: `eq.${round.campaign_id}`, status: 'eq.resolved', order: 'number.desc', limit: '2',
|
||||||
}),
|
}),
|
||||||
@@ -135,38 +142,49 @@ if (!supabaseUrl || !serviceKey) {
|
|||||||
const characters = rawCharacters.map(row => CharacterSchema.parse({
|
const characters = rawCharacters.map(row => CharacterSchema.parse({
|
||||||
id: row.id, name: row.name, concept: row.concept, controller: row.controller,
|
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,
|
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({
|
const intents = rawIntents.map(row => PlayerIntentSchema.parse({
|
||||||
id: row.id, roundId: row.round_id, memberId: row.member_id, characterId: row.character_id,
|
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,
|
action: row.action, ready: row.ready, createdAt: row.created_at, updatedAt: row.updated_at,
|
||||||
}))
|
}))
|
||||||
const scene = String(round.campaigns.current_scene ?? '')
|
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({
|
const context = buildRoundContext({
|
||||||
scene,
|
scene,
|
||||||
storySummary: rawStorySummaries[0]?.summary ?? null,
|
storySummary: rawStorySummaries[0]?.summary ?? null,
|
||||||
recentRounds: rawRecentRounds.map(previous => ({ number: previous.number, narration: previous.narration, nextPrompt: previous.next_prompt })),
|
recentRounds: rawRecentRounds.map(previous => ({ number: previous.number, narration: previous.narration, nextPrompt: previous.next_prompt })),
|
||||||
intents,
|
intents,
|
||||||
characters,
|
characters,
|
||||||
memories: rawMemories.map(memory => ({
|
memories: retrieval.memories ?? [],
|
||||||
summary: memory.summary, importance: memory.importance, tags: memory.tags, entityIds: memory.entity_ids,
|
entities: retrieval.entities ?? [],
|
||||||
})),
|
relationships: retrieval.relationships ?? [],
|
||||||
|
activeGoals: retrieval.activeGoals ?? [],
|
||||||
})
|
})
|
||||||
const plan = validateAndOrderRoundPlan(await planRound(context), context)
|
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 rolls = resolveChecks(plan.checks, characters)
|
||||||
const permittedEvents = bindMechanicalEventsToRolls(plan.proposedEvents, rolls)
|
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 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 shouldSummarize = shouldUpdateStorySummary(Number(round.number))
|
||||||
const summary = shouldSummarize
|
const summary = shouldSummarize
|
||||||
? await summarizeStory({
|
? await summarizeStory({
|
||||||
previousSummary: context.storySummary,
|
previousSummary: context.storySummary,
|
||||||
rounds: [...context.recentRounds.map(previous => ({ number: previous.number, narration: previous.narration })), { number: Number(round.number), narration: resolution.narration }],
|
rounds: [...context.recentRounds.map(previous => ({ number: previous.number, narration: previous.narration })), { number: Number(round.number), narration: resolution.narration }],
|
||||||
})
|
}, { onUsage: recordUsage })
|
||||||
: null
|
: 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',
|
method: 'POST',
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
p_round_id: round.id,
|
p_round_id: round.id,
|
||||||
@@ -174,19 +192,22 @@ if (!supabaseUrl || !serviceKey) {
|
|||||||
p_next_prompt: resolution.nextPrompt,
|
p_next_prompt: resolution.nextPrompt,
|
||||||
p_rolls: rolls,
|
p_rolls: rolls,
|
||||||
p_events: safeEvents,
|
p_events: safeEvents,
|
||||||
p_character_states: applied.characters.map(character => ({ id: character.id, hp: character.hp, inventory: character.inventory, statuses: character.statuses })),
|
p_character_states: applied.characters.map(character => ({
|
||||||
p_memory: sanitizeRoundMemory(resolution.memory),
|
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,
|
p_idempotency_key: job.id,
|
||||||
...(job.claimToken ? { p_worker_id: job.claimToken } : {}),
|
...(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 }
|
return { narration: resolution.narration, rolls: rolls.length }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
64
apps/worker/src/memory-regression.test.ts
Normal file
64
apps/worker/src/memory-regression.test.ts
Normal 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)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { describe, expect, it } from 'vitest'
|
import { describe, expect, it } from 'vitest'
|
||||||
import type { Character, PlayerIntent, RoundPlan } from '@dng/shared'
|
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 => ({
|
const character = (id: string, controller: Character['controller']): Character => ({
|
||||||
id, controller, name: id, concept: 'A campaign hero', userId: controller === 'human' ? 'user' : null,
|
id, controller, name: id, concept: 'A campaign hero', userId: controller === 'human' ? 'user' : null,
|
||||||
@@ -61,11 +61,44 @@ describe('round orchestration', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('drops model-authored memory labels that PostgreSQL cannot cast to uuid[]', () => {
|
it('drops model-authored memory labels that PostgreSQL cannot cast to uuid[]', () => {
|
||||||
|
const knownId = '9ea0ef06-34bf-4cf3-8f2f-af1a65180d0e'
|
||||||
expect(sanitizeRoundMemory({
|
expect(sanitizeRoundMemory({
|
||||||
summary: 'The party learned who controls the gate.',
|
summary: 'The party learned who controls the gate.',
|
||||||
importance: 4,
|
importance: 4,
|
||||||
tags: ['gate'],
|
tags: ['gate'],
|
||||||
entityIds: ['the-gatekeeper', '9ea0ef06-34bf-4cf3-8f2f-af1a65180d0e', '9ea0ef06-34bf-4cf3-8f2f-af1a65180d0e'],
|
entityIds: ['the-gatekeeper', knownId, knownId, '8ea0ef06-34bf-4cf3-8f2f-af1a65180d0e'],
|
||||||
})?.entityIds).toEqual(['9ea0ef06-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)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -2,7 +2,10 @@ import {
|
|||||||
RoundContextSchema,
|
RoundContextSchema,
|
||||||
RoundPlanSchema,
|
RoundPlanSchema,
|
||||||
type Character,
|
type Character,
|
||||||
|
type ContextEntity,
|
||||||
type PlayerIntent,
|
type PlayerIntent,
|
||||||
|
type QuestState,
|
||||||
|
type RelationshipState,
|
||||||
type RoundContext,
|
type RoundContext,
|
||||||
type RoundPlan,
|
type RoundPlan,
|
||||||
type RoundResolution,
|
type RoundResolution,
|
||||||
@@ -17,6 +20,17 @@ export interface RoundContextInput {
|
|||||||
intents: PlayerIntent[]
|
intents: PlayerIntent[]
|
||||||
characters: Character[]
|
characters: Character[]
|
||||||
memories: Array<{ summary: string; importance?: number; tags?: string[]; entityIds?: string[] }>
|
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 {
|
export function buildRoundContext(input: RoundContextInput): RoundContext {
|
||||||
@@ -51,6 +65,9 @@ export function buildRoundContext(input: RoundContextInput): RoundContext {
|
|||||||
humanIntents,
|
humanIntents,
|
||||||
aiCharacters,
|
aiCharacters,
|
||||||
activeCharacters: input.characters,
|
activeCharacters: input.characters,
|
||||||
|
entities: input.entities ?? [],
|
||||||
|
relationships: input.relationships ?? [],
|
||||||
|
activeGoals: input.activeGoals ?? [],
|
||||||
memories: rankedMemories,
|
memories: rankedMemories,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -58,6 +75,9 @@ export function buildRoundContext(input: RoundContextInput): RoundContext {
|
|||||||
export function validateAndOrderRoundPlan(planInput: unknown, context: RoundContext): RoundPlan {
|
export function validateAndOrderRoundPlan(planInput: unknown, context: RoundContext): RoundPlan {
|
||||||
const plan = RoundPlanSchema.parse(planInput)
|
const plan = RoundPlanSchema.parse(planInput)
|
||||||
const characterIds = new Set(context.activeCharacters.map(character => character.id))
|
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 aiOrder = new Map(context.aiCharacters.map((character, index) => [character.id, index]))
|
||||||
const seenAiActors = new Set<string>()
|
const seenAiActors = new Set<string>()
|
||||||
|
|
||||||
@@ -69,15 +89,31 @@ export function validateAndOrderRoundPlan(planInput: unknown, context: RoundCont
|
|||||||
if (['ability', 'skill', 'savingThrow'].includes(check.kind) && check.difficulty === undefined) {
|
if (['ability', 'skill', 'savingThrow'].includes(check.kind) && check.difficulty === undefined) {
|
||||||
throw new Error(`${check.kind} checks require a Difficulty Class`)
|
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 (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`)
|
if (['damage', 'healing'].includes(check.kind) && !check.targetId) throw new Error(`${check.kind} rolls require a target`)
|
||||||
}
|
}
|
||||||
for (const event of plan.proposedEvents) {
|
for (const event of plan.proposedEvents) {
|
||||||
if (event.actorId && !characterIds.has(event.actorId)) throw new Error(`Unknown event actor ${event.actorId}`)
|
const mechanical = ['damage', 'healing', 'inventory', 'status', 'temporaryHp', 'resource', 'rest'].includes(event.type)
|
||||||
if (event.targetId && !characterIds.has(event.targetId)) throw new Error(`Unknown event target ${event.targetId}`)
|
if (event.actorId && !(mechanical ? characterIds : worldStateIds).has(event.actorId)) throw new Error(`Unknown event actor ${event.actorId}`)
|
||||||
if (['damage', 'healing', 'inventory', 'status'].includes(event.type) && !event.targetId) {
|
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`)
|
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
|
const aiActions = plan.aiActions
|
||||||
@@ -108,10 +144,12 @@ export function shouldUpdateStorySummary(roundNumber: number): boolean {
|
|||||||
* are useful prose but cannot be cast to that column and would roll back an
|
* are useful prose but cannot be cast to that column and would roll back an
|
||||||
* otherwise valid round. Preserve only storage-compatible references.
|
* 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
|
if (!memory) return null
|
||||||
return {
|
return {
|
||||||
...memory,
|
...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)),
|
||||||
|
))],
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,7 +23,8 @@
|
|||||||
"test:unit": "vitest run",
|
"test:unit": "vitest run",
|
||||||
"test:watch": "vitest",
|
"test:watch": "vitest",
|
||||||
"typecheck": "pnpm -r --if-present typecheck",
|
"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": {
|
"dependencies": {
|
||||||
"postgres": "3.4.7"
|
"postgres": "3.4.7"
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { describe, expect, it } from 'vitest'
|
import { describe, expect, it } from 'vitest'
|
||||||
import { abilityModifier, applyMechanicalEvents, bindMechanicalEventsToRolls, resolveCheck, resolveChecks, shouldCloseRound, type RandomSource } from './index'
|
import { abilityModifier, applyMechanicalEvents, bindMechanicalEventsToRolls, proficiencyBonus, resolveCheck, resolveChecks, shouldCloseRound, type RandomSource } from './index'
|
||||||
import type { Character } from '@dng/shared'
|
import type { Character } from '@dng/shared'
|
||||||
|
|
||||||
const fixed: RandomSource = { integer: () => 12 }
|
const fixed: RandomSource = { integer: () => 12 }
|
||||||
@@ -91,13 +91,13 @@ describe('game engine', () => {
|
|||||||
{ integer: () => 4 },
|
{ integer: () => 4 },
|
||||||
)
|
)
|
||||||
const events = bindMechanicalEventsToRolls([
|
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])
|
], [damage])
|
||||||
expect(events[0]?.value).toBe(6)
|
expect(events[0]?.value).toBe(6)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('clamps mechanical state', () => {
|
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.characters[0]?.hp).toBe(0)
|
||||||
expect(result.audit).toHaveLength(1)
|
expect(result.audit).toHaveLength(1)
|
||||||
})
|
})
|
||||||
@@ -107,4 +107,73 @@ describe('game engine', () => {
|
|||||||
expect(shouldCloseRound(['a', 'b'], ['a', 'b'])).toBe(true)
|
expect(shouldCloseRound(['a', 'b'], ['a', 'b'])).toBe(true)
|
||||||
expect(shouldCloseRound(['a', 'b'], [], true)).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 })
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { randomInt, randomUUID } from 'node:crypto'
|
import { randomInt, randomUUID } from 'node:crypto'
|
||||||
import type { AbilityKey, Character, CheckRequest, DiceRoll, ProposedEvent, SkillKey } from '@dng/shared'
|
import type { AbilityKey, Character, CharacterRules, CheckRequest, DamageType, DiceRoll, ProposedEvent, SkillKey } from '@dng/shared'
|
||||||
|
|
||||||
export interface RandomSource {
|
export interface RandomSource {
|
||||||
integer(min: number, max: number): number
|
integer(min: number, max: number): number
|
||||||
@@ -16,6 +16,11 @@ export function abilityModifier(score: number): number {
|
|||||||
return Math.floor((score - 10) / 2)
|
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) {
|
export function rollDice(formula: string, random: RandomSource = secureRandom) {
|
||||||
const match = /^(\d+)d(\d+)(?:([+-])(\d+))?$/.exec(formula)
|
const match = /^(\d+)d(\d+)(?:([+-])(\d+))?$/.exec(formula)
|
||||||
if (!match) throw new Error(`Unsupported dice formula: ${formula}`)
|
if (!match) throw new Error(`Unsupported dice formula: ${formula}`)
|
||||||
@@ -86,6 +91,54 @@ function usesProficiency(request: CheckRequest): boolean {
|
|||||||
return request.proficient === true
|
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(
|
export function resolveCheck(
|
||||||
request: CheckRequest,
|
request: CheckRequest,
|
||||||
actor: Character,
|
actor: Character,
|
||||||
@@ -112,11 +165,27 @@ export function resolveCheck(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 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 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 keptValue = mode === 'advantage' ? Math.max(...rolls) : mode === 'disadvantage' ? Math.min(...rolls) : rolls[0]!
|
||||||
const modifier = abilityModifier(actor.abilities[ability]) + (usesProficiency(request) ? actor.proficiency : 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 total = keptValue + modifier
|
||||||
if (request.kind === 'attack' && (!request.targetId || !target || target.id !== request.targetId)) {
|
if (request.kind === 'attack' && (!request.targetId || !target || target.id !== request.targetId)) {
|
||||||
throw new Error('Attack rolls require the active target character')
|
throw new Error('Attack rolls require the active target character')
|
||||||
@@ -125,18 +194,19 @@ export function resolveCheck(
|
|||||||
const automaticAttackOutcome = request.kind === 'attack'
|
const automaticAttackOutcome = request.kind === 'attack'
|
||||||
? keptValue === 20 ? true : keptValue === 1 ? false : null
|
? keptValue === 20 ? true : keptValue === 1 ? false : null
|
||||||
: null
|
: null
|
||||||
|
const automaticSaveOutcome = automaticallyFailedSave(request, actor) ? false : null
|
||||||
return {
|
return {
|
||||||
id: randomUUID(),
|
id: randomUUID(),
|
||||||
checkKind: request.kind,
|
checkKind: request.kind,
|
||||||
formula: request.mode === 'normal'
|
formula: mode === 'normal'
|
||||||
? `1d20${modifier >= 0 ? '+' : ''}${modifier}`
|
? `1d20${modifier >= 0 ? '+' : ''}${modifier}`
|
||||||
: `2d20${request.mode === 'advantage' ? 'kh1' : 'kl1'}${modifier >= 0 ? '+' : ''}${modifier}`,
|
: `2d20${mode === 'advantage' ? 'kh1' : 'kl1'}${modifier >= 0 ? '+' : ''}${modifier}`,
|
||||||
rolls,
|
rolls,
|
||||||
kept: [keptValue],
|
kept: [keptValue],
|
||||||
modifier,
|
modifier,
|
||||||
total,
|
total,
|
||||||
difficulty,
|
difficulty,
|
||||||
success: difficulty === null ? null : automaticAttackOutcome ?? total >= difficulty,
|
success: difficulty === null ? null : automaticAttackOutcome ?? automaticSaveOutcome ?? total >= difficulty,
|
||||||
actorId: actor.id,
|
actorId: actor.id,
|
||||||
targetId: request.targetId ?? null,
|
targetId: request.targetId ?? null,
|
||||||
createdAt: new Date().toISOString(),
|
createdAt: new Date().toISOString(),
|
||||||
@@ -193,26 +263,145 @@ export interface AppliedState {
|
|||||||
audit: Array<{ event: ProposedEvent; before: unknown; after: unknown }>
|
audit: Array<{ event: ProposedEvent; before: unknown; after: unknown }>
|
||||||
}
|
}
|
||||||
|
|
||||||
export function applyMechanicalEvents(characters: Character[], events: ProposedEvent[]): AppliedState {
|
function cloneRules(rules: CharacterRules | undefined): CharacterRules | undefined {
|
||||||
const next = characters.map(character => ({ ...character, inventory: [...character.inventory], statuses: [...character.statuses] }))
|
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'] = []
|
const audit: AppliedState['audit'] = []
|
||||||
|
|
||||||
|
applyDeathSavingThrows(next, rolls)
|
||||||
|
|
||||||
for (const event of events) {
|
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)
|
const target = next.find(character => character.id === event.targetId)
|
||||||
if (!target) throw new Error(`Unknown event target: ${event.targetId}`)
|
if (!target) throw new Error(`Unknown event target: ${event.targetId}`)
|
||||||
const before = structuredClone(target)
|
const before = structuredClone(target)
|
||||||
|
|
||||||
if (event.type === 'damage') target.hp = Math.max(0, target.hp - Math.max(0, event.value ?? 0))
|
if (event.type === 'damage') {
|
||||||
if (event.type === 'healing') target.hp = Math.min(target.maxHp, target.hp + Math.max(0, event.value ?? 0))
|
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.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.includes(event.item)) target.inventory.push(event.item)
|
||||||
if ((event.value ?? 1) < 0) target.inventory = target.inventory.filter(item => item !== event.item)
|
if ((event.value ?? 1) < 0) target.inventory = target.inventory.filter(item => item !== event.item)
|
||||||
}
|
}
|
||||||
if (event.type === 'status' && 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.item.toLowerCase() === 'exhaustion' && target.rules) {
|
||||||
if ((event.value ?? 1) < 0) target.statuses = target.statuses.filter(status => status !== event.item)
|
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) })
|
audit.push({ event, before, after: structuredClone(target) })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,9 @@ const draft = {
|
|||||||
defense: 13,
|
defense: 13,
|
||||||
proficiency: 2,
|
proficiency: 2,
|
||||||
inventory: ['Signal lens', 'Field toolkit'],
|
inventory: ['Signal lens', 'Field toolkit'],
|
||||||
|
skillProficiencies: ['arcana', 'history', 'investigation', 'perception'],
|
||||||
|
skillExpertise: ['investigation'],
|
||||||
|
savingThrowProficiencies: ['int', 'wis'],
|
||||||
persona: {
|
persona: {
|
||||||
voice: 'Precise, with dry humor.',
|
voice: 'Precise, with dry humor.',
|
||||||
motivation: 'Prove the relay is speaking from the future.',
|
motivation: 'Prove the relay is speaking from the future.',
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { describe, expect, it } from 'vitest'
|
import { describe, expect, it } from 'vitest'
|
||||||
import { CheckRequestSchema, PlayerIntentSchema } from './index'
|
import { CharacterRulesSchema, CheckRequestSchema, PlayerIntentSchema } from './index'
|
||||||
|
|
||||||
describe('shared database contracts', () => {
|
describe('shared database contracts', () => {
|
||||||
it('accepts PostgREST timestamptz values with an explicit UTC offset', () => {
|
it('accepts PostgREST timestamptz values with an explicit UTC offset', () => {
|
||||||
@@ -28,4 +28,21 @@ describe('shared database contracts', () => {
|
|||||||
difficulty: 13, mode: 'normal', reason: 'Resist the whisper',
|
difficulty: 13, mode: 'normal', reason: 'Resist the whisper',
|
||||||
})).toMatchObject({ kind: 'savingThrow', ability: 'wis' })
|
})).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')
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -18,6 +18,62 @@ export const skillKeys = [
|
|||||||
export const SkillKeySchema = z.enum(skillKeys)
|
export const SkillKeySchema = z.enum(skillKeys)
|
||||||
export type SkillKey = z.infer<typeof SkillKeySchema>
|
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({
|
export const AbilityScoresSchema = z.object({
|
||||||
str: z.number().int().min(1).max(30),
|
str: z.number().int().min(1).max(30),
|
||||||
dex: z.number().int().min(1).max(30),
|
dex: z.number().int().min(1).max(30),
|
||||||
@@ -55,6 +111,9 @@ export const CharacterDraftSchema = z.object({
|
|||||||
defense: z.number().int().min(1).max(40),
|
defense: z.number().int().min(1).max(40),
|
||||||
proficiency: z.number().int().min(1).max(10),
|
proficiency: z.number().int().min(1).max(10),
|
||||||
inventory: z.array(z.string().trim().min(1).max(120)).max(12),
|
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,
|
persona: CharacterPersonaSchema,
|
||||||
}).strict().refine(value => value.hp <= value.maxHp, {
|
}).strict().refine(value => value.hp <= value.maxHp, {
|
||||||
message: 'hp must not exceed maxHp',
|
message: 'hp must not exceed maxHp',
|
||||||
@@ -92,6 +151,7 @@ export const CharacterSchema = z.object({
|
|||||||
inventory: z.array(z.string().max(120)).max(50).default([]),
|
inventory: z.array(z.string().max(120)).max(50).default([]),
|
||||||
statuses: z.array(z.string().max(80)).max(12).default([]),
|
statuses: z.array(z.string().max(80)).max(12).default([]),
|
||||||
persona: z.record(z.string(), z.unknown()).optional(),
|
persona: z.record(z.string(), z.unknown()).optional(),
|
||||||
|
rules: CharacterRulesSchema.optional(),
|
||||||
})
|
})
|
||||||
export type Character = z.infer<typeof CharacterSchema>
|
export type Character = z.infer<typeof CharacterSchema>
|
||||||
|
|
||||||
@@ -107,6 +167,32 @@ export const PlayerIntentSchema = z.object({
|
|||||||
})
|
})
|
||||||
export type PlayerIntent = z.infer<typeof PlayerIntentSchema>
|
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({
|
export const RoundContextSchema = z.object({
|
||||||
scene: z.string().max(6000),
|
scene: z.string().max(6000),
|
||||||
storySummary: z.string().max(6000).nullable(),
|
storySummary: z.string().max(6000).nullable(),
|
||||||
@@ -118,6 +204,9 @@ export const RoundContextSchema = z.object({
|
|||||||
humanIntents: z.array(PlayerIntentSchema).max(8),
|
humanIntents: z.array(PlayerIntentSchema).max(8),
|
||||||
aiCharacters: z.array(CharacterSchema).max(8),
|
aiCharacters: z.array(CharacterSchema).max(8),
|
||||||
activeCharacters: z.array(CharacterSchema).max(16),
|
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({
|
memories: z.array(z.object({
|
||||||
summary: z.string().min(1).max(1200),
|
summary: z.string().min(1).max(1200),
|
||||||
importance: z.number().int().min(1).max(5).optional(),
|
importance: z.number().int().min(1).max(5).optional(),
|
||||||
@@ -129,7 +218,7 @@ export type RoundContext = z.infer<typeof RoundContextSchema>
|
|||||||
|
|
||||||
export const CheckRequestSchema = z.object({
|
export const CheckRequestSchema = z.object({
|
||||||
actorId: z.string().min(1),
|
actorId: z.string().min(1),
|
||||||
kind: z.enum(['ability', 'skill', 'savingThrow', 'attack', 'initiative', 'damage', 'healing']),
|
kind: z.enum(['ability', 'skill', 'savingThrow', 'deathSavingThrow', 'attack', 'initiative', 'damage', 'healing']),
|
||||||
ability: AbilityKeySchema.optional(),
|
ability: AbilityKeySchema.optional(),
|
||||||
skill: SkillKeySchema.optional(),
|
skill: SkillKeySchema.optional(),
|
||||||
proficient: z.boolean().optional(),
|
proficient: z.boolean().optional(),
|
||||||
@@ -137,6 +226,7 @@ export const CheckRequestSchema = z.object({
|
|||||||
targetId: z.string().optional(),
|
targetId: z.string().optional(),
|
||||||
mode: z.enum(['normal', 'advantage', 'disadvantage']).default('normal'),
|
mode: z.enum(['normal', 'advantage', 'disadvantage']).default('normal'),
|
||||||
dice: z.string().regex(/^\d+d\d+(?:[+-]\d+)?$/).optional(),
|
dice: z.string().regex(/^\d+d\d+(?:[+-]\d+)?$/).optional(),
|
||||||
|
damageType: DamageTypeSchema.optional(),
|
||||||
reason: z.string().min(1).max(500),
|
reason: z.string().min(1).max(500),
|
||||||
})
|
})
|
||||||
export type CheckRequest = z.infer<typeof CheckRequestSchema>
|
export type CheckRequest = z.infer<typeof CheckRequestSchema>
|
||||||
@@ -158,11 +248,12 @@ export const DiceRollSchema = z.object({
|
|||||||
export type DiceRoll = z.infer<typeof DiceRollSchema>
|
export type DiceRoll = z.infer<typeof DiceRollSchema>
|
||||||
|
|
||||||
export const ProposedEventSchema = z.object({
|
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),
|
actorId: z.string().nullable().default(null),
|
||||||
targetId: z.string().nullable().default(null),
|
targetId: z.string().nullable().default(null),
|
||||||
value: z.number().int().nullable().default(null),
|
value: z.number().int().nullable().default(null),
|
||||||
item: z.string().max(120).nullable().default(null),
|
item: z.string().max(120).nullable().default(null),
|
||||||
|
damageType: DamageTypeSchema.nullable().default(null),
|
||||||
description: z.string().min(1).max(1000),
|
description: z.string().min(1).max(1000),
|
||||||
})
|
})
|
||||||
export type ProposedEvent = z.infer<typeof ProposedEventSchema>
|
export type ProposedEvent = z.infer<typeof ProposedEventSchema>
|
||||||
|
|||||||
@@ -203,5 +203,12 @@ while (Date.now() < deadline) {
|
|||||||
}
|
}
|
||||||
if (!completed) throw new Error('[smoke] Multiplayer round did not resolve within 120 seconds.')
|
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.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}).`)
|
||||||
|
|||||||
@@ -2780,6 +2780,911 @@ grant execute on function public.dng_schema_version() to service_role;
|
|||||||
|
|
||||||
notify pgrst, 'reload schema';
|
notify pgrst, 'reload schema';
|
||||||
|
|
||||||
|
-- ============================================================================
|
||||||
|
-- 0012_versioned_srd_rules.sql
|
||||||
|
-- ============================================================================
|
||||||
|
|
||||||
|
-- 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';
|
||||||
|
|
||||||
|
-- ============================================================================
|
||||||
|
-- 0013_memory_and_stability.sql
|
||||||
|
-- ============================================================================
|
||||||
|
|
||||||
|
-- Weeks 7–8: 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';
|
||||||
|
|
||||||
|
|
||||||
do $$
|
do $$
|
||||||
begin
|
begin
|
||||||
@@ -2804,7 +3709,7 @@ begin
|
|||||||
if to_regprocedure('public.stage_four_create_character(uuid,uuid,text,text,text,jsonb,integer,integer,integer,integer,jsonb,jsonb,jsonb)') is null then
|
if to_regprocedure('public.stage_four_create_character(uuid,uuid,text,text,text,jsonb,integer,integer,integer,integer,jsonb,jsonb,jsonb)') is null then
|
||||||
raise exception 'D&G bootstrap verification failed: character creation RPC is missing';
|
raise exception 'D&G bootstrap verification failed: character creation RPC is missing';
|
||||||
end if;
|
end if;
|
||||||
if public.dng_schema_version() <> 11 then
|
if public.dng_schema_version() <> 13 then
|
||||||
raise exception 'D&G bootstrap verification failed: unexpected schema version';
|
raise exception 'D&G bootstrap verification failed: unexpected schema version';
|
||||||
end if;
|
end if;
|
||||||
end;
|
end;
|
||||||
|
|||||||
352
supabase/migrations/0012_versioned_srd_rules.sql
Normal file
352
supabase/migrations/0012_versioned_srd_rules.sql
Normal 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';
|
||||||
543
supabase/migrations/0013_memory_and_stability.sql
Normal file
543
supabase/migrations/0013_memory_and_stability.sql
Normal file
@@ -0,0 +1,543 @@
|
|||||||
|
-- Weeks 7–8: 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';
|
||||||
Reference in New Issue
Block a user