Compare commits
1 Commits
56bddafbe8
...
agent/code
| Author | SHA1 | Date | |
|---|---|---|---|
| 98d65bcde9 |
@@ -58,7 +58,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.
|
||||
2. Create a world in the dynamic coauthor chat. Unfinished conversations and generated drafts appear on the dashboard and resume after a reload.
|
||||
3. Review every starting-world field, including the owner-only hidden threat, then confirm it.
|
||||
4. A new campaign starts immediately with an owner-controlled hero and a persistent AI companion. Additional players create a human character manually or ask the coauthor for an editable draft; owners can add more AI companions the same way.
|
||||
4. Create a human character manually or ask the coauthor for an editable draft. Owners can add persistent AI companions the same way.
|
||||
5. The owner creates an expiring private invite link. Signed-in email users join through `/join/:token`.
|
||||
6. Players save actions and mark them ready. The final ready action queues the round automatically; the owner can also continue without waiting.
|
||||
7. The worker resolves server-owned rolls and state, publishes narration, and opens the next round. Visible campaign tabs synchronize every two seconds and immediately when the tab regains focus.
|
||||
|
||||
@@ -8,10 +8,7 @@ const initials = computed(() => {
|
||||
const source = typeof displayName === 'string' && displayName.trim() ? displayName : session.value?.user.email ?? ''
|
||||
return source.trim().split(/\s+/).map(part => part[0]).join('').slice(0, 2).toUpperCase() || 'D&G'
|
||||
})
|
||||
const avatarUrl = computed(() => {
|
||||
const value = session.value?.user.user_metadata?.avatar_url
|
||||
return typeof value === 'string' && value ? value : ''
|
||||
})
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -21,9 +18,7 @@ const avatarUrl = computed(() => {
|
||||
<div class="topbar-center"><span class="status-dot" /> PRIVATE ALPHA <b v-if="section">/ {{ section }}</b></div>
|
||||
<div class="topbar-actions">
|
||||
<NuxtLink to="/dashboard" class="icon-link" aria-label="Dashboard">⌂</NuxtLink>
|
||||
<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>
|
||||
<NuxtLink v-if="session" to="/profile" class="avatar" :title="`Manage profile for ${session.user.email ?? ''}`" aria-label="Manage profile">{{ initials }}</NuxtLink>
|
||||
<div v-else class="avatar">D&G</div>
|
||||
</div>
|
||||
</header>
|
||||
@@ -32,5 +27,5 @@ const avatarUrl = computed(() => {
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.shell{min-height:100vh}.topbar{position:sticky;top:0;z-index:20;height:76px;padding:0 clamp(18px,4vw,64px);display:grid;grid-template-columns:1fr auto 1fr;align-items:center;border-bottom:1px solid var(--line);background:rgba(10,10,10,.9);backdrop-filter:blur(18px)}.topbar-center{font:500 10px/1 var(--mono);color:var(--muted);letter-spacing:.18em}.topbar-center b{color:var(--ink);font-weight:500}.status-dot{display:inline-block;width:6px;height:6px;margin-right:8px;border-radius:50%;background:var(--acid);box-shadow:0 0 12px var(--acid)}.topbar-actions{justify-self:end;display:flex;align-items:center;gap:14px}.icon-link{display:grid;place-items:center;width:36px;height:36px;border:1px solid var(--line);color:var(--muted);text-decoration:none}.avatar{position:relative;overflow:hidden;display:grid;place-items:center;width:36px;height:36px;padding:0;border:0;border-radius:50%;background:var(--acid);color:#0a0a0a;text-decoration:none;font:700 10px var(--mono)}.avatar img{position:absolute;inset:0;width:100%;height:100%;object-fit:cover}.avatar span{position:relative}@media(max-width:700px){.topbar{grid-template-columns:1fr auto}.topbar-center{display:none}}
|
||||
.shell{min-height:100vh}.topbar{position:sticky;top:0;z-index:20;height:76px;padding:0 clamp(18px,4vw,64px);display:grid;grid-template-columns:1fr auto 1fr;align-items:center;border-bottom:1px solid var(--line);background:rgba(10,10,10,.9);backdrop-filter:blur(18px)}.topbar-center{font:500 10px/1 var(--mono);color:var(--muted);letter-spacing:.18em}.topbar-center b{color:var(--ink);font-weight:500}.status-dot{display:inline-block;width:6px;height:6px;margin-right:8px;border-radius:50%;background:var(--acid);box-shadow:0 0 12px var(--acid)}.topbar-actions{justify-self:end;display:flex;align-items:center;gap:14px}.icon-link{display:grid;place-items:center;width:36px;height:36px;border:1px solid var(--line);color:var(--muted);text-decoration:none}.avatar{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);text-decoration:none}@media(max-width:700px){.topbar{grid-template-columns:1fr auto}.topbar-center{display:none}}
|
||||
</style>
|
||||
|
||||
88
apps/web/composables/useDngAuth.test.ts
Normal file
88
apps/web/composables/useDngAuth.test.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { useDngAuth } from './useDngAuth'
|
||||
|
||||
const states = new Map<string, { value: unknown }>()
|
||||
|
||||
function activeSession() {
|
||||
return {
|
||||
accessToken: 'access-token',
|
||||
refreshToken: 'refresh-token',
|
||||
expiresAt: Date.now() + 600_000,
|
||||
user: {
|
||||
id: 'user-123',
|
||||
email: 'old-name@example.com',
|
||||
user_metadata: { display_name: 'Old Name' },
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
describe('useDngAuth profile updates', () => {
|
||||
beforeEach(() => {
|
||||
states.clear()
|
||||
vi.restoreAllMocks()
|
||||
vi.stubGlobal('useRuntimeConfig', () => ({
|
||||
public: {
|
||||
supabaseUrl: 'https://supabase.example',
|
||||
supabaseAnonKey: 'anon-key',
|
||||
},
|
||||
}))
|
||||
vi.stubGlobal('useState', (key: string, factory: () => unknown) => {
|
||||
if (!states.has(key)) states.set(key, { value: factory() })
|
||||
return states.get(key)
|
||||
})
|
||||
})
|
||||
|
||||
it('updates both the public profile and authenticated user metadata', async () => {
|
||||
const fetchMock = vi.fn()
|
||||
.mockResolvedValueOnce(undefined)
|
||||
.mockResolvedValueOnce({
|
||||
id: 'user-123',
|
||||
email: 'old-name@example.com',
|
||||
user_metadata: { display_name: 'New Name' },
|
||||
})
|
||||
vi.stubGlobal('$fetch', fetchMock)
|
||||
|
||||
const auth = useDngAuth()
|
||||
auth.session.value = activeSession()
|
||||
|
||||
await auth.updateProfile(' New Name ')
|
||||
|
||||
expect(fetchMock).toHaveBeenNthCalledWith(1, 'https://supabase.example/rest/v1/profiles?id=eq.user-123', expect.objectContaining({
|
||||
method: 'PATCH',
|
||||
body: { display_name: 'New Name' },
|
||||
}))
|
||||
expect(fetchMock).toHaveBeenNthCalledWith(2, 'https://supabase.example/auth/v1/user', expect.objectContaining({
|
||||
method: 'PUT',
|
||||
body: { data: { display_name: 'New Name' } },
|
||||
}))
|
||||
expect(auth.session.value?.user.user_metadata?.display_name).toBe('New Name')
|
||||
})
|
||||
|
||||
it('rolls the public profile back when the auth metadata update fails', async () => {
|
||||
const fetchMock = vi.fn()
|
||||
.mockResolvedValueOnce(undefined)
|
||||
.mockRejectedValueOnce(new Error('Auth update failed'))
|
||||
.mockResolvedValueOnce(undefined)
|
||||
vi.stubGlobal('$fetch', fetchMock)
|
||||
|
||||
const auth = useDngAuth()
|
||||
auth.session.value = activeSession()
|
||||
|
||||
await expect(auth.updateProfile('New Name')).rejects.toThrow('Auth update failed')
|
||||
expect(fetchMock).toHaveBeenNthCalledWith(3, 'https://supabase.example/rest/v1/profiles?id=eq.user-123', expect.objectContaining({
|
||||
body: { display_name: 'Old Name' },
|
||||
}))
|
||||
expect(auth.session.value?.user.user_metadata?.display_name).toBe('Old Name')
|
||||
})
|
||||
|
||||
it('rejects invalid display names before making a request', async () => {
|
||||
const fetchMock = vi.fn()
|
||||
vi.stubGlobal('$fetch', fetchMock)
|
||||
|
||||
const auth = useDngAuth()
|
||||
auth.session.value = activeSession()
|
||||
|
||||
await expect(auth.updateProfile(' ')).rejects.toThrow('between 2 and 80 characters')
|
||||
expect(fetchMock).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -211,62 +211,42 @@ export function useDngAuth() {
|
||||
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 updateProfile(displayName: string) {
|
||||
const nextDisplayName = displayName.trim()
|
||||
if (nextDisplayName.length < 2 || nextDisplayName.length > 80) {
|
||||
throw new Error('Display name must contain between 2 and 80 characters.')
|
||||
}
|
||||
}
|
||||
|
||||
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.')
|
||||
const token = await accessToken()
|
||||
if (!session.value) throw new Error('Enter the alpha to continue.')
|
||||
const activeSession = session.value
|
||||
|
||||
await $fetch(`${config.public.supabaseUrl}/rest/v1/profiles?id=eq.${encodeURIComponent(activeSession.user.id)}`, {
|
||||
method: 'PATCH',
|
||||
headers: { ...authHeaders(token), Prefer: 'return=minimal' },
|
||||
body: { display_name: nextDisplayName },
|
||||
})
|
||||
|
||||
try {
|
||||
const user = normalizeUser(await $fetch<DngAuthUser>(`${config.public.supabaseUrl}/auth/v1/user`, {
|
||||
method: 'PUT',
|
||||
headers: authHeaders(token),
|
||||
body: { data: { display_name: nextDisplayName } },
|
||||
}))
|
||||
persist({ ...activeSession, user })
|
||||
return user
|
||||
} catch (cause) {
|
||||
const previousDisplayName = activeSession.user.user_metadata?.display_name
|
||||
const rollbackDisplayName = typeof previousDisplayName === 'string' && previousDisplayName.trim()
|
||||
? previousDisplayName.trim()
|
||||
: activeSession.user.email?.split('@')[0] || 'Adventurer'
|
||||
await $fetch(`${config.public.supabaseUrl}/rest/v1/profiles?id=eq.${encodeURIComponent(activeSession.user.id)}`, {
|
||||
method: 'PATCH',
|
||||
headers: { ...authHeaders(token), Prefer: 'return=minimal' },
|
||||
body: { display_name: rollbackDisplayName },
|
||||
}).catch(() => undefined)
|
||||
throw cause
|
||||
}
|
||||
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() {
|
||||
@@ -296,20 +276,5 @@ export function useDngAuth() {
|
||||
hydrated.value = true
|
||||
}
|
||||
|
||||
return {
|
||||
session,
|
||||
hydrated,
|
||||
restore,
|
||||
refresh,
|
||||
signUp,
|
||||
signIn,
|
||||
requestPasswordReset,
|
||||
updatePassword,
|
||||
updateUserMetadata,
|
||||
uploadAvatar,
|
||||
removeAvatar,
|
||||
accessToken,
|
||||
signOut,
|
||||
invalidate,
|
||||
}
|
||||
return { session, hydrated, restore, refresh, signUp, signIn, requestPasswordReset, updatePassword, updateProfile, accessToken, signOut, invalidate }
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ type Controller = 'human' | 'ai' | 'delegated'
|
||||
interface CampaignPayload {
|
||||
campaign: Record<string, any>
|
||||
world: Record<string, any>
|
||||
members: Array<Record<string, any> & { profile?: { id?: string; display_name?: string; avatar_url?: string | null } | null }>
|
||||
members: Array<Record<string, any> & { profile?: { display_name?: string } | null }>
|
||||
characters: Array<Record<string, any>>
|
||||
round: Record<string, any> | null
|
||||
rounds: Array<Record<string, any>>
|
||||
@@ -51,11 +51,7 @@ const myIntent = computed(() => payload.value?.intents.find(intent =>
|
||||
intent.member_id === currentMember.value?.id || intent.character_id === myCharacter.value?.id,
|
||||
))
|
||||
const isReady = computed(() => Boolean(myIntent.value?.ready))
|
||||
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 activeHumanMembers = computed(() => payload.value?.members.filter(member => member.active !== false) ?? [])
|
||||
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 roundBusy = computed(() => ['queued', 'resolving'].includes(String(currentRound.value?.status)))
|
||||
@@ -315,7 +311,7 @@ onBeforeUnmount(() => {
|
||||
<aside class="control-panel">
|
||||
<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="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 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 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>
|
||||
<button v-if="isOwner && currentRound?.status==='open'" class="force-button" :disabled="mutating" @click="forceRound">CONTINUE WITHOUT WAITING <span>↗</span></button>
|
||||
@@ -338,12 +334,4 @@ 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.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}
|
||||
.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>
|
||||
|
||||
115
apps/web/pages/profile.vue
Normal file
115
apps/web/pages/profile.vue
Normal file
@@ -0,0 +1,115 @@
|
||||
<script setup lang="ts">
|
||||
const auth = useDngAuth()
|
||||
const displayName = ref('')
|
||||
const savedDisplayName = ref('')
|
||||
const loading = ref(true)
|
||||
const saving = ref(false)
|
||||
const signingOut = ref(false)
|
||||
const error = ref('')
|
||||
const success = ref('')
|
||||
|
||||
const email = computed(() => auth.session.value?.user.email ?? '')
|
||||
const hasChanges = computed(() => displayName.value.trim() !== savedDisplayName.value)
|
||||
|
||||
function messageFrom(cause: unknown, fallback: string) {
|
||||
const value = cause as { data?: { msg?: string; message?: string; error_description?: string }; message?: string }
|
||||
return value.data?.msg ?? value.data?.message ?? value.data?.error_description ?? value.message ?? fallback
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const session = await auth.restore()
|
||||
if (!session) {
|
||||
await navigateTo('/auth/sign-in', { replace: true })
|
||||
return
|
||||
}
|
||||
const value = session.user.user_metadata?.display_name
|
||||
savedDisplayName.value = typeof value === 'string' && value.trim() ? value.trim() : session.user.email?.split('@')[0] ?? 'Adventurer'
|
||||
displayName.value = savedDisplayName.value
|
||||
} catch (cause) {
|
||||
error.value = messageFrom(cause, 'Could not load your profile.')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
})
|
||||
|
||||
async function saveProfile() {
|
||||
error.value = ''
|
||||
success.value = ''
|
||||
const nextDisplayName = displayName.value.trim()
|
||||
if (nextDisplayName.length < 2 || nextDisplayName.length > 80) {
|
||||
error.value = 'Display name must contain between 2 and 80 characters.'
|
||||
return
|
||||
}
|
||||
|
||||
saving.value = true
|
||||
try {
|
||||
await auth.updateProfile(nextDisplayName)
|
||||
displayName.value = nextDisplayName
|
||||
savedDisplayName.value = nextDisplayName
|
||||
success.value = 'PROFILE UPDATED.'
|
||||
} catch (cause) {
|
||||
error.value = messageFrom(cause, 'Could not update your profile.')
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function leave() {
|
||||
signingOut.value = true
|
||||
error.value = ''
|
||||
try {
|
||||
await auth.signOut()
|
||||
await navigateTo('/', { replace: true })
|
||||
} catch (cause) {
|
||||
error.value = messageFrom(cause, 'Could not sign out.')
|
||||
signingOut.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppShell section="ACCOUNT">
|
||||
<div class="profile-page noise">
|
||||
<section class="profile-intro">
|
||||
<p class="kicker">PLAYER IDENTITY</p>
|
||||
<h1>YOUR PROFILE<span>.</span></h1>
|
||||
<p>Manage how your party sees you and control access to your account.</p>
|
||||
</section>
|
||||
|
||||
<section class="profile-card">
|
||||
<div class="identity-mark" aria-hidden="true">{{ savedDisplayName.slice(0, 2).toUpperCase() || 'D&G' }}</div>
|
||||
<div class="profile-content">
|
||||
<small>ACCOUNT DETAILS</small>
|
||||
<form v-if="!loading" class="profile-form" @submit.prevent="saveProfile">
|
||||
<label>DISPLAY NAME<input v-model="displayName" name="name" autocomplete="name" minlength="2" maxlength="80" required placeholder="How your party sees you"></label>
|
||||
<label>EMAIL<input :value="email" name="email" type="email" autocomplete="email" readonly></label>
|
||||
<p class="field-note">Your email is fixed to protect campaign ownership. Use password recovery if you need new credentials.</p>
|
||||
<p v-if="error" class="form-message error" role="alert">{{ error }}</p>
|
||||
<p v-if="success" class="form-message success" role="status">{{ success }}</p>
|
||||
<div class="profile-actions">
|
||||
<button class="save-button" :disabled="saving || signingOut || !hasChanges">{{ saving ? 'SAVING…' : 'SAVE PROFILE →' }}</button>
|
||||
<NuxtLink to="/auth/forgot-password">RESET PASSWORD</NuxtLink>
|
||||
</div>
|
||||
</form>
|
||||
<p v-else class="loading-state">RECEIVING ACCOUNT DETAILS…</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="session-card">
|
||||
<div><small>SESSION CONTROL</small><h2>LEAVE THE TABLE.</h2><p>Sign out on this device. Your worlds and campaign progress stay saved.</p></div>
|
||||
<button :disabled="signingOut || loading" @click="leave">{{ signingOut ? 'SIGNING OUT…' : 'SIGN OUT' }}</button>
|
||||
</section>
|
||||
</div>
|
||||
</AppShell>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.profile-page{min-height:calc(100vh - 76px);padding:clamp(42px,6vw,86px) clamp(20px,6vw,88px)}
|
||||
.profile-intro{max-width:760px}.kicker{margin:0;font:500 9px var(--mono);letter-spacing:.2em;color:var(--acid)}.profile-intro h1{margin:14px 0 12px;font:600 clamp(44px,6vw,82px)/1 var(--display);letter-spacing:-.06em}.profile-intro h1 span{color:var(--acid)}.profile-intro>p:last-child{color:var(--muted);line-height:1.7}
|
||||
.profile-card{display:grid;grid-template-columns:minmax(220px,.7fr) minmax(0,1.3fr);max-width:1050px;margin-top:52px;border:1px solid var(--line);background:#0e0e0d}.identity-mark{min-height:460px;display:grid;place-items:center;border-right:1px solid var(--line);background:radial-gradient(circle at 50% 50%,#303018 0 3%,#15170c 17%,#090909 64%);color:var(--acid);font:600 clamp(48px,7vw,92px) var(--display);letter-spacing:-.08em}.profile-content{padding:clamp(30px,5vw,58px)}.profile-content>small,.session-card small{font:600 8px var(--mono);letter-spacing:.17em;color:var(--acid)}
|
||||
.profile-form{display:grid;gap:18px;margin-top:30px}.profile-form label{display:grid;gap:9px;font:600 8px var(--mono);letter-spacing:.13em;color:#b5b5ae}.profile-form input{width:100%;min-height:50px;padding:0 15px;border:1px solid #373732;background:#090909;color:var(--ink);font:12px var(--body);outline:none}.profile-form input:focus{border-color:var(--acid)}.profile-form input[readonly]{color:var(--muted);cursor:not-allowed}.field-note{margin:0;color:var(--muted);font-size:11px;line-height:1.65}.form-message{margin:0;padding:12px 14px;font:500 9px/1.55 var(--mono)}.form-message.error{border:1px solid #6b372e;background:#221310;color:#ffab98}.form-message.success{border:1px solid #526425;background:#151a0d;color:var(--acid)}.profile-actions{display:flex;align-items:center;gap:20px;margin-top:8px}.profile-actions button{min-height:50px;padding:0 22px;border:0;background:var(--acid);color:#080808;font:700 9px var(--mono);letter-spacing:.12em}.profile-actions button:disabled{opacity:.45;cursor:not-allowed}.profile-actions a{color:var(--muted);font:600 8px var(--mono);letter-spacing:.1em}.loading-state{margin-top:30px;color:var(--muted);font:500 9px var(--mono);letter-spacing:.12em}
|
||||
.session-card{display:flex;align-items:center;justify-content:space-between;gap:30px;max-width:1050px;margin-top:18px;padding:30px clamp(28px,4vw,46px);border:1px solid var(--line);background:#0c0c0b}.session-card h2{margin:12px 0 8px;font:600 clamp(20px,3vw,30px) var(--display);letter-spacing:-.04em}.session-card p{margin:0;color:var(--muted);font-size:12px;line-height:1.6}.session-card button{min-width:150px;min-height:46px;border:1px solid #6b372e;background:transparent;color:#ffab98;font:600 9px var(--mono);letter-spacing:.12em}.session-card button:disabled{opacity:.45;cursor:wait}
|
||||
@media(max-width:760px){.profile-card{grid-template-columns:1fr}.identity-mark{min-height:210px;border-right:0;border-bottom:1px solid var(--line)}.session-card{align-items:flex-start;flex-direction:column}.session-card button{width:100%}}
|
||||
@media(max-width:520px){.profile-intro h1{font-size:clamp(36px,13vw,54px)}.profile-content{padding:28px 22px}.profile-actions{align-items:stretch;flex-direction:column}.profile-actions button{width:100%}.profile-actions a{text-align:center}.session-card{padding:26px 22px}}
|
||||
</style>
|
||||
@@ -1,74 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
interface ProfileView {
|
||||
id: string
|
||||
display_name: string
|
||||
description: string
|
||||
avatar_url: string | null
|
||||
created_at: string
|
||||
}
|
||||
|
||||
interface SharedCampaign {
|
||||
id: string
|
||||
title: string
|
||||
status: string
|
||||
role: string
|
||||
joined_at: string
|
||||
}
|
||||
|
||||
const route = useRoute()
|
||||
const { api } = useDngApi()
|
||||
const auth = useDngAuth()
|
||||
const profile = ref<ProfileView | null>(null)
|
||||
const campaigns = ref<SharedCampaign[]>([])
|
||||
const loading = ref(true)
|
||||
const error = ref('')
|
||||
const profileId = computed(() => String(route.params.id ?? ''))
|
||||
const returnCampaign = computed(() => typeof route.query.campaign === 'string' ? route.query.campaign : '')
|
||||
const initials = computed(() => (profile.value?.display_name || 'Adventurer')
|
||||
.split(/\s+/).map(part => part[0]).join('').slice(0, 2).toUpperCase())
|
||||
|
||||
function messageFrom(cause: unknown) {
|
||||
const value = cause as { data?: { statusMessage?: string }; message?: string }
|
||||
return value.data?.statusMessage ?? value.message ?? 'This player profile is not available.'
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
await auth.restore()
|
||||
const result = await api<{ profile: ProfileView; sharedCampaigns: SharedCampaign[] }>(`/api/v1/profiles/${profileId.value}`)
|
||||
profile.value = result.profile
|
||||
campaigns.value = result.sharedCampaigns
|
||||
} catch (cause) {
|
||||
error.value = messageFrom(cause)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppShell section="PLAYER RECORD">
|
||||
<div v-if="loading" class="public-state noise"><i /><p>LOCATING PLAYER RECORD…</p></div>
|
||||
<div v-else-if="!profile" class="public-state noise"><small>PRIVATE RECORD</small><h1>PLAYER NOT FOUND.</h1><p>{{ error }}</p><NuxtLink :to="returnCampaign ? `/campaign/${returnCampaign}` : '/dashboard'">GO BACK</NuxtLink></div>
|
||||
<div v-else class="public-profile noise">
|
||||
<nav><NuxtLink :to="returnCampaign ? `/campaign/${returnCampaign}` : '/dashboard'">← {{ returnCampaign ? 'BACK TO CAMPAIGN' : 'BACK TO DASHBOARD' }}</NuxtLink><NuxtLink v-if="profile.id === auth.session.value?.user.id" to="/profile">MANAGE PROFILE →</NuxtLink></nav>
|
||||
<main>
|
||||
<div class="public-avatar"><span>{{ initials }}</span><img v-if="profile.avatar_url" :src="profile.avatar_url" :alt="`${profile.display_name}'s profile picture`"><i>VERIFIED<br>PARTY MEMBER</i></div>
|
||||
<section class="public-copy">
|
||||
<p class="eyebrow">PLAYER PROFILE / DUNGEONS & GROUND</p>
|
||||
<h1>{{ profile.display_name }}<span>.</span></h1>
|
||||
<blockquote>{{ profile.description || 'This adventurer has not written a profile description yet.' }}</blockquote>
|
||||
<dl>
|
||||
<div><dt>REGISTERED</dt><dd>{{ new Date(profile.created_at).toLocaleDateString(undefined, { dateStyle: 'long' }) }}</dd></div>
|
||||
<div><dt>SHARED UNIVERSES</dt><dd>{{ campaigns.length }}</dd></div>
|
||||
</dl>
|
||||
<div v-if="campaigns.length" class="shared-worlds"><small>YOUR SHARED UNIVERSES</small><NuxtLink v-for="campaign in campaigns" :key="campaign.id" :to="`/campaign/${campaign.id}`"><span><b>{{ campaign.title }}</b><em>{{ campaign.role }} · joined {{ new Date(campaign.joined_at).toLocaleDateString() }}</em></span><i>↗</i></NuxtLink></div>
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
</AppShell>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.public-profile{min-height:calc(100vh - 76px);padding:32px clamp(20px,6vw,88px) 80px}.public-profile nav{display:flex;justify-content:space-between;gap:20px;padding:0 0 28px;border-bottom:1px solid var(--line)}.public-profile nav a,.public-state a{color:var(--muted);text-decoration:none;font:600 8px var(--mono);letter-spacing:.1em}.public-profile main{display:grid;grid-template-columns:minmax(300px,42%) minmax(0,1fr);min-height:650px;border:1px solid var(--line);border-top:0;background:#0e0e0d}.public-avatar{position:relative;overflow:hidden;display:grid;place-items:center;min-height:650px;border-right:1px solid var(--line);background:radial-gradient(circle at 50% 42%,#4e5724,#17170f 33%,#080808 70%);color:var(--acid);font:600 clamp(50px,8vw,110px) var(--display)}.public-avatar::after{content:"";position:absolute;inset:28px;border:1px solid rgba(217,247,95,.2)}.public-avatar img{position:absolute;inset:0;width:100%;height:100%;object-fit:cover}.public-avatar i{position:absolute;z-index:2;left:50px;bottom:45px;color:var(--ink);font:500 8px/1.6 var(--mono);letter-spacing:.16em;font-style:normal}.public-copy{padding:clamp(35px,6vw,80px);align-self:center}.eyebrow{color:var(--acid);font:500 8px var(--mono);letter-spacing:.18em}.public-copy h1,.public-state h1{margin:18px 0 28px;font:600 clamp(42px,6vw,78px)/1.04 var(--display);letter-spacing:-.07em;overflow-wrap:anywhere}.public-copy h1 span{color:var(--acid)}blockquote{margin:0;padding-left:22px;border-left:2px solid var(--acid);color:#c9c9c2;font:400 clamp(14px,2vw,19px)/1.8 var(--body)}dl{display:grid;grid-template-columns:1fr 1fr;margin:42px 0;border-top:1px solid var(--line);border-bottom:1px solid var(--line)}dl div{padding:18px 0}dl div+div{padding-left:24px;border-left:1px solid var(--line)}dt{color:var(--muted);font:500 7px var(--mono);letter-spacing:.13em}dd{margin:7px 0 0;font-size:11px}.shared-worlds>small{color:var(--acid);font:500 7px var(--mono);letter-spacing:.14em}.shared-worlds>a{display:flex;align-items:center;justify-content:space-between;gap:20px;padding:16px 0;border-bottom:1px solid var(--line);color:var(--ink);text-decoration:none}.shared-worlds span{display:flex;flex-direction:column;gap:6px}.shared-worlds b{font:600 11px var(--display)}.shared-worlds em{color:var(--muted);font:500 7px var(--mono);text-transform:uppercase;font-style:normal}.shared-worlds>a>i{color:var(--acid);font-style:normal}.public-state{min-height:calc(100vh - 76px);display:grid;place-content:center;justify-items:center;text-align:center;padding:30px}.public-state>i{width:24px;height:24px;border:2px solid var(--line);border-top-color:var(--acid);border-radius:50%;animation:spin .8s linear infinite}.public-state small{color:var(--acid);font:500 8px var(--mono);letter-spacing:.15em}.public-state p{color:var(--muted)}@keyframes spin{to{transform:rotate(360deg)}}@media(max-width:820px){.public-profile main{grid-template-columns:1fr}.public-avatar{min-height:380px;border-right:0;border-bottom:1px solid var(--line)}.public-avatar i{left:28px;bottom:26px}.public-copy{padding:34px 26px}}@media(max-width:480px){dl{grid-template-columns:1fr}dl div+div{padding-left:0;border-left:0;border-top:1px solid var(--line)}}
|
||||
</style>
|
||||
File diff suppressed because one or more lines are too long
@@ -16,8 +16,6 @@ interface StoredSession {
|
||||
confirmed_world_id?: string | null
|
||||
}
|
||||
|
||||
const questionLimit = 4
|
||||
|
||||
const { api } = useDngApi()
|
||||
const route = useRoute()
|
||||
const stage = ref<Stage>('seed')
|
||||
@@ -35,14 +33,7 @@ const messages = ref<Message[]>([
|
||||
])
|
||||
|
||||
const displayedQuestion = computed(() => currentQuestion.value ? { key: currentQuestion.value.id, label: currentQuestion.value.label, options: currentQuestion.value.options } : null)
|
||||
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))
|
||||
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))
|
||||
|
||||
function addMessage(role: Message['role'], body: string, label?: string) {
|
||||
messages.value.push({ id: `${Date.now()}-${messages.value.length}`, role, body, label })
|
||||
@@ -126,7 +117,6 @@ async function resume(id: string) {
|
||||
|
||||
async function answerQuestion(key: string, value: string) {
|
||||
if (stage.value !== 'questions' || currentQuestion.value?.id !== key || busy.value || !sessionId.value) return
|
||||
const answeredQuestion = currentQuestion.value
|
||||
busy.value = true
|
||||
pendingAnswer.value = { key, value }
|
||||
try {
|
||||
@@ -134,10 +124,9 @@ async function answerQuestion(key: string, value: string) {
|
||||
method: 'POST', body: { content: value },
|
||||
})
|
||||
answers[key] = value
|
||||
addMessage('coauthor', answeredQuestion.label, `COAUTHOR · QUESTION ${Math.min(answerCount.value + 1, questionLimit)} OF ${questionLimit}`)
|
||||
addMessage('player', value)
|
||||
currentQuestion.value = null
|
||||
answerCount.value = Math.min(answerCount.value + 1, questionLimit)
|
||||
answerCount.value = Math.min(answerCount.value + 1, 5)
|
||||
pendingAnswer.value = null
|
||||
retryAction.value = 'respond'
|
||||
await requestNextQuestion()
|
||||
@@ -151,13 +140,14 @@ async function answerQuestion(key: string, value: string) {
|
||||
async function requestNextQuestion() {
|
||||
if (!sessionId.value) return
|
||||
const result = await api<RespondResult>(`/api/v1/coauthor/sessions/${sessionId.value}/respond`, { method: 'POST' })
|
||||
if (result.readyToGenerate || answerCount.value >= questionLimit) {
|
||||
if (result.readyToGenerate || answerCount.value >= 5) {
|
||||
currentQuestion.value = null
|
||||
await generate()
|
||||
return
|
||||
}
|
||||
if (!result.question) throw new Error('The coauthor did not return its next question.')
|
||||
currentQuestion.value = result.question
|
||||
addMessage('coauthor', result.question.label, `COAUTHOR · QUESTION ${Math.min(answerCount.value + 1, 5)} OF UP TO 5`)
|
||||
}
|
||||
|
||||
function errorText(error: unknown) {
|
||||
@@ -257,7 +247,7 @@ onMounted(() => {
|
||||
<main v-if="stage !== 'preview' && stage !== 'confirming'" class="coauthor">
|
||||
<p class="kicker">COAUTHOR / SESSION 01</p>
|
||||
<h1>BUILD THE<br>IMPOSSIBLE<span>.</span></h1>
|
||||
<CoauthorConversation :messages="conversationMessages" :question="displayedQuestion" :selected-answer="currentQuestion ? answers[currentQuestion.id] : undefined" :busy="stage === 'generating' || busy" @answer="answerQuestion" @retry="retry" />
|
||||
<CoauthorConversation :messages="messages" :question="displayedQuestion" :selected-answer="currentQuestion ? answers[currentQuestion.id] : undefined" :busy="stage === 'generating' || busy" @answer="answerQuestion" @retry="retry" />
|
||||
<form v-if="stage === 'seed'" class="seed" @submit.prevent="begin">
|
||||
<textarea v-model="seed" aria-label="World idea" rows="3" maxlength="1200" autofocus />
|
||||
<button :disabled="!seed.trim() || busy">{{ busy ? 'SAVING…' : 'BEGIN' }} <span>→</span></button>
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { requireCampaignAccess, requireStageTwoUser, stageTwoApiError, stageTwoDatabase, stageTwoUuid } from '~/server/utils/stage-two-supabase'
|
||||
import { visibleProfile, type ProfileRecord } from '~/server/utils/profile'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
try {
|
||||
@@ -31,12 +30,11 @@ export default defineEventHandler(async (event) => {
|
||||
const round = openRounds[0] ?? fallbackRounds[0] ?? null
|
||||
const userIds = [...new Set(members.map(member => String(member.user_id)))]
|
||||
const profiles = userIds.length
|
||||
? await stageTwoDatabase<ProfileRecord[]>(
|
||||
`profiles?select=id,display_name,description,avatar_path,created_at,updated_at&id=in.(${userIds.join(',')})`,
|
||||
? await stageTwoDatabase<Array<{ id: string; display_name: string }>>(
|
||||
`profiles?select=id,display_name&id=in.(${userIds.join(',')})`,
|
||||
)
|
||||
: []
|
||||
const supabaseUrl = useRuntimeConfig().supabaseUrl
|
||||
const profileById = new Map(profiles.map(profile => [profile.id, visibleProfile(profile, supabaseUrl)]))
|
||||
const profileById = new Map(profiles.map(profile => [profile.id, profile]))
|
||||
const visibleMembers = members.map(member => ({ ...member, profile: profileById.get(String(member.user_id)) ?? null }))
|
||||
const visibleRounds = rounds.slice().reverse()
|
||||
const visibleRoundIds = visibleRounds.map(item => String(item.id))
|
||||
|
||||
@@ -1,9 +1,17 @@
|
||||
import { z } from 'zod'
|
||||
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'
|
||||
|
||||
const MessageSchema = z.object({ role: z.enum(['user', 'assistant']), content: z.string().min(1).max(5000) })
|
||||
const QuestionSchema = z.object({
|
||||
question: z.string().trim().min(5).max(300),
|
||||
options: z.array(z.string().trim().min(1).max(100)).length(3),
|
||||
})
|
||||
const StoredQuestionSchema = z.object({
|
||||
id: z.string().trim().min(1).max(80),
|
||||
label: z.string().trim().min(5).max(300),
|
||||
options: z.array(z.string().trim().min(1).max(100)).length(3),
|
||||
})
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
try {
|
||||
@@ -19,42 +27,29 @@ export default defineEventHandler(async (event) => {
|
||||
const answerCount = Math.max(0, messages.filter(message => message.role === 'user').length - 1)
|
||||
if (answerCount >= 4) return { readyToGenerate: true }
|
||||
if (session.current_question) {
|
||||
const storedQuestion = StoredCoauthorQuestionSchema.safeParse(session.current_question)
|
||||
if (storedQuestion.success) return { readyToGenerate: false, question: storedQuestion.data }
|
||||
return { readyToGenerate: false, question: StoredQuestionSchema.parse(session.current_question) }
|
||||
}
|
||||
|
||||
let question: z.infer<typeof CoauthorQuestionSchema> | undefined
|
||||
for (let attempt = 0; attempt < 2 && !question; attempt += 1) {
|
||||
const correction = attempt
|
||||
? ' The previous response was malformed. Ensure every option is a natural-language answer, not punctuation, a JSON key, or a copy of the question.'
|
||||
: ''
|
||||
const completion = buildJsonCompletion(useRuntimeConfig(), {
|
||||
system: `You are the Dungeons & Ground coauthor. Based only on this conversation, ask one focused question that makes the original TTRPG world more playable. This is clarification ${answerCount + 1} of 4. Do not repeat a topic already answered. Adapt to the requested genre. Use the language of the first user message for the question and every answer option unless the user explicitly requests another language. Provide exactly three concise, mutually distinct natural-language answers to the question. Never use punctuation or JSON field names as an option, and never repeat the question as an option. Keep everything suitable for ages 13+.${correction}`,
|
||||
messages,
|
||||
schemaName: 'coauthor_question',
|
||||
jsonSchema: CoauthorQuestionSchema.toJSONSchema(),
|
||||
const completion = buildJsonCompletion(useRuntimeConfig(), {
|
||||
system: `You are the Dungeons & Ground coauthor. Based only on this conversation, ask one focused question that makes the original TTRPG world more playable. This is clarification ${answerCount + 1} of 4. Do not repeat a topic already answered. Adapt to the requested genre. Provide exactly three concise, mutually distinct answer options. Keep everything suitable for ages 13+.`,
|
||||
messages,
|
||||
schemaName: 'coauthor_question',
|
||||
jsonSchema: QuestionSchema.toJSONSchema(),
|
||||
})
|
||||
const controller = new AbortController()
|
||||
const timeout = setTimeout(() => controller.abort(), 30_000)
|
||||
let response: Response
|
||||
try {
|
||||
response = await fetch(completion.endpoint, {
|
||||
method: 'POST', headers: completion.headers, body: JSON.stringify(completion.body), signal: controller.signal,
|
||||
})
|
||||
const controller = new AbortController()
|
||||
const timeout = setTimeout(() => controller.abort(), 30_000)
|
||||
let response: Response
|
||||
try {
|
||||
response = await fetch(completion.endpoint, {
|
||||
method: 'POST', headers: completion.headers, body: JSON.stringify(completion.body), signal: controller.signal,
|
||||
})
|
||||
} 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.
|
||||
}
|
||||
} finally {
|
||||
clearTimeout(timeout)
|
||||
}
|
||||
if (!question) throw createError({ statusCode: 502, statusMessage: 'The coauthor returned invalid answer options. Try the question again.' })
|
||||
if (!response.ok) throw createError({ statusCode: 502, statusMessage: 'The coauthor is temporarily unavailable.' })
|
||||
const question = QuestionSchema.parse(parseJsonCompletion(await response.json()))
|
||||
requireStageTwoSafeText([question.question, ...question.options].join('\n'))
|
||||
const storedQuestion = StoredCoauthorQuestionSchema.parse({
|
||||
const storedQuestion = StoredQuestionSchema.parse({
|
||||
id: `question-${answerCount + 1}`,
|
||||
label: question.question,
|
||||
options: question.options,
|
||||
|
||||
@@ -1,37 +0,0 @@
|
||||
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)
|
||||
}
|
||||
})
|
||||
@@ -1,65 +0,0 @@
|
||||
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)
|
||||
}
|
||||
})
|
||||
@@ -1,30 +0,0 @@
|
||||
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()
|
||||
})
|
||||
})
|
||||
@@ -1,63 +0,0 @@
|
||||
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,
|
||||
})
|
||||
}
|
||||
})
|
||||
@@ -1,29 +0,0 @@
|
||||
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()
|
||||
})
|
||||
})
|
||||
@@ -1,26 +0,0 @@
|
||||
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,
|
||||
}
|
||||
}
|
||||
@@ -123,12 +123,6 @@ const { worldId } = await appRequest(`/api/v1/coauthor/sessions/${sessionId}/con
|
||||
const { campaignId } = await appRequest('/api/v1/campaigns', owner.token, {
|
||||
method: 'POST', body: JSON.stringify({ worldId, title: `Smoke Party ${suffix}` }),
|
||||
})
|
||||
const starterParty = await appRequest(`/api/v1/campaigns/${campaignId}`, owner.token)
|
||||
const ownerCharacter = starterParty.characters.find(character => character.user_id === owner.id && character.controller === 'human')
|
||||
const automaticCompanion = starterParty.characters.find(character => character.controller === 'ai')
|
||||
if (!ownerCharacter || !automaticCompanion || starterParty.round?.status !== 'open') {
|
||||
throw new Error('[smoke] A new campaign did not start with an actionable owner hero, an AI companion, and an open round.')
|
||||
}
|
||||
|
||||
console.log('[smoke] Joining the second player through an invite…')
|
||||
const { token: inviteToken } = await appRequest(`/api/v1/campaigns/${campaignId}/invites`, owner.token, {
|
||||
@@ -143,11 +137,14 @@ const characterBody = (name, concept) => JSON.stringify({
|
||||
name, concept, controller: 'human', abilities: { str: 10, dex: 10, con: 10, int: 12, wis: 11, cha: 9 },
|
||||
hp: 12, maxHp: 12, defense: 12, proficiency: 2, inventory: [], statuses: [], persona: {},
|
||||
})
|
||||
const ownerCharacter = await appRequest(`/api/v1/campaigns/${campaignId}/characters`, owner.token, {
|
||||
method: 'POST', body: characterBody('Iris Vale', 'A methodical station engineer.'),
|
||||
})
|
||||
const playerCharacter = await appRequest(`/api/v1/campaigns/${campaignId}/characters`, player.token, {
|
||||
method: 'POST', body: characterBody('Rowan Pike', 'A curious xenobotanist.'),
|
||||
})
|
||||
|
||||
console.log('[smoke] Verifying AI drafting while keeping the automatic companion…')
|
||||
console.log('[smoke] Drafting and adding a persistent AI companion…')
|
||||
const suggested = await appRequest(`/api/v1/campaigns/${campaignId}/characters/suggest`, owner.token, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ controller: 'ai', concept: 'A damaged garden caretaker who values living things over orders.' }),
|
||||
@@ -155,6 +152,10 @@ const suggested = await appRequest(`/api/v1/campaigns/${campaignId}/characters/s
|
||||
if (!suggested?.character?.name || !suggested.character?.persona?.motivation) {
|
||||
throw new Error('[smoke] Character coauthor returned an incomplete draft.')
|
||||
}
|
||||
await appRequest(`/api/v1/campaigns/${campaignId}/characters`, owner.token, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ ...suggested.character, controller: 'ai', statuses: [] }),
|
||||
})
|
||||
|
||||
const initial = await appRequest(`/api/v1/campaigns/${campaignId}`, owner.token)
|
||||
if (initial.members.length !== 2) throw new Error(`[smoke] Expected 2 members, received ${initial.members.length}.`)
|
||||
@@ -164,7 +165,7 @@ const roundId = initial.round.id
|
||||
console.log('[smoke] Verifying that the round waits for both humans…')
|
||||
await appRequest(`/api/v1/campaigns/${campaignId}/rounds/${roundId}/intent`, owner.token, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ characterId: ownerCharacter.id, action: 'The owner checks the airlock telemetry for a safe path.', ready: true }),
|
||||
body: JSON.stringify({ characterId: ownerCharacter.character.id, action: 'Iris checks the airlock telemetry for a safe path.', ready: true }),
|
||||
})
|
||||
await new Promise(resolve => setTimeout(resolve, 1_500))
|
||||
const waiting = await appRequest(`/api/v1/campaigns/${campaignId}`, player.token)
|
||||
|
||||
@@ -2258,292 +2258,6 @@ grant execute on function public.dng_schema_version() to service_role;
|
||||
|
||||
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 the party receives one persistent AI companion.
|
||||
|
||||
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.characters(
|
||||
campaign_id, user_id, name, concept, controller, abilities, hp, max_hp,
|
||||
defense, proficiency, inventory, statuses, persona
|
||||
) values (
|
||||
v_campaign_id,
|
||||
null,
|
||||
'Echo',
|
||||
left('A persistent AI companion shaped by the ' || v_world.genre || ' world of ' || v_world.title || ', ready to support the player without taking over their choices.', 600),
|
||||
'ai'::public.character_controller,
|
||||
'{"str":9,"dex":13,"con":11,"int":13,"wis":12,"cha":10}'::jsonb,
|
||||
11,
|
||||
11,
|
||||
13,
|
||||
2,
|
||||
'["Survey kit","Emergency supplies"]'::jsonb,
|
||||
'[]'::jsonb,
|
||||
jsonb_build_object(
|
||||
'voice', 'Observant, concise, and quietly curious.',
|
||||
'motivation', 'Help the party understand this unfamiliar world.',
|
||||
'flaw', 'Sometimes values patterns more than instinct.',
|
||||
'bond', 'Stays beside the player when the path turns dangerous.'
|
||||
)
|
||||
);
|
||||
|
||||
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
|
||||
);
|
||||
|
||||
insert into public.characters(
|
||||
campaign_id, user_id, name, concept, controller, abilities, hp, max_hp,
|
||||
defense, proficiency, inventory, statuses, persona
|
||||
)
|
||||
select
|
||||
campaign.id,
|
||||
null,
|
||||
'Echo',
|
||||
left('A persistent AI companion shaped by the ' || world.genre || ' world of ' || world.title || ', ready to support the player without taking over their choices.', 600),
|
||||
'ai'::public.character_controller,
|
||||
'{"str":9,"dex":13,"con":11,"int":13,"wis":12,"cha":10}'::jsonb,
|
||||
11,
|
||||
11,
|
||||
13,
|
||||
2,
|
||||
'["Survey kit","Emergency supplies"]'::jsonb,
|
||||
'[]'::jsonb,
|
||||
jsonb_build_object(
|
||||
'voice', 'Observant, concise, and quietly curious.',
|
||||
'motivation', 'Help the party understand this unfamiliar world.',
|
||||
'flaw', 'Sometimes values patterns more than instinct.',
|
||||
'bond', 'Stays beside the player when the path turns dangerous.'
|
||||
)
|
||||
from public.campaigns campaign
|
||||
join public.worlds world on world.id = campaign.world_id
|
||||
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
|
||||
);
|
||||
|
||||
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';
|
||||
|
||||
|
||||
do $$
|
||||
begin
|
||||
@@ -2568,7 +2282,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
|
||||
raise exception 'D&G bootstrap verification failed: character creation RPC is missing';
|
||||
end if;
|
||||
if public.dng_schema_version() <> 10 then
|
||||
if public.dng_schema_version() <> 8 then
|
||||
raise exception 'D&G bootstrap verification failed: unexpected schema version';
|
||||
end if;
|
||||
end;
|
||||
|
||||
@@ -1,61 +0,0 @@
|
||||
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';
|
||||
@@ -1,215 +0,0 @@
|
||||
-- Make a newly launched campaign playable immediately: the owner receives a
|
||||
-- human-controlled starter and the party receives one persistent AI companion.
|
||||
|
||||
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.characters(
|
||||
campaign_id, user_id, name, concept, controller, abilities, hp, max_hp,
|
||||
defense, proficiency, inventory, statuses, persona
|
||||
) values (
|
||||
v_campaign_id,
|
||||
null,
|
||||
'Echo',
|
||||
left('A persistent AI companion shaped by the ' || v_world.genre || ' world of ' || v_world.title || ', ready to support the player without taking over their choices.', 600),
|
||||
'ai'::public.character_controller,
|
||||
'{"str":9,"dex":13,"con":11,"int":13,"wis":12,"cha":10}'::jsonb,
|
||||
11,
|
||||
11,
|
||||
13,
|
||||
2,
|
||||
'["Survey kit","Emergency supplies"]'::jsonb,
|
||||
'[]'::jsonb,
|
||||
jsonb_build_object(
|
||||
'voice', 'Observant, concise, and quietly curious.',
|
||||
'motivation', 'Help the party understand this unfamiliar world.',
|
||||
'flaw', 'Sometimes values patterns more than instinct.',
|
||||
'bond', 'Stays beside the player when the path turns dangerous.'
|
||||
)
|
||||
);
|
||||
|
||||
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
|
||||
);
|
||||
|
||||
insert into public.characters(
|
||||
campaign_id, user_id, name, concept, controller, abilities, hp, max_hp,
|
||||
defense, proficiency, inventory, statuses, persona
|
||||
)
|
||||
select
|
||||
campaign.id,
|
||||
null,
|
||||
'Echo',
|
||||
left('A persistent AI companion shaped by the ' || world.genre || ' world of ' || world.title || ', ready to support the player without taking over their choices.', 600),
|
||||
'ai'::public.character_controller,
|
||||
'{"str":9,"dex":13,"con":11,"int":13,"wis":12,"cha":10}'::jsonb,
|
||||
11,
|
||||
11,
|
||||
13,
|
||||
2,
|
||||
'["Survey kit","Emergency supplies"]'::jsonb,
|
||||
'[]'::jsonb,
|
||||
jsonb_build_object(
|
||||
'voice', 'Observant, concise, and quietly curious.',
|
||||
'motivation', 'Help the party understand this unfamiliar world.',
|
||||
'flaw', 'Sometimes values patterns more than instinct.',
|
||||
'bond', 'Stays beside the player when the path turns dangerous.'
|
||||
)
|
||||
from public.campaigns campaign
|
||||
join public.worlds world on world.id = campaign.world_id
|
||||
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
|
||||
);
|
||||
|
||||
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';
|
||||
Reference in New Issue
Block a user