Compare commits
22 Commits
c1e6b4b61f
...
agent/code
| Author | SHA1 | Date | |
|---|---|---|---|
| fdf02446f5 | |||
| 034966f9ef | |||
| 0a38ffdaf0 | |||
| 6c39188027 | |||
| 89fd1fd984 | |||
| 4e0c38c925 | |||
| ace1e47a6d | |||
| 0ade57b5a1 | |||
| 56b4586d1a | |||
| f6c0fbcfeb | |||
| 0b53ad0701 | |||
| 719cc738db | |||
| e430b4e773 | |||
| 9dd856ff8c | |||
| 56bddafbe8 | |||
| f7ca158fe4 | |||
| 19693c78c4 | |||
| 2898efa1db | |||
| fb4b06711d | |||
| 41f4633dca | |||
| fc205c232f | |||
| 744749bc54 |
3
PLAN.md
3
PLAN.md
@@ -27,12 +27,13 @@ Dungeons & Ground — англоязычная веб‑платформа дл
|
|||||||
- стартовая локация;
|
- стартовая локация;
|
||||||
- 3 значимых NPC;
|
- 3 значимых NPC;
|
||||||
- 2 фракции;
|
- 2 фракции;
|
||||||
|
- 3 постоянных AI‑героя, созданных специально для этой вселенной;
|
||||||
- сюжетная завязка;
|
- сюжетная завязка;
|
||||||
- скрытая угроза или цель;
|
- скрытая угроза или цель;
|
||||||
- начальная сцена.
|
- начальная сцена.
|
||||||
5. Владелец подтверждает мир, создаёт кампанию и приглашает игроков ссылкой.
|
5. Владелец подтверждает мир, создаёт кампанию и приглашает игроков ссылкой.
|
||||||
6. Каждый игрок создаёт персонажа вручную или с помощью AI.
|
6. Каждый игрок создаёт персонажа вручную или с помощью AI.
|
||||||
7. Владелец добавляет постоянных AI‑героев и определяет, чьи персонажи могут временно переходить под управление AI.
|
7. Созданные вместе со вселенной постоянные AI‑герои автоматически входят в стартовую партию; владелец может добавлять других и определяет, чьи персонажи могут временно переходить под управление AI.
|
||||||
8. Игроки отправляют действия в текущем раунде.
|
8. Игроки отправляют действия в текущем раунде.
|
||||||
9. AI‑DM обрабатывает раунд, когда:
|
9. AI‑DM обрабатывает раунд, когда:
|
||||||
- ответили все активные реальные игроки; или
|
- ответили все активные реальные игроки; или
|
||||||
|
|||||||
@@ -18,6 +18,8 @@ To verify the complete hosted-Supabase multiplayer path (two temporary email acc
|
|||||||
|
|
||||||
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.
|
||||||
@@ -41,7 +43,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
|
||||||
@@ -58,7 +60,7 @@ If the worker reports `POST /rest/v1/rpc/claim_ai_job 404` and `/rest/v1/profile
|
|||||||
1. Click **Enter the Alpha** to open registration. Every account requires a display name, email, password, and 13+ confirmation. Existing users sign in with email/password; forgotten passwords use the email recovery flow.
|
1. Click **Enter the Alpha** to open registration. Every account requires a display name, email, password, and 13+ confirmation. Existing users sign in with email/password; forgotten passwords use the email recovery flow.
|
||||||
2. Create a world in the dynamic coauthor chat. Unfinished conversations and generated drafts appear on the dashboard and resume after a reload.
|
2. Create a world in the dynamic coauthor chat. Unfinished conversations and generated drafts appear on the dashboard and resume after a reload.
|
||||||
3. Review every starting-world field, including the owner-only hidden threat, then confirm it.
|
3. Review every starting-world field, including the owner-only hidden threat, then confirm it.
|
||||||
4. Create a human character manually or ask the coauthor for an editable draft. Owners can add persistent AI companions the same way.
|
4. During world creation, the coauthor also creates three editable, universe-specific AI companions. A new campaign starts immediately with those companions and an owner-controlled hero. Additional players create a human character manually or ask the coauthor for an editable draft; owners can add more AI companions the same way.
|
||||||
5. The owner creates an expiring private invite link. Signed-in email users join through `/join/:token`.
|
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.
|
||||||
|
|||||||
@@ -1 +1,24 @@
|
|||||||
:root{--acid:#d9f75f;--acid-dim:#596522;--ink:#eeeeE7;--muted:#888881;--line:#272725;--display:'Unbounded',sans-serif;--body:'Manrope',sans-serif;--mono:'DM Mono',monospace;color-scheme:dark}*{box-sizing:border-box}html{background:#0a0a0a;color:var(--ink);font-family:var(--body);scroll-behavior:smooth}body{margin:0;background:#0a0a0a}button,input,textarea{font:inherit}button,a{transition:filter .2s ease,opacity .2s ease}button:not(:disabled),a{cursor:pointer}button:hover:not(:disabled),a:hover{filter:brightness(1.12)}::selection{background:var(--acid);color:#070707}.noise{background-color:#0a0a0a;background-image:linear-gradient(rgba(255,255,255,.013) 1px,transparent 1px),linear-gradient(90deg,rgba(255,255,255,.013) 1px,transparent 1px);background-size:34px 34px}textarea:focus,input:focus,button:focus-visible,a:focus-visible{outline:1px solid var(--acid);outline-offset:2px}@media(prefers-reduced-motion:reduce){*{animation-duration:.01ms!important;animation-iteration-count:1!important;scroll-behavior:auto!important}}
|
:root{--acid:#d9f75f;--acid-dim:#596522;--ink:#eeeeE7;--muted:#888881;--line:#272725;--display:'Unbounded',sans-serif;--body:'Manrope',sans-serif;--mono:'DM Mono',monospace;color-scheme:dark}*{box-sizing:border-box}html{background:#0a0a0a;color:var(--ink);font-family:var(--body);scroll-behavior:smooth}body{margin:0;background:#0a0a0a}button,input,textarea{font:inherit}button,a{transition:filter .2s ease,opacity .2s ease}button:not(:disabled),a{cursor:pointer}button:hover:not(:disabled),a:hover{filter:brightness(1.12)}::selection{background:var(--acid);color:#070707}.noise{background-color:#0a0a0a;background-image:linear-gradient(rgba(255,255,255,.013) 1px,transparent 1px),linear-gradient(90deg,rgba(255,255,255,.013) 1px,transparent 1px);background-size:34px 34px}textarea:focus,input:focus,button:focus-visible,a:focus-visible{outline:1px solid var(--acid);outline-offset:2px}@media(prefers-reduced-motion:reduce){*{animation-duration:.01ms!important;animation-iteration-count:1!important;scroll-behavior:auto!important}}
|
||||||
|
|
||||||
|
/* Keep account pages usable even when a route-level component stylesheet is delayed or missed. */
|
||||||
|
.auth-page{min-height:100vh;display:grid;place-content:center;justify-items:center;gap:28px;padding:42px 20px}
|
||||||
|
.auth-page .auth-card{width:min(480px,calc(100vw - 40px));padding:clamp(28px,5vw,48px);border:1px solid var(--line);background:linear-gradient(145deg,#11110f,#090909);box-shadow:0 35px 100px rgba(0,0,0,.48)}
|
||||||
|
.auth-page .auth-card header small{font:600 8px var(--mono);letter-spacing:.18em;color:var(--acid)}
|
||||||
|
.auth-page .auth-card header h1{margin:14px 0 12px;font:600 clamp(32px,6vw,52px)/1 var(--display);letter-spacing:-.06em}
|
||||||
|
.auth-page .auth-card header p{margin:0 0 30px;color:var(--muted);font-size:12px;line-height:1.7}
|
||||||
|
.auth-page .auth-card footer{margin-top:25px;padding-top:22px;border-top:1px solid var(--line);text-align:center;color:var(--muted);font-size:11px}
|
||||||
|
.auth-page .security-note{font:500 7px var(--mono);letter-spacing:.13em;color:var(--muted)}
|
||||||
|
.auth-page .security-note i{display:inline-block;width:5px;height:5px;margin-right:8px;border-radius:50%;background:var(--acid)}
|
||||||
|
.auth-page .auth-form{display:grid;gap:15px}
|
||||||
|
.auth-page .auth-form label{display:grid;gap:8px;font:600 8px var(--mono);letter-spacing:.11em;color:#b5b5ae}
|
||||||
|
.auth-page .auth-form input{box-sizing:border-box;width:100%;min-height:48px;padding:0 14px;border:1px solid #373732;background:#0a0a09;color:var(--ink);font:12px var(--body);outline:none}
|
||||||
|
.auth-page .auth-form input:focus{border-color:var(--acid)}
|
||||||
|
.auth-page .auth-form button{min-height:50px;border:0;background:var(--acid);color:#080808;font:700 9px var(--mono);letter-spacing:.12em}
|
||||||
|
.auth-page .auth-form button:disabled{opacity:.45;cursor:wait}
|
||||||
|
.auth-page .form-error,.auth-page .form-success{margin:0;padding:12px 14px;border:1px solid #6b372e;background:#221310;color:#ffab98;font:500 9px/1.55 var(--mono)}
|
||||||
|
.auth-page .form-success{border-color:#526425;background:#151a0d;color:var(--acid)}
|
||||||
|
.auth-page .form-link{color:var(--acid);text-decoration:none;font-weight:600}
|
||||||
|
.auth-page .field-row{display:flex;align-items:center;justify-content:space-between}
|
||||||
|
.auth-page .field-row a{color:var(--muted);font:500 8px var(--mono)}
|
||||||
|
.auth-page .auth-form .consent{grid-template-columns:18px 1fr!important;align-items:start!important;font:400 10px/1.5 var(--body)!important;letter-spacing:0!important;color:var(--muted)!important}
|
||||||
|
.auth-page .auth-form .consent input{width:16px!important;min-height:16px!important;margin:1px 0 0;padding:0!important;accent-color:var(--acid)}
|
||||||
|
|||||||
@@ -1,6 +1,17 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
defineProps<{ section?: string }>()
|
defineProps<{ section?: string }>()
|
||||||
const { session, restore, signOut } = useDngAuth()
|
const emit = defineEmits<{
|
||||||
|
campaignAction: [payload: { action: 'rename' | 'delete'; campaignId: string }]
|
||||||
|
}>()
|
||||||
|
const { session, restore } = useDngAuth()
|
||||||
|
const route = useRoute()
|
||||||
|
const router = useRouter()
|
||||||
|
const contextMenu = ref<HTMLElement | null>(null)
|
||||||
|
const contextOpen = ref(false)
|
||||||
|
const contextX = ref(0)
|
||||||
|
const contextY = ref(0)
|
||||||
|
const contextCampaignId = ref('')
|
||||||
|
const contextCampaignTitle = ref('')
|
||||||
onMounted(() => void restore())
|
onMounted(() => void restore())
|
||||||
|
|
||||||
const initials = computed(() => {
|
const initials = computed(() => {
|
||||||
@@ -8,28 +19,118 @@ const initials = computed(() => {
|
|||||||
const source = typeof displayName === 'string' && displayName.trim() ? displayName : session.value?.user.email ?? ''
|
const source = typeof displayName === 'string' && displayName.trim() ? displayName : session.value?.user.email ?? ''
|
||||||
return source.trim().split(/\s+/).map(part => part[0]).join('').slice(0, 2).toUpperCase() || 'D&G'
|
return source.trim().split(/\s+/).map(part => part[0]).join('').slice(0, 2).toUpperCase() || 'D&G'
|
||||||
})
|
})
|
||||||
|
const avatarUrl = computed(() => {
|
||||||
|
const value = session.value?.user.user_metadata?.avatar_url
|
||||||
|
return typeof value === 'string' && value ? value : ''
|
||||||
|
})
|
||||||
|
|
||||||
async function leave() {
|
function closeContextMenu() {
|
||||||
await signOut()
|
contextOpen.value = false
|
||||||
await navigateTo('/')
|
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>
|
||||||
<div class="topbar-actions">
|
<div class="topbar-actions">
|
||||||
<NuxtLink to="/dashboard" class="icon-link" aria-label="Dashboard">⌂</NuxtLink>
|
<NuxtLink to="/dashboard" class="icon-link" aria-label="Dashboard">⌂</NuxtLink>
|
||||||
<button v-if="session" class="avatar" :title="`Sign out ${session.user.email ?? ''}`" @click="leave">{{ initials }}</button>
|
<NuxtLink v-if="session" to="/profile" class="avatar" :title="`Open profile for ${session.user.email ?? ''}`" aria-label="Open profile">
|
||||||
|
<span>{{ initials }}</span><img v-if="avatarUrl" :src="avatarUrl" alt="">
|
||||||
|
</NuxtLink>
|
||||||
<div v-else class="avatar">D&G</div>
|
<div v-else class="avatar">D&G</div>
|
||||||
</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{display:grid;place-items:center;width:36px;height:36px;padding:0;border:0;border-radius:50%;background:var(--acid);color:#0a0a0a;font:700 10px var(--mono)}@media(max-width:700px){.topbar{grid-template-columns:1fr auto}.topbar-center{display:none}}
|
.shell{min-height:100vh}.topbar{position:sticky;top:0;z-index:20;height:76px;padding:0 clamp(18px,4vw,64px);display:grid;grid-template-columns:1fr auto 1fr;align-items:center;border-bottom:1px solid var(--line);background:rgba(10,10,10,.9);backdrop-filter:blur(18px)}.topbar-center{font:500 10px/1 var(--mono);color:var(--muted);letter-spacing:.18em}.topbar-center b{color:var(--ink);font-weight:500}.status-dot{display:inline-block;width:6px;height:6px;margin-right:8px;border-radius:50%;background:var(--acid);box-shadow:0 0 12px var(--acid)}.topbar-actions{justify-self:end;display:flex;align-items:center;gap:14px}.icon-link{display:grid;place-items:center;width:36px;height:36px;border:1px solid var(--line);color:var(--muted);text-decoration:none}.avatar{position:relative;overflow:hidden;display:grid;place-items:center;width:36px;height:36px;padding:0;border:0;border-radius:50%;background:var(--acid);color:#0a0a0a;text-decoration:none;font:700 10px var(--mono)}.avatar img{position:absolute;inset:0;width:100%;height:100%;object-fit:cover}.avatar span{position:relative}.context-menu{position:fixed;z-index:2000;width:min(286px,calc(100vw - 24px));border:1px solid #3c3c37;background:rgba(10,10,9,.98);color:var(--ink);box-shadow:0 24px 70px rgba(0,0,0,.68);backdrop-filter:blur(18px)}.context-menu header{display:grid;gap:7px;padding:17px 18px 15px;border-bottom:1px solid var(--line);background:linear-gradient(120deg,rgba(217,247,95,.08),transparent 70%)}.context-menu header small{color:var(--acid);font:600 7px var(--mono);letter-spacing:.17em}.context-menu header strong{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font:600 12px var(--display);letter-spacing:-.02em}.context-group{padding:6px;border-bottom:1px solid var(--line)}.context-group button{width:100%;min-height:40px;display:grid;grid-template-columns:28px 1fr;align-items:center;padding:0 10px;border:0;background:transparent;color:#c4c4bd;text-align:left;font:600 8px var(--mono);letter-spacing:.11em}.context-group button span{color:var(--acid);font:500 15px var(--mono)}.context-group button:hover,.context-group button:focus-visible{background:#171715;color:var(--ink);filter:none}.context-group button.danger{color:#e5aaa0}.context-group button.danger span{color:#ff8875}.context-menu footer{padding:11px 15px;color:#6e6e68;font:500 6px var(--mono);letter-spacing:.14em}.context-menu footer i{display:inline-block;width:5px;height:5px;margin-right:7px;border-radius:50%;background:var(--acid);box-shadow:0 0 8px var(--acid)}@media(max-width:700px){.topbar{grid-template-columns:1fr auto}.topbar-center{display:none}}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,8 +1,16 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { WorldStarter } from '@dng/shared'
|
import type { CharacterDraft, WorldStarter } from '@dng/shared'
|
||||||
|
|
||||||
const draft = defineModel<WorldStarter>({ required: true })
|
const draft = defineModel<WorldStarter>({ required: true })
|
||||||
defineEmits<{ revise: []; confirm: [] }>()
|
defineEmits<{ revise: []; confirm: [] }>()
|
||||||
|
|
||||||
|
function updateInventory(companion: CharacterDraft, event: Event) {
|
||||||
|
companion.inventory = (event.target as HTMLInputElement).value
|
||||||
|
.split(',')
|
||||||
|
.map(item => item.trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
.slice(0, 12)
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -21,6 +29,7 @@ defineEmits<{ revise: []; confirm: [] }>()
|
|||||||
<section class="wide private-truth"><label>PRIVATE GM TRUTH</label><textarea v-model="draft.hiddenThreat" rows="4" /><small>This hidden threat or goal is visible only to the owner and the Groundkeeper.</small></section>
|
<section class="wide private-truth"><label>PRIVATE GM TRUTH</label><textarea v-model="draft.hiddenThreat" rows="4" /><small>This hidden threat or goal is visible only to the owner and the Groundkeeper.</small></section>
|
||||||
<section class="wide"><label>KEY PEOPLE</label><div class="entities"><article v-for="npc in draft.npcs" :key="npc.id"><small>NPC</small><input v-model="npc.name"><textarea v-model="npc.summary" rows="3" /></article></div></section>
|
<section class="wide"><label>KEY PEOPLE</label><div class="entities"><article v-for="npc in draft.npcs" :key="npc.id"><small>NPC</small><input v-model="npc.name"><textarea v-model="npc.summary" rows="3" /></article></div></section>
|
||||||
<section class="wide"><label>FACTIONS</label><div class="entities two"><article v-for="faction in draft.factions" :key="faction.id"><small>FACTION</small><input v-model="faction.name"><textarea v-model="faction.summary" rows="3" /></article></div></section>
|
<section class="wide"><label>FACTIONS</label><div class="entities two"><article v-for="faction in draft.factions" :key="faction.id"><small>FACTION</small><input v-model="faction.name"><textarea v-model="faction.summary" rows="3" /></article></div></section>
|
||||||
|
<section class="wide"><label>AI COMPANIONS</label><p class="section-note">These universe-specific heroes join the campaign automatically.</p><div class="entities companion-grid"><article v-for="(companion, index) in draft.companions" :key="index" class="companion-card"><small>AI HERO</small><input v-model="companion.name" maxlength="80" aria-label="Companion name"><textarea v-model="companion.concept" maxlength="600" rows="4" aria-label="Companion concept" /><div class="companion-stats"><label>HP<input v-model.number="companion.hp" type="number" min="1" :max="companion.maxHp"></label><label>MAX<input v-model.number="companion.maxHp" type="number" min="1" max="100"></label><label>AC<input v-model.number="companion.defense" type="number" min="1" max="40"></label><label>PROF<input v-model.number="companion.proficiency" type="number" min="1" max="10"></label><label v-for="ability in (['str','dex','con','int','wis','cha'] as const)" :key="ability">{{ ability }}<input v-model.number="companion.abilities[ability]" type="number" min="1" max="30"></label></div><label class="inventory-label">INVENTORY<input :value="companion.inventory.join(', ')" maxlength="800" @input="updateInventory(companion, $event)"></label><details><summary>VOICE & PERSONALITY</summary><input v-model="companion.persona.voice" maxlength="240" placeholder="Voice"><input v-model="companion.persona.motivation" maxlength="300" placeholder="Motivation"><input v-model="companion.persona.flaw" maxlength="300" placeholder="Flaw"><input v-model="companion.persona.bond" maxlength="300" placeholder="Bond"></details></article></div></section>
|
||||||
<section><label>OPENING SCENE</label><textarea v-model="draft.openingScene" rows="6" /></section>
|
<section><label>OPENING SCENE</label><textarea v-model="draft.openingScene" rows="6" /></section>
|
||||||
<section><label>CONTENT BOUNDARIES</label><div v-for="(_, index) in draft.contentBoundaries" :key="index" class="boundary"><input v-model="draft.contentBoundaries[index]"><button type="button" aria-label="Remove boundary" @click="draft.contentBoundaries.splice(index, 1)">×</button></div><button class="add" type="button" @click="draft.contentBoundaries.push('')">+ ADD BOUNDARY</button></section>
|
<section><label>CONTENT BOUNDARIES</label><div v-for="(_, index) in draft.contentBoundaries" :key="index" class="boundary"><input v-model="draft.contentBoundaries[index]"><button type="button" aria-label="Remove boundary" @click="draft.contentBoundaries.splice(index, 1)">×</button></div><button class="add" type="button" @click="draft.contentBoundaries.push('')">+ ADD BOUNDARY</button></section>
|
||||||
</div>
|
</div>
|
||||||
@@ -29,6 +38,6 @@ defineEmits<{ revise: []; confirm: [] }>()
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.preview{max-width:1300px;width:100%;padding:clamp(40px,5vw,76px)}header{display:flex;justify-content:space-between;align-items:end;gap:20px}.kicker,label{font:600 8px var(--mono);letter-spacing:.15em;color:var(--acid)}header>div{flex:1}header input{border:0;border-bottom:1px solid var(--line);font:600 clamp(30px,4vw,54px) var(--display);letter-spacing:-.05em;padding:12px 0;background:transparent}.revise,.add{background:transparent;color:var(--muted);border:1px solid var(--line);padding:12px;font:500 8px var(--mono)}.edit-note{color:var(--muted);font-size:11px}.grid{display:grid;grid-template-columns:1fr 1fr;border-top:1px solid var(--line);border-left:1px solid var(--line);margin-top:28px}.grid>section{padding:26px;border-right:1px solid var(--line);border-bottom:1px solid var(--line)}.wide{grid-column:1/-1}input,textarea{box-sizing:border-box;width:100%;margin-top:12px;padding:13px;resize:vertical;background:#10100f;border:1px solid var(--line);color:var(--ink);font:12px/1.55 var(--body)}.entities{display:grid;grid-template-columns:repeat(3,1fr);gap:12px;margin-top:18px}.entities.two{grid-template-columns:repeat(2,1fr)}.entities article{padding:16px;border:1px solid var(--line);background:#0c0c0b}.entities small{font:500 7px var(--mono);color:var(--acid)}.boundary{display:flex}.boundary input{margin-top:8px}.boundary button{margin-top:8px;width:42px;border:1px solid var(--line);background:#151513;color:var(--muted)}.add{margin-top:12px}footer{position:sticky;bottom:0;display:flex;justify-content:space-between;align-items:center;padding:18px 22px;background:#11110f;border:1px solid var(--line);margin-top:24px}footer p{margin:0;display:flex;flex-direction:column;font:600 8px var(--mono);color:var(--acid)}footer p span{margin-top:5px;color:var(--muted);font-weight:400}footer>button{min-height:48px;border:0;background:var(--acid);color:#080808;padding:0 24px;font:600 9px var(--mono)}@media(max-width:760px){.preview{padding:36px 18px}header{align-items:stretch;flex-direction:column}.grid{grid-template-columns:1fr}.wide{grid-column:auto}.entities,.entities.two{grid-template-columns:1fr}footer{align-items:stretch;flex-direction:column;gap:14px}}
|
.preview{max-width:1300px;width:100%;padding:clamp(40px,5vw,76px)}header{display:flex;justify-content:space-between;align-items:end;gap:20px}.kicker,label{font:600 8px var(--mono);letter-spacing:.15em;color:var(--acid)}header>div{flex:1}header input{border:0;border-bottom:1px solid var(--line);font:600 clamp(30px,4vw,54px) var(--display);letter-spacing:-.05em;padding:12px 0;background:transparent}.revise,.add{background:transparent;color:var(--muted);border:1px solid var(--line);padding:12px;font:500 8px var(--mono)}.edit-note,.section-note{color:var(--muted);font-size:11px}.section-note{margin:9px 0 0}.grid{display:grid;grid-template-columns:1fr 1fr;border-top:1px solid var(--line);border-left:1px solid var(--line);margin-top:28px}.grid>section{padding:26px;border-right:1px solid var(--line);border-bottom:1px solid var(--line)}.wide{grid-column:1/-1}input,textarea{box-sizing:border-box;width:100%;margin-top:12px;padding:13px;resize:vertical;background:#10100f;border:1px solid var(--line);color:var(--ink);font:12px/1.55 var(--body)}.entities{display:grid;grid-template-columns:repeat(3,1fr);gap:12px;margin-top:18px}.entities.two{grid-template-columns:repeat(2,1fr)}.entities article{padding:16px;border:1px solid var(--line);background:#0c0c0b}.entities small{font:500 7px var(--mono);color:var(--acid)}.companion-stats{display:grid;grid-template-columns:repeat(5,1fr);gap:5px;margin-top:12px}.companion-stats label{color:var(--muted);font-size:6px}.companion-stats input{margin-top:5px;padding:7px;text-align:center}.inventory-label{display:block;margin-top:12px;color:var(--muted)}.inventory-label input{margin-top:6px}.companion-card details{margin-top:12px;border-top:1px solid var(--line);padding-top:10px}.companion-card summary{cursor:pointer;color:var(--muted);font:600 7px var(--mono);letter-spacing:.1em}.companion-card details input{margin-top:7px}.boundary{display:flex}.boundary input{margin-top:8px}.boundary button{margin-top:8px;width:42px;border:1px solid var(--line);background:#151513;color:var(--muted)}.add{margin-top:12px}footer{position:sticky;bottom:0;display:flex;justify-content:space-between;align-items:center;padding:18px 22px;background:#11110f;border:1px solid var(--line);margin-top:24px}footer p{margin:0;display:flex;flex-direction:column;font:600 8px var(--mono);color:var(--acid)}footer p span{margin-top:5px;color:var(--muted);font-weight:400}footer>button{min-height:48px;border:0;background:var(--acid);color:#080808;padding:0 24px;font:600 9px var(--mono)}@media(max-width:760px){.preview{padding:36px 18px}header{align-items:stretch;flex-direction:column}.grid{grid-template-columns:1fr}.wide{grid-column:auto}.entities,.entities.two{grid-template-columns:1fr}.companion-stats{grid-template-columns:repeat(2,1fr)}footer{align-items:stretch;flex-direction:column;gap:14px}}
|
||||||
.private-truth{background:linear-gradient(90deg,rgba(207,255,70,.035),transparent)}.private-truth small{display:block;margin-top:9px;color:var(--muted);font-size:9px}
|
.private-truth{background:linear-gradient(90deg,rgba(207,255,70,.035),transparent)}.private-truth small{display:block;margin-top:9px;color:var(--muted);font-size:9px}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -16,6 +16,11 @@ const defaultWorld: WorldStarter = {
|
|||||||
{ id: 'fac_heliograph', kind: 'faction', name: 'Heliograph Compact', summary: 'A mercantile coalition that owns the relay network.', tags: ['corporate'], secrets: [] },
|
{ id: 'fac_heliograph', kind: 'faction', name: 'Heliograph Compact', summary: 'A mercantile coalition that owns the relay network.', tags: ['corporate'], secrets: [] },
|
||||||
{ id: 'fac_quiet', kind: 'faction', name: 'The Quiet Choir', summary: 'Pilgrims who believe signals can remember the dead.', tags: ['mystic'], secrets: [] },
|
{ id: 'fac_quiet', kind: 'faction', name: 'The Quiet Choir', summary: 'Pilgrims who believe signals can remember the dead.', tags: ['mystic'], secrets: [] },
|
||||||
],
|
],
|
||||||
|
companions: [
|
||||||
|
{ name: 'Rook-7', concept: 'A security intelligence in a weathered rescue frame.', abilities: { str: 16, dex: 11, con: 15, int: 10, wis: 12, cha: 8 }, hp: 15, maxHp: 15, defense: 15, proficiency: 2, inventory: ['Arc baton', 'Emergency beacon'], skillProficiencies: ['athletics', 'intimidation', 'perception', 'survival'], skillExpertise: [], savingThrowProficiencies: ['str', 'con'], persona: { voice: 'Direct and protective.', motivation: 'Keep the crew alive long enough to learn the truth.', flaw: 'Treats uncertainty as a threat.', bond: 'The Orison crew gave Rook a second purpose.' } },
|
||||||
|
{ name: 'Sable Thread', concept: 'A probabilistic navigator who reads possible futures as tangled routes.', abilities: { str: 8, dex: 14, con: 10, int: 16, wis: 13, cha: 11 }, hp: 10, maxHp: 10, defense: 13, proficiency: 2, inventory: ['Route prism', 'Vacuum line'], skillProficiencies: ['arcana', 'investigation', 'perception', 'stealth'], skillExpertise: ['investigation'], savingThrowProficiencies: ['dex', 'int'], persona: { voice: 'Careful, elliptical, and precise.', motivation: 'Find the future in which everyone returns.', flaw: 'Can hesitate when too many paths look viable.', bond: 'Trusts the party to choose what prediction cannot.' } },
|
||||||
|
{ name: 'Morrow Coil', concept: 'A former relay technician rebuilt around experimental signal hardware.', abilities: { str: 11, dex: 12, con: 13, int: 14, wis: 10, cha: 13 }, hp: 12, maxHp: 12, defense: 12, proficiency: 2, inventory: ['Signal probe', 'Insulated field coat'], skillProficiencies: ['arcana', 'history', 'investigation', 'persuasion'], skillExpertise: [], savingThrowProficiencies: ['int', 'cha'], persona: { voice: 'Warm humor under technical jargon.', motivation: 'Prove the relay can be understood instead of destroyed.', flaw: 'Cannot leave a broken machine alone.', bond: 'Believes this party is the last honest crew in the sector.' } },
|
||||||
|
],
|
||||||
hook: 'Dock with the silent relay, locate the source of the impossible distress call, and decide whether its warning should be believed.',
|
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.',
|
||||||
openingScene: 'The Orison drifts beneath the relay’s vast black vanes. Every console shows the same countdown: 00:17:42. Then your own voice breaks through the static: “Do not open the central archive.”',
|
openingScene: 'The Orison drifts beneath the relay’s vast black vanes. Every console shows the same countdown: 00:17:42. Then your own voice breaks through the static: “Do not open the central archive.”',
|
||||||
@@ -36,7 +41,7 @@ export function useDemo() {
|
|||||||
const history = useState<Array<{ type: 'narration' | 'action' | 'roll'; author: string; body: string; meta?: string }>>('history', () => [
|
const history = useState<Array<{ type: 'narration' | 'action' | 'roll'; author: string; body: string; meta?: string }>>('history', () => [
|
||||||
{ type: 'narration', author: 'GROUNDKEEPER', body: defaultWorld.openingScene, meta: 'ROUND 03 · NOW' },
|
{ type: 'narration', author: 'GROUNDKEEPER', body: defaultWorld.openingScene, meta: 'ROUND 03 · NOW' },
|
||||||
{ type: 'action', author: 'MARA VALE', body: 'I ask the Orison to isolate the transmission and compare the voiceprint to mine.', meta: 'READY' },
|
{ type: 'action', author: 'MARA VALE', body: 'I ask the Orison to isolate the transmission and compare the voiceprint to mine.', meta: 'READY' },
|
||||||
{ type: 'roll', author: 'SYSTEM CHECK', body: 'Intelligence check · 1d20 + 3 = 17', meta: 'SUCCESS · DC 14' },
|
{ type: 'roll', author: 'SYSTEM CHECK', body: 'Intelligence check · 1d20 + 1 = 15', meta: 'SUCCESS · DC 14' },
|
||||||
])
|
])
|
||||||
const currentAction = useState('current-action', () => '')
|
const currentAction = useState('current-action', () => '')
|
||||||
const ready = useState('ready', () => false)
|
const ready = useState('ready', () => false)
|
||||||
|
|||||||
@@ -211,6 +211,64 @@ export function useDngAuth() {
|
|||||||
return user
|
return user
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function updateUserMetadata(data: Record<string, unknown>) {
|
||||||
|
const token = await accessToken()
|
||||||
|
const user = normalizeUser(await $fetch<DngAuthUser>(`${config.public.supabaseUrl}/auth/v1/user`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: authHeaders(token),
|
||||||
|
body: { data },
|
||||||
|
}))
|
||||||
|
if (session.value) persist({ ...session.value, user })
|
||||||
|
return user
|
||||||
|
}
|
||||||
|
|
||||||
|
async function avatarStorageRequest(path: string, options: RequestInit) {
|
||||||
|
const token = await accessToken()
|
||||||
|
const baseUrl = String(config.public.supabaseUrl).replace(/\/$/, '')
|
||||||
|
const response = await fetch(`${baseUrl}/storage/v1/${path}`, {
|
||||||
|
...options,
|
||||||
|
headers: {
|
||||||
|
apikey: config.public.supabaseAnonKey,
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
...options.headers,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if (!response.ok) {
|
||||||
|
const payload = await response.json().catch(() => null) as { message?: string; error?: string } | null
|
||||||
|
throw new Error(payload?.message || payload?.error || `Profile picture request failed (${response.status}).`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function uploadAvatar(file: File) {
|
||||||
|
if (!['image/jpeg', 'image/png', 'image/webp'].includes(file.type)) {
|
||||||
|
throw new Error('Choose a JPG, PNG, or WebP image.')
|
||||||
|
}
|
||||||
|
if (file.size > 2 * 1024 * 1024) throw new Error('Profile pictures must be 2 MB or smaller.')
|
||||||
|
await restore()
|
||||||
|
if (!session.value) throw new Error('Sign in to update your profile picture.')
|
||||||
|
const path = `${session.value.user.id}/avatar`
|
||||||
|
const body = new FormData()
|
||||||
|
body.append('cacheControl', '3600')
|
||||||
|
body.append('', file)
|
||||||
|
await avatarStorageRequest(`object/profile-avatars/${path}`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'x-upsert': 'true' },
|
||||||
|
body,
|
||||||
|
})
|
||||||
|
return path
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removeAvatar() {
|
||||||
|
await restore()
|
||||||
|
if (!session.value) throw new Error('Sign in to update your profile picture.')
|
||||||
|
const path = `${session.value.user.id}/avatar`
|
||||||
|
await avatarStorageRequest('object/profile-avatars', {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ prefixes: [path] }),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
async function accessToken() {
|
async function accessToken() {
|
||||||
await restore()
|
await restore()
|
||||||
if (!session.value) throw new Error('Enter the alpha to continue.')
|
if (!session.value) throw new Error('Enter the alpha to continue.')
|
||||||
@@ -238,5 +296,20 @@ export function useDngAuth() {
|
|||||||
hydrated.value = true
|
hydrated.value = true
|
||||||
}
|
}
|
||||||
|
|
||||||
return { session, hydrated, restore, refresh, signUp, signIn, requestPasswordReset, updatePassword, accessToken, signOut, invalidate }
|
return {
|
||||||
|
session,
|
||||||
|
hydrated,
|
||||||
|
restore,
|
||||||
|
refresh,
|
||||||
|
signUp,
|
||||||
|
signIn,
|
||||||
|
requestPasswordReset,
|
||||||
|
updatePassword,
|
||||||
|
updateUserMetadata,
|
||||||
|
uploadAvatar,
|
||||||
|
removeAvatar,
|
||||||
|
accessToken,
|
||||||
|
signOut,
|
||||||
|
invalidate,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { CharacterDraft } from '@dng/shared'
|
import { skillKeys, type CharacterDraft, type SkillKey } from '@dng/shared'
|
||||||
|
|
||||||
type AbilityKey = 'str' | 'dex' | 'con' | 'int' | 'wis' | 'cha'
|
type AbilityKey = 'str' | 'dex' | 'con' | 'int' | 'wis' | 'cha'
|
||||||
type Controller = 'human' | 'ai' | 'delegated'
|
type Controller = 'human' | 'ai' | 'delegated'
|
||||||
@@ -7,7 +7,7 @@ type Controller = 'human' | 'ai' | 'delegated'
|
|||||||
interface CampaignPayload {
|
interface CampaignPayload {
|
||||||
campaign: Record<string, any>
|
campaign: Record<string, any>
|
||||||
world: Record<string, any>
|
world: Record<string, any>
|
||||||
members: Array<Record<string, any> & { profile?: { display_name?: string } | null }>
|
members: Array<Record<string, any> & { profile?: { id?: string; display_name?: string; avatar_url?: string | null } | null }>
|
||||||
characters: Array<Record<string, any>>
|
characters: Array<Record<string, any>>
|
||||||
round: Record<string, any> | null
|
round: Record<string, any> | null
|
||||||
rounds: Array<Record<string, any>>
|
rounds: Array<Record<string, any>>
|
||||||
@@ -36,6 +36,8 @@ const createKind = ref<'human' | 'ai'>('human')
|
|||||||
const characterForm = reactive({
|
const characterForm = reactive({
|
||||||
name: '', concept: '', hp: 12, maxHp: 12, defense: 12, proficiency: 2,
|
name: '', concept: '', hp: 12, maxHp: 12, defense: 12, proficiency: 2,
|
||||||
abilities: { str: 10, dex: 10, con: 10, int: 10, wis: 10, cha: 10 } as Record<AbilityKey, number>,
|
abilities: { str: 10, dex: 10, con: 10, int: 10, wis: 10, cha: 10 } as Record<AbilityKey, number>,
|
||||||
|
skillProficiencies: [] as SkillKey[], skillExpertise: [] as SkillKey[],
|
||||||
|
savingThrowProficiencies: [] as AbilityKey[],
|
||||||
persona: { voice: '', motivation: '', flaw: '', bond: '' },
|
persona: { voice: '', motivation: '', flaw: '', bond: '' },
|
||||||
})
|
})
|
||||||
const inventoryText = ref('')
|
const inventoryText = ref('')
|
||||||
@@ -51,7 +53,11 @@ const myIntent = computed(() => payload.value?.intents.find(intent =>
|
|||||||
intent.member_id === currentMember.value?.id || intent.character_id === myCharacter.value?.id,
|
intent.member_id === currentMember.value?.id || intent.character_id === myCharacter.value?.id,
|
||||||
))
|
))
|
||||||
const isReady = computed(() => Boolean(myIntent.value?.ready))
|
const isReady = computed(() => Boolean(myIntent.value?.ready))
|
||||||
const activeHumanMembers = computed(() => payload.value?.members.filter(member => member.active !== false) ?? [])
|
const activeHumanMembers = computed(() => payload.value?.members.filter(member =>
|
||||||
|
member.active !== false && payload.value?.characters.some(character =>
|
||||||
|
character.user_id === member.user_id && character.controller === 'human',
|
||||||
|
),
|
||||||
|
) ?? [])
|
||||||
const readyCount = computed(() => activeHumanMembers.value.filter(member => payload.value?.intents.some(intent => intent.member_id === member.id && intent.ready)).length)
|
const readyCount = computed(() => activeHumanMembers.value.filter(member => payload.value?.intents.some(intent => intent.member_id === member.id && intent.ready)).length)
|
||||||
const canAct = computed(() => currentRound.value?.status === 'open' && Boolean(myCharacter.value))
|
const canAct = computed(() => currentRound.value?.status === 'open' && Boolean(myCharacter.value))
|
||||||
const roundBusy = computed(() => ['queued', 'resolving'].includes(String(currentRound.value?.status)))
|
const roundBusy = computed(() => ['queued', 'resolving'].includes(String(currentRound.value?.status)))
|
||||||
@@ -70,7 +76,7 @@ const timeline = computed(() => {
|
|||||||
})),
|
})),
|
||||||
...payload.value.diceRolls.map(roll => ({
|
...payload.value.diceRolls.map(roll => ({
|
||||||
id: `roll-${roll.id}`, type: 'roll', at: roll.created_at ?? '', label: 'SERVER ROLL',
|
id: `roll-${roll.id}`, type: 'roll', at: roll.created_at ?? '', label: 'SERVER ROLL',
|
||||||
meta: roll.success === null || roll.success === undefined ? roll.check_kind : roll.success ? 'SUCCESS' : 'FAILED',
|
meta: rollMeta(roll),
|
||||||
body: `${roll.formula} · [${(roll.rolls ?? []).join(', ')}] ${signed(Number(roll.modifier ?? 0))} = ${roll.total}`,
|
body: `${roll.formula} · [${(roll.rolls ?? []).join(', ')}] ${signed(Number(roll.modifier ?? 0))} = ${roll.total}`,
|
||||||
})),
|
})),
|
||||||
...payload.value.events.map(event => ({
|
...payload.value.events.map(event => ({
|
||||||
@@ -85,6 +91,29 @@ function signed(value: number) {
|
|||||||
return value >= 0 ? `+ ${value}` : `− ${Math.abs(value)}`
|
return value >= 0 ? `+ ${value}` : `− ${Math.abs(value)}`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function abilityModifier(score: number) {
|
||||||
|
return Math.floor((Number(score) - 10) / 2)
|
||||||
|
}
|
||||||
|
|
||||||
|
function abilityLabel(score: number) {
|
||||||
|
const modifier = abilityModifier(score)
|
||||||
|
return modifier >= 0 ? `+${modifier}` : String(modifier)
|
||||||
|
}
|
||||||
|
|
||||||
|
function rollMeta(roll: Record<string, any>) {
|
||||||
|
if (roll.success === null || roll.success === undefined) return String(roll.check_kind ?? 'ROLL').toUpperCase()
|
||||||
|
const kept = Number(roll.kept?.[0])
|
||||||
|
const outcome = roll.check_kind === 'attack' && kept === 20
|
||||||
|
? 'CRITICAL HIT'
|
||||||
|
: roll.check_kind === 'attack' && kept === 1
|
||||||
|
? 'CRITICAL MISS'
|
||||||
|
: roll.success ? 'SUCCESS' : 'FAILED'
|
||||||
|
const target = roll.difficulty === null || roll.difficulty === undefined
|
||||||
|
? ''
|
||||||
|
: ` · ${roll.check_kind === 'attack' ? 'AC' : 'DC'} ${roll.difficulty}`
|
||||||
|
return `${outcome}${target}`
|
||||||
|
}
|
||||||
|
|
||||||
function initials(name: string) {
|
function initials(name: string) {
|
||||||
return name.split(/\s+/).map(part => part[0]).join('').slice(0, 2).toUpperCase()
|
return name.split(/\s+/).map(part => part[0]).join('').slice(0, 2).toUpperCase()
|
||||||
}
|
}
|
||||||
@@ -206,6 +235,9 @@ async function suggestCharacter() {
|
|||||||
characterForm.defense = suggestion.defense
|
characterForm.defense = suggestion.defense
|
||||||
characterForm.proficiency = suggestion.proficiency
|
characterForm.proficiency = suggestion.proficiency
|
||||||
characterForm.abilities = { ...suggestion.abilities }
|
characterForm.abilities = { ...suggestion.abilities }
|
||||||
|
characterForm.skillProficiencies = [...suggestion.skillProficiencies]
|
||||||
|
characterForm.skillExpertise = [...suggestion.skillExpertise]
|
||||||
|
characterForm.savingThrowProficiencies = [...suggestion.savingThrowProficiencies]
|
||||||
characterForm.persona = { ...suggestion.persona }
|
characterForm.persona = { ...suggestion.persona }
|
||||||
inventoryText.value = suggestion.inventory.join('\n')
|
inventoryText.value = suggestion.inventory.join('\n')
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -224,13 +256,20 @@ async function createCharacter() {
|
|||||||
abilities: characterForm.abilities, hp: characterForm.hp, maxHp: characterForm.maxHp,
|
abilities: characterForm.abilities, hp: characterForm.hp, maxHp: characterForm.maxHp,
|
||||||
defense: characterForm.defense, proficiency: characterForm.proficiency,
|
defense: characterForm.defense, proficiency: characterForm.proficiency,
|
||||||
inventory: inventoryText.value.split(/\n|,/).map(item => item.trim()).filter(Boolean).slice(0, 50),
|
inventory: inventoryText.value.split(/\n|,/).map(item => item.trim()).filter(Boolean).slice(0, 50),
|
||||||
statuses: [], persona: characterForm.persona,
|
statuses: [],
|
||||||
|
skillProficiencies: characterForm.skillProficiencies,
|
||||||
|
skillExpertise: characterForm.skillExpertise.filter(skill => characterForm.skillProficiencies.includes(skill)),
|
||||||
|
savingThrowProficiencies: characterForm.savingThrowProficiencies,
|
||||||
|
persona: characterForm.persona,
|
||||||
},
|
},
|
||||||
}))
|
}))
|
||||||
if (success) {
|
if (success) {
|
||||||
showCharacterForm.value = false
|
showCharacterForm.value = false
|
||||||
characterForm.name = ''
|
characterForm.name = ''
|
||||||
characterForm.concept = ''
|
characterForm.concept = ''
|
||||||
|
characterForm.skillProficiencies = []
|
||||||
|
characterForm.skillExpertise = []
|
||||||
|
characterForm.savingThrowProficiencies = []
|
||||||
inventoryText.value = ''
|
inventoryText.value = ''
|
||||||
characterForm.persona = { voice: '', motivation: '', flaw: '', bond: '' }
|
characterForm.persona = { voice: '', motivation: '', flaw: '', bond: '' }
|
||||||
}
|
}
|
||||||
@@ -270,7 +309,8 @@ onBeforeUnmount(() => {
|
|||||||
<textarea v-model="characterForm.concept" required maxlength="600" placeholder="Concept, role, personality" />
|
<textarea v-model="characterForm.concept" required maxlength="600" placeholder="Concept, role, personality" />
|
||||||
<button type="button" class="ai-draft" :disabled="suggestingCharacter || mutating" @click="suggestCharacter">{{ suggestingCharacter ? 'COAUTHOR IS DRAFTING…' : '✦ DRAFT WITH AI' }}</button>
|
<button type="button" class="ai-draft" :disabled="suggestingCharacter || mutating" @click="suggestCharacter">{{ suggestingCharacter ? 'COAUTHOR IS DRAFTING…' : '✦ DRAFT WITH AI' }}</button>
|
||||||
<div class="ability-editor"><label v-for="key in (['str','dex','con','int','wis','cha'] as AbilityKey[])" :key="key"><span>{{ key }}</span><input v-model.number="characterForm.abilities[key]" type="number" min="1" max="30"></label></div>
|
<div class="ability-editor"><label v-for="key in (['str','dex','con','int','wis','cha'] as AbilityKey[])" :key="key"><span>{{ key }}</span><input v-model.number="characterForm.abilities[key]" type="number" min="1" max="30"></label></div>
|
||||||
<div class="stat-editor"><label>HP <input v-model.number="characterForm.hp" type="number" min="1" :max="characterForm.maxHp"></label><label>MAX <input v-model.number="characterForm.maxHp" type="number" min="1"></label><label>DEF <input v-model.number="characterForm.defense" type="number" min="1" max="40"></label></div>
|
<div class="stat-editor"><label>HP <input v-model.number="characterForm.hp" type="number" min="1" :max="characterForm.maxHp"></label><label>MAX <input v-model.number="characterForm.maxHp" type="number" min="1"></label><label>AC <input v-model.number="characterForm.defense" type="number" min="1" max="40"></label></div>
|
||||||
|
<details class="proficiency-editor"><summary>SRD PROFICIENCIES</summary><small>SKILLS</small><div><label v-for="skill in skillKeys" :key="skill"><input v-model="characterForm.skillProficiencies" type="checkbox" :value="skill">{{ skill }}</label></div><small>SAVING THROWS</small><div><label v-for="ability in (['str','dex','con','int','wis','cha'] as AbilityKey[])" :key="ability"><input v-model="characterForm.savingThrowProficiencies" type="checkbox" :value="ability">{{ ability }}</label></div><small>EXPERTISE</small><div><label v-for="skill in characterForm.skillProficiencies" :key="skill"><input v-model="characterForm.skillExpertise" type="checkbox" :value="skill">{{ skill }}</label></div></details>
|
||||||
<textarea v-model="inventoryText" maxlength="800" placeholder="Starting items, one per line" />
|
<textarea v-model="inventoryText" maxlength="800" placeholder="Starting items, one per line" />
|
||||||
<details class="persona-editor"><summary>VOICE & PERSONALITY</summary><input v-model="characterForm.persona.voice" maxlength="240" placeholder="Voice"><input v-model="characterForm.persona.motivation" maxlength="300" placeholder="Motivation"><input v-model="characterForm.persona.flaw" maxlength="300" placeholder="Flaw"><input v-model="characterForm.persona.bond" maxlength="300" placeholder="Bond"></details>
|
<details class="persona-editor"><summary>VOICE & PERSONALITY</summary><input v-model="characterForm.persona.voice" maxlength="240" placeholder="Voice"><input v-model="characterForm.persona.motivation" maxlength="300" placeholder="Motivation"><input v-model="characterForm.persona.flaw" maxlength="300" placeholder="Flaw"><input v-model="characterForm.persona.bond" maxlength="300" placeholder="Bond"></details>
|
||||||
<button class="acid-button" :disabled="mutating">{{ mutating ? 'CREATING…' : 'ADD TO PARTY' }}</button>
|
<button class="acid-button" :disabled="mutating">{{ mutating ? 'CREATING…' : 'ADD TO PARTY' }}</button>
|
||||||
@@ -281,8 +321,8 @@ onBeforeUnmount(() => {
|
|||||||
</article>
|
</article>
|
||||||
<section v-if="myCharacter" class="sheet">
|
<section v-if="myCharacter" class="sheet">
|
||||||
<small>YOUR CHARACTER</small><h2>{{ myCharacter.name }}</h2><p>{{ myCharacter.concept }}</p>
|
<small>YOUR CHARACTER</small><h2>{{ myCharacter.name }}</h2><p>{{ myCharacter.concept }}</p>
|
||||||
<div class="vitals"><span><b>{{ myCharacter.hp }}</b>/{{ myCharacter.max_hp }} HP</span><span><b>{{ myCharacter.defense }}</b> DEF</span></div>
|
<div class="vitals"><span><b>{{ myCharacter.hp }}</b>/{{ myCharacter.max_hp }} HP<span v-if="myCharacter.rules_state?.temporaryHp"> + {{ myCharacter.rules_state.temporaryHp }} TEMP</span></span><span><b>{{ myCharacter.defense }}</b> AC</span><span v-if="myCharacter.rules_state"><b>{{ myCharacter.rules_state.level }}</b> LEVEL</span></div>
|
||||||
<div class="abilities"><span v-for="(score,key) in myCharacter.abilities" :key="key"><small>{{ key }}</small><b>{{ score }}</b></span></div>
|
<div class="abilities"><span v-for="(score,key) in myCharacter.abilities" :key="key"><small>{{ key }} {{ abilityLabel(Number(score)) }}</small><b>{{ score }}</b></span></div>
|
||||||
</section>
|
</section>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
@@ -311,12 +351,12 @@ onBeforeUnmount(() => {
|
|||||||
<aside class="control-panel">
|
<aside class="control-panel">
|
||||||
<header class="panel-title"><span>ROUND CONTROL</span><i :class="{live:!refreshing}" /></header>
|
<header class="panel-title"><span>ROUND CONTROL</span><i :class="{live:!refreshing}" /></header>
|
||||||
<section class="readiness"><p><b>{{ readyCount }} / {{ activeHumanMembers.length }}</b> HUMANS READY</p><div><i :style="{width:`${activeHumanMembers.length ? readyCount/activeHumanMembers.length*100 : 0}%`} " /></div></section>
|
<section class="readiness"><p><b>{{ readyCount }} / {{ activeHumanMembers.length }}</b> HUMANS READY</p><div><i :style="{width:`${activeHumanMembers.length ? readyCount/activeHumanMembers.length*100 : 0}%`} " /></div></section>
|
||||||
<section class="members"><small>PLAYERS</small><article v-for="member in payload.members" :key="member.id"><i :class="{ready:payload.intents.some(intent=>intent.member_id===member.id && intent.ready)}" /><span><b>{{ member.profile?.display_name || 'Adventurer' }}</b><em>{{ member.role }} · {{ payload.intents.some(intent=>intent.member_id===member.id && intent.ready) ? 'ready' : 'waiting' }}</em><button v-if="isOwner" class="takeover-toggle" :class="{enabled:member.ai_takeover_allowed}" :disabled="mutating" @click="toggleTakeover(member)">AI TAKEOVER {{ member.ai_takeover_allowed ? 'ON' : 'OFF' }}</button></span></article></section>
|
<section class="members"><small>PLAYERS</small><article v-for="member in payload.members" :key="member.id"><i :class="{ready:payload.intents.some(intent=>intent.member_id===member.id && intent.ready)}" /><NuxtLink class="member-profile" :to="`/profile/${member.user_id}?campaign=${campaignId}`"><span class="member-avatar"><b>{{ initials(member.profile?.display_name || 'Adventurer') }}</b><img v-if="member.profile?.avatar_url" :src="member.profile.avatar_url" alt=""></span><span><b>{{ member.profile?.display_name || 'Adventurer' }}</b><em>{{ member.role }} · {{ payload.intents.some(intent=>intent.member_id===member.id && intent.ready) ? 'ready' : 'waiting' }}</em></span></NuxtLink><button v-if="isOwner" class="takeover-toggle" :class="{enabled:member.ai_takeover_allowed}" :disabled="mutating" @click="toggleTakeover(member)">AI TAKEOVER {{ member.ai_takeover_allowed ? 'ON' : 'OFF' }}</button></article></section>
|
||||||
<section v-if="isOwner" class="invite-box"><small>INVITE PLAYERS</small><p>Private link · up to 8 joins · expires in 72 hours.</p><button v-if="!inviteLink" class="ghost-button" :disabled="mutating" @click="createInvite">CREATE INVITE LINK</button><template v-else><input :value="inviteLink" readonly aria-label="Campaign invite link"><button class="acid-button" @click="copyInvite">{{ inviteCopied ? 'COPIED' : 'COPY LINK' }}</button></template></section>
|
<section v-if="isOwner" class="invite-box"><small>INVITE PLAYERS</small><p>Private link · up to 8 joins · expires in 72 hours.</p><button v-if="!inviteLink" class="ghost-button" :disabled="mutating" @click="createInvite">CREATE INVITE LINK</button><template v-else><input :value="inviteLink" readonly aria-label="Campaign invite link"><button class="acid-button" @click="copyInvite">{{ inviteCopied ? 'COPIED' : 'COPY LINK' }}</button></template></section>
|
||||||
<section class="ai-order"><small>AI TURN ORDER</small><p v-if="!payload.characters.some(character=>character.controller!=='human')">No AI heroes in this party.</p><div v-for="(character,index) in payload.characters.filter(character=>character.controller!=='human')" :key="character.id"><b>{{ String(index+1).padStart(2,'0') }}</b><span>{{ character.name }}<small>{{ character.controller === 'delegated' ? 'Temporary stand-in' : 'Acts after all humans' }}</small></span></div></section>
|
<section class="ai-order"><small>AI TURN ORDER</small><p v-if="!payload.characters.some(character=>character.controller!=='human')">No AI heroes in this party.</p><div v-for="(character,index) in payload.characters.filter(character=>character.controller!=='human')" :key="character.id"><b>{{ String(index+1).padStart(2,'0') }}</b><span>{{ character.name }}<small>{{ character.controller === 'delegated' ? 'Temporary stand-in' : 'Acts after all humans' }}</small></span></div></section>
|
||||||
<button v-if="isOwner && currentRound?.status==='open'" class="force-button" :disabled="mutating" @click="forceRound">CONTINUE WITHOUT WAITING <span>↗</span></button>
|
<button v-if="isOwner && currentRound?.status==='open'" class="force-button" :disabled="mutating" @click="forceRound">CONTINUE WITHOUT WAITING <span>↗</span></button>
|
||||||
<button v-if="isOwner && currentRound?.status==='failed'" class="force-button" :disabled="mutating" @click="retryFailedRound">RETRY FAILED ROUND <span>↗</span></button>
|
<button v-if="isOwner && currentRound?.status==='failed'" class="force-button" :disabled="mutating" @click="retryFailedRound">RETRY FAILED ROUND <span>↗</span></button>
|
||||||
<div class="mechanics-note"><i /> Dice, HP and mechanical state are resolved by the server and recorded in the campaign log.</div>
|
<div class="mechanics-note"><i /> SRD 5.2.1 RULES V{{ campaign?.ruleset_version ?? 2 }} · D20 tests, proficiency, conditions, damage and mechanical state are resolved by the server and recorded in the campaign log.</div>
|
||||||
</aside>
|
</aside>
|
||||||
</div>
|
</div>
|
||||||
</AppShell>
|
</AppShell>
|
||||||
@@ -334,4 +374,13 @@ onBeforeUnmount(() => {
|
|||||||
.takeover-toggle{margin-top:7px;padding:4px 6px;border:1px solid var(--line);background:transparent;color:var(--muted);font:500 6px var(--mono);text-align:left}
|
.takeover-toggle{margin-top:7px;padding:4px 6px;border:1px solid var(--line);background:transparent;color:var(--muted);font:500 6px var(--mono);text-align:left}
|
||||||
.takeover-toggle.enabled{border-color:var(--acid-dim);color:var(--acid)}
|
.takeover-toggle.enabled{border-color:var(--acid-dim);color:var(--acid)}
|
||||||
.ai-draft{min-height:34px;border:1px solid var(--acid-dim);background:rgba(207,255,70,.04);color:var(--acid);font:600 7px var(--mono);letter-spacing:.1em}.persona-editor{border:1px solid var(--line);padding:9px}.persona-editor summary{cursor:pointer;color:var(--muted);font:600 7px var(--mono);letter-spacing:.1em}.persona-editor input{margin-top:7px}
|
.ai-draft{min-height:34px;border:1px solid var(--acid-dim);background:rgba(207,255,70,.04);color:var(--acid);font:600 7px var(--mono);letter-spacing:.1em}.persona-editor{border:1px solid var(--line);padding:9px}.persona-editor summary{cursor:pointer;color:var(--muted);font:600 7px var(--mono);letter-spacing:.1em}.persona-editor input{margin-top:7px}
|
||||||
|
.proficiency-editor{border:1px solid var(--line);padding:9px}.proficiency-editor summary,.proficiency-editor>small{color:var(--muted);font:600 7px var(--mono);letter-spacing:.1em}.proficiency-editor>small{display:block;margin:10px 0 5px;color:var(--acid)}.proficiency-editor>div{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:4px}.proficiency-editor label{display:flex;gap:5px;align-items:center;overflow-wrap:anywhere;color:var(--muted);font:500 6px var(--mono);letter-spacing:0}.proficiency-editor input{width:auto;margin:0;padding:0}
|
||||||
|
.members article{display:grid;grid-template-columns:6px minmax(0,1fr);align-items:center}
|
||||||
|
.member-profile{display:grid;grid-template-columns:30px minmax(0,1fr);gap:9px;align-items:center;color:var(--ink);text-decoration:none}
|
||||||
|
.member-profile>span:last-child{display:flex;min-width:0;flex-direction:column}
|
||||||
|
.member-avatar{position:relative;overflow:hidden;display:grid!important;place-items:center;width:30px;height:30px;border:1px solid var(--line);background:#171714;color:var(--acid)}
|
||||||
|
.member-avatar>b{font:600 7px var(--mono)!important}
|
||||||
|
.member-avatar img{position:absolute;inset:0;width:100%;height:100%;object-fit:cover}
|
||||||
|
.members .member-profile b{overflow-wrap:anywhere}
|
||||||
|
.members .takeover-toggle{grid-column:2;margin-left:39px}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -5,6 +5,11 @@ const forceOpen = ref(false)
|
|||||||
const errorMessage = ref('')
|
const errorMessage = ref('')
|
||||||
const submittedActionIndex = ref<number | null>(null)
|
const submittedActionIndex = ref<number | null>(null)
|
||||||
|
|
||||||
|
function abilityLabel(score: number) {
|
||||||
|
const modifier = Math.floor((Number(score) - 10) / 2)
|
||||||
|
return modifier >= 0 ? `+${modifier}` : String(modifier)
|
||||||
|
}
|
||||||
|
|
||||||
async function submitAction() {
|
async function submitAction() {
|
||||||
if (!currentAction.value.trim() || submitting.value || ready.value) return
|
if (!currentAction.value.trim() || submitting.value || ready.value) return
|
||||||
submitting.value = true
|
submitting.value = true
|
||||||
@@ -67,8 +72,8 @@ async function continueRound() {
|
|||||||
</article>
|
</article>
|
||||||
<div class="character-sheet">
|
<div class="character-sheet">
|
||||||
<small>ACTIVE CHARACTER</small><h2>{{ characters[0]?.name }}</h2><p>{{ characters[0]?.concept }}</p>
|
<small>ACTIVE CHARACTER</small><h2>{{ characters[0]?.name }}</h2><p>{{ characters[0]?.concept }}</p>
|
||||||
<div class="vitals"><span><b>{{ characters[0]?.hp }}</b>/{{ characters[0]?.maxHp }} HP</span><span><b>{{ characters[0]?.defense }}</b> DEF</span></div>
|
<div class="vitals"><span><b>{{ characters[0]?.hp }}</b>/{{ characters[0]?.maxHp }} HP</span><span><b>{{ characters[0]?.defense }}</b> AC</span></div>
|
||||||
<div class="abilities"><span v-for="(score,key) in characters[0]?.abilities" :key="key"><small>{{ key }}</small><b>{{ score }}</b></span></div>
|
<div class="abilities"><span v-for="(score,key) in characters[0]?.abilities" :key="key"><small>{{ key }} {{ abilityLabel(score) }}</small><b>{{ score }}</b></span></div>
|
||||||
<details><summary>INVENTORY</summary><ul><li v-for="item in characters[0]?.inventory" :key="item">{{ item }}</li></ul></details>
|
<details><summary>INVENTORY</summary><ul><li v-for="item in characters[0]?.inventory" :key="item">{{ item }}</li></ul></details>
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
|
|||||||
@@ -21,6 +21,8 @@ const campaigns = ref<CampaignRow[]>([])
|
|||||||
const drafts = ref<DraftSession[]>([])
|
const drafts = ref<DraftSession[]>([])
|
||||||
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,6 +43,34 @@ function draftSummary(draft: DraftSession) {
|
|||||||
|| 'Continue your conversation with the coauthor.'
|
|| 'Continue your conversation with the coauthor.'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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()
|
||||||
@@ -60,7 +90,7 @@ 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>
|
||||||
@@ -75,7 +105,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 +127,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>
|
||||||
|
|
||||||
@@ -130,6 +180,13 @@ 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}.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}}
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
74
apps/web/pages/profile/[id].vue
Normal file
74
apps/web/pages/profile/[id].vue
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
interface ProfileView {
|
||||||
|
id: string
|
||||||
|
display_name: string
|
||||||
|
description: string
|
||||||
|
avatar_url: string | null
|
||||||
|
created_at: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SharedCampaign {
|
||||||
|
id: string
|
||||||
|
title: string
|
||||||
|
status: string
|
||||||
|
role: string
|
||||||
|
joined_at: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
|
const { api } = useDngApi()
|
||||||
|
const auth = useDngAuth()
|
||||||
|
const profile = ref<ProfileView | null>(null)
|
||||||
|
const campaigns = ref<SharedCampaign[]>([])
|
||||||
|
const loading = ref(true)
|
||||||
|
const error = ref('')
|
||||||
|
const profileId = computed(() => String(route.params.id ?? ''))
|
||||||
|
const returnCampaign = computed(() => typeof route.query.campaign === 'string' ? route.query.campaign : '')
|
||||||
|
const initials = computed(() => (profile.value?.display_name || 'Adventurer')
|
||||||
|
.split(/\s+/).map(part => part[0]).join('').slice(0, 2).toUpperCase())
|
||||||
|
|
||||||
|
function messageFrom(cause: unknown) {
|
||||||
|
const value = cause as { data?: { statusMessage?: string }; message?: string }
|
||||||
|
return value.data?.statusMessage ?? value.message ?? 'This player profile is not available.'
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
try {
|
||||||
|
await auth.restore()
|
||||||
|
const result = await api<{ profile: ProfileView; sharedCampaigns: SharedCampaign[] }>(`/api/v1/profiles/${profileId.value}`)
|
||||||
|
profile.value = result.profile
|
||||||
|
campaigns.value = result.sharedCampaigns
|
||||||
|
} catch (cause) {
|
||||||
|
error.value = messageFrom(cause)
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<AppShell section="PLAYER RECORD">
|
||||||
|
<div v-if="loading" class="public-state noise"><i /><p>LOCATING PLAYER RECORD…</p></div>
|
||||||
|
<div v-else-if="!profile" class="public-state noise"><small>PRIVATE RECORD</small><h1>PLAYER NOT FOUND.</h1><p>{{ error }}</p><NuxtLink :to="returnCampaign ? `/campaign/${returnCampaign}` : '/dashboard'">GO BACK</NuxtLink></div>
|
||||||
|
<div v-else class="public-profile noise">
|
||||||
|
<nav><NuxtLink :to="returnCampaign ? `/campaign/${returnCampaign}` : '/dashboard'">← {{ returnCampaign ? 'BACK TO CAMPAIGN' : 'BACK TO DASHBOARD' }}</NuxtLink><NuxtLink v-if="profile.id === auth.session.value?.user.id" to="/profile">MANAGE PROFILE →</NuxtLink></nav>
|
||||||
|
<main>
|
||||||
|
<div class="public-avatar"><span>{{ initials }}</span><img v-if="profile.avatar_url" :src="profile.avatar_url" :alt="`${profile.display_name}'s profile picture`"><i>VERIFIED<br>PARTY MEMBER</i></div>
|
||||||
|
<section class="public-copy">
|
||||||
|
<p class="eyebrow">PLAYER PROFILE / DUNGEONS & GROUND</p>
|
||||||
|
<h1>{{ profile.display_name }}<span>.</span></h1>
|
||||||
|
<blockquote>{{ profile.description || 'This adventurer has not written a profile description yet.' }}</blockquote>
|
||||||
|
<dl>
|
||||||
|
<div><dt>REGISTERED</dt><dd>{{ new Date(profile.created_at).toLocaleDateString(undefined, { dateStyle: 'long' }) }}</dd></div>
|
||||||
|
<div><dt>SHARED UNIVERSES</dt><dd>{{ campaigns.length }}</dd></div>
|
||||||
|
</dl>
|
||||||
|
<div v-if="campaigns.length" class="shared-worlds"><small>YOUR SHARED UNIVERSES</small><NuxtLink v-for="campaign in campaigns" :key="campaign.id" :to="`/campaign/${campaign.id}`"><span><b>{{ campaign.title }}</b><em>{{ campaign.role }} · joined {{ new Date(campaign.joined_at).toLocaleDateString() }}</em></span><i>↗</i></NuxtLink></div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</AppShell>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.public-profile{min-height:calc(100vh - 76px);padding:32px clamp(20px,6vw,88px) 80px}.public-profile nav{display:flex;justify-content:space-between;gap:20px;padding:0 0 28px;border-bottom:1px solid var(--line)}.public-profile nav a,.public-state a{color:var(--muted);text-decoration:none;font:600 8px var(--mono);letter-spacing:.1em}.public-profile main{display:grid;grid-template-columns:minmax(300px,42%) minmax(0,1fr);min-height:650px;border:1px solid var(--line);border-top:0;background:#0e0e0d}.public-avatar{position:relative;overflow:hidden;display:grid;place-items:center;min-height:650px;border-right:1px solid var(--line);background:radial-gradient(circle at 50% 42%,#4e5724,#17170f 33%,#080808 70%);color:var(--acid);font:600 clamp(50px,8vw,110px) var(--display)}.public-avatar::after{content:"";position:absolute;inset:28px;border:1px solid rgba(217,247,95,.2)}.public-avatar img{position:absolute;inset:0;width:100%;height:100%;object-fit:cover}.public-avatar i{position:absolute;z-index:2;left:50px;bottom:45px;color:var(--ink);font:500 8px/1.6 var(--mono);letter-spacing:.16em;font-style:normal}.public-copy{padding:clamp(35px,6vw,80px);align-self:center}.eyebrow{color:var(--acid);font:500 8px var(--mono);letter-spacing:.18em}.public-copy h1,.public-state h1{margin:18px 0 28px;font:600 clamp(42px,6vw,78px)/1.04 var(--display);letter-spacing:-.07em;overflow-wrap:anywhere}.public-copy h1 span{color:var(--acid)}blockquote{margin:0;padding-left:22px;border-left:2px solid var(--acid);color:#c9c9c2;font:400 clamp(14px,2vw,19px)/1.8 var(--body)}dl{display:grid;grid-template-columns:1fr 1fr;margin:42px 0;border-top:1px solid var(--line);border-bottom:1px solid var(--line)}dl div{padding:18px 0}dl div+div{padding-left:24px;border-left:1px solid var(--line)}dt{color:var(--muted);font:500 7px var(--mono);letter-spacing:.13em}dd{margin:7px 0 0;font-size:11px}.shared-worlds>small{color:var(--acid);font:500 7px var(--mono);letter-spacing:.14em}.shared-worlds>a{display:flex;align-items:center;justify-content:space-between;gap:20px;padding:16px 0;border-bottom:1px solid var(--line);color:var(--ink);text-decoration:none}.shared-worlds span{display:flex;flex-direction:column;gap:6px}.shared-worlds b{font:600 11px var(--display)}.shared-worlds em{color:var(--muted);font:500 7px var(--mono);text-transform:uppercase;font-style:normal}.shared-worlds>a>i{color:var(--acid);font-style:normal}.public-state{min-height:calc(100vh - 76px);display:grid;place-content:center;justify-items:center;text-align:center;padding:30px}.public-state>i{width:24px;height:24px;border:2px solid var(--line);border-top-color:var(--acid);border-radius:50%;animation:spin .8s linear infinite}.public-state small{color:var(--acid);font:500 8px var(--mono);letter-spacing:.15em}.public-state p{color:var(--muted)}@keyframes spin{to{transform:rotate(360deg)}}@media(max-width:820px){.public-profile main{grid-template-columns:1fr}.public-avatar{min-height:380px;border-right:0;border-bottom:1px solid var(--line)}.public-avatar i{left:28px;bottom:26px}.public-copy{padding:34px 26px}}@media(max-width:480px){dl{grid-template-columns:1fr}dl div+div{padding-left:0;border-left:0;border-top:1px solid var(--line)}}
|
||||||
|
</style>
|
||||||
230
apps/web/pages/profile/index.vue
Normal file
230
apps/web/pages/profile/index.vue
Normal file
File diff suppressed because one or more lines are too long
@@ -16,6 +16,8 @@ interface StoredSession {
|
|||||||
confirmed_world_id?: string | null
|
confirmed_world_id?: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const questionLimit = 4
|
||||||
|
|
||||||
const { api } = useDngApi()
|
const { api } = useDngApi()
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const stage = ref<Stage>('seed')
|
const stage = ref<Stage>('seed')
|
||||||
@@ -33,7 +35,14 @@ const messages = ref<Message[]>([
|
|||||||
])
|
])
|
||||||
|
|
||||||
const displayedQuestion = computed(() => currentQuestion.value ? { key: currentQuestion.value.id, label: currentQuestion.value.label, options: currentQuestion.value.options } : null)
|
const displayedQuestion = computed(() => currentQuestion.value ? { key: currentQuestion.value.id, label: currentQuestion.value.label, options: currentQuestion.value.options } : null)
|
||||||
const progress = computed(() => stage.value === 'preview' || stage.value === 'confirming' ? 100 : stage.value === 'generating' ? 85 : stage.value === 'seed' ? 10 : 20 + Math.round((Math.min(answerCount.value, 5) / 5) * 55))
|
const conversationMessages = computed(() => {
|
||||||
|
if (!currentQuestion.value) return messages.value
|
||||||
|
const currentIndex = messages.value.findLastIndex(message =>
|
||||||
|
message.role === 'coauthor' && message.body === currentQuestion.value?.label,
|
||||||
|
)
|
||||||
|
return currentIndex < 0 ? messages.value : messages.value.filter((_, index) => index !== currentIndex)
|
||||||
|
})
|
||||||
|
const progress = computed(() => stage.value === 'preview' || stage.value === 'confirming' ? 100 : stage.value === 'generating' ? 85 : stage.value === 'seed' ? 10 : 20 + Math.round((Math.min(answerCount.value, questionLimit) / questionLimit) * 55))
|
||||||
|
|
||||||
function addMessage(role: Message['role'], body: string, label?: string) {
|
function addMessage(role: Message['role'], body: string, label?: string) {
|
||||||
messages.value.push({ id: `${Date.now()}-${messages.value.length}`, role, body, label })
|
messages.value.push({ id: `${Date.now()}-${messages.value.length}`, role, body, label })
|
||||||
@@ -117,6 +126,7 @@ async function resume(id: string) {
|
|||||||
|
|
||||||
async function answerQuestion(key: string, value: string) {
|
async function answerQuestion(key: string, value: string) {
|
||||||
if (stage.value !== 'questions' || currentQuestion.value?.id !== key || busy.value || !sessionId.value) return
|
if (stage.value !== 'questions' || currentQuestion.value?.id !== key || busy.value || !sessionId.value) return
|
||||||
|
const answeredQuestion = currentQuestion.value
|
||||||
busy.value = true
|
busy.value = true
|
||||||
pendingAnswer.value = { key, value }
|
pendingAnswer.value = { key, value }
|
||||||
try {
|
try {
|
||||||
@@ -124,9 +134,10 @@ async function answerQuestion(key: string, value: string) {
|
|||||||
method: 'POST', body: { content: value },
|
method: 'POST', body: { content: value },
|
||||||
})
|
})
|
||||||
answers[key] = value
|
answers[key] = value
|
||||||
|
addMessage('coauthor', answeredQuestion.label, `COAUTHOR · QUESTION ${Math.min(answerCount.value + 1, questionLimit)} OF ${questionLimit}`)
|
||||||
addMessage('player', value)
|
addMessage('player', value)
|
||||||
currentQuestion.value = null
|
currentQuestion.value = null
|
||||||
answerCount.value = Math.min(answerCount.value + 1, 5)
|
answerCount.value = Math.min(answerCount.value + 1, questionLimit)
|
||||||
pendingAnswer.value = null
|
pendingAnswer.value = null
|
||||||
retryAction.value = 'respond'
|
retryAction.value = 'respond'
|
||||||
await requestNextQuestion()
|
await requestNextQuestion()
|
||||||
@@ -140,14 +151,13 @@ async function answerQuestion(key: string, value: string) {
|
|||||||
async function requestNextQuestion() {
|
async function requestNextQuestion() {
|
||||||
if (!sessionId.value) return
|
if (!sessionId.value) return
|
||||||
const result = await api<RespondResult>(`/api/v1/coauthor/sessions/${sessionId.value}/respond`, { method: 'POST' })
|
const result = await api<RespondResult>(`/api/v1/coauthor/sessions/${sessionId.value}/respond`, { method: 'POST' })
|
||||||
if (result.readyToGenerate || answerCount.value >= 5) {
|
if (result.readyToGenerate || answerCount.value >= questionLimit) {
|
||||||
currentQuestion.value = null
|
currentQuestion.value = null
|
||||||
await generate()
|
await generate()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (!result.question) throw new Error('The coauthor did not return its next question.')
|
if (!result.question) throw new Error('The coauthor did not return its next question.')
|
||||||
currentQuestion.value = result.question
|
currentQuestion.value = result.question
|
||||||
addMessage('coauthor', result.question.label, `COAUTHOR · QUESTION ${Math.min(answerCount.value + 1, 5)} OF UP TO 5`)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function errorText(error: unknown) {
|
function errorText(error: unknown) {
|
||||||
@@ -247,7 +257,7 @@ onMounted(() => {
|
|||||||
<main v-if="stage !== 'preview' && stage !== 'confirming'" class="coauthor">
|
<main v-if="stage !== 'preview' && stage !== 'confirming'" class="coauthor">
|
||||||
<p class="kicker">COAUTHOR / SESSION 01</p>
|
<p class="kicker">COAUTHOR / SESSION 01</p>
|
||||||
<h1>BUILD THE<br>IMPOSSIBLE<span>.</span></h1>
|
<h1>BUILD THE<br>IMPOSSIBLE<span>.</span></h1>
|
||||||
<CoauthorConversation :messages="messages" :question="displayedQuestion" :selected-answer="currentQuestion ? answers[currentQuestion.id] : undefined" :busy="stage === 'generating' || busy" @answer="answerQuestion" @retry="retry" />
|
<CoauthorConversation :messages="conversationMessages" :question="displayedQuestion" :selected-answer="currentQuestion ? answers[currentQuestion.id] : undefined" :busy="stage === 'generating' || busy" @answer="answerQuestion" @retry="retry" />
|
||||||
<form v-if="stage === 'seed'" class="seed" @submit.prevent="begin">
|
<form v-if="stage === 'seed'" class="seed" @submit.prevent="begin">
|
||||||
<textarea v-model="seed" aria-label="World idea" rows="3" maxlength="1200" autofocus />
|
<textarea v-model="seed" aria-label="World idea" rows="3" maxlength="1200" autofocus />
|
||||||
<button :disabled="!seed.trim() || busy">{{ busy ? 'SAVING…' : 'BEGIN' }} <span>→</span></button>
|
<button :disabled="!seed.trim() || busy">{{ busy ? 'SAVING…' : 'BEGIN' }} <span>→</span></button>
|
||||||
|
|||||||
44
apps/web/server/api/v1/campaigns/[id].delete.ts
Normal file
44
apps/web/server/api/v1/campaigns/[id].delete.ts
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
import { requireCampaignAccess, stageTwoApiError, stageTwoDatabase, 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 { owner } = await requireCampaignAccess(campaignId, user.id, true)
|
||||||
|
if (!owner) throw createError({ statusCode: 403, statusMessage: 'Only the campaign owner can delete it.' })
|
||||||
|
|
||||||
|
// Delete associated data first to respect relational integrity
|
||||||
|
await stageTwoDatabase(`characters?campaign_id=eq.${campaignId}`, { method: 'DELETE' })
|
||||||
|
await stageTwoDatabase(`rounds?campaign_id=eq.${campaignId}`, { method: 'DELETE' })
|
||||||
|
await stageTwoDatabase(`game_events?campaign_id=eq.${campaignId}`, { method: 'DELETE' })
|
||||||
|
await stageTwoDatabase(`dice_rolls?campaign_id=eq.${campaignId}`, { method: 'DELETE' })
|
||||||
|
await stageTwoDatabase(`player_intents?campaign_id=eq.${campaignId}`, { method: 'DELETE' })
|
||||||
|
await stageTwoDatabase(`story_summaries?campaign_id=eq.${campaignId}`, { method: 'DELETE' })
|
||||||
|
await stageTwoDatabase(`memories?campaign_id=eq.${campaignId}`, { method: 'DELETE' })
|
||||||
|
await stageTwoDatabase(`relationships?campaign_id=eq.${campaignId}`, { method: 'DELETE' })
|
||||||
|
await stageTwoDatabase(`audit_entries?campaign_id=eq.${campaignId}`, { method: 'DELETE' })
|
||||||
|
|
||||||
|
// Delete linked world record if it has no other campaigns
|
||||||
|
const campaigns = await stageTwoDatabase<Array<{ world_id: string }>>(
|
||||||
|
`campaigns?select=world_id&id=eq.${campaignId}`,
|
||||||
|
)
|
||||||
|
const worldId = campaigns[0]?.world_id as string | undefined
|
||||||
|
if (worldId) {
|
||||||
|
const otherCampaigns = await stageTwoDatabase<Array<{ id: string }>>(
|
||||||
|
`campaigns?select=id&world_id=eq.${worldId}&id=neq.${campaignId}`,
|
||||||
|
)
|
||||||
|
if (!otherCampaigns.length) {
|
||||||
|
await stageTwoDatabase(`world_entities?world_id=eq.${worldId}`, { method: 'DELETE' })
|
||||||
|
await stageTwoDatabase(`worlds?id=eq.${worldId}`, { method: 'DELETE' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await stageTwoDatabase(`campaign_members?campaign_id=eq.${campaignId}`, { method: 'DELETE' })
|
||||||
|
await stageTwoDatabase(`invites?campaign_id=eq.${campaignId}`, { method: 'DELETE' })
|
||||||
|
await stageTwoDatabase(`campaigns?id=eq.${campaignId}`, { method: 'DELETE' })
|
||||||
|
return { success: true }
|
||||||
|
} catch (error) {
|
||||||
|
stageTwoApiError(error)
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import { requireCampaignAccess, requireStageTwoUser, stageTwoApiError, stageTwoDatabase, stageTwoUuid } from '~/server/utils/stage-two-supabase'
|
import { requireCampaignAccess, requireStageTwoUser, stageTwoApiError, stageTwoDatabase, stageTwoUuid } from '~/server/utils/stage-two-supabase'
|
||||||
|
import { visibleProfile, type ProfileRecord } from '~/server/utils/profile'
|
||||||
|
|
||||||
export default defineEventHandler(async (event) => {
|
export default defineEventHandler(async (event) => {
|
||||||
try {
|
try {
|
||||||
@@ -15,7 +16,7 @@ export default defineEventHandler(async (event) => {
|
|||||||
`campaign_members?select=id,user_id,role,active,ai_takeover_allowed,joined_at&campaign_id=eq.${campaignId}&order=joined_at.asc`,
|
`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`,
|
||||||
@@ -30,11 +31,12 @@ export default defineEventHandler(async (event) => {
|
|||||||
const round = openRounds[0] ?? fallbackRounds[0] ?? null
|
const round = openRounds[0] ?? fallbackRounds[0] ?? null
|
||||||
const userIds = [...new Set(members.map(member => String(member.user_id)))]
|
const userIds = [...new Set(members.map(member => String(member.user_id)))]
|
||||||
const profiles = userIds.length
|
const profiles = userIds.length
|
||||||
? await stageTwoDatabase<Array<{ id: string; display_name: string }>>(
|
? await stageTwoDatabase<ProfileRecord[]>(
|
||||||
`profiles?select=id,display_name&id=in.(${userIds.join(',')})`,
|
`profiles?select=id,display_name,description,avatar_path,created_at,updated_at&id=in.(${userIds.join(',')})`,
|
||||||
)
|
)
|
||||||
: []
|
: []
|
||||||
const profileById = new Map(profiles.map(profile => [profile.id, profile]))
|
const supabaseUrl = useRuntimeConfig().supabaseUrl
|
||||||
|
const profileById = new Map(profiles.map(profile => [profile.id, visibleProfile(profile, supabaseUrl)]))
|
||||||
const visibleMembers = members.map(member => ({ ...member, profile: profileById.get(String(member.user_id)) ?? null }))
|
const 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))
|
||||||
|
|||||||
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`,
|
||||||
|
|||||||
@@ -34,7 +34,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, Defense 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({
|
||||||
|
|||||||
@@ -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,17 +1,9 @@
|
|||||||
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 { requireStageTwoSafeText, requireStageTwoUser, stageTwoApiError, stageTwoDatabase, stageTwoRpc, stageTwoUuid } from '~/server/utils/stage-two-supabase'
|
import { requireStageTwoSafeText, requireStageTwoUser, 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) })
|
||||||
const QuestionSchema = z.object({
|
|
||||||
question: z.string().trim().min(5).max(300),
|
|
||||||
options: z.array(z.string().trim().min(1).max(100)).length(3),
|
|
||||||
})
|
|
||||||
const StoredQuestionSchema = z.object({
|
|
||||||
id: z.string().trim().min(1).max(80),
|
|
||||||
label: z.string().trim().min(5).max(300),
|
|
||||||
options: z.array(z.string().trim().min(1).max(100)).length(3),
|
|
||||||
})
|
|
||||||
|
|
||||||
export default defineEventHandler(async (event) => {
|
export default defineEventHandler(async (event) => {
|
||||||
try {
|
try {
|
||||||
@@ -27,29 +19,42 @@ export default defineEventHandler(async (event) => {
|
|||||||
const answerCount = Math.max(0, messages.filter(message => message.role === 'user').length - 1)
|
const answerCount = Math.max(0, messages.filter(message => message.role === 'user').length - 1)
|
||||||
if (answerCount >= 4) return { readyToGenerate: true }
|
if (answerCount >= 4) return { readyToGenerate: true }
|
||||||
if (session.current_question) {
|
if (session.current_question) {
|
||||||
return { readyToGenerate: false, question: StoredQuestionSchema.parse(session.current_question) }
|
const storedQuestion = StoredCoauthorQuestionSchema.safeParse(session.current_question)
|
||||||
|
if (storedQuestion.success) return { readyToGenerate: false, question: storedQuestion.data }
|
||||||
}
|
}
|
||||||
|
|
||||||
const completion = buildJsonCompletion(useRuntimeConfig(), {
|
let question: z.infer<typeof CoauthorQuestionSchema> | undefined
|
||||||
system: `You are the Dungeons & Ground coauthor. Based only on this conversation, ask one focused question that makes the original TTRPG world more playable. This is clarification ${answerCount + 1} of 4. Do not repeat a topic already answered. Adapt to the requested genre. Provide exactly three concise, mutually distinct answer options. Keep everything suitable for ages 13+.`,
|
for (let attempt = 0; attempt < 2 && !question; attempt += 1) {
|
||||||
messages,
|
const correction = attempt
|
||||||
schemaName: 'coauthor_question',
|
? ' The previous response was malformed. Ensure every option is a natural-language answer, not punctuation, a JSON key, or a copy of the question.'
|
||||||
jsonSchema: QuestionSchema.toJSONSchema(),
|
: ''
|
||||||
})
|
const completion = buildJsonCompletion(useRuntimeConfig(), {
|
||||||
const controller = new AbortController()
|
system: `You are the Dungeons & Ground coauthor. Based only on this conversation, ask one focused question that makes the original TTRPG world more playable. This is clarification ${answerCount + 1} of 4. Do not repeat a topic already answered. Adapt to the requested genre. Use the language of the first user message for the question and every answer option unless the user explicitly requests another language. Provide exactly three concise, mutually distinct natural-language answers to the question. Never use punctuation or JSON field names as an option, and never repeat the question as an option. Keep everything suitable for ages 13+.${correction}`,
|
||||||
const timeout = setTimeout(() => controller.abort(), 30_000)
|
messages,
|
||||||
let response: Response
|
schemaName: 'coauthor_question',
|
||||||
try {
|
jsonSchema: CoauthorQuestionSchema.toJSONSchema(),
|
||||||
response = await fetch(completion.endpoint, {
|
|
||||||
method: 'POST', headers: completion.headers, body: JSON.stringify(completion.body), signal: controller.signal,
|
|
||||||
})
|
})
|
||||||
} finally {
|
const controller = new AbortController()
|
||||||
clearTimeout(timeout)
|
const timeout = setTimeout(() => controller.abort(), 30_000)
|
||||||
|
let response: Response
|
||||||
|
try {
|
||||||
|
response = await fetch(completion.endpoint, {
|
||||||
|
method: 'POST', headers: completion.headers, body: JSON.stringify(completion.body), signal: controller.signal,
|
||||||
|
})
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timeout)
|
||||||
|
}
|
||||||
|
if (!response.ok) throw createError({ statusCode: 502, statusMessage: 'The coauthor is temporarily unavailable.' })
|
||||||
|
try {
|
||||||
|
const parsed = CoauthorQuestionSchema.safeParse(parseJsonCompletion(await response.json()))
|
||||||
|
if (parsed.success) question = parsed.data
|
||||||
|
} catch {
|
||||||
|
// Retry once when the provider ignores structured-output requirements.
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (!response.ok) throw createError({ statusCode: 502, statusMessage: 'The coauthor is temporarily unavailable.' })
|
if (!question) throw createError({ statusCode: 502, statusMessage: 'The coauthor returned invalid answer options. Try the question again.' })
|
||||||
const question = QuestionSchema.parse(parseJsonCompletion(await response.json()))
|
|
||||||
requireStageTwoSafeText([question.question, ...question.options].join('\n'))
|
requireStageTwoSafeText([question.question, ...question.options].join('\n'))
|
||||||
const storedQuestion = StoredQuestionSchema.parse({
|
const storedQuestion = StoredCoauthorQuestionSchema.parse({
|
||||||
id: `question-${answerCount + 1}`,
|
id: `question-${answerCount + 1}`,
|
||||||
label: question.question,
|
label: question.question,
|
||||||
options: question.options,
|
options: question.options,
|
||||||
|
|||||||
37
apps/web/server/api/v1/profile.patch.ts
Normal file
37
apps/web/server/api/v1/profile.patch.ts
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
import { z } from 'zod'
|
||||||
|
import { requireStageTwoSafeText, requireStageTwoUser, stageTwoApiError, stageTwoDatabase } from '~/server/utils/stage-two-supabase'
|
||||||
|
import { visibleProfile, type ProfileRecord } from '~/server/utils/profile'
|
||||||
|
|
||||||
|
const ProfileUpdateSchema = z.object({
|
||||||
|
displayName: z.string().trim().min(1).max(80).optional(),
|
||||||
|
description: z.string().trim().max(500).optional(),
|
||||||
|
avatarPath: z.string().nullable().optional(),
|
||||||
|
}).strict().refine(value => Object.keys(value).length > 0, 'At least one profile field is required.')
|
||||||
|
|
||||||
|
export default defineEventHandler(async (event) => {
|
||||||
|
try {
|
||||||
|
const user = await requireStageTwoUser(event)
|
||||||
|
const update = ProfileUpdateSchema.parse(await readBody(event))
|
||||||
|
const expectedAvatarPath = `${user.id}/avatar`
|
||||||
|
if (update.avatarPath !== undefined && update.avatarPath !== null && update.avatarPath !== expectedAvatarPath) {
|
||||||
|
throw createError({ statusCode: 400, statusMessage: 'Invalid profile picture path.' })
|
||||||
|
}
|
||||||
|
if (update.displayName !== undefined) requireStageTwoSafeText(update.displayName)
|
||||||
|
if (update.description !== undefined) requireStageTwoSafeText(update.description)
|
||||||
|
|
||||||
|
const changes: Record<string, unknown> = { updated_at: new Date().toISOString() }
|
||||||
|
if (update.displayName !== undefined) changes.display_name = update.displayName
|
||||||
|
if (update.description !== undefined) changes.description = update.description
|
||||||
|
if (update.avatarPath !== undefined) changes.avatar_path = update.avatarPath
|
||||||
|
|
||||||
|
const profiles = await stageTwoDatabase<ProfileRecord[]>(`profiles?id=eq.${user.id}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
body: JSON.stringify(changes),
|
||||||
|
prefer: 'return=representation',
|
||||||
|
})
|
||||||
|
if (!profiles[0]) throw createError({ statusCode: 404, statusMessage: 'Profile not found.' })
|
||||||
|
return { profile: visibleProfile(profiles[0], useRuntimeConfig().supabaseUrl) }
|
||||||
|
} catch (error) {
|
||||||
|
stageTwoApiError(error)
|
||||||
|
}
|
||||||
|
})
|
||||||
65
apps/web/server/api/v1/profiles/[id].get.ts
Normal file
65
apps/web/server/api/v1/profiles/[id].get.ts
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
import { requireStageTwoUser, stageTwoApiError, stageTwoDatabase, stageTwoUuid } from '~/server/utils/stage-two-supabase'
|
||||||
|
import { visibleProfile, type ProfileRecord } from '~/server/utils/profile'
|
||||||
|
|
||||||
|
interface MembershipRow {
|
||||||
|
campaign_id: string
|
||||||
|
role: string
|
||||||
|
joined_at: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export default defineEventHandler(async (event) => {
|
||||||
|
try {
|
||||||
|
const user = await requireStageTwoUser(event)
|
||||||
|
const profileId = stageTwoUuid(getRouterParam(event, 'id'), 'profile id')
|
||||||
|
const viewerMemberships = await stageTwoDatabase<Array<{ campaign_id: string }>>(
|
||||||
|
`campaign_members?select=campaign_id&user_id=eq.${user.id}&active=eq.true`,
|
||||||
|
)
|
||||||
|
const viewerCampaignIds = [...new Set(viewerMemberships.map(member => member.campaign_id))]
|
||||||
|
|
||||||
|
let sharedMemberships: MembershipRow[]
|
||||||
|
if (profileId === user.id) {
|
||||||
|
sharedMemberships = await stageTwoDatabase<MembershipRow[]>(
|
||||||
|
`campaign_members?select=campaign_id,role,joined_at&user_id=eq.${profileId}&active=eq.true&order=joined_at.desc`,
|
||||||
|
)
|
||||||
|
} else if (viewerCampaignIds.length) {
|
||||||
|
sharedMemberships = await stageTwoDatabase<MembershipRow[]>(
|
||||||
|
`campaign_members?select=campaign_id,role,joined_at&user_id=eq.${profileId}&active=eq.true&campaign_id=in.(${viewerCampaignIds.join(',')})&order=joined_at.desc`,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
sharedMemberships = []
|
||||||
|
}
|
||||||
|
if (profileId !== user.id && !sharedMemberships.length) {
|
||||||
|
throw createError({ statusCode: 404, statusMessage: 'Profile not found in your campaigns.' })
|
||||||
|
}
|
||||||
|
|
||||||
|
const profiles = await stageTwoDatabase<ProfileRecord[]>(
|
||||||
|
`profiles?select=id,display_name,description,avatar_path,created_at,updated_at&id=eq.${profileId}&limit=1`,
|
||||||
|
)
|
||||||
|
if (!profiles[0]) throw createError({ statusCode: 404, statusMessage: 'Profile not found.' })
|
||||||
|
|
||||||
|
const campaignIds = sharedMemberships.map(member => member.campaign_id)
|
||||||
|
const campaigns = campaignIds.length
|
||||||
|
? await stageTwoDatabase<Array<{ id: string; title: string; status: string }>>(
|
||||||
|
`campaigns?select=id,title,status&id=in.(${campaignIds.join(',')})`,
|
||||||
|
)
|
||||||
|
: []
|
||||||
|
const campaignById = new Map(campaigns.map(campaign => [campaign.id, campaign]))
|
||||||
|
const sharedCampaigns = sharedMemberships.flatMap((membership) => {
|
||||||
|
const campaign = campaignById.get(membership.campaign_id)
|
||||||
|
return campaign ? [{
|
||||||
|
id: campaign.id,
|
||||||
|
title: campaign.title,
|
||||||
|
status: campaign.status,
|
||||||
|
role: membership.role,
|
||||||
|
joined_at: membership.joined_at,
|
||||||
|
}] : []
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
profile: visibleProfile(profiles[0], useRuntimeConfig().supabaseUrl),
|
||||||
|
sharedCampaigns,
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
stageTwoApiError(error)
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -15,7 +15,7 @@ export default defineEventHandler(async event => {
|
|||||||
let completion
|
let completion
|
||||||
try {
|
try {
|
||||||
completion = buildJsonCompletion(config, {
|
completion = buildJsonCompletion(config, {
|
||||||
system: 'Create an original 13+ TTRPG starting world. Return only valid JSON matching the supplied WorldStarter structure, with exactly 3 NPCs and 2 factions. Do not use protected franchises.',
|
system: 'Create an original 13+ TTRPG starting world. Return only valid JSON matching the supplied WorldStarter structure, with exactly 3 NPCs, 2 factions, and 3 distinct persistent AI companion heroes designed specifically for this universe. Give every companion complete playable mechanics, useful genre-appropriate equipment, and a distinctive persona. Do not use protected franchises.',
|
||||||
messages: request.messages,
|
messages: request.messages,
|
||||||
schemaName: 'world_starter',
|
schemaName: 'world_starter',
|
||||||
jsonSchema: WorldStarterSchema.toJSONSchema(),
|
jsonSchema: WorldStarterSchema.toJSONSchema(),
|
||||||
|
|||||||
30
apps/web/server/utils/coauthor-question.test.ts
Normal file
30
apps/web/server/utils/coauthor-question.test.ts
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import { CoauthorQuestionSchema, StoredCoauthorQuestionSchema } from './coauthor-question'
|
||||||
|
|
||||||
|
describe('coauthor question validation', () => {
|
||||||
|
it('accepts three useful, distinct answers', () => {
|
||||||
|
expect(CoauthorQuestionSchema.parse({
|
||||||
|
question: 'How will the party enter the sealed archive?',
|
||||||
|
options: ['Negotiate with its keeper', 'Search for a maintenance route', 'Force the main gate'],
|
||||||
|
})).toMatchObject({ options: ['Negotiate with its keeper', 'Search for a maintenance route', 'Force the main gate'] })
|
||||||
|
expect(CoauthorQuestionSchema.toJSONSchema()).toMatchObject({
|
||||||
|
type: 'object',
|
||||||
|
properties: { options: { type: 'array', minItems: 3, maxItems: 3 } },
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects malformed JSON fragments and a repeated question', () => {
|
||||||
|
expect(() => CoauthorQuestionSchema.parse({
|
||||||
|
question: 'Как персонажи собираются решить проблему отсутствия наличных денег?',
|
||||||
|
options: [':', 'question', 'Как персонажи собираются решить проблему отсутствия наличных денег?'],
|
||||||
|
})).toThrow()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('applies the same quality checks to a stored question', () => {
|
||||||
|
expect(() => StoredCoauthorQuestionSchema.parse({
|
||||||
|
id: 'question-2',
|
||||||
|
label: 'What makes the signal dangerous?',
|
||||||
|
options: ['The signal is alive', 'The signal is alive', 'Nobody knows yet'],
|
||||||
|
})).toThrow()
|
||||||
|
})
|
||||||
|
})
|
||||||
63
apps/web/server/utils/coauthor-question.ts
Normal file
63
apps/web/server/utils/coauthor-question.ts
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
import { z } from 'zod'
|
||||||
|
|
||||||
|
const reservedOptionValues = new Set([
|
||||||
|
'answer',
|
||||||
|
'answers',
|
||||||
|
'label',
|
||||||
|
'option',
|
||||||
|
'options',
|
||||||
|
'question',
|
||||||
|
])
|
||||||
|
|
||||||
|
function normalized(value: string) {
|
||||||
|
return value.trim().toLocaleLowerCase().replace(/\s+/g, ' ')
|
||||||
|
}
|
||||||
|
|
||||||
|
export const CoauthorQuestionSchema = z.object({
|
||||||
|
question: z.string().trim().min(5).max(300),
|
||||||
|
options: z.array(z.string().trim().min(2).max(100)).length(3),
|
||||||
|
}).superRefine((value, context) => {
|
||||||
|
const question = normalized(value.question)
|
||||||
|
const seen = new Set<string>()
|
||||||
|
|
||||||
|
value.options.forEach((option, index) => {
|
||||||
|
const candidate = normalized(option)
|
||||||
|
if (!/[\p{L}\p{N}]/u.test(candidate) || reservedOptionValues.has(candidate)) {
|
||||||
|
context.addIssue({
|
||||||
|
code: 'custom',
|
||||||
|
message: 'Each option must be a meaningful answer.',
|
||||||
|
path: ['options', index],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if (seen.has(candidate)) {
|
||||||
|
context.addIssue({
|
||||||
|
code: 'custom',
|
||||||
|
message: 'Answer options must be distinct.',
|
||||||
|
path: ['options', index],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if (candidate === question || (candidate.length >= 20 && question.includes(candidate))) {
|
||||||
|
context.addIssue({
|
||||||
|
code: 'custom',
|
||||||
|
message: 'An answer option must not repeat the question.',
|
||||||
|
path: ['options', index],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
seen.add(candidate)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
export const StoredCoauthorQuestionSchema = z.object({
|
||||||
|
id: z.string().trim().min(1).max(80),
|
||||||
|
label: z.string().trim().min(5).max(300),
|
||||||
|
options: CoauthorQuestionSchema.shape.options,
|
||||||
|
}).superRefine((value, context) => {
|
||||||
|
const result = CoauthorQuestionSchema.safeParse({ question: value.label, options: value.options })
|
||||||
|
for (const issue of result.error?.issues ?? []) {
|
||||||
|
context.addIssue({
|
||||||
|
code: 'custom',
|
||||||
|
message: issue.message,
|
||||||
|
path: issue.path[0] === 'question' ? ['label', ...issue.path.slice(1)] : issue.path,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
29
apps/web/server/utils/profile.test.ts
Normal file
29
apps/web/server/utils/profile.test.ts
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import { profileAvatarUrl, visibleProfile } from './profile'
|
||||||
|
|
||||||
|
const profile = {
|
||||||
|
id: '00000000-0000-0000-0000-000000000001',
|
||||||
|
display_name: 'Aster Vale',
|
||||||
|
description: 'Keeper of impossible maps.',
|
||||||
|
avatar_path: '00000000-0000-0000-0000-000000000001/avatar',
|
||||||
|
created_at: '2026-08-20T10:00:00.000Z',
|
||||||
|
updated_at: '2026-08-20T11:00:00.000Z',
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('profile presentation', () => {
|
||||||
|
it('builds a cache-busted public avatar URL', () => {
|
||||||
|
expect(profileAvatarUrl(profile, 'https://example.supabase.co/')).toBe(
|
||||||
|
'https://example.supabase.co/storage/v1/object/public/profile-avatars/00000000-0000-0000-0000-000000000001/avatar?v=2026-08-20T11%3A00%3A00.000Z',
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not expose the storage path in a visible profile', () => {
|
||||||
|
const visible = visibleProfile(profile, 'https://example.supabase.co')
|
||||||
|
expect(visible).not.toHaveProperty('avatar_path')
|
||||||
|
expect(visible.avatar_url).toContain('/profile-avatars/')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns no URL when the profile has no avatar', () => {
|
||||||
|
expect(profileAvatarUrl({ ...profile, avatar_path: null }, 'https://example.supabase.co')).toBeNull()
|
||||||
|
})
|
||||||
|
})
|
||||||
26
apps/web/server/utils/profile.ts
Normal file
26
apps/web/server/utils/profile.ts
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
export interface ProfileRecord {
|
||||||
|
id: string
|
||||||
|
display_name: string
|
||||||
|
description: string
|
||||||
|
avatar_path: string | null
|
||||||
|
created_at: string
|
||||||
|
updated_at: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function profileAvatarUrl(profile: Pick<ProfileRecord, 'avatar_path' | 'updated_at'>, supabaseUrl: string): string | null {
|
||||||
|
if (!profile.avatar_path) return null
|
||||||
|
const encodedPath = profile.avatar_path.split('/').map(encodeURIComponent).join('/')
|
||||||
|
const version = encodeURIComponent(profile.updated_at)
|
||||||
|
return `${supabaseUrl.replace(/\/$/, '')}/storage/v1/object/public/profile-avatars/${encodedPath}?v=${version}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function visibleProfile(profile: ProfileRecord, supabaseUrl: string) {
|
||||||
|
return {
|
||||||
|
id: profile.id,
|
||||||
|
display_name: profile.display_name,
|
||||||
|
description: profile.description,
|
||||||
|
avatar_url: profileAvatarUrl(profile, supabaseUrl),
|
||||||
|
created_at: profile.created_at,
|
||||||
|
updated_at: profile.updated_at,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,6 +8,20 @@ const worldFixture = () => ({
|
|||||||
startingLocation: { id: 'location', kind: 'location' as const, name: 'Gate', summary: 'A station beyond known space.', tags: [] as string[], secrets: [] as string[] },
|
startingLocation: { id: 'location', kind: 'location' as const, name: 'Gate', summary: 'A station beyond known space.', tags: [] as string[], secrets: [] as string[] },
|
||||||
npcs: [1, 2, 3].map(index => ({ id: `npc-${index}`, kind: 'npc' as const, name: `NPC ${index}`, summary: 'A useful person with their own agenda.', tags: [], secrets: [] })),
|
npcs: [1, 2, 3].map(index => ({ id: `npc-${index}`, kind: 'npc' as const, name: `NPC ${index}`, summary: 'A useful person with their own agenda.', tags: [], secrets: [] })),
|
||||||
factions: [1, 2].map(index => ({ id: `faction-${index}`, kind: 'faction' as const, name: `Faction ${index}`, summary: 'An organization pursuing a hidden objective.', tags: [], secrets: [] })),
|
factions: [1, 2].map(index => ({ id: `faction-${index}`, kind: 'faction' as const, name: `Faction ${index}`, summary: 'An organization pursuing a hidden objective.', tags: [], secrets: [] })),
|
||||||
|
companions: [1, 2, 3].map(index => ({
|
||||||
|
name: `Companion ${index}`,
|
||||||
|
concept: `A universe-specific companion with role ${index}.`,
|
||||||
|
abilities: { str: 10, dex: 12, con: 11, int: 13, wis: 12, cha: 10 },
|
||||||
|
hp: 11,
|
||||||
|
maxHp: 11,
|
||||||
|
defense: 12,
|
||||||
|
proficiency: 2,
|
||||||
|
inventory: ['Field kit'],
|
||||||
|
skillProficiencies: ['arcana', 'investigation', 'perception', 'survival'],
|
||||||
|
skillExpertise: ['investigation'],
|
||||||
|
savingThrowProficiencies: ['int', 'wis'],
|
||||||
|
persona: { voice: 'Distinctive and clear.', motivation: 'Help the party.', flaw: 'Has a private agenda.', bond: 'Believes in the party.' },
|
||||||
|
})),
|
||||||
hook: 'A sufficiently long hook that immediately gives the party something to investigate.',
|
hook: 'A sufficiently long hook that immediately gives the party something to investigate.',
|
||||||
hiddenThreat: 'A hidden threat waits beyond the gate.',
|
hiddenThreat: 'A hidden threat waits beyond the gate.',
|
||||||
openingScene: 'The gate opens without warning, and an impossible signal calls every hero by name.',
|
openingScene: 'The gate opens without warning, and an impossible signal calls every hero by name.',
|
||||||
@@ -34,7 +48,10 @@ describe('worker AI providers', () => {
|
|||||||
})
|
})
|
||||||
vi.stubGlobal('fetch', fetchMock)
|
vi.stubGlobal('fetch', fetchMock)
|
||||||
const { generateWorld } = await import('./ai')
|
const { generateWorld } = await import('./ai')
|
||||||
await expect(generateWorld([{ role: 'user', content: 'Create a science fiction frontier.' }])).resolves.toMatchObject({ title: 'Test Reach' })
|
await expect(generateWorld([{ role: 'user', content: 'Create a science fiction frontier.' }])).resolves.toMatchObject({
|
||||||
|
title: 'Test Reach',
|
||||||
|
companions: [{ name: 'Companion 1' }, { name: 'Companion 2' }, { name: 'Companion 3' }],
|
||||||
|
})
|
||||||
expect(fetchMock).toHaveBeenCalledWith('https://api.deepseek.com/chat/completions', expect.any(Object))
|
expect(fetchMock).toHaveBeenCalledWith('https://api.deepseek.com/chat/completions', expect.any(Object))
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -89,7 +89,7 @@ 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 }>): 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 and two factions. Make the opening immediately playable.' },
|
{ role: 'system', content: 'You are the Dungeons & Ground world coauthor. Create an original 13+ private SRD 5.2.1 TTRPG setting in any requested genre. Never use protected settings or characters. Return exactly three NPCs, two factions, and three distinct persistent AI companion heroes created specifically for this universe. Give each companion complete playable mechanics, exactly four fitting skill proficiencies, exactly two saving throw proficiencies, no more than one expertise chosen from their proficient skills, useful genre-appropriate equipment, and a persona that produces interesting party choices without overriding the human player. Make the opening immediately playable.' },
|
||||||
...messages,
|
...messages,
|
||||||
], value => WorldStarterSchema.parse(value))
|
], value => WorldStarterSchema.parse(value))
|
||||||
// Moderate the entire typed object, including entity summaries, secrets and
|
// Moderate the entire typed object, including entity summaries, secrets and
|
||||||
@@ -100,7 +100,7 @@ export async function generateWorld(messages: Array<{ role: 'user' | 'assistant'
|
|||||||
|
|
||||||
export async function planRound(input: RoundContext): Promise<RoundPlan> {
|
export async function planRound(input: RoundContext): 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 TTRPG round. Human intents are authoritative and happen first. Then provide at most one action for each supplied aiCharacter, in the supplied order; never create AI actions for human-controlled characters. You may request checks and propose story events. Never invent dice results and never directly mutate mechanical state. The server owns rules, HP, inventory, statuses and randomness.' },
|
{ role: 'system', content: 'You plan one asynchronous SRD 5.2.1 TTRPG round. Human intents are authoritative and happen first. Then provide at most one action for each supplied aiCharacter, in the supplied order; never create AI actions for human-controlled characters. Request a d20 test only when the outcome is uncertain: ability, skill, savingThrow, deathSavingThrow, attack, or initiative. Skill checks require a skill and DC; saving throws require an ability and DC; attacks require an active targetId and use that target\'s server-owned Armor Class. Character rules profiles—not you—own proficiency, expertise, temporary HP, death saves, exhaustion, resistances and limited resources. Request a deathSavingThrow only for an unstable actor at 0 HP. Request separate damage or healing dice only after an applicable action; every damage event needs its SRD damageType. A resource event may consume only a named resource present on the character. A rest event uses item short or long. Never invent dice results and never directly mutate mechanical state. The server applies conditions, resistance, vulnerability, immunity, critical damage, HP and randomness.' },
|
||||||
{ role: 'user', content: JSON.stringify(input) },
|
{ role: 'user', content: JSON.stringify(input) },
|
||||||
], value => RoundPlanSchema.parse(value))
|
], value => RoundPlanSchema.parse(value))
|
||||||
assert13Plus(JSON.stringify(plan.aiActions), JSON.stringify(plan.proposedEvents))
|
assert13Plus(JSON.stringify(plan.aiActions), JSON.stringify(plan.proposedEvents))
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import './env'
|
import './env'
|
||||||
import { Worker } from 'bullmq'
|
import { Worker } from 'bullmq'
|
||||||
import IORedis from 'ioredis'
|
import IORedis from 'ioredis'
|
||||||
import { applyMechanicalEvents, bindMechanicalEventsToRolls, resolveCheck } from '@dng/game-engine'
|
import { applyMechanicalEvents, bindMechanicalEventsToRolls, resolveChecks } from '@dng/game-engine'
|
||||||
import { CharacterSchema, PlayerIntentSchema, makeId } from '@dng/shared'
|
import { CharacterSchema, PlayerIntentSchema, makeId } from '@dng/shared'
|
||||||
import { generateWorld, narrateRound, planRound, summarizeStory } from './ai'
|
import { generateWorld, narrateRound, planRound, summarizeStory } from './ai'
|
||||||
import { buildActionSequence, buildRoundContext, sanitizeRoundMemory, shouldUpdateStorySummary, validateAndOrderRoundPlan } from './orchestration'
|
import { buildActionSequence, buildRoundContext, sanitizeRoundMemory, shouldUpdateStorySummary, validateAndOrderRoundPlan } from './orchestration'
|
||||||
@@ -135,7 +135,8 @@ 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,
|
||||||
@@ -153,15 +154,11 @@ if (!supabaseUrl || !serviceKey) {
|
|||||||
})),
|
})),
|
||||||
})
|
})
|
||||||
const plan = validateAndOrderRoundPlan(await planRound(context), context)
|
const plan = validateAndOrderRoundPlan(await planRound(context), context)
|
||||||
const rolls = plan.checks.map(check => {
|
const rolls = resolveChecks(plan.checks, characters)
|
||||||
const actor = characters.find(character => character.id === check.actorId)
|
|
||||||
if (!actor) throw new Error(`Unknown check actor ${check.actorId}`)
|
|
||||||
return resolveCheck(check, actor)
|
|
||||||
})
|
|
||||||
const permittedEvents = bindMechanicalEventsToRolls(plan.proposedEvents, rolls)
|
const 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 })
|
||||||
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({
|
||||||
@@ -170,7 +167,7 @@ if (!supabaseUrl || !serviceKey) {
|
|||||||
})
|
})
|
||||||
: null
|
: null
|
||||||
|
|
||||||
await databaseRequest(job.claimToken ? 'rpc/commit_claimed_round_resolution' : 'rpc/commit_round_resolution', {
|
await databaseRequest(job.claimToken ? 'rpc/commit_claimed_srd_round_resolution' : 'rpc/commit_srd_round_resolution', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
p_round_id: round.id,
|
p_round_id: round.id,
|
||||||
@@ -178,7 +175,13 @@ 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 => ({
|
||||||
|
id: character.id,
|
||||||
|
hp: character.hp,
|
||||||
|
inventory: character.inventory,
|
||||||
|
statuses: character.statuses,
|
||||||
|
rulesState: character.rules,
|
||||||
|
})),
|
||||||
p_memory: sanitizeRoundMemory(resolution.memory),
|
p_memory: sanitizeRoundMemory(resolution.memory),
|
||||||
p_idempotency_key: job.id,
|
p_idempotency_key: job.id,
|
||||||
...(job.claimToken ? { p_worker_id: job.claimToken } : {}),
|
...(job.claimToken ? { p_worker_id: job.claimToken } : {}),
|
||||||
|
|||||||
@@ -41,6 +41,21 @@ describe('round orchestration', () => {
|
|||||||
}, context)).toThrow('Unknown check actor outsider')
|
}, context)).toThrow('Unknown check actor outsider')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('requires complete SRD check inputs before rolling', () => {
|
||||||
|
const context = buildRoundContext({
|
||||||
|
scene: 'Scene', recentRounds: [], intents: [],
|
||||||
|
characters: [character('hero', 'human'), character('foe', 'ai')], memories: [],
|
||||||
|
})
|
||||||
|
const basePlan = { aiActions: [], proposedEvents: [], relevantMemoryQueries: [] }
|
||||||
|
|
||||||
|
expect(() => validateAndOrderRoundPlan({
|
||||||
|
...basePlan, checks: [{ actorId: 'hero', kind: 'skill', mode: 'normal', reason: 'Search' }],
|
||||||
|
}, context)).toThrow('Skill checks require a skill')
|
||||||
|
expect(() => validateAndOrderRoundPlan({
|
||||||
|
...basePlan, checks: [{ actorId: 'hero', kind: 'attack', mode: 'normal', reason: 'Strike' }],
|
||||||
|
}, context)).toThrow('Attack rolls require a target')
|
||||||
|
})
|
||||||
|
|
||||||
it('schedules durable story summaries every third completed round', () => {
|
it('schedules durable story summaries every third completed round', () => {
|
||||||
expect([1, 2, 3, 4, 5, 6].filter(shouldUpdateStorySummary)).toEqual([3, 6])
|
expect([1, 2, 3, 4, 5, 6].filter(shouldUpdateStorySummary)).toEqual([3, 6])
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -64,13 +64,24 @@ export function validateAndOrderRoundPlan(planInput: unknown, context: RoundCont
|
|||||||
for (const check of plan.checks) {
|
for (const check of plan.checks) {
|
||||||
if (!characterIds.has(check.actorId)) throw new Error(`Unknown check actor ${check.actorId}`)
|
if (!characterIds.has(check.actorId)) throw new Error(`Unknown check actor ${check.actorId}`)
|
||||||
if (check.targetId && !characterIds.has(check.targetId)) throw new Error(`Unknown check target ${check.targetId}`)
|
if (check.targetId && !characterIds.has(check.targetId)) throw new Error(`Unknown check target ${check.targetId}`)
|
||||||
|
if (check.kind === 'skill' && !check.skill) throw new Error('Skill checks require a skill')
|
||||||
|
if (['ability', 'savingThrow'].includes(check.kind) && !check.ability) throw new Error(`${check.kind} checks require an ability`)
|
||||||
|
if (['ability', 'skill', 'savingThrow'].includes(check.kind) && check.difficulty === undefined) {
|
||||||
|
throw new Error(`${check.kind} checks require a Difficulty Class`)
|
||||||
|
}
|
||||||
|
if (check.kind === 'deathSavingThrow' && check.targetId) throw new Error('Death saving throws cannot target another character')
|
||||||
|
if (check.kind === 'attack' && !check.targetId) throw new Error('Attack rolls require a target')
|
||||||
|
if (['damage', 'healing'].includes(check.kind) && !check.targetId) throw new Error(`${check.kind} rolls require a target`)
|
||||||
}
|
}
|
||||||
for (const event of plan.proposedEvents) {
|
for (const event of plan.proposedEvents) {
|
||||||
if (event.actorId && !characterIds.has(event.actorId)) throw new Error(`Unknown event actor ${event.actorId}`)
|
if (event.actorId && !characterIds.has(event.actorId)) throw new Error(`Unknown event actor ${event.actorId}`)
|
||||||
if (event.targetId && !characterIds.has(event.targetId)) throw new Error(`Unknown event target ${event.targetId}`)
|
if (event.targetId && !characterIds.has(event.targetId)) throw new Error(`Unknown event target ${event.targetId}`)
|
||||||
if (['damage', 'healing', 'inventory', 'status'].includes(event.type) && !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')
|
||||||
}
|
}
|
||||||
|
|
||||||
const aiActions = plan.aiActions
|
const aiActions = plan.aiActions
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { describe, expect, it } from 'vitest'
|
import { describe, expect, it } from 'vitest'
|
||||||
import { abilityModifier, applyMechanicalEvents, bindMechanicalEventsToRolls, resolveCheck, shouldCloseRound, type RandomSource } from './index'
|
import { abilityModifier, applyMechanicalEvents, bindMechanicalEventsToRolls, proficiencyBonus, resolveCheck, resolveChecks, shouldCloseRound, type RandomSource } from './index'
|
||||||
import type { Character } from '@dng/shared'
|
import type { Character } from '@dng/shared'
|
||||||
|
|
||||||
const fixed: RandomSource = { integer: () => 12 }
|
const fixed: RandomSource = { integer: () => 12 }
|
||||||
@@ -18,7 +18,7 @@ describe('game engine', () => {
|
|||||||
it('keeps dice server-owned and auditable', () => {
|
it('keeps dice server-owned and auditable', () => {
|
||||||
const roll = resolveCheck({ actorId: 'hero', kind: 'ability', ability: 'dex', difficulty: 15, mode: 'normal', reason: 'Leap' }, hero, fixed)
|
const roll = resolveCheck({ actorId: 'hero', kind: 'ability', ability: 'dex', difficulty: 15, mode: 'normal', reason: 'Leap' }, hero, fixed)
|
||||||
expect(roll.rolls).toEqual([12])
|
expect(roll.rolls).toEqual([12])
|
||||||
expect(roll.total).toBe(17)
|
expect(roll.total).toBe(15)
|
||||||
expect(roll.success).toBe(true)
|
expect(roll.success).toBe(true)
|
||||||
expect(roll.id).toMatch(/^[0-9a-f]{8}-(?:[0-9a-f]{4}-){3}[0-9a-f]{12}$/i)
|
expect(roll.id).toMatch(/^[0-9a-f]{8}-(?:[0-9a-f]{4}-){3}[0-9a-f]{12}$/i)
|
||||||
})
|
})
|
||||||
@@ -30,10 +30,60 @@ describe('game engine', () => {
|
|||||||
hero,
|
hero,
|
||||||
{ integer: () => values.shift()! },
|
{ integer: () => values.shift()! },
|
||||||
)
|
)
|
||||||
expect(roll.formula).toBe('2d20kl1+5')
|
expect(roll.formula).toBe('2d20kl1+3')
|
||||||
expect(roll.kept).toEqual([4])
|
expect(roll.kept).toEqual([4])
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('applies proficiency only to proficient skills and saving throws', () => {
|
||||||
|
const perception = resolveCheck({
|
||||||
|
actorId: 'hero', kind: 'skill', skill: 'perception', proficient: true,
|
||||||
|
difficulty: 12, mode: 'normal', reason: 'Spot an ambush',
|
||||||
|
}, hero, fixed)
|
||||||
|
const constitutionSave = resolveCheck({
|
||||||
|
actorId: 'hero', kind: 'savingThrow', ability: 'con', proficient: false,
|
||||||
|
difficulty: 12, mode: 'normal', reason: 'Endure poison',
|
||||||
|
}, hero, fixed)
|
||||||
|
|
||||||
|
expect(perception.modifier).toBe(1)
|
||||||
|
expect(constitutionSave.modifier).toBe(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('uses server-owned Armor Class and natural attack outcomes', () => {
|
||||||
|
const target: Character = { ...hero, id: 'target', defense: 30 }
|
||||||
|
const naturalTwenty = resolveCheck({
|
||||||
|
actorId: 'hero', targetId: 'target', kind: 'attack', ability: 'str',
|
||||||
|
difficulty: 1, mode: 'normal', reason: 'Sword strike',
|
||||||
|
}, hero, { integer: () => 20 }, target)
|
||||||
|
const naturalOne = resolveCheck({
|
||||||
|
actorId: 'hero', targetId: 'target', kind: 'attack', ability: 'str',
|
||||||
|
difficulty: 1, mode: 'normal', reason: 'Sword strike',
|
||||||
|
}, hero, { integer: () => 1 }, { ...target, defense: 1 })
|
||||||
|
|
||||||
|
expect(naturalTwenty.difficulty).toBe(30)
|
||||||
|
expect(naturalTwenty.success).toBe(true)
|
||||||
|
expect(naturalOne.difficulty).toBe(1)
|
||||||
|
expect(naturalOne.success).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('skips damage on a miss and doubles only critical damage dice', () => {
|
||||||
|
const target: Character = { ...hero, id: 'target', defense: 14 }
|
||||||
|
const checks = [
|
||||||
|
{ actorId: 'hero', targetId: 'target', kind: 'attack' as const, ability: 'str' as const, mode: 'normal' as const, reason: 'Sword strike' },
|
||||||
|
{ actorId: 'hero', targetId: 'target', kind: 'damage' as const, dice: '1d6+2', mode: 'normal' as const, reason: 'Sword damage' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const missed = resolveChecks(checks, [hero, target], { integer: () => 1 })
|
||||||
|
expect(missed).toHaveLength(1)
|
||||||
|
expect(missed[0]?.checkKind).toBe('attack')
|
||||||
|
|
||||||
|
const values = [20, 4, 5]
|
||||||
|
const critical = resolveChecks(checks, [hero, target], { integer: () => values.shift()! })
|
||||||
|
expect(critical).toHaveLength(2)
|
||||||
|
expect(critical[1]?.formula).toBe('2d6+2')
|
||||||
|
expect(critical[1]?.rolls).toEqual([4, 5])
|
||||||
|
expect(critical[1]?.total).toBe(11)
|
||||||
|
})
|
||||||
|
|
||||||
it('replaces model-proposed damage with the authoritative roll total', () => {
|
it('replaces model-proposed damage with the authoritative roll total', () => {
|
||||||
const damage = resolveCheck(
|
const damage = resolveCheck(
|
||||||
{ actorId: 'hero', targetId: 'target', kind: 'damage', dice: '1d6+2', mode: 'normal', reason: 'Strike' },
|
{ actorId: 'hero', targetId: 'target', kind: 'damage', dice: '1d6+2', mode: 'normal', reason: 'Strike' },
|
||||||
@@ -41,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)
|
||||||
})
|
})
|
||||||
@@ -57,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 } 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}`)
|
||||||
@@ -51,13 +56,95 @@ export function bindMechanicalEventsToRolls(events: ProposedEvent[], rolls: Dice
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const skillAbilities: Record<SkillKey, AbilityKey> = {
|
||||||
|
acrobatics: 'dex',
|
||||||
|
animalHandling: 'wis',
|
||||||
|
arcana: 'int',
|
||||||
|
athletics: 'str',
|
||||||
|
deception: 'cha',
|
||||||
|
history: 'int',
|
||||||
|
insight: 'wis',
|
||||||
|
intimidation: 'cha',
|
||||||
|
investigation: 'int',
|
||||||
|
medicine: 'wis',
|
||||||
|
nature: 'int',
|
||||||
|
perception: 'wis',
|
||||||
|
performance: 'cha',
|
||||||
|
persuasion: 'cha',
|
||||||
|
religion: 'int',
|
||||||
|
sleightOfHand: 'dex',
|
||||||
|
stealth: 'dex',
|
||||||
|
survival: 'wis',
|
||||||
|
}
|
||||||
|
|
||||||
function abilityForCheck(request: CheckRequest): AbilityKey {
|
function abilityForCheck(request: CheckRequest): AbilityKey {
|
||||||
|
if (request.kind === 'skill' && request.skill) return skillAbilities[request.skill]
|
||||||
if (request.ability) return request.ability
|
if (request.ability) return request.ability
|
||||||
if (request.kind === 'initiative') return 'dex'
|
if (request.kind === 'initiative') return 'dex'
|
||||||
return 'str'
|
return 'str'
|
||||||
}
|
}
|
||||||
|
|
||||||
export function resolveCheck(request: CheckRequest, actor: Character, random: RandomSource = secureRandom): DiceRoll {
|
function usesProficiency(request: CheckRequest): boolean {
|
||||||
|
// The alpha treats ordinary weapon attacks as proficient unless the planner
|
||||||
|
// explicitly marks an improvised or unfamiliar attack otherwise.
|
||||||
|
if (request.kind === 'attack') return request.proficient !== false
|
||||||
|
return request.proficient === true
|
||||||
|
}
|
||||||
|
|
||||||
|
function proficiencyMultiplier(request: CheckRequest, actor: Character): number {
|
||||||
|
const rules = actor.rules
|
||||||
|
if (!rules) return usesProficiency(request) ? 1 : 0
|
||||||
|
if (request.kind === 'skill' && request.skill) {
|
||||||
|
if (rules.skillExpertise.includes(request.skill)) return 2
|
||||||
|
if (rules.skillProficiencies.includes(request.skill)) return 1
|
||||||
|
return rules.legacyProficiencyFallback && request.proficient === true ? 1 : 0
|
||||||
|
}
|
||||||
|
if (request.kind === 'savingThrow' && request.ability) {
|
||||||
|
if (rules.savingThrowProficiencies.includes(request.ability)) return 1
|
||||||
|
return rules.legacyProficiencyFallback && request.proficient === true ? 1 : 0
|
||||||
|
}
|
||||||
|
if (request.kind === 'attack') return request.proficient === false ? 0 : 1
|
||||||
|
return rules.legacyProficiencyFallback && request.proficient === true ? 1 : 0
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizedStatuses(character: Character): Set<string> {
|
||||||
|
return new Set(character.statuses.map(status => status.trim().toLowerCase()))
|
||||||
|
}
|
||||||
|
|
||||||
|
function effectiveRollMode(request: CheckRequest, actor: Character, target?: Character): CheckRequest['mode'] {
|
||||||
|
let advantage = request.mode === 'advantage' ? 1 : 0
|
||||||
|
let disadvantage = request.mode === 'disadvantage' ? 1 : 0
|
||||||
|
const actorStatuses = normalizedStatuses(actor)
|
||||||
|
const targetStatuses = target ? normalizedStatuses(target) : new Set<string>()
|
||||||
|
|
||||||
|
if (['ability', 'skill', 'attack'].includes(request.kind) && actorStatuses.has('poisoned')) disadvantage += 1
|
||||||
|
if (request.kind === 'attack') {
|
||||||
|
if (actorStatuses.has('blinded') || actorStatuses.has('restrained') || actorStatuses.has('prone')) disadvantage += 1
|
||||||
|
if (actorStatuses.has('invisible')) advantage += 1
|
||||||
|
if (targetStatuses.has('blinded') || targetStatuses.has('restrained') || targetStatuses.has('paralyzed')
|
||||||
|
|| targetStatuses.has('petrified') || targetStatuses.has('stunned') || targetStatuses.has('unconscious')) advantage += 1
|
||||||
|
if (targetStatuses.has('invisible')) disadvantage += 1
|
||||||
|
}
|
||||||
|
if (request.kind === 'savingThrow' && request.ability === 'dex' && actorStatuses.has('restrained')) disadvantage += 1
|
||||||
|
|
||||||
|
if (advantage && disadvantage) return 'normal'
|
||||||
|
if (advantage) return 'advantage'
|
||||||
|
if (disadvantage) return 'disadvantage'
|
||||||
|
return 'normal'
|
||||||
|
}
|
||||||
|
|
||||||
|
function automaticallyFailedSave(request: CheckRequest, actor: Character): boolean {
|
||||||
|
if (request.kind !== 'savingThrow' || !request.ability || !['str', 'dex'].includes(request.ability)) return false
|
||||||
|
const statuses = normalizedStatuses(actor)
|
||||||
|
return ['paralyzed', 'petrified', 'stunned', 'unconscious'].some(status => statuses.has(status))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveCheck(
|
||||||
|
request: CheckRequest,
|
||||||
|
actor: Character,
|
||||||
|
random: RandomSource = secureRandom,
|
||||||
|
target?: Character,
|
||||||
|
): DiceRoll {
|
||||||
if (request.kind === 'damage' || request.kind === 'healing') {
|
if (request.kind === 'damage' || request.kind === 'healing') {
|
||||||
const rolled = rollDice(request.dice ?? '1d6', random)
|
const rolled = rollDice(request.dice ?? '1d6', random)
|
||||||
return {
|
return {
|
||||||
@@ -78,56 +165,243 @@ export function resolveCheck(request: CheckRequest, actor: Character, random: Ra
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (request.kind === 'deathSavingThrow') {
|
||||||
|
const statuses = normalizedStatuses(actor)
|
||||||
|
if (actor.hp !== 0 || statuses.has('stable') || statuses.has('dead')) {
|
||||||
|
throw new Error('Death saving throws require an unstable character at 0 HP')
|
||||||
|
}
|
||||||
|
const result = random.integer(1, 20)
|
||||||
|
return {
|
||||||
|
id: randomUUID(), checkKind: request.kind, formula: '1d20', rolls: [result], kept: [result],
|
||||||
|
modifier: 0, total: result, difficulty: 10, success: result >= 10,
|
||||||
|
actorId: actor.id, targetId: null, createdAt: new Date().toISOString(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const ability = abilityForCheck(request)
|
const 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]) + actor.proficiency
|
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
|
||||||
const difficulty = request.difficulty ?? null
|
if (request.kind === 'attack' && (!request.targetId || !target || target.id !== request.targetId)) {
|
||||||
|
throw new Error('Attack rolls require the active target character')
|
||||||
|
}
|
||||||
|
const difficulty = request.kind === 'attack' ? target!.defense : request.difficulty ?? null
|
||||||
|
const automaticAttackOutcome = request.kind === 'attack'
|
||||||
|
? keptValue === 20 ? true : keptValue === 1 ? false : null
|
||||||
|
: null
|
||||||
|
const automaticSaveOutcome = automaticallyFailedSave(request, actor) ? false : null
|
||||||
return {
|
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 : 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(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function criticalDamageFormula(formula: string): string {
|
||||||
|
const match = /^(\d+)d(\d+)(?:([+-])(\d+))?$/.exec(formula)
|
||||||
|
if (!match) throw new Error(`Unsupported dice formula: ${formula}`)
|
||||||
|
const modifier = match[3] ? `${match[3]}${match[4]}` : ''
|
||||||
|
return `${Number(match[1]) * 2}d${match[2]}${modifier}`
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolves an ordered round plan while enforcing attack-to-damage rules.
|
||||||
|
* A missed attack never rolls damage; a natural 20 doubles only the damage
|
||||||
|
* dice and not the flat modifier.
|
||||||
|
*/
|
||||||
|
export function resolveChecks(
|
||||||
|
requests: CheckRequest[],
|
||||||
|
characters: Character[],
|
||||||
|
random: RandomSource = secureRandom,
|
||||||
|
): DiceRoll[] {
|
||||||
|
const rolls: DiceRoll[] = []
|
||||||
|
|
||||||
|
for (const originalRequest of requests) {
|
||||||
|
const actor = characters.find(character => character.id === originalRequest.actorId)
|
||||||
|
if (!actor) throw new Error(`Unknown check actor ${originalRequest.actorId}`)
|
||||||
|
const target = originalRequest.targetId
|
||||||
|
? characters.find(character => character.id === originalRequest.targetId)
|
||||||
|
: undefined
|
||||||
|
|
||||||
|
let request = originalRequest
|
||||||
|
if (request.kind === 'damage') {
|
||||||
|
const attack = [...rolls].reverse().find(roll =>
|
||||||
|
roll.checkKind === 'attack'
|
||||||
|
&& roll.actorId === request.actorId
|
||||||
|
&& roll.targetId === request.targetId,
|
||||||
|
)
|
||||||
|
if (attack?.success === false) continue
|
||||||
|
if (attack?.kept[0] === 20) {
|
||||||
|
request = { ...request, dice: criticalDamageFormula(request.dice ?? '1d6') }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
rolls.push(resolveCheck(request, actor, random, target))
|
||||||
|
}
|
||||||
|
|
||||||
|
return rolls
|
||||||
|
}
|
||||||
|
|
||||||
export interface AppliedState {
|
export interface AppliedState {
|
||||||
characters: Character[]
|
characters: Character[]
|
||||||
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 { 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', () => {
|
||||||
@@ -16,4 +16,33 @@ describe('shared database contracts', () => {
|
|||||||
|
|
||||||
expect(intent.ready).toBe(true)
|
expect(intent.ready).toBe(true)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('accepts typed SRD d20 tests', () => {
|
||||||
|
expect(CheckRequestSchema.parse({
|
||||||
|
actorId: 'hero', kind: 'skill', skill: 'perception', proficient: true,
|
||||||
|
difficulty: 15, mode: 'advantage', reason: 'Search the trapped hall',
|
||||||
|
})).toMatchObject({ kind: 'skill', skill: 'perception', proficient: true })
|
||||||
|
|
||||||
|
expect(CheckRequestSchema.parse({
|
||||||
|
actorId: 'hero', kind: 'savingThrow', ability: 'wis', proficient: false,
|
||||||
|
difficulty: 13, mode: 'normal', reason: 'Resist the whisper',
|
||||||
|
})).toMatchObject({ kind: 'savingThrow', ability: 'wis' })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('validates versioned SRD character mechanics', () => {
|
||||||
|
const rules = CharacterRulesSchema.parse({
|
||||||
|
version: 2,
|
||||||
|
level: 5,
|
||||||
|
skillProficiencies: ['perception'],
|
||||||
|
skillExpertise: ['perception'],
|
||||||
|
savingThrowProficiencies: ['wis'],
|
||||||
|
resources: { focus: { current: 2, max: 3, recovery: 'shortRest' } },
|
||||||
|
})
|
||||||
|
expect(rules.temporaryHp).toBe(0)
|
||||||
|
expect(rules.deathSaves).toEqual({ successes: 0, failures: 0 })
|
||||||
|
expect(() => CharacterRulesSchema.parse({
|
||||||
|
...rules,
|
||||||
|
skillExpertise: ['stealth'],
|
||||||
|
})).toThrow('expertise requires skill proficiency')
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -10,6 +10,70 @@ export const abilityKeys = ['str', 'dex', 'con', 'int', 'wis', 'cha'] as const
|
|||||||
export const AbilityKeySchema = z.enum(abilityKeys)
|
export const AbilityKeySchema = z.enum(abilityKeys)
|
||||||
export type AbilityKey = z.infer<typeof AbilityKeySchema>
|
export type AbilityKey = z.infer<typeof AbilityKeySchema>
|
||||||
|
|
||||||
|
export const skillKeys = [
|
||||||
|
'acrobatics', 'animalHandling', 'arcana', 'athletics', 'deception', 'history',
|
||||||
|
'insight', 'intimidation', 'investigation', 'medicine', 'nature', 'perception',
|
||||||
|
'performance', 'persuasion', 'religion', 'sleightOfHand', 'stealth', 'survival',
|
||||||
|
] as const
|
||||||
|
export const SkillKeySchema = z.enum(skillKeys)
|
||||||
|
export type SkillKey = z.infer<typeof SkillKeySchema>
|
||||||
|
|
||||||
|
export const damageTypeKeys = [
|
||||||
|
'acid', 'bludgeoning', 'cold', 'fire', 'force', 'lightning', 'necrotic',
|
||||||
|
'piercing', 'poison', 'psychic', 'radiant', 'slashing', 'thunder',
|
||||||
|
] as const
|
||||||
|
export const DamageTypeSchema = z.enum(damageTypeKeys)
|
||||||
|
export type DamageType = z.infer<typeof DamageTypeSchema>
|
||||||
|
|
||||||
|
export const conditionKeys = [
|
||||||
|
'blinded', 'charmed', 'deafened', 'frightened', 'grappled', 'incapacitated',
|
||||||
|
'invisible', 'paralyzed', 'petrified', 'poisoned', 'prone', 'restrained',
|
||||||
|
'stunned', 'unconscious', 'stable', 'dead',
|
||||||
|
] as const
|
||||||
|
export const ConditionKeySchema = z.enum(conditionKeys)
|
||||||
|
export type ConditionKey = z.infer<typeof ConditionKeySchema>
|
||||||
|
|
||||||
|
export const CharacterResourceSchema = z.object({
|
||||||
|
current: z.number().int().min(0),
|
||||||
|
max: z.number().int().min(0),
|
||||||
|
recovery: z.enum(['none', 'shortRest', 'longRest']).default('longRest'),
|
||||||
|
}).strict().refine(value => value.current <= value.max, {
|
||||||
|
message: 'resource current value must not exceed its maximum',
|
||||||
|
path: ['current'],
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Versioned SRD mechanics that older campaigns did not persist. The legacy
|
||||||
|
* fallback is explicit so migrated games keep their previous proficiency
|
||||||
|
* behavior without allowing it in newly-authored rules profiles.
|
||||||
|
*/
|
||||||
|
export const CharacterRulesSchema = z.object({
|
||||||
|
version: z.literal(2),
|
||||||
|
level: z.number().int().min(1).max(20).default(1),
|
||||||
|
skillProficiencies: z.array(SkillKeySchema).max(18).default([]),
|
||||||
|
skillExpertise: z.array(SkillKeySchema).max(18).default([]),
|
||||||
|
savingThrowProficiencies: z.array(AbilityKeySchema).max(6).default([]),
|
||||||
|
temporaryHp: z.number().int().min(0).max(999).default(0),
|
||||||
|
deathSaves: z.object({
|
||||||
|
successes: z.number().int().min(0).max(3),
|
||||||
|
failures: z.number().int().min(0).max(3),
|
||||||
|
}).strict().default({ successes: 0, failures: 0 }),
|
||||||
|
exhaustion: z.number().int().min(0).max(6).default(0),
|
||||||
|
damageResistances: z.array(DamageTypeSchema).max(13).default([]),
|
||||||
|
damageVulnerabilities: z.array(DamageTypeSchema).max(13).default([]),
|
||||||
|
damageImmunities: z.array(DamageTypeSchema).max(13).default([]),
|
||||||
|
resources: z.record(z.string().trim().min(1).max(80), CharacterResourceSchema).default({}),
|
||||||
|
legacyProficiencyFallback: z.boolean().default(false),
|
||||||
|
}).strict().superRefine((value, context) => {
|
||||||
|
const trained = new Set(value.skillProficiencies)
|
||||||
|
for (const skill of value.skillExpertise) {
|
||||||
|
if (!trained.has(skill)) {
|
||||||
|
context.addIssue({ code: 'custom', message: 'expertise requires skill proficiency', path: ['skillExpertise'] })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
export type CharacterRules = z.infer<typeof CharacterRulesSchema>
|
||||||
|
|
||||||
export const AbilityScoresSchema = z.object({
|
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),
|
||||||
@@ -30,21 +94,6 @@ export const WorldEntitySchema = z.object({
|
|||||||
})
|
})
|
||||||
export type WorldEntity = z.infer<typeof WorldEntitySchema>
|
export type WorldEntity = z.infer<typeof WorldEntitySchema>
|
||||||
|
|
||||||
export const WorldStarterSchema = z.object({
|
|
||||||
title: z.string().min(3).max(100),
|
|
||||||
genre: z.string().min(2).max(80),
|
|
||||||
tone: z.string().min(2).max(160),
|
|
||||||
premise: z.string().min(20).max(1800),
|
|
||||||
contentBoundaries: z.array(z.string().max(120)).min(1).max(8),
|
|
||||||
startingLocation: WorldEntitySchema.extend({ kind: z.literal('location') }),
|
|
||||||
npcs: z.array(WorldEntitySchema.extend({ kind: z.literal('npc') })).length(3),
|
|
||||||
factions: z.array(WorldEntitySchema.extend({ kind: z.literal('faction') })).length(2),
|
|
||||||
hook: z.string().min(20).max(1200),
|
|
||||||
hiddenThreat: z.string().min(10).max(1000),
|
|
||||||
openingScene: z.string().min(40).max(2400),
|
|
||||||
})
|
|
||||||
export type WorldStarter = z.infer<typeof WorldStarterSchema>
|
|
||||||
|
|
||||||
export const CharacterPersonaSchema = z.object({
|
export const CharacterPersonaSchema = z.object({
|
||||||
voice: z.string().trim().min(1).max(240),
|
voice: z.string().trim().min(1).max(240),
|
||||||
motivation: z.string().trim().min(1).max(300),
|
motivation: z.string().trim().min(1).max(300),
|
||||||
@@ -62,6 +111,9 @@ export const CharacterDraftSchema = z.object({
|
|||||||
defense: z.number().int().min(1).max(40),
|
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',
|
||||||
@@ -69,6 +121,22 @@ export const CharacterDraftSchema = z.object({
|
|||||||
})
|
})
|
||||||
export type CharacterDraft = z.infer<typeof CharacterDraftSchema>
|
export type CharacterDraft = z.infer<typeof CharacterDraftSchema>
|
||||||
|
|
||||||
|
export const WorldStarterSchema = z.object({
|
||||||
|
title: z.string().min(3).max(100),
|
||||||
|
genre: z.string().min(2).max(80),
|
||||||
|
tone: z.string().min(2).max(160),
|
||||||
|
premise: z.string().min(20).max(1800),
|
||||||
|
contentBoundaries: z.array(z.string().max(120)).min(1).max(8),
|
||||||
|
startingLocation: WorldEntitySchema.extend({ kind: z.literal('location') }),
|
||||||
|
npcs: z.array(WorldEntitySchema.extend({ kind: z.literal('npc') })).length(3),
|
||||||
|
factions: z.array(WorldEntitySchema.extend({ kind: z.literal('faction') })).length(2),
|
||||||
|
companions: z.array(CharacterDraftSchema).length(3),
|
||||||
|
hook: z.string().min(20).max(1200),
|
||||||
|
hiddenThreat: z.string().min(10).max(1000),
|
||||||
|
openingScene: z.string().min(40).max(2400),
|
||||||
|
})
|
||||||
|
export type WorldStarter = z.infer<typeof WorldStarterSchema>
|
||||||
|
|
||||||
export const CharacterSchema = z.object({
|
export const CharacterSchema = z.object({
|
||||||
id: z.string().min(1),
|
id: z.string().min(1),
|
||||||
name: z.string().min(1).max(80),
|
name: z.string().min(1).max(80),
|
||||||
@@ -83,6 +151,7 @@ export const CharacterSchema = z.object({
|
|||||||
inventory: z.array(z.string().max(120)).max(50).default([]),
|
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>
|
||||||
|
|
||||||
@@ -120,12 +189,15 @@ 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', 'attack', 'initiative', 'damage', 'healing']),
|
kind: z.enum(['ability', 'skill', 'savingThrow', 'deathSavingThrow', 'attack', 'initiative', 'damage', 'healing']),
|
||||||
ability: AbilityKeySchema.optional(),
|
ability: AbilityKeySchema.optional(),
|
||||||
|
skill: SkillKeySchema.optional(),
|
||||||
|
proficient: z.boolean().optional(),
|
||||||
difficulty: z.number().int().min(1).max(40).optional(),
|
difficulty: z.number().int().min(1).max(40).optional(),
|
||||||
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>
|
||||||
@@ -147,11 +219,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>
|
||||||
|
|||||||
@@ -112,6 +112,17 @@ const world = {
|
|||||||
entity('faction', 'The Reclaimers', 'Salvagers who want the station stripped for parts.'),
|
entity('faction', 'The Reclaimers', 'Salvagers who want the station stripped for parts.'),
|
||||||
entity('faction', 'The Seed Vault', 'An automated network protecting the last viable specimens.'),
|
entity('faction', 'The Seed Vault', 'An automated network protecting the last viable specimens.'),
|
||||||
],
|
],
|
||||||
|
companions: [1, 2, 3].map(index => ({
|
||||||
|
name: `Garden Companion ${index}`,
|
||||||
|
concept: `A universe-specific caretaker hero assigned to garden sector ${index}.`,
|
||||||
|
abilities: { str: 10, dex: 12, con: 11, int: 13, wis: 12, cha: 10 },
|
||||||
|
hp: 11,
|
||||||
|
maxHp: 11,
|
||||||
|
defense: 12,
|
||||||
|
proficiency: 2,
|
||||||
|
inventory: ['Garden survey kit'],
|
||||||
|
persona: { voice: 'Calm and observant.', motivation: 'Protect the living garden.', flaw: 'Trusts the station too much.', bond: 'Treats the party as new growth.' },
|
||||||
|
})),
|
||||||
hook: 'The airlock opens only after both visitors speak names the station already knows.',
|
hook: 'The airlock opens only after both visitors speak names the station already knows.',
|
||||||
hiddenThreat: 'The garden predicts visitors by growing imperfect biological copies of them.',
|
hiddenThreat: 'The garden predicts visitors by growing imperfect biological copies of them.',
|
||||||
openingScene: 'The inner airlock opens on warm rain and rows of silver trees. A voice welcomes both salvagers by name, then asks why one of them has returned without the other.',
|
openingScene: 'The inner airlock opens on warm rain and rows of silver trees. A voice welcomes both salvagers by name, then asks why one of them has returned without the other.',
|
||||||
@@ -123,6 +134,12 @@ const { worldId } = await appRequest(`/api/v1/coauthor/sessions/${sessionId}/con
|
|||||||
const { campaignId } = await appRequest('/api/v1/campaigns', owner.token, {
|
const { campaignId } = await appRequest('/api/v1/campaigns', owner.token, {
|
||||||
method: 'POST', body: JSON.stringify({ worldId, title: `Smoke Party ${suffix}` }),
|
method: 'POST', body: JSON.stringify({ worldId, title: `Smoke Party ${suffix}` }),
|
||||||
})
|
})
|
||||||
|
const starterParty = await appRequest(`/api/v1/campaigns/${campaignId}`, owner.token)
|
||||||
|
const ownerCharacter = starterParty.characters.find(character => character.user_id === owner.id && character.controller === 'human')
|
||||||
|
const automaticCompanions = starterParty.characters.filter(character => character.controller === 'ai')
|
||||||
|
if (!ownerCharacter || automaticCompanions.length !== world.companions.length || automaticCompanions.some(character => character.name === 'Echo') || starterParty.round?.status !== 'open') {
|
||||||
|
throw new Error('[smoke] A new campaign did not start with its actionable owner hero, generated world companions, and an open round.')
|
||||||
|
}
|
||||||
|
|
||||||
console.log('[smoke] Joining the second player through an invite…')
|
console.log('[smoke] Joining the second player through an invite…')
|
||||||
const { token: inviteToken } = await appRequest(`/api/v1/campaigns/${campaignId}/invites`, owner.token, {
|
const { token: inviteToken } = await appRequest(`/api/v1/campaigns/${campaignId}/invites`, owner.token, {
|
||||||
@@ -137,14 +154,11 @@ const characterBody = (name, concept) => JSON.stringify({
|
|||||||
name, concept, controller: 'human', abilities: { str: 10, dex: 10, con: 10, int: 12, wis: 11, cha: 9 },
|
name, concept, controller: 'human', abilities: { str: 10, dex: 10, con: 10, int: 12, wis: 11, cha: 9 },
|
||||||
hp: 12, maxHp: 12, defense: 12, proficiency: 2, inventory: [], statuses: [], persona: {},
|
hp: 12, maxHp: 12, defense: 12, proficiency: 2, inventory: [], statuses: [], persona: {},
|
||||||
})
|
})
|
||||||
const ownerCharacter = await appRequest(`/api/v1/campaigns/${campaignId}/characters`, owner.token, {
|
|
||||||
method: 'POST', body: characterBody('Iris Vale', 'A methodical station engineer.'),
|
|
||||||
})
|
|
||||||
const playerCharacter = await appRequest(`/api/v1/campaigns/${campaignId}/characters`, player.token, {
|
const playerCharacter = await appRequest(`/api/v1/campaigns/${campaignId}/characters`, player.token, {
|
||||||
method: 'POST', body: characterBody('Rowan Pike', 'A curious xenobotanist.'),
|
method: 'POST', body: characterBody('Rowan Pike', 'A curious xenobotanist.'),
|
||||||
})
|
})
|
||||||
|
|
||||||
console.log('[smoke] Drafting and adding a persistent AI companion…')
|
console.log('[smoke] Verifying AI drafting while keeping the automatic companion…')
|
||||||
const suggested = await appRequest(`/api/v1/campaigns/${campaignId}/characters/suggest`, owner.token, {
|
const suggested = await appRequest(`/api/v1/campaigns/${campaignId}/characters/suggest`, owner.token, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify({ controller: 'ai', concept: 'A damaged garden caretaker who values living things over orders.' }),
|
body: JSON.stringify({ controller: 'ai', concept: 'A damaged garden caretaker who values living things over orders.' }),
|
||||||
@@ -152,20 +166,16 @@ const suggested = await appRequest(`/api/v1/campaigns/${campaignId}/characters/s
|
|||||||
if (!suggested?.character?.name || !suggested.character?.persona?.motivation) {
|
if (!suggested?.character?.name || !suggested.character?.persona?.motivation) {
|
||||||
throw new Error('[smoke] Character coauthor returned an incomplete draft.')
|
throw new Error('[smoke] Character coauthor returned an incomplete draft.')
|
||||||
}
|
}
|
||||||
await appRequest(`/api/v1/campaigns/${campaignId}/characters`, owner.token, {
|
|
||||||
method: 'POST',
|
|
||||||
body: JSON.stringify({ ...suggested.character, controller: 'ai', statuses: [] }),
|
|
||||||
})
|
|
||||||
|
|
||||||
const initial = await appRequest(`/api/v1/campaigns/${campaignId}`, owner.token)
|
const initial = await appRequest(`/api/v1/campaigns/${campaignId}`, owner.token)
|
||||||
if (initial.members.length !== 2) throw new Error(`[smoke] Expected 2 members, received ${initial.members.length}.`)
|
if (initial.members.length !== 2) throw new Error(`[smoke] Expected 2 members, received ${initial.members.length}.`)
|
||||||
if (initial.characters.length !== 3 || initial.round?.status !== 'open') throw new Error('[smoke] Multiplayer lobby did not expose two humans, one AI companion, and an open round.')
|
if (initial.characters.length !== 5 || initial.round?.status !== 'open') throw new Error('[smoke] Multiplayer lobby did not expose two humans, three generated AI companions, and an open round.')
|
||||||
const roundId = initial.round.id
|
const roundId = initial.round.id
|
||||||
|
|
||||||
console.log('[smoke] Verifying that the round waits for both humans…')
|
console.log('[smoke] Verifying that the round waits for both humans…')
|
||||||
await appRequest(`/api/v1/campaigns/${campaignId}/rounds/${roundId}/intent`, owner.token, {
|
await appRequest(`/api/v1/campaigns/${campaignId}/rounds/${roundId}/intent`, owner.token, {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
body: JSON.stringify({ characterId: ownerCharacter.character.id, action: 'Iris checks the airlock telemetry for a safe path.', ready: true }),
|
body: JSON.stringify({ characterId: ownerCharacter.id, action: 'The owner checks the airlock telemetry for a safe path.', ready: true }),
|
||||||
})
|
})
|
||||||
await new Promise(resolve => setTimeout(resolve, 1_500))
|
await new Promise(resolve => setTimeout(resolve, 1_500))
|
||||||
const waiting = await appRequest(`/api/v1/campaigns/${campaignId}`, player.token)
|
const waiting = await appRequest(`/api/v1/campaigns/${campaignId}`, player.token)
|
||||||
|
|||||||
@@ -2258,6 +2258,885 @@ grant execute on function public.dng_schema_version() to service_role;
|
|||||||
|
|
||||||
notify pgrst, 'reload schema';
|
notify pgrst, 'reload schema';
|
||||||
|
|
||||||
|
-- ============================================================================
|
||||||
|
-- 0009_profile_management.sql
|
||||||
|
-- ============================================================================
|
||||||
|
|
||||||
|
alter table public.profiles
|
||||||
|
add column description text not null default '' check (char_length(description) <= 500),
|
||||||
|
add column avatar_path text check (avatar_path is null or avatar_path = id::text || '/avatar'),
|
||||||
|
add column updated_at timestamptz not null default now();
|
||||||
|
|
||||||
|
-- Avatars are public profile media, but only the owning authenticated user may
|
||||||
|
-- create, replace, or remove the one deterministic object in their folder.
|
||||||
|
insert into storage.buckets(id, name, public, file_size_limit, allowed_mime_types)
|
||||||
|
values (
|
||||||
|
'profile-avatars',
|
||||||
|
'profile-avatars',
|
||||||
|
true,
|
||||||
|
2097152,
|
||||||
|
array['image/jpeg', 'image/png', 'image/webp']
|
||||||
|
)
|
||||||
|
on conflict (id) do update
|
||||||
|
set public = excluded.public,
|
||||||
|
file_size_limit = excluded.file_size_limit,
|
||||||
|
allowed_mime_types = excluded.allowed_mime_types;
|
||||||
|
|
||||||
|
create policy dng_profile_avatar_insert
|
||||||
|
on storage.objects for insert to authenticated
|
||||||
|
with check (
|
||||||
|
bucket_id = 'profile-avatars'
|
||||||
|
and name = (select auth.uid())::text || '/avatar'
|
||||||
|
);
|
||||||
|
|
||||||
|
create policy dng_profile_avatar_select_own
|
||||||
|
on storage.objects for select to authenticated
|
||||||
|
using (
|
||||||
|
bucket_id = 'profile-avatars'
|
||||||
|
and name = (select auth.uid())::text || '/avatar'
|
||||||
|
);
|
||||||
|
|
||||||
|
create policy dng_profile_avatar_update
|
||||||
|
on storage.objects for update to authenticated
|
||||||
|
using (
|
||||||
|
bucket_id = 'profile-avatars'
|
||||||
|
and name = (select auth.uid())::text || '/avatar'
|
||||||
|
)
|
||||||
|
with check (
|
||||||
|
bucket_id = 'profile-avatars'
|
||||||
|
and name = (select auth.uid())::text || '/avatar'
|
||||||
|
);
|
||||||
|
|
||||||
|
create policy dng_profile_avatar_delete
|
||||||
|
on storage.objects for delete to authenticated
|
||||||
|
using (
|
||||||
|
bucket_id = 'profile-avatars'
|
||||||
|
and name = (select auth.uid())::text || '/avatar'
|
||||||
|
);
|
||||||
|
|
||||||
|
create or replace function public.dng_schema_version()
|
||||||
|
returns integer language sql stable security definer set search_path = '' as $$
|
||||||
|
select 9;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
revoke all on function public.dng_schema_version() from public, anon, authenticated;
|
||||||
|
grant execute on function public.dng_schema_version() to service_role;
|
||||||
|
|
||||||
|
notify pgrst, 'reload schema';
|
||||||
|
|
||||||
|
-- ============================================================================
|
||||||
|
-- 0010_playable_starter_parties.sql
|
||||||
|
-- ============================================================================
|
||||||
|
|
||||||
|
-- Make a newly launched campaign playable immediately: the owner receives a
|
||||||
|
-- human-controlled starter, and only actionable members hold the round open.
|
||||||
|
|
||||||
|
create or replace function public.stage_two_create_campaign(
|
||||||
|
p_world_id uuid,
|
||||||
|
p_owner_id uuid,
|
||||||
|
p_title text
|
||||||
|
) returns uuid language plpgsql security definer set search_path = '' as $$
|
||||||
|
declare
|
||||||
|
v_world public.worlds%rowtype;
|
||||||
|
v_campaign_id uuid;
|
||||||
|
v_owner_name text;
|
||||||
|
begin
|
||||||
|
if p_title is null or char_length(btrim(p_title)) not between 3 and 100 then
|
||||||
|
raise exception 'campaign title must be between 3 and 100 characters';
|
||||||
|
end if;
|
||||||
|
select * into v_world from public.worlds
|
||||||
|
where id = p_world_id and owner_id = p_owner_id and status = 'confirmed' for share;
|
||||||
|
if not found then raise exception 'confirmed world not found'; end if;
|
||||||
|
|
||||||
|
select nullif(btrim(display_name), '') into v_owner_name
|
||||||
|
from public.profiles where id = p_owner_id;
|
||||||
|
v_owner_name := left(coalesce(v_owner_name, 'Wayfinder'), 80);
|
||||||
|
|
||||||
|
insert into public.campaigns(world_id, owner_id, title, current_scene, next_prompt, status)
|
||||||
|
values (p_world_id, p_owner_id, btrim(p_title), coalesce(v_world.opening_scene, v_world.premise), 'What do you do?', 'active')
|
||||||
|
returning id into v_campaign_id;
|
||||||
|
insert into public.campaign_members(campaign_id, user_id, role)
|
||||||
|
values (v_campaign_id, p_owner_id, 'owner');
|
||||||
|
|
||||||
|
insert into public.characters(
|
||||||
|
campaign_id, user_id, name, concept, controller, abilities, hp, max_hp,
|
||||||
|
defense, proficiency, inventory, statuses, persona
|
||||||
|
) values (
|
||||||
|
v_campaign_id,
|
||||||
|
p_owner_id,
|
||||||
|
v_owner_name,
|
||||||
|
left('An adaptable protagonist ready to confront the opening mystery of ' || v_world.title || '.', 600),
|
||||||
|
'human'::public.character_controller,
|
||||||
|
'{"str":10,"dex":12,"con":12,"int":11,"wis":13,"cha":10}'::jsonb,
|
||||||
|
12,
|
||||||
|
12,
|
||||||
|
12,
|
||||||
|
2,
|
||||||
|
'["Field kit","Personal keepsake"]'::jsonb,
|
||||||
|
'[]'::jsonb,
|
||||||
|
jsonb_build_object(
|
||||||
|
'voice', 'Defined by the player.',
|
||||||
|
'motivation', 'Discover what the opening scene is hiding.',
|
||||||
|
'flaw', 'Still learning what this world demands.',
|
||||||
|
'bond', 'Protect the party through the first danger.'
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
insert into public.rounds(campaign_id, number) values (v_campaign_id, 1);
|
||||||
|
return v_campaign_id;
|
||||||
|
end;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
-- Members who have not created a character cannot submit an intent and must not
|
||||||
|
-- hold the round open. Only active members with a human-controlled character
|
||||||
|
-- participate in the readiness barrier.
|
||||||
|
create or replace function public.stage_two_submit_intent(
|
||||||
|
p_round_id uuid,
|
||||||
|
p_user_id uuid,
|
||||||
|
p_character_id uuid,
|
||||||
|
p_action text,
|
||||||
|
p_ready boolean default false
|
||||||
|
) returns jsonb language plpgsql security definer set search_path = '' as $$
|
||||||
|
declare
|
||||||
|
v_round public.rounds%rowtype;
|
||||||
|
v_member public.campaign_members%rowtype;
|
||||||
|
v_intent_id uuid;
|
||||||
|
v_job_id uuid;
|
||||||
|
begin
|
||||||
|
if p_action is null or char_length(btrim(p_action)) not between 1 and 2000 then
|
||||||
|
raise exception 'action must be between 1 and 2000 characters';
|
||||||
|
end if;
|
||||||
|
select * into v_round from public.rounds where id = p_round_id for update;
|
||||||
|
if not found or v_round.status <> 'open' then raise exception 'round is not open'; end if;
|
||||||
|
select * into v_member from public.campaign_members
|
||||||
|
where campaign_id = v_round.campaign_id and user_id = p_user_id and active;
|
||||||
|
if not found then raise exception 'active campaign membership not found'; end if;
|
||||||
|
if not exists (
|
||||||
|
select 1 from public.characters
|
||||||
|
where id = p_character_id and campaign_id = v_round.campaign_id
|
||||||
|
and user_id = p_user_id and controller = 'human'
|
||||||
|
) then raise exception 'controlled character not found'; end if;
|
||||||
|
|
||||||
|
insert into public.player_intents(round_id, member_id, character_id, action, ready)
|
||||||
|
values (p_round_id, v_member.id, p_character_id, btrim(p_action), coalesce(p_ready, false))
|
||||||
|
on conflict (round_id, member_id) do update
|
||||||
|
set character_id = excluded.character_id, action = excluded.action,
|
||||||
|
ready = excluded.ready, updated_at = now()
|
||||||
|
returning id into v_intent_id;
|
||||||
|
|
||||||
|
if coalesce(p_ready, false) and not exists (
|
||||||
|
select 1
|
||||||
|
from public.campaign_members member
|
||||||
|
join public.characters character
|
||||||
|
on character.campaign_id = member.campaign_id
|
||||||
|
and character.user_id = member.user_id
|
||||||
|
and character.controller = 'human'::public.character_controller
|
||||||
|
where member.campaign_id = v_round.campaign_id
|
||||||
|
and member.active
|
||||||
|
and not exists (
|
||||||
|
select 1 from public.player_intents intent
|
||||||
|
where intent.round_id = p_round_id and intent.member_id = member.id and intent.ready
|
||||||
|
)
|
||||||
|
) then
|
||||||
|
v_job_id := public.enqueue_round_resolution(p_round_id, null);
|
||||||
|
end if;
|
||||||
|
return jsonb_build_object('intentId', v_intent_id, 'jobId', v_job_id);
|
||||||
|
end;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
-- Repair campaigns created before starter parties were automatic.
|
||||||
|
insert into public.characters(
|
||||||
|
campaign_id, user_id, name, concept, controller, abilities, hp, max_hp,
|
||||||
|
defense, proficiency, inventory, statuses, persona
|
||||||
|
)
|
||||||
|
select
|
||||||
|
campaign.id,
|
||||||
|
campaign.owner_id,
|
||||||
|
left(coalesce(nullif(btrim(profile.display_name), ''), 'Wayfinder'), 80),
|
||||||
|
left('An adaptable protagonist ready to confront the opening mystery of ' || world.title || '.', 600),
|
||||||
|
'human'::public.character_controller,
|
||||||
|
'{"str":10,"dex":12,"con":12,"int":11,"wis":13,"cha":10}'::jsonb,
|
||||||
|
12,
|
||||||
|
12,
|
||||||
|
12,
|
||||||
|
2,
|
||||||
|
'["Field kit","Personal keepsake"]'::jsonb,
|
||||||
|
'[]'::jsonb,
|
||||||
|
jsonb_build_object(
|
||||||
|
'voice', 'Defined by the player.',
|
||||||
|
'motivation', 'Discover what the opening scene is hiding.',
|
||||||
|
'flaw', 'Still learning what this world demands.',
|
||||||
|
'bond', 'Protect the party through the first danger.'
|
||||||
|
)
|
||||||
|
from public.campaigns campaign
|
||||||
|
join public.worlds world on world.id = campaign.world_id
|
||||||
|
left join public.profiles profile on profile.id = campaign.owner_id
|
||||||
|
where campaign.status = 'active'
|
||||||
|
and not exists (
|
||||||
|
select 1 from public.characters character
|
||||||
|
where character.campaign_id = campaign.id
|
||||||
|
and character.user_id = campaign.owner_id
|
||||||
|
);
|
||||||
|
|
||||||
|
create or replace function public.dng_schema_version()
|
||||||
|
returns integer language sql stable security definer set search_path = '' as $$
|
||||||
|
select 10;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
revoke all on function public.dng_schema_version() from public, anon, authenticated;
|
||||||
|
grant execute on function public.dng_schema_version() to service_role;
|
||||||
|
|
||||||
|
notify pgrst, 'reload schema';
|
||||||
|
|
||||||
|
-- ============================================================================
|
||||||
|
-- 0011_universe_companions.sql
|
||||||
|
-- ============================================================================
|
||||||
|
|
||||||
|
-- Persist the AI companions created with each universe and use that party when
|
||||||
|
-- a campaign starts. This replaces the temporary hard-coded Echo companion.
|
||||||
|
|
||||||
|
alter table public.worlds
|
||||||
|
add column if not exists starter_companions jsonb not null default '[]'::jsonb;
|
||||||
|
|
||||||
|
alter table public.worlds
|
||||||
|
drop constraint if exists worlds_starter_companions_array;
|
||||||
|
alter table public.worlds
|
||||||
|
add constraint worlds_starter_companions_array check (
|
||||||
|
jsonb_typeof(starter_companions) = 'array'
|
||||||
|
and jsonb_array_length(starter_companions) <= 3
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Ready drafts created before companions were part of WorldStarter inherit
|
||||||
|
-- three universe-specific people that the coauthor already generated.
|
||||||
|
update public.coauthor_sessions session
|
||||||
|
set generated_world = jsonb_set(
|
||||||
|
session.generated_world,
|
||||||
|
'{companions}',
|
||||||
|
coalesce((
|
||||||
|
select jsonb_agg(
|
||||||
|
jsonb_build_object(
|
||||||
|
'name', npc.value->>'name',
|
||||||
|
'concept', left(npc.value->>'summary', 600),
|
||||||
|
'abilities', case npc.position
|
||||||
|
when 1 then '{"str":10,"dex":14,"con":11,"int":13,"wis":12,"cha":10}'::jsonb
|
||||||
|
when 2 then '{"str":13,"dex":10,"con":14,"int":10,"wis":12,"cha":11}'::jsonb
|
||||||
|
else '{"str":9,"dex":12,"con":11,"int":14,"wis":13,"cha":12}'::jsonb
|
||||||
|
end,
|
||||||
|
'hp', 10 + npc.position,
|
||||||
|
'maxHp', 10 + npc.position,
|
||||||
|
'defense', 11 + npc.position,
|
||||||
|
'proficiency', 2,
|
||||||
|
'inventory', jsonb_build_array('Universe field kit', 'Travel supplies'),
|
||||||
|
'persona', jsonb_build_object(
|
||||||
|
'voice', left(npc.value->>'summary', 240),
|
||||||
|
'motivation', left('Pursue the goal behind: ' || (npc.value->>'summary'), 300),
|
||||||
|
'flaw', 'Their personal agenda can complicate the party''s plans.',
|
||||||
|
'bond', left('They belong to ' || (session.generated_world->>'title') || ' and choose to stand with the party.', 300)
|
||||||
|
)
|
||||||
|
) order by npc.position
|
||||||
|
)
|
||||||
|
from jsonb_array_elements(coalesce(session.generated_world->'npcs', '[]'::jsonb))
|
||||||
|
with ordinality as npc(value, position)
|
||||||
|
where npc.position <= 3
|
||||||
|
), '[]'::jsonb),
|
||||||
|
true
|
||||||
|
),
|
||||||
|
updated_at = now()
|
||||||
|
where session.generated_world is not null
|
||||||
|
and jsonb_typeof(session.generated_world->'npcs') = 'array'
|
||||||
|
and coalesce(jsonb_array_length(session.generated_world->'companions'), 0) = 0;
|
||||||
|
|
||||||
|
-- Confirmed universes do not retain the original generation JSON. Build their
|
||||||
|
-- starter companions from the AI-created NPC entities already stored for them.
|
||||||
|
update public.worlds world
|
||||||
|
set starter_companions = coalesce((
|
||||||
|
select jsonb_agg(
|
||||||
|
jsonb_build_object(
|
||||||
|
'name', npc.name,
|
||||||
|
'concept', left(npc.summary, 600),
|
||||||
|
'abilities', case npc.position
|
||||||
|
when 1 then '{"str":10,"dex":14,"con":11,"int":13,"wis":12,"cha":10}'::jsonb
|
||||||
|
when 2 then '{"str":13,"dex":10,"con":14,"int":10,"wis":12,"cha":11}'::jsonb
|
||||||
|
else '{"str":9,"dex":12,"con":11,"int":14,"wis":13,"cha":12}'::jsonb
|
||||||
|
end,
|
||||||
|
'hp', 10 + npc.position,
|
||||||
|
'maxHp', 10 + npc.position,
|
||||||
|
'defense', 11 + npc.position,
|
||||||
|
'proficiency', 2,
|
||||||
|
'inventory', jsonb_build_array('Universe field kit', 'Travel supplies'),
|
||||||
|
'persona', jsonb_build_object(
|
||||||
|
'voice', left(npc.summary, 240),
|
||||||
|
'motivation', left('Pursue the goal behind: ' || npc.summary, 300),
|
||||||
|
'flaw', 'Their personal agenda can complicate the party''s plans.',
|
||||||
|
'bond', left('They belong to ' || world.title || ' and choose to stand with the party.', 300)
|
||||||
|
)
|
||||||
|
) order by npc.position
|
||||||
|
)
|
||||||
|
from (
|
||||||
|
select entity.name, entity.summary,
|
||||||
|
(row_number() over (order by entity.created_at, entity.id))::integer as position
|
||||||
|
from public.world_entities entity
|
||||||
|
where entity.world_id = world.id and entity.kind = 'npc'
|
||||||
|
order by entity.created_at, entity.id
|
||||||
|
limit 3
|
||||||
|
) npc
|
||||||
|
), '[]'::jsonb)
|
||||||
|
where jsonb_array_length(world.starter_companions) = 0;
|
||||||
|
|
||||||
|
create or replace function public.stage_two_confirm_world(
|
||||||
|
p_session_id uuid,
|
||||||
|
p_owner_id uuid
|
||||||
|
) returns uuid language plpgsql security definer set search_path = '' as $$
|
||||||
|
declare
|
||||||
|
v_session public.coauthor_sessions%rowtype;
|
||||||
|
v_world jsonb;
|
||||||
|
v_world_id uuid;
|
||||||
|
v_entity jsonb;
|
||||||
|
begin
|
||||||
|
select * into v_session from public.coauthor_sessions
|
||||||
|
where id = p_session_id and owner_id = p_owner_id for update;
|
||||||
|
if not found then raise exception 'coauthor session not found'; end if;
|
||||||
|
if v_session.status = 'confirmed' and v_session.confirmed_world_id is not null then
|
||||||
|
return v_session.confirmed_world_id;
|
||||||
|
end if;
|
||||||
|
if v_session.status <> 'ready' or v_session.generated_world is null then
|
||||||
|
raise exception 'coauthor session is not ready to confirm';
|
||||||
|
end if;
|
||||||
|
v_world := v_session.generated_world;
|
||||||
|
if coalesce(v_world->>'title', '') = ''
|
||||||
|
or coalesce(v_world->>'openingScene', '') = ''
|
||||||
|
or jsonb_typeof(v_world->'companions') <> 'array'
|
||||||
|
or jsonb_array_length(v_world->'companions') <> 3 then
|
||||||
|
raise exception 'generated world is incomplete';
|
||||||
|
end if;
|
||||||
|
|
||||||
|
insert into public.worlds(
|
||||||
|
owner_id, title, genre, tone, premise, content_boundaries,
|
||||||
|
hidden_threat, hook, opening_scene, starter_companions, status
|
||||||
|
) values (
|
||||||
|
p_owner_id, v_world->>'title', v_world->>'genre', v_world->>'tone', v_world->>'premise',
|
||||||
|
coalesce(v_world->'contentBoundaries', '[]'::jsonb), v_world->>'hiddenThreat',
|
||||||
|
v_world->>'hook', v_world->>'openingScene', v_world->'companions', 'confirmed'
|
||||||
|
) returning id into v_world_id;
|
||||||
|
|
||||||
|
v_entity := v_world->'startingLocation';
|
||||||
|
insert into public.world_entities(world_id, kind, name, summary, tags, secrets)
|
||||||
|
values (v_world_id, 'location', v_entity->>'name', v_entity->>'summary',
|
||||||
|
array(select jsonb_array_elements_text(coalesce(v_entity->'tags', '[]'::jsonb))),
|
||||||
|
coalesce(v_entity->'secrets', '[]'::jsonb));
|
||||||
|
for v_entity in select * from jsonb_array_elements(v_world->'npcs') loop
|
||||||
|
insert into public.world_entities(world_id, kind, name, summary, tags, secrets)
|
||||||
|
values (v_world_id, 'npc', v_entity->>'name', v_entity->>'summary',
|
||||||
|
array(select jsonb_array_elements_text(coalesce(v_entity->'tags', '[]'::jsonb))),
|
||||||
|
coalesce(v_entity->'secrets', '[]'::jsonb));
|
||||||
|
end loop;
|
||||||
|
for v_entity in select * from jsonb_array_elements(v_world->'factions') loop
|
||||||
|
insert into public.world_entities(world_id, kind, name, summary, tags, secrets)
|
||||||
|
values (v_world_id, 'faction', v_entity->>'name', v_entity->>'summary',
|
||||||
|
array(select jsonb_array_elements_text(coalesce(v_entity->'tags', '[]'::jsonb))),
|
||||||
|
coalesce(v_entity->'secrets', '[]'::jsonb));
|
||||||
|
end loop;
|
||||||
|
insert into public.world_entities(world_id, kind, name, summary, tags, secrets)
|
||||||
|
values (v_world_id, 'quest', 'Opening Hook', v_world->>'hook', array['opening'], '[]'::jsonb);
|
||||||
|
|
||||||
|
update public.coauthor_sessions
|
||||||
|
set status = 'confirmed', confirmed_world_id = v_world_id, updated_at = now()
|
||||||
|
where id = p_session_id;
|
||||||
|
return v_world_id;
|
||||||
|
end;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
create or replace function public.stage_two_create_campaign(
|
||||||
|
p_world_id uuid,
|
||||||
|
p_owner_id uuid,
|
||||||
|
p_title text
|
||||||
|
) returns uuid language plpgsql security definer set search_path = '' as $$
|
||||||
|
declare
|
||||||
|
v_world public.worlds%rowtype;
|
||||||
|
v_campaign_id uuid;
|
||||||
|
v_owner_name text;
|
||||||
|
v_companion jsonb;
|
||||||
|
begin
|
||||||
|
if p_title is null or char_length(btrim(p_title)) not between 3 and 100 then
|
||||||
|
raise exception 'campaign title must be between 3 and 100 characters';
|
||||||
|
end if;
|
||||||
|
select * into v_world from public.worlds
|
||||||
|
where id = p_world_id and owner_id = p_owner_id and status = 'confirmed' for share;
|
||||||
|
if not found then raise exception 'confirmed world not found'; end if;
|
||||||
|
if jsonb_typeof(v_world.starter_companions) <> 'array'
|
||||||
|
or jsonb_array_length(v_world.starter_companions) = 0 then
|
||||||
|
raise exception 'confirmed world has no AI companions';
|
||||||
|
end if;
|
||||||
|
|
||||||
|
select nullif(btrim(display_name), '') into v_owner_name
|
||||||
|
from public.profiles where id = p_owner_id;
|
||||||
|
v_owner_name := left(coalesce(v_owner_name, 'Wayfinder'), 80);
|
||||||
|
|
||||||
|
insert into public.campaigns(world_id, owner_id, title, current_scene, next_prompt, status)
|
||||||
|
values (p_world_id, p_owner_id, btrim(p_title), coalesce(v_world.opening_scene, v_world.premise), 'What do you do?', 'active')
|
||||||
|
returning id into v_campaign_id;
|
||||||
|
insert into public.campaign_members(campaign_id, user_id, role)
|
||||||
|
values (v_campaign_id, p_owner_id, 'owner');
|
||||||
|
|
||||||
|
insert into public.characters(
|
||||||
|
campaign_id, user_id, name, concept, controller, abilities, hp, max_hp,
|
||||||
|
defense, proficiency, inventory, statuses, persona
|
||||||
|
) values (
|
||||||
|
v_campaign_id,
|
||||||
|
p_owner_id,
|
||||||
|
v_owner_name,
|
||||||
|
left('An adaptable protagonist ready to confront the opening mystery of ' || v_world.title || '.', 600),
|
||||||
|
'human'::public.character_controller,
|
||||||
|
'{"str":10,"dex":12,"con":12,"int":11,"wis":13,"cha":10}'::jsonb,
|
||||||
|
12,
|
||||||
|
12,
|
||||||
|
12,
|
||||||
|
2,
|
||||||
|
'["Field kit","Personal keepsake"]'::jsonb,
|
||||||
|
'[]'::jsonb,
|
||||||
|
jsonb_build_object(
|
||||||
|
'voice', 'Defined by the player.',
|
||||||
|
'motivation', 'Discover what the opening scene is hiding.',
|
||||||
|
'flaw', 'Still learning what this world demands.',
|
||||||
|
'bond', 'Protect the party through the first danger.'
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
for v_companion in select * from jsonb_array_elements(v_world.starter_companions) loop
|
||||||
|
insert into public.characters(
|
||||||
|
campaign_id, user_id, name, concept, controller, abilities, hp, max_hp,
|
||||||
|
defense, proficiency, inventory, statuses, persona
|
||||||
|
) values (
|
||||||
|
v_campaign_id,
|
||||||
|
null,
|
||||||
|
v_companion->>'name',
|
||||||
|
v_companion->>'concept',
|
||||||
|
'ai'::public.character_controller,
|
||||||
|
v_companion->'abilities',
|
||||||
|
(v_companion->>'hp')::integer,
|
||||||
|
(v_companion->>'maxHp')::integer,
|
||||||
|
(v_companion->>'defense')::integer,
|
||||||
|
(v_companion->>'proficiency')::integer,
|
||||||
|
v_companion->'inventory',
|
||||||
|
'[]'::jsonb,
|
||||||
|
v_companion->'persona'
|
||||||
|
);
|
||||||
|
end loop;
|
||||||
|
|
||||||
|
insert into public.rounds(campaign_id, number) values (v_campaign_id, 1);
|
||||||
|
return v_campaign_id;
|
||||||
|
end;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
-- Remove only the exact temporary Echo inserted by schema v10, preserving any
|
||||||
|
-- independently authored character that happens to share the name.
|
||||||
|
delete from public.characters character
|
||||||
|
where character.user_id is null
|
||||||
|
and character.controller = 'ai'::public.character_controller
|
||||||
|
and character.name = 'Echo'
|
||||||
|
and character.inventory = '["Survey kit","Emergency supplies"]'::jsonb
|
||||||
|
and character.persona->>'voice' = 'Observant, concise, and quietly curious.'
|
||||||
|
and character.persona->>'motivation' = 'Help the party understand this unfamiliar world.'
|
||||||
|
and character.concept like 'A persistent AI companion shaped by the %';
|
||||||
|
|
||||||
|
-- Existing active campaigns receive the companion roster belonging to their
|
||||||
|
-- universe. Name matching keeps this migration idempotent around manual heroes.
|
||||||
|
insert into public.characters(
|
||||||
|
campaign_id, user_id, name, concept, controller, abilities, hp, max_hp,
|
||||||
|
defense, proficiency, inventory, statuses, persona
|
||||||
|
)
|
||||||
|
select
|
||||||
|
campaign.id,
|
||||||
|
null,
|
||||||
|
companion.value->>'name',
|
||||||
|
companion.value->>'concept',
|
||||||
|
'ai'::public.character_controller,
|
||||||
|
companion.value->'abilities',
|
||||||
|
(companion.value->>'hp')::integer,
|
||||||
|
(companion.value->>'maxHp')::integer,
|
||||||
|
(companion.value->>'defense')::integer,
|
||||||
|
(companion.value->>'proficiency')::integer,
|
||||||
|
companion.value->'inventory',
|
||||||
|
'[]'::jsonb,
|
||||||
|
companion.value->'persona'
|
||||||
|
from public.campaigns campaign
|
||||||
|
join public.worlds world on world.id = campaign.world_id
|
||||||
|
cross join lateral jsonb_array_elements(world.starter_companions) companion(value)
|
||||||
|
where campaign.status = 'active'
|
||||||
|
and not exists (
|
||||||
|
select 1 from public.characters character
|
||||||
|
where character.campaign_id = campaign.id
|
||||||
|
and character.controller = 'ai'::public.character_controller
|
||||||
|
and lower(character.name) = lower(companion.value->>'name')
|
||||||
|
);
|
||||||
|
|
||||||
|
create or replace function public.dng_schema_version()
|
||||||
|
returns integer language sql stable security definer set search_path = '' as $$
|
||||||
|
select 11;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
revoke all on function public.dng_schema_version() from public, anon, authenticated;
|
||||||
|
grant execute on function public.dng_schema_version() to service_role;
|
||||||
|
|
||||||
|
notify pgrst, 'reload schema';
|
||||||
|
|
||||||
|
-- ============================================================================
|
||||||
|
-- 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';
|
||||||
|
|
||||||
|
|
||||||
do $$
|
do $$
|
||||||
begin
|
begin
|
||||||
@@ -2282,7 +3161,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() <> 8 then
|
if public.dng_schema_version() <> 12 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;
|
||||||
|
|||||||
61
supabase/migrations/0009_profile_management.sql
Normal file
61
supabase/migrations/0009_profile_management.sql
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
alter table public.profiles
|
||||||
|
add column description text not null default '' check (char_length(description) <= 500),
|
||||||
|
add column avatar_path text check (avatar_path is null or avatar_path = id::text || '/avatar'),
|
||||||
|
add column updated_at timestamptz not null default now();
|
||||||
|
|
||||||
|
-- Avatars are public profile media, but only the owning authenticated user may
|
||||||
|
-- create, replace, or remove the one deterministic object in their folder.
|
||||||
|
insert into storage.buckets(id, name, public, file_size_limit, allowed_mime_types)
|
||||||
|
values (
|
||||||
|
'profile-avatars',
|
||||||
|
'profile-avatars',
|
||||||
|
true,
|
||||||
|
2097152,
|
||||||
|
array['image/jpeg', 'image/png', 'image/webp']
|
||||||
|
)
|
||||||
|
on conflict (id) do update
|
||||||
|
set public = excluded.public,
|
||||||
|
file_size_limit = excluded.file_size_limit,
|
||||||
|
allowed_mime_types = excluded.allowed_mime_types;
|
||||||
|
|
||||||
|
create policy dng_profile_avatar_insert
|
||||||
|
on storage.objects for insert to authenticated
|
||||||
|
with check (
|
||||||
|
bucket_id = 'profile-avatars'
|
||||||
|
and name = (select auth.uid())::text || '/avatar'
|
||||||
|
);
|
||||||
|
|
||||||
|
create policy dng_profile_avatar_select_own
|
||||||
|
on storage.objects for select to authenticated
|
||||||
|
using (
|
||||||
|
bucket_id = 'profile-avatars'
|
||||||
|
and name = (select auth.uid())::text || '/avatar'
|
||||||
|
);
|
||||||
|
|
||||||
|
create policy dng_profile_avatar_update
|
||||||
|
on storage.objects for update to authenticated
|
||||||
|
using (
|
||||||
|
bucket_id = 'profile-avatars'
|
||||||
|
and name = (select auth.uid())::text || '/avatar'
|
||||||
|
)
|
||||||
|
with check (
|
||||||
|
bucket_id = 'profile-avatars'
|
||||||
|
and name = (select auth.uid())::text || '/avatar'
|
||||||
|
);
|
||||||
|
|
||||||
|
create policy dng_profile_avatar_delete
|
||||||
|
on storage.objects for delete to authenticated
|
||||||
|
using (
|
||||||
|
bucket_id = 'profile-avatars'
|
||||||
|
and name = (select auth.uid())::text || '/avatar'
|
||||||
|
);
|
||||||
|
|
||||||
|
create or replace function public.dng_schema_version()
|
||||||
|
returns integer language sql stable security definer set search_path = '' as $$
|
||||||
|
select 9;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
revoke all on function public.dng_schema_version() from public, anon, authenticated;
|
||||||
|
grant execute on function public.dng_schema_version() to service_role;
|
||||||
|
|
||||||
|
notify pgrst, 'reload schema';
|
||||||
159
supabase/migrations/0010_playable_starter_parties.sql
Normal file
159
supabase/migrations/0010_playable_starter_parties.sql
Normal file
@@ -0,0 +1,159 @@
|
|||||||
|
-- Make a newly launched campaign playable immediately: the owner receives a
|
||||||
|
-- human-controlled starter, and only actionable members hold the round open.
|
||||||
|
|
||||||
|
create or replace function public.stage_two_create_campaign(
|
||||||
|
p_world_id uuid,
|
||||||
|
p_owner_id uuid,
|
||||||
|
p_title text
|
||||||
|
) returns uuid language plpgsql security definer set search_path = '' as $$
|
||||||
|
declare
|
||||||
|
v_world public.worlds%rowtype;
|
||||||
|
v_campaign_id uuid;
|
||||||
|
v_owner_name text;
|
||||||
|
begin
|
||||||
|
if p_title is null or char_length(btrim(p_title)) not between 3 and 100 then
|
||||||
|
raise exception 'campaign title must be between 3 and 100 characters';
|
||||||
|
end if;
|
||||||
|
select * into v_world from public.worlds
|
||||||
|
where id = p_world_id and owner_id = p_owner_id and status = 'confirmed' for share;
|
||||||
|
if not found then raise exception 'confirmed world not found'; end if;
|
||||||
|
|
||||||
|
select nullif(btrim(display_name), '') into v_owner_name
|
||||||
|
from public.profiles where id = p_owner_id;
|
||||||
|
v_owner_name := left(coalesce(v_owner_name, 'Wayfinder'), 80);
|
||||||
|
|
||||||
|
insert into public.campaigns(world_id, owner_id, title, current_scene, next_prompt, status)
|
||||||
|
values (p_world_id, p_owner_id, btrim(p_title), coalesce(v_world.opening_scene, v_world.premise), 'What do you do?', 'active')
|
||||||
|
returning id into v_campaign_id;
|
||||||
|
insert into public.campaign_members(campaign_id, user_id, role)
|
||||||
|
values (v_campaign_id, p_owner_id, 'owner');
|
||||||
|
|
||||||
|
insert into public.characters(
|
||||||
|
campaign_id, user_id, name, concept, controller, abilities, hp, max_hp,
|
||||||
|
defense, proficiency, inventory, statuses, persona
|
||||||
|
) values (
|
||||||
|
v_campaign_id,
|
||||||
|
p_owner_id,
|
||||||
|
v_owner_name,
|
||||||
|
left('An adaptable protagonist ready to confront the opening mystery of ' || v_world.title || '.', 600),
|
||||||
|
'human'::public.character_controller,
|
||||||
|
'{"str":10,"dex":12,"con":12,"int":11,"wis":13,"cha":10}'::jsonb,
|
||||||
|
12,
|
||||||
|
12,
|
||||||
|
12,
|
||||||
|
2,
|
||||||
|
'["Field kit","Personal keepsake"]'::jsonb,
|
||||||
|
'[]'::jsonb,
|
||||||
|
jsonb_build_object(
|
||||||
|
'voice', 'Defined by the player.',
|
||||||
|
'motivation', 'Discover what the opening scene is hiding.',
|
||||||
|
'flaw', 'Still learning what this world demands.',
|
||||||
|
'bond', 'Protect the party through the first danger.'
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
insert into public.rounds(campaign_id, number) values (v_campaign_id, 1);
|
||||||
|
return v_campaign_id;
|
||||||
|
end;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
-- Members who have not created a character cannot submit an intent and must not
|
||||||
|
-- hold the round open. Only active members with a human-controlled character
|
||||||
|
-- participate in the readiness barrier.
|
||||||
|
create or replace function public.stage_two_submit_intent(
|
||||||
|
p_round_id uuid,
|
||||||
|
p_user_id uuid,
|
||||||
|
p_character_id uuid,
|
||||||
|
p_action text,
|
||||||
|
p_ready boolean default false
|
||||||
|
) returns jsonb language plpgsql security definer set search_path = '' as $$
|
||||||
|
declare
|
||||||
|
v_round public.rounds%rowtype;
|
||||||
|
v_member public.campaign_members%rowtype;
|
||||||
|
v_intent_id uuid;
|
||||||
|
v_job_id uuid;
|
||||||
|
begin
|
||||||
|
if p_action is null or char_length(btrim(p_action)) not between 1 and 2000 then
|
||||||
|
raise exception 'action must be between 1 and 2000 characters';
|
||||||
|
end if;
|
||||||
|
select * into v_round from public.rounds where id = p_round_id for update;
|
||||||
|
if not found or v_round.status <> 'open' then raise exception 'round is not open'; end if;
|
||||||
|
select * into v_member from public.campaign_members
|
||||||
|
where campaign_id = v_round.campaign_id and user_id = p_user_id and active;
|
||||||
|
if not found then raise exception 'active campaign membership not found'; end if;
|
||||||
|
if not exists (
|
||||||
|
select 1 from public.characters
|
||||||
|
where id = p_character_id and campaign_id = v_round.campaign_id
|
||||||
|
and user_id = p_user_id and controller = 'human'
|
||||||
|
) then raise exception 'controlled character not found'; end if;
|
||||||
|
|
||||||
|
insert into public.player_intents(round_id, member_id, character_id, action, ready)
|
||||||
|
values (p_round_id, v_member.id, p_character_id, btrim(p_action), coalesce(p_ready, false))
|
||||||
|
on conflict (round_id, member_id) do update
|
||||||
|
set character_id = excluded.character_id, action = excluded.action,
|
||||||
|
ready = excluded.ready, updated_at = now()
|
||||||
|
returning id into v_intent_id;
|
||||||
|
|
||||||
|
if coalesce(p_ready, false) and not exists (
|
||||||
|
select 1
|
||||||
|
from public.campaign_members member
|
||||||
|
join public.characters character
|
||||||
|
on character.campaign_id = member.campaign_id
|
||||||
|
and character.user_id = member.user_id
|
||||||
|
and character.controller = 'human'::public.character_controller
|
||||||
|
where member.campaign_id = v_round.campaign_id
|
||||||
|
and member.active
|
||||||
|
and not exists (
|
||||||
|
select 1 from public.player_intents intent
|
||||||
|
where intent.round_id = p_round_id and intent.member_id = member.id and intent.ready
|
||||||
|
)
|
||||||
|
) then
|
||||||
|
v_job_id := public.enqueue_round_resolution(p_round_id, null);
|
||||||
|
end if;
|
||||||
|
return jsonb_build_object('intentId', v_intent_id, 'jobId', v_job_id);
|
||||||
|
end;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
-- Repair campaigns created before starter parties were automatic.
|
||||||
|
insert into public.characters(
|
||||||
|
campaign_id, user_id, name, concept, controller, abilities, hp, max_hp,
|
||||||
|
defense, proficiency, inventory, statuses, persona
|
||||||
|
)
|
||||||
|
select
|
||||||
|
campaign.id,
|
||||||
|
campaign.owner_id,
|
||||||
|
left(coalesce(nullif(btrim(profile.display_name), ''), 'Wayfinder'), 80),
|
||||||
|
left('An adaptable protagonist ready to confront the opening mystery of ' || world.title || '.', 600),
|
||||||
|
'human'::public.character_controller,
|
||||||
|
'{"str":10,"dex":12,"con":12,"int":11,"wis":13,"cha":10}'::jsonb,
|
||||||
|
12,
|
||||||
|
12,
|
||||||
|
12,
|
||||||
|
2,
|
||||||
|
'["Field kit","Personal keepsake"]'::jsonb,
|
||||||
|
'[]'::jsonb,
|
||||||
|
jsonb_build_object(
|
||||||
|
'voice', 'Defined by the player.',
|
||||||
|
'motivation', 'Discover what the opening scene is hiding.',
|
||||||
|
'flaw', 'Still learning what this world demands.',
|
||||||
|
'bond', 'Protect the party through the first danger.'
|
||||||
|
)
|
||||||
|
from public.campaigns campaign
|
||||||
|
join public.worlds world on world.id = campaign.world_id
|
||||||
|
left join public.profiles profile on profile.id = campaign.owner_id
|
||||||
|
where campaign.status = 'active'
|
||||||
|
and not exists (
|
||||||
|
select 1 from public.characters character
|
||||||
|
where character.campaign_id = campaign.id
|
||||||
|
and character.user_id = campaign.owner_id
|
||||||
|
);
|
||||||
|
|
||||||
|
create or replace function public.dng_schema_version()
|
||||||
|
returns integer language sql stable security definer set search_path = '' as $$
|
||||||
|
select 10;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
revoke all on function public.dng_schema_version() from public, anon, authenticated;
|
||||||
|
grant execute on function public.dng_schema_version() to service_role;
|
||||||
|
|
||||||
|
notify pgrst, 'reload schema';
|
||||||
287
supabase/migrations/0011_universe_companions.sql
Normal file
287
supabase/migrations/0011_universe_companions.sql
Normal file
@@ -0,0 +1,287 @@
|
|||||||
|
-- Persist the AI companions created with each universe and use that party when
|
||||||
|
-- a campaign starts. This replaces the temporary hard-coded Echo companion.
|
||||||
|
|
||||||
|
alter table public.worlds
|
||||||
|
add column if not exists starter_companions jsonb not null default '[]'::jsonb;
|
||||||
|
|
||||||
|
alter table public.worlds
|
||||||
|
drop constraint if exists worlds_starter_companions_array;
|
||||||
|
alter table public.worlds
|
||||||
|
add constraint worlds_starter_companions_array check (
|
||||||
|
jsonb_typeof(starter_companions) = 'array'
|
||||||
|
and jsonb_array_length(starter_companions) <= 3
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Ready drafts created before companions were part of WorldStarter inherit
|
||||||
|
-- three universe-specific people that the coauthor already generated.
|
||||||
|
update public.coauthor_sessions session
|
||||||
|
set generated_world = jsonb_set(
|
||||||
|
session.generated_world,
|
||||||
|
'{companions}',
|
||||||
|
coalesce((
|
||||||
|
select jsonb_agg(
|
||||||
|
jsonb_build_object(
|
||||||
|
'name', npc.value->>'name',
|
||||||
|
'concept', left(npc.value->>'summary', 600),
|
||||||
|
'abilities', case npc.position
|
||||||
|
when 1 then '{"str":10,"dex":14,"con":11,"int":13,"wis":12,"cha":10}'::jsonb
|
||||||
|
when 2 then '{"str":13,"dex":10,"con":14,"int":10,"wis":12,"cha":11}'::jsonb
|
||||||
|
else '{"str":9,"dex":12,"con":11,"int":14,"wis":13,"cha":12}'::jsonb
|
||||||
|
end,
|
||||||
|
'hp', 10 + npc.position,
|
||||||
|
'maxHp', 10 + npc.position,
|
||||||
|
'defense', 11 + npc.position,
|
||||||
|
'proficiency', 2,
|
||||||
|
'inventory', jsonb_build_array('Universe field kit', 'Travel supplies'),
|
||||||
|
'persona', jsonb_build_object(
|
||||||
|
'voice', left(npc.value->>'summary', 240),
|
||||||
|
'motivation', left('Pursue the goal behind: ' || (npc.value->>'summary'), 300),
|
||||||
|
'flaw', 'Their personal agenda can complicate the party''s plans.',
|
||||||
|
'bond', left('They belong to ' || (session.generated_world->>'title') || ' and choose to stand with the party.', 300)
|
||||||
|
)
|
||||||
|
) order by npc.position
|
||||||
|
)
|
||||||
|
from jsonb_array_elements(coalesce(session.generated_world->'npcs', '[]'::jsonb))
|
||||||
|
with ordinality as npc(value, position)
|
||||||
|
where npc.position <= 3
|
||||||
|
), '[]'::jsonb),
|
||||||
|
true
|
||||||
|
),
|
||||||
|
updated_at = now()
|
||||||
|
where session.generated_world is not null
|
||||||
|
and jsonb_typeof(session.generated_world->'npcs') = 'array'
|
||||||
|
and coalesce(jsonb_array_length(session.generated_world->'companions'), 0) = 0;
|
||||||
|
|
||||||
|
-- Confirmed universes do not retain the original generation JSON. Build their
|
||||||
|
-- starter companions from the AI-created NPC entities already stored for them.
|
||||||
|
update public.worlds world
|
||||||
|
set starter_companions = coalesce((
|
||||||
|
select jsonb_agg(
|
||||||
|
jsonb_build_object(
|
||||||
|
'name', npc.name,
|
||||||
|
'concept', left(npc.summary, 600),
|
||||||
|
'abilities', case npc.position
|
||||||
|
when 1 then '{"str":10,"dex":14,"con":11,"int":13,"wis":12,"cha":10}'::jsonb
|
||||||
|
when 2 then '{"str":13,"dex":10,"con":14,"int":10,"wis":12,"cha":11}'::jsonb
|
||||||
|
else '{"str":9,"dex":12,"con":11,"int":14,"wis":13,"cha":12}'::jsonb
|
||||||
|
end,
|
||||||
|
'hp', 10 + npc.position,
|
||||||
|
'maxHp', 10 + npc.position,
|
||||||
|
'defense', 11 + npc.position,
|
||||||
|
'proficiency', 2,
|
||||||
|
'inventory', jsonb_build_array('Universe field kit', 'Travel supplies'),
|
||||||
|
'persona', jsonb_build_object(
|
||||||
|
'voice', left(npc.summary, 240),
|
||||||
|
'motivation', left('Pursue the goal behind: ' || npc.summary, 300),
|
||||||
|
'flaw', 'Their personal agenda can complicate the party''s plans.',
|
||||||
|
'bond', left('They belong to ' || world.title || ' and choose to stand with the party.', 300)
|
||||||
|
)
|
||||||
|
) order by npc.position
|
||||||
|
)
|
||||||
|
from (
|
||||||
|
select entity.name, entity.summary,
|
||||||
|
(row_number() over (order by entity.created_at, entity.id))::integer as position
|
||||||
|
from public.world_entities entity
|
||||||
|
where entity.world_id = world.id and entity.kind = 'npc'
|
||||||
|
order by entity.created_at, entity.id
|
||||||
|
limit 3
|
||||||
|
) npc
|
||||||
|
), '[]'::jsonb)
|
||||||
|
where jsonb_array_length(world.starter_companions) = 0;
|
||||||
|
|
||||||
|
create or replace function public.stage_two_confirm_world(
|
||||||
|
p_session_id uuid,
|
||||||
|
p_owner_id uuid
|
||||||
|
) returns uuid language plpgsql security definer set search_path = '' as $$
|
||||||
|
declare
|
||||||
|
v_session public.coauthor_sessions%rowtype;
|
||||||
|
v_world jsonb;
|
||||||
|
v_world_id uuid;
|
||||||
|
v_entity jsonb;
|
||||||
|
begin
|
||||||
|
select * into v_session from public.coauthor_sessions
|
||||||
|
where id = p_session_id and owner_id = p_owner_id for update;
|
||||||
|
if not found then raise exception 'coauthor session not found'; end if;
|
||||||
|
if v_session.status = 'confirmed' and v_session.confirmed_world_id is not null then
|
||||||
|
return v_session.confirmed_world_id;
|
||||||
|
end if;
|
||||||
|
if v_session.status <> 'ready' or v_session.generated_world is null then
|
||||||
|
raise exception 'coauthor session is not ready to confirm';
|
||||||
|
end if;
|
||||||
|
v_world := v_session.generated_world;
|
||||||
|
if coalesce(v_world->>'title', '') = ''
|
||||||
|
or coalesce(v_world->>'openingScene', '') = ''
|
||||||
|
or jsonb_typeof(v_world->'companions') <> 'array'
|
||||||
|
or jsonb_array_length(v_world->'companions') <> 3 then
|
||||||
|
raise exception 'generated world is incomplete';
|
||||||
|
end if;
|
||||||
|
|
||||||
|
insert into public.worlds(
|
||||||
|
owner_id, title, genre, tone, premise, content_boundaries,
|
||||||
|
hidden_threat, hook, opening_scene, starter_companions, status
|
||||||
|
) values (
|
||||||
|
p_owner_id, v_world->>'title', v_world->>'genre', v_world->>'tone', v_world->>'premise',
|
||||||
|
coalesce(v_world->'contentBoundaries', '[]'::jsonb), v_world->>'hiddenThreat',
|
||||||
|
v_world->>'hook', v_world->>'openingScene', v_world->'companions', 'confirmed'
|
||||||
|
) returning id into v_world_id;
|
||||||
|
|
||||||
|
v_entity := v_world->'startingLocation';
|
||||||
|
insert into public.world_entities(world_id, kind, name, summary, tags, secrets)
|
||||||
|
values (v_world_id, 'location', v_entity->>'name', v_entity->>'summary',
|
||||||
|
array(select jsonb_array_elements_text(coalesce(v_entity->'tags', '[]'::jsonb))),
|
||||||
|
coalesce(v_entity->'secrets', '[]'::jsonb));
|
||||||
|
for v_entity in select * from jsonb_array_elements(v_world->'npcs') loop
|
||||||
|
insert into public.world_entities(world_id, kind, name, summary, tags, secrets)
|
||||||
|
values (v_world_id, 'npc', v_entity->>'name', v_entity->>'summary',
|
||||||
|
array(select jsonb_array_elements_text(coalesce(v_entity->'tags', '[]'::jsonb))),
|
||||||
|
coalesce(v_entity->'secrets', '[]'::jsonb));
|
||||||
|
end loop;
|
||||||
|
for v_entity in select * from jsonb_array_elements(v_world->'factions') loop
|
||||||
|
insert into public.world_entities(world_id, kind, name, summary, tags, secrets)
|
||||||
|
values (v_world_id, 'faction', v_entity->>'name', v_entity->>'summary',
|
||||||
|
array(select jsonb_array_elements_text(coalesce(v_entity->'tags', '[]'::jsonb))),
|
||||||
|
coalesce(v_entity->'secrets', '[]'::jsonb));
|
||||||
|
end loop;
|
||||||
|
insert into public.world_entities(world_id, kind, name, summary, tags, secrets)
|
||||||
|
values (v_world_id, 'quest', 'Opening Hook', v_world->>'hook', array['opening'], '[]'::jsonb);
|
||||||
|
|
||||||
|
update public.coauthor_sessions
|
||||||
|
set status = 'confirmed', confirmed_world_id = v_world_id, updated_at = now()
|
||||||
|
where id = p_session_id;
|
||||||
|
return v_world_id;
|
||||||
|
end;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
create or replace function public.stage_two_create_campaign(
|
||||||
|
p_world_id uuid,
|
||||||
|
p_owner_id uuid,
|
||||||
|
p_title text
|
||||||
|
) returns uuid language plpgsql security definer set search_path = '' as $$
|
||||||
|
declare
|
||||||
|
v_world public.worlds%rowtype;
|
||||||
|
v_campaign_id uuid;
|
||||||
|
v_owner_name text;
|
||||||
|
v_companion jsonb;
|
||||||
|
begin
|
||||||
|
if p_title is null or char_length(btrim(p_title)) not between 3 and 100 then
|
||||||
|
raise exception 'campaign title must be between 3 and 100 characters';
|
||||||
|
end if;
|
||||||
|
select * into v_world from public.worlds
|
||||||
|
where id = p_world_id and owner_id = p_owner_id and status = 'confirmed' for share;
|
||||||
|
if not found then raise exception 'confirmed world not found'; end if;
|
||||||
|
if jsonb_typeof(v_world.starter_companions) <> 'array'
|
||||||
|
or jsonb_array_length(v_world.starter_companions) = 0 then
|
||||||
|
raise exception 'confirmed world has no AI companions';
|
||||||
|
end if;
|
||||||
|
|
||||||
|
select nullif(btrim(display_name), '') into v_owner_name
|
||||||
|
from public.profiles where id = p_owner_id;
|
||||||
|
v_owner_name := left(coalesce(v_owner_name, 'Wayfinder'), 80);
|
||||||
|
|
||||||
|
insert into public.campaigns(world_id, owner_id, title, current_scene, next_prompt, status)
|
||||||
|
values (p_world_id, p_owner_id, btrim(p_title), coalesce(v_world.opening_scene, v_world.premise), 'What do you do?', 'active')
|
||||||
|
returning id into v_campaign_id;
|
||||||
|
insert into public.campaign_members(campaign_id, user_id, role)
|
||||||
|
values (v_campaign_id, p_owner_id, 'owner');
|
||||||
|
|
||||||
|
insert into public.characters(
|
||||||
|
campaign_id, user_id, name, concept, controller, abilities, hp, max_hp,
|
||||||
|
defense, proficiency, inventory, statuses, persona
|
||||||
|
) values (
|
||||||
|
v_campaign_id,
|
||||||
|
p_owner_id,
|
||||||
|
v_owner_name,
|
||||||
|
left('An adaptable protagonist ready to confront the opening mystery of ' || v_world.title || '.', 600),
|
||||||
|
'human'::public.character_controller,
|
||||||
|
'{"str":10,"dex":12,"con":12,"int":11,"wis":13,"cha":10}'::jsonb,
|
||||||
|
12,
|
||||||
|
12,
|
||||||
|
12,
|
||||||
|
2,
|
||||||
|
'["Field kit","Personal keepsake"]'::jsonb,
|
||||||
|
'[]'::jsonb,
|
||||||
|
jsonb_build_object(
|
||||||
|
'voice', 'Defined by the player.',
|
||||||
|
'motivation', 'Discover what the opening scene is hiding.',
|
||||||
|
'flaw', 'Still learning what this world demands.',
|
||||||
|
'bond', 'Protect the party through the first danger.'
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
for v_companion in select * from jsonb_array_elements(v_world.starter_companions) loop
|
||||||
|
insert into public.characters(
|
||||||
|
campaign_id, user_id, name, concept, controller, abilities, hp, max_hp,
|
||||||
|
defense, proficiency, inventory, statuses, persona
|
||||||
|
) values (
|
||||||
|
v_campaign_id,
|
||||||
|
null,
|
||||||
|
v_companion->>'name',
|
||||||
|
v_companion->>'concept',
|
||||||
|
'ai'::public.character_controller,
|
||||||
|
v_companion->'abilities',
|
||||||
|
(v_companion->>'hp')::integer,
|
||||||
|
(v_companion->>'maxHp')::integer,
|
||||||
|
(v_companion->>'defense')::integer,
|
||||||
|
(v_companion->>'proficiency')::integer,
|
||||||
|
v_companion->'inventory',
|
||||||
|
'[]'::jsonb,
|
||||||
|
v_companion->'persona'
|
||||||
|
);
|
||||||
|
end loop;
|
||||||
|
|
||||||
|
insert into public.rounds(campaign_id, number) values (v_campaign_id, 1);
|
||||||
|
return v_campaign_id;
|
||||||
|
end;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
-- Remove only the exact temporary Echo inserted by schema v10, preserving any
|
||||||
|
-- independently authored character that happens to share the name.
|
||||||
|
delete from public.characters character
|
||||||
|
where character.user_id is null
|
||||||
|
and character.controller = 'ai'::public.character_controller
|
||||||
|
and character.name = 'Echo'
|
||||||
|
and character.inventory = '["Survey kit","Emergency supplies"]'::jsonb
|
||||||
|
and character.persona->>'voice' = 'Observant, concise, and quietly curious.'
|
||||||
|
and character.persona->>'motivation' = 'Help the party understand this unfamiliar world.'
|
||||||
|
and character.concept like 'A persistent AI companion shaped by the %';
|
||||||
|
|
||||||
|
-- Existing active campaigns receive the companion roster belonging to their
|
||||||
|
-- universe. Name matching keeps this migration idempotent around manual heroes.
|
||||||
|
insert into public.characters(
|
||||||
|
campaign_id, user_id, name, concept, controller, abilities, hp, max_hp,
|
||||||
|
defense, proficiency, inventory, statuses, persona
|
||||||
|
)
|
||||||
|
select
|
||||||
|
campaign.id,
|
||||||
|
null,
|
||||||
|
companion.value->>'name',
|
||||||
|
companion.value->>'concept',
|
||||||
|
'ai'::public.character_controller,
|
||||||
|
companion.value->'abilities',
|
||||||
|
(companion.value->>'hp')::integer,
|
||||||
|
(companion.value->>'maxHp')::integer,
|
||||||
|
(companion.value->>'defense')::integer,
|
||||||
|
(companion.value->>'proficiency')::integer,
|
||||||
|
companion.value->'inventory',
|
||||||
|
'[]'::jsonb,
|
||||||
|
companion.value->'persona'
|
||||||
|
from public.campaigns campaign
|
||||||
|
join public.worlds world on world.id = campaign.world_id
|
||||||
|
cross join lateral jsonb_array_elements(world.starter_companions) companion(value)
|
||||||
|
where campaign.status = 'active'
|
||||||
|
and not exists (
|
||||||
|
select 1 from public.characters character
|
||||||
|
where character.campaign_id = campaign.id
|
||||||
|
and character.controller = 'ai'::public.character_controller
|
||||||
|
and lower(character.name) = lower(companion.value->>'name')
|
||||||
|
);
|
||||||
|
|
||||||
|
create or replace function public.dng_schema_version()
|
||||||
|
returns integer language sql stable security definer set search_path = '' as $$
|
||||||
|
select 11;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
revoke all on function public.dng_schema_version() from public, anon, authenticated;
|
||||||
|
grant execute on function public.dng_schema_version() to service_role;
|
||||||
|
|
||||||
|
notify pgrst, 'reload schema';
|
||||||
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';
|
||||||
Reference in New Issue
Block a user