Add AI-assisted world and character drafting flows
All checks were successful
CI / validate (push) Successful in 14m3s
All checks were successful
CI / validate (push) Successful in 14m3s
This commit is contained in:
17
README.md
17
README.md
@@ -14,7 +14,7 @@ pnpm dev:all
|
||||
|
||||
Open `http://localhost:3000`. `pnpm dev:all` starts the Nuxt site and the AI worker in one terminal. To run them separately, use `pnpm dev` in the first terminal and `pnpm dev:worker` in the second.
|
||||
|
||||
To verify the complete hosted-Supabase multiplayer path (two temporary guests, invite, shared readiness, AI resolution, and the next round), keep `dev:all` running and execute `pnpm smoke:multiplayer` in another terminal.
|
||||
To verify the complete hosted-Supabase multiplayer path (two temporary guests, invite, AI character draft, persistent AI companion, shared readiness, AI resolution, and the next round), keep `dev:all` running and execute `pnpm smoke:multiplayer` in another terminal.
|
||||
|
||||
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.
|
||||
|
||||
@@ -22,6 +22,8 @@ The web and worker commands both load this root `.env` file explicitly. Environm
|
||||
|
||||
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.
|
||||
|
||||
Both legacy JWT service-role keys and current `sb_secret_…` Supabase keys are supported. Keep either form server-only; never expose it through a `NUXT_PUBLIC_` variable.
|
||||
|
||||
Select OpenRouter or the official DeepSeek API with `AI_PROVIDER=openrouter|deepseek` and provide the matching API key. OpenRouter retains JSON Schema structured output; DeepSeek uses its official JSON mode, followed by the same Zod validation.
|
||||
|
||||
Use Node.js 18.20.5 or newer. The lockfile pins the web toolchain to the Node 18-compatible Nuxt 3.15, Nitro 2.10, and Vite 6 line; CI verifies the project on Node 18.20.8.
|
||||
@@ -51,11 +53,12 @@ Use Node.js 18.20.5 or newer. The lockfile pins the web toolchain to the Node 18
|
||||
|
||||
If the worker reports `POST /rest/v1/rpc/claim_ai_job 404` and `/rest/v1/profiles` also returns 404, the base schema was never installed. Run the generated `supabase/bootstrap.sql` in the SQL Editor and restart `pnpm dev:all`. Do not run only `0002`: it depends on the tables created by `0001`.
|
||||
|
||||
## Stage two flow
|
||||
## Weeks 3–4 flow
|
||||
|
||||
1. Click **Enter the Alpha** to create a persistent guest session without email. Do not sign out or clear site storage unless you are prepared to lose that guest account. An allowlisted email magic link is still available as an alternative.
|
||||
2. Create a world in the coauthor chat, review the editable result, and confirm it.
|
||||
3. Create human characters and optional AI companions in the campaign lobby.
|
||||
4. The owner creates an expiring private invite link. Guest or allowlisted-email users join through `/join/:token`.
|
||||
5. Players save actions and mark them ready. The final ready action queues the round automatically; the owner can also continue without waiting.
|
||||
6. 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.
|
||||
2. Create a world in the dynamic coauthor chat. Unfinished conversations and generated drafts appear on the dashboard and resume after a reload.
|
||||
3. Review every starting-world field, including the owner-only hidden threat, then confirm it.
|
||||
4. Create a human character manually or ask the coauthor for an editable draft. Owners can add persistent AI companions the same way.
|
||||
5. The owner creates an expiring private invite link. Guest or allowlisted-email users join through `/join/:token`.
|
||||
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.
|
||||
|
||||
@@ -18,6 +18,7 @@ defineEmits<{ revise: []; confirm: [] }>()
|
||||
<section><label>TONE</label><input v-model="draft.tone"></section>
|
||||
<section><label>STARTING LOCATION</label><input v-model="draft.startingLocation.name"><textarea v-model="draft.startingLocation.summary" rows="3" /></section>
|
||||
<section><label>OPENING HOOK</label><textarea v-model="draft.hook" rows="5" /></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>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><label>OPENING SCENE</label><textarea v-model="draft.openingScene" rows="6" /></section>
|
||||
@@ -29,4 +30,5 @@ defineEmits<{ revise: []; confirm: [] }>()
|
||||
|
||||
<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}}
|
||||
.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>
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import type { CharacterDraft } from '@dng/shared'
|
||||
|
||||
type AbilityKey = 'str' | 'dex' | 'con' | 'int' | 'wis' | 'cha'
|
||||
type Controller = 'human' | 'ai' | 'delegated'
|
||||
|
||||
@@ -22,6 +24,7 @@ const payload = ref<CampaignPayload | null>(null)
|
||||
const loading = ref(true)
|
||||
const refreshing = ref(false)
|
||||
const mutating = ref(false)
|
||||
const suggestingCharacter = ref(false)
|
||||
const errorMessage = ref('')
|
||||
const mutationError = ref('')
|
||||
const actionText = ref('')
|
||||
@@ -33,13 +36,16 @@ const createKind = ref<'human' | 'ai'>('human')
|
||||
const characterForm = reactive({
|
||||
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>,
|
||||
persona: { voice: '', motivation: '', flaw: '', bond: '' },
|
||||
})
|
||||
const inventoryText = ref('')
|
||||
let pollTimer: ReturnType<typeof setInterval> | undefined
|
||||
|
||||
const campaign = computed(() => payload.value?.campaign)
|
||||
const currentRound = computed(() => payload.value?.round)
|
||||
const isOwner = computed(() => Boolean(campaign.value && auth.session.value?.user.id === campaign.value.owner_id))
|
||||
const currentMember = computed(() => payload.value?.members.find(member => member.user_id === auth.session.value?.user.id))
|
||||
const ownedCharacter = computed(() => payload.value?.characters.find(character => character.user_id === auth.session.value?.user.id))
|
||||
const myCharacter = computed(() => payload.value?.characters.find(character => character.user_id === auth.session.value?.user.id && character.controller === 'human'))
|
||||
const myIntent = computed(() => payload.value?.intents.find(intent =>
|
||||
intent.member_id === currentMember.value?.id || intent.character_id === myCharacter.value?.id,
|
||||
@@ -178,6 +184,37 @@ async function toggleTakeover(member: Record<string, any>) {
|
||||
}))
|
||||
}
|
||||
|
||||
function toggleCharacterForm() {
|
||||
showCharacterForm.value = !showCharacterForm.value
|
||||
if (showCharacterForm.value) createKind.value = ownedCharacter.value ? 'ai' : 'human'
|
||||
}
|
||||
|
||||
async function suggestCharacter() {
|
||||
if (suggestingCharacter.value) return
|
||||
suggestingCharacter.value = true
|
||||
mutationError.value = ''
|
||||
try {
|
||||
const result = await api<{ character: CharacterDraft }>(`/api/v1/campaigns/${campaignId.value}/characters/suggest`, {
|
||||
method: 'POST',
|
||||
body: { concept: characterForm.concept.trim(), controller: createKind.value },
|
||||
})
|
||||
const suggestion = result.character
|
||||
characterForm.name = suggestion.name
|
||||
characterForm.concept = suggestion.concept
|
||||
characterForm.hp = suggestion.hp
|
||||
characterForm.maxHp = suggestion.maxHp
|
||||
characterForm.defense = suggestion.defense
|
||||
characterForm.proficiency = suggestion.proficiency
|
||||
characterForm.abilities = { ...suggestion.abilities }
|
||||
characterForm.persona = { ...suggestion.persona }
|
||||
inventoryText.value = suggestion.inventory.join('\n')
|
||||
} catch (error) {
|
||||
mutationError.value = messageFrom(error)
|
||||
} finally {
|
||||
suggestingCharacter.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function createCharacter() {
|
||||
const controller: Controller = createKind.value === 'ai' ? 'ai' : 'human'
|
||||
const success = await runMutation(() => api(`/api/v1/campaigns/${campaignId.value}/characters`, {
|
||||
@@ -185,13 +222,17 @@ async function createCharacter() {
|
||||
body: {
|
||||
name: characterForm.name.trim(), concept: characterForm.concept.trim(), controller,
|
||||
abilities: characterForm.abilities, hp: characterForm.hp, maxHp: characterForm.maxHp,
|
||||
defense: characterForm.defense, proficiency: characterForm.proficiency, inventory: [], statuses: [], persona: {},
|
||||
defense: characterForm.defense, proficiency: characterForm.proficiency,
|
||||
inventory: inventoryText.value.split(/\n|,/).map(item => item.trim()).filter(Boolean).slice(0, 50),
|
||||
statuses: [], persona: characterForm.persona,
|
||||
},
|
||||
}))
|
||||
if (success) {
|
||||
showCharacterForm.value = false
|
||||
characterForm.name = ''
|
||||
characterForm.concept = ''
|
||||
inventoryText.value = ''
|
||||
characterForm.persona = { voice: '', motivation: '', flaw: '', bond: '' }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -222,13 +263,16 @@ onBeforeUnmount(() => {
|
||||
|
||||
<div v-else class="campaign-layout">
|
||||
<aside class="party-panel">
|
||||
<header class="panel-title"><span>PARTY / {{ payload.characters.length }}</span><button v-if="!myCharacter || isOwner" aria-label="Create character" @click="showCharacterForm = !showCharacterForm">{{ showCharacterForm ? '×' : '+' }}</button></header>
|
||||
<header class="panel-title"><span>PARTY / {{ payload.characters.length }}</span><button v-if="!ownedCharacter || isOwner" aria-label="Create character" @click="toggleCharacterForm">{{ showCharacterForm ? '×' : '+' }}</button></header>
|
||||
<form v-if="showCharacterForm" class="character-create" @submit.prevent="createCharacter">
|
||||
<div v-if="isOwner" class="kind-switch"><button type="button" :class="{active:createKind==='human'}" @click="createKind='human'">HUMAN</button><button type="button" :class="{active:createKind==='ai'}" @click="createKind='ai'">AI HERO</button></div>
|
||||
<div v-if="isOwner" class="kind-switch"><button type="button" :class="{active:createKind==='human'}" :disabled="Boolean(ownedCharacter)" @click="createKind='human'">HUMAN</button><button type="button" :class="{active:createKind==='ai'}" @click="createKind='ai'">AI HERO</button></div>
|
||||
<input v-model="characterForm.name" required maxlength="80" placeholder="Character name">
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<button class="acid-button" :disabled="mutating">{{ mutating ? 'CREATING…' : 'ADD TO PARTY' }}</button>
|
||||
</form>
|
||||
<div v-if="!payload.characters.length" class="empty-card"><b>NO HEROES YET</b><p>Create a character to begin this campaign.</p></div>
|
||||
@@ -289,4 +333,5 @@ onBeforeUnmount(() => {
|
||||
.invite-box button{width:100%}
|
||||
.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}
|
||||
</style>
|
||||
|
||||
@@ -6,21 +6,50 @@ interface CampaignRow {
|
||||
status: string
|
||||
updated_at: string
|
||||
}
|
||||
interface DraftSession {
|
||||
id: string
|
||||
status: string
|
||||
messages: Array<{ role: 'user' | 'assistant'; content: string }>
|
||||
generated_world?: { title?: string; premise?: string } | null
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
const { api } = useDngApi()
|
||||
const auth = useDngAuth()
|
||||
const filter = ref<'all' | 'active' | 'drafts'>('all')
|
||||
const campaigns = ref<CampaignRow[]>([])
|
||||
const drafts = ref<DraftSession[]>([])
|
||||
const loading = ref(true)
|
||||
const error = ref('')
|
||||
|
||||
const visibleCampaigns = computed(() => filter.value === 'active'
|
||||
? campaigns.value.filter(campaign => campaign.status === 'active')
|
||||
: campaigns.value)
|
||||
: filter.value === 'drafts' ? [] : campaigns.value)
|
||||
const visibleDrafts = computed(() => filter.value === 'active' ? [] : drafts.value)
|
||||
const displayName = computed(() => {
|
||||
const value = auth.session.value?.user.user_metadata?.display_name
|
||||
return typeof value === 'string' && value.trim() ? value.trim() : 'ADVENTURER'
|
||||
})
|
||||
|
||||
function draftTitle(draft: DraftSession) {
|
||||
return draft.generated_world?.title || 'UNFINISHED UNIVERSE'
|
||||
}
|
||||
|
||||
function draftSummary(draft: DraftSession) {
|
||||
return draft.generated_world?.premise
|
||||
|| draft.messages.find(message => message.role === 'user')?.content
|
||||
|| 'Continue your conversation with the coauthor.'
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const result = await api<{ campaigns: CampaignRow[] }>('/api/v1/campaigns')
|
||||
campaigns.value = result.campaigns
|
||||
await auth.restore()
|
||||
const [campaignResult, draftResult] = await Promise.all([
|
||||
api<{ campaigns: CampaignRow[] }>('/api/v1/campaigns'),
|
||||
api<{ sessions: DraftSession[] }>('/api/v1/coauthor/sessions'),
|
||||
])
|
||||
campaigns.value = campaignResult.campaigns
|
||||
drafts.value = draftResult.sessions
|
||||
} catch (cause) {
|
||||
const value = cause as { data?: { statusMessage?: string }; message?: string }
|
||||
error.value = value.data?.statusMessage ?? value.message ?? 'Could not load your campaigns.'
|
||||
@@ -34,13 +63,13 @@ onMounted(async () => {
|
||||
<AppShell section="COMMAND DECK">
|
||||
<div class="dash-wrap noise">
|
||||
<section class="dash-head">
|
||||
<div><p class="kicker">WELCOME BACK, MARA</p><h1>YOUR UNIVERSES<span>.</span></h1><p>Every world remembers where you left it.</p></div>
|
||||
<div><p class="kicker">WELCOME BACK, {{ displayName.toUpperCase() }}</p><h1>YOUR UNIVERSES<span>.</span></h1><p>Every world remembers where you left it.</p></div>
|
||||
<NuxtLink to="/worlds/new" class="create-button"><span>+</span> CREATE A UNIVERSE</NuxtLink>
|
||||
</section>
|
||||
|
||||
<div class="filter-row">
|
||||
<button v-for="item in ['all','active','drafts']" :key="item" :class="{active:filter===item}" @click="filter=item as typeof filter">{{ item }}</button>
|
||||
<span>{{ campaigns.length }} / 5 ALPHA CAMPAIGNS</span>
|
||||
<span>{{ campaigns.length }} CAMPAIGNS · {{ drafts.length }} DRAFTS</span>
|
||||
</div>
|
||||
|
||||
<section class="world-grid">
|
||||
@@ -50,16 +79,20 @@ onMounted(async () => {
|
||||
<div class="world-art"><div class="eclipse" /><span>{{ campaign.status.toUpperCase() }} CAMPAIGN</span></div>
|
||||
<div class="world-info"><small>PRIVATE MULTIPLAYER</small><h2>{{ campaign.title }}</h2><p>{{ campaign.current_scene }}</p><div><b>CONTINUE STORY</b><span>{{ new Date(campaign.updated_at).toLocaleDateString() }}</span></div></div>
|
||||
</NuxtLink>
|
||||
<NuxtLink v-for="draft in visibleDrafts" :key="draft.id" :to="`/worlds/new?session=${draft.id}`" class="world-card active-world draft-world">
|
||||
<div class="world-art"><div class="eclipse" /><span>{{ draft.status.toUpperCase() }} DRAFT</span></div>
|
||||
<div class="world-info"><small>PRIVATE COAUTHOR SESSION</small><h2>{{ draftTitle(draft) }}</h2><p>{{ draftSummary(draft) }}</p><div><b>RESUME CREATION</b><span>{{ new Date(draft.updated_at).toLocaleDateString() }}</span></div></div>
|
||||
</NuxtLink>
|
||||
<NuxtLink to="/worlds/new" class="world-card new-card">
|
||||
<span class="plus">+</span><h2>MAKE THE NEXT<br>IMPOSSIBLE PLACE</h2><p>Begin with a sentence. The coauthor will ask the rest.</p>
|
||||
</NuxtLink>
|
||||
</section>
|
||||
|
||||
<section class="system-strip"><span><i /> OPENROUTER ADAPTER 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>
|
||||
</AppShell>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.dash-wrap{min-height:calc(100vh - 76px);padding:clamp(42px,6vw,86px) clamp(20px,6vw,88px)}.dash-head{display:flex;align-items:end;justify-content:space-between;gap:30px}.kicker{font:500 9px var(--mono);letter-spacing:.2em;color:var(--acid)}.dash-head h1{margin:14px 0 10px;font:600 clamp(44px,6vw,82px)/1 var(--display);letter-spacing:-.06em}.dash-head h1 span{color:var(--acid)}.dash-head p{color:var(--muted)}.create-button{display:flex;align-items:center;gap:18px;padding:18px 22px;background:var(--acid);color:#090909;text-decoration:none;font:600 10px var(--mono);letter-spacing:.12em}.create-button span{font-size:20px}.filter-row{display:flex;align-items:center;gap:10px;margin:58px 0 24px;border-bottom:1px solid var(--line)}.filter-row button{padding:0 4px 16px;background:none;border:0;color:var(--muted);font:500 9px var(--mono);text-transform:uppercase;letter-spacing:.14em;margin-right:18px}.filter-row button.active{color:var(--ink);border-bottom:2px solid var(--acid)}.filter-row>span{margin-left:auto;padding-bottom:16px;font:500 8px var(--mono);color:var(--muted)}.world-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:18px}.load-state{grid-column:1/-1;padding:34px;border:1px solid var(--line);font:500 9px var(--mono);color:var(--muted)}.load-state.error{color:#ff9d85}.load-state a{color:var(--acid)}.world-card{min-height:420px;border:1px solid var(--line);color:var(--ink);text-decoration:none;background:#0e0e0d;transition:.25s ease}.world-card:hover{border-color:#65655e;transform:translateY(-3px)}.active-world{display:grid;grid-template-columns:.75fr 1.25fr}.world-art{position:relative;overflow:hidden;display:grid;place-items:center;background:radial-gradient(circle at 50% 60%,#7d8240 0 2%,#303018 4%,#0b0b0a 36%,#030303 72%)}.world-art:before{content:"";position:absolute;width:320px;height:320px;border:1px solid #38382d;border-radius:50%;box-shadow:0 0 0 34px #111,0 0 0 35px #26261d}.eclipse{position:absolute;width:126px;height:126px;border-radius:50%;background:#020202;box-shadow:0 0 50px var(--acid-dim)}.world-art span{position:absolute;left:20px;top:20px;padding:9px 11px;background:var(--acid);color:#0a0a0a;font:600 8px var(--mono);letter-spacing:.12em}.world-info{padding:42px;display:flex;flex-direction:column}.world-info small{font:500 8px var(--mono);letter-spacing:.14em;color:var(--acid);text-transform:uppercase}.world-info h2,.new-card h2{font:600 clamp(25px,3vw,42px)/1.05 var(--display);letter-spacing:-.05em;margin:22px 0}.world-info p,.new-card p{color:var(--muted);font-size:13px;line-height:1.7;display:-webkit-box;-webkit-line-clamp:5;-webkit-box-orient:vertical;overflow:hidden}.world-info div{margin-top:auto;padding-top:26px;border-top:1px solid var(--line);display:flex;justify-content:space-between;font:500 8px var(--mono);color:var(--muted)}.world-info b{color:var(--acid)}.new-card{padding:48px;display:flex;flex-direction:column;justify-content:flex-end;background:linear-gradient(145deg,#121211,#090909)}.new-card .plus{margin-bottom:auto;font:300 42px var(--body);color:var(--acid)}.system-strip{margin-top:32px;padding:18px 0;border-top:1px solid var(--line);display:flex;justify-content:space-between;font:500 8px var(--mono);letter-spacing:.12em;color:var(--muted)}.system-strip i{display:inline-block;width:6px;height:6px;background:var(--acid);border-radius:50%;margin-right:8px}.system-strip b{color:var(--acid)}@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:520px){.filter-row>span{display:none}.world-info,.new-card{padding:28px}.active-world{min-height:580px}}
|
||||
.draft-world .world-art{background:radial-gradient(circle at 50% 60%,#3f4720 0 2%,#1c2011 9%,#080808 58%)}.draft-world .world-art span{background:transparent;color:var(--acid);border:1px solid var(--acid-dim)}
|
||||
</style>
|
||||
|
||||
@@ -1,12 +1,23 @@
|
||||
<script setup lang="ts">
|
||||
import type { WorldStarter } from '@dng/shared'
|
||||
import { WorldStarterSchema, type WorldStarter } from '@dng/shared'
|
||||
|
||||
type Stage = 'seed' | 'questions' | 'generating' | 'preview' | 'confirming'
|
||||
interface Message { id: string; role: 'coauthor' | 'player' | 'status'; body: string; label?: string }
|
||||
interface DynamicQuestion { id: string; label: string; options: string[] }
|
||||
interface RespondResult { readyToGenerate: boolean; question?: DynamicQuestion }
|
||||
interface StoredMessage { role: 'user' | 'assistant'; content: string }
|
||||
interface StoredSession {
|
||||
id: string
|
||||
status: 'collecting' | 'generating' | 'ready' | 'failed' | 'confirmed'
|
||||
messages: StoredMessage[]
|
||||
current_question?: DynamicQuestion | null
|
||||
generatedWorld?: unknown
|
||||
generated_world?: unknown
|
||||
confirmed_world_id?: string | null
|
||||
}
|
||||
|
||||
const { api } = useDngApi()
|
||||
const route = useRoute()
|
||||
const stage = ref<Stage>('seed')
|
||||
const busy = ref(false)
|
||||
const sessionId = ref<string | null>(null)
|
||||
@@ -47,6 +58,63 @@ async function begin() {
|
||||
}
|
||||
}
|
||||
|
||||
async function resume(id: string) {
|
||||
busy.value = true
|
||||
retryAction.value = 'respond'
|
||||
try {
|
||||
const result = await api<{ session: StoredSession }>(`/api/v1/coauthor/sessions/${id}`)
|
||||
const session = result.session
|
||||
sessionId.value = session.id
|
||||
const storedMessages = Array.isArray(session.messages) ? session.messages : []
|
||||
seed.value = storedMessages.find(message => message.role === 'user')?.content ?? seed.value
|
||||
answerCount.value = Math.max(0, storedMessages.filter(message => message.role === 'user').length - 1)
|
||||
let questionNumber = 0
|
||||
messages.value = [messages.value[0]!, ...storedMessages.map((message, index) => {
|
||||
if (message.role === 'assistant') questionNumber += 1
|
||||
return {
|
||||
id: `stored-${index}`,
|
||||
role: message.role === 'assistant' ? 'coauthor' as const : 'player' as const,
|
||||
body: message.content,
|
||||
label: message.role === 'assistant' ? `COAUTHOR · QUESTION ${questionNumber} OF 4` : undefined,
|
||||
}
|
||||
})]
|
||||
|
||||
if (session.status === 'confirmed') {
|
||||
await navigateTo('/dashboard')
|
||||
return
|
||||
}
|
||||
if (session.status === 'ready') {
|
||||
const parsed = WorldStarterSchema.safeParse(session.generatedWorld ?? session.generated_world)
|
||||
if (!parsed.success) throw new Error('This saved world draft is invalid. Generate it again.')
|
||||
draft.value = parsed.data
|
||||
stage.value = 'preview'
|
||||
addMessage('status', `“${parsed.data.title}” was restored.`, 'DRAFT RESTORED')
|
||||
return
|
||||
}
|
||||
if (session.status === 'generating') {
|
||||
busy.value = false
|
||||
await generate()
|
||||
return
|
||||
}
|
||||
if (session.status === 'failed') {
|
||||
stage.value = 'questions'
|
||||
retryAction.value = 'generate'
|
||||
addMessage('status', 'The previous generation failed. Your answers are safe.', 'ERROR')
|
||||
return
|
||||
}
|
||||
|
||||
stage.value = 'questions'
|
||||
currentQuestion.value = session.current_question ?? null
|
||||
if (!currentQuestion.value) await requestNextQuestion()
|
||||
} catch (error) {
|
||||
stage.value = 'seed'
|
||||
sessionId.value = null
|
||||
addMessage('status', errorText(error) || 'This saved coauthor session could not be restored.', 'ERROR')
|
||||
} finally {
|
||||
busy.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function answerQuestion(key: string, value: string) {
|
||||
if (stage.value !== 'questions' || currentQuestion.value?.id !== key || busy.value || !sessionId.value) return
|
||||
busy.value = true
|
||||
@@ -154,6 +222,11 @@ async function confirm() {
|
||||
stage.value = 'preview'
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
const resumeId = typeof route.query.session === 'string' ? route.query.session : ''
|
||||
if (resumeId) void resume(resumeId)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -164,7 +237,7 @@ async function confirm() {
|
||||
<div class="progress"><i :style="{ width: `${progress}%` }" /></div>
|
||||
<ol>
|
||||
<li :class="{ active: stage === 'seed', done: stage !== 'seed' }"><b>01</b><span>Seed idea<small>Say what cannot exist yet.</small></span></li>
|
||||
<li :class="{ active: stage === 'questions', done: answerCount >= 3 }"><b>02</b><span>Shape the signal<small>{{ answerCount }} / up to 5 answers captured.</small></span></li>
|
||||
<li :class="{ active: stage === 'questions', done: answerCount >= 4 }"><b>02</b><span>Shape the signal<small>{{ answerCount }} / 4 answers captured.</small></span></li>
|
||||
<li :class="{ active: stage === 'generating', done: stage === 'preview' || stage === 'confirming' }"><b>03</b><span>Generate structure<small>A playable starting kit.</small></span></li>
|
||||
<li :class="{ active: stage === 'preview' || stage === 'confirming' }"><b>04</b><span>Review & launch<small>You remain the final author.</small></span></li>
|
||||
</ol>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { AbilityScoresSchema } from '@dng/shared'
|
||||
import { z } from 'zod'
|
||||
import { requireCampaignAccess, requireStageTwoSafeText, requireStageTwoUser, stageTwoApiError, stageTwoDatabase, stageTwoUuid } from '~/server/utils/stage-two-supabase'
|
||||
import { requireCampaignAccess, requireStageTwoSafeText, requireStageTwoUser, stageTwoApiError, stageTwoDatabase, stageTwoRpc, stageTwoUuid } from '~/server/utils/stage-two-supabase'
|
||||
|
||||
const BodySchema = z.object({
|
||||
name: z.string().trim().min(1).max(80),
|
||||
@@ -26,25 +26,24 @@ export default defineEventHandler(async (event) => {
|
||||
throw createError({ statusCode: 403, statusMessage: 'Only the campaign owner can add AI heroes.' })
|
||||
}
|
||||
requireStageTwoSafeText([body.name, body.concept, ...body.inventory, ...body.statuses, JSON.stringify(body.persona)].join('\n'))
|
||||
const rows = await stageTwoDatabase<Array<Record<string, unknown>>>('characters', {
|
||||
method: 'POST',
|
||||
prefer: 'return=representation',
|
||||
body: JSON.stringify({
|
||||
campaign_id: campaignId,
|
||||
user_id: body.controller === 'human' ? user.id : null,
|
||||
name: body.name,
|
||||
concept: body.concept,
|
||||
controller: body.controller,
|
||||
abilities: body.abilities,
|
||||
hp: body.hp,
|
||||
max_hp: body.maxHp,
|
||||
defense: body.defense,
|
||||
proficiency: body.proficiency,
|
||||
inventory: body.inventory,
|
||||
statuses: body.statuses,
|
||||
persona: body.persona,
|
||||
}),
|
||||
const characterId = await stageTwoRpc<string>('stage_four_create_character', {
|
||||
p_campaign_id: campaignId,
|
||||
p_actor_id: user.id,
|
||||
p_controller: body.controller,
|
||||
p_name: body.name,
|
||||
p_concept: body.concept,
|
||||
p_abilities: body.abilities,
|
||||
p_hp: body.hp,
|
||||
p_max_hp: body.maxHp,
|
||||
p_defense: body.defense,
|
||||
p_proficiency: body.proficiency,
|
||||
p_inventory: body.inventory,
|
||||
p_statuses: body.statuses,
|
||||
p_persona: body.persona,
|
||||
})
|
||||
const rows = await stageTwoDatabase<Array<Record<string, unknown>>>(
|
||||
`characters?select=*&id=eq.${characterId}&campaign_id=eq.${campaignId}&limit=1`,
|
||||
)
|
||||
setResponseStatus(event, 201)
|
||||
return { character: rows[0] }
|
||||
} catch (error) {
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
import { CharacterDraftSchema } from '@dng/shared'
|
||||
import { z } from 'zod'
|
||||
import { buildJsonCompletion, parseJsonCompletion } from '~/server/utils/ai-provider'
|
||||
import {
|
||||
requireCampaignAccess,
|
||||
requireStageTwoSafeText,
|
||||
requireStageTwoUser,
|
||||
stageTwoApiError,
|
||||
stageTwoDatabase,
|
||||
stageTwoUuid,
|
||||
} from '~/server/utils/stage-two-supabase'
|
||||
|
||||
const BodySchema = z.object({
|
||||
concept: z.string().trim().max(600).default(''),
|
||||
controller: z.enum(['human', 'ai']).default('human'),
|
||||
}).strict()
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
try {
|
||||
const user = await requireStageTwoUser(event)
|
||||
const campaignId = stageTwoUuid(getRouterParam(event, 'id'), 'campaign id')
|
||||
const access = await requireCampaignAccess(campaignId, user.id)
|
||||
const body = BodySchema.parse((await readBody(event)) ?? {})
|
||||
if (body.controller === 'ai' && !access.owner) {
|
||||
throw createError({ statusCode: 403, statusMessage: 'Only the campaign owner can add AI heroes.' })
|
||||
}
|
||||
if (body.concept) requireStageTwoSafeText(body.concept)
|
||||
|
||||
const worlds = await stageTwoDatabase<Array<Record<string, unknown>>>(
|
||||
`worlds?select=title,genre,tone,premise,content_boundaries&` +
|
||||
`id=eq.${String(access.campaign.world_id)}&limit=1`,
|
||||
)
|
||||
const world = worlds[0]
|
||||
if (!world) throw createError({ statusCode: 404, statusMessage: 'Campaign world not found.' })
|
||||
|
||||
const completion = buildJsonCompletion(useRuntimeConfig(), {
|
||||
system: `You create an original, editable Dungeons & Ground character draft for a private 13+ TTRPG campaign. Adapt archetype, equipment and voice to the supplied genre, but always use STR, DEX, CON, INT, WIS and CHA. Keep an alpha hero grounded: ability scores 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.`,
|
||||
messages: [{
|
||||
role: 'user',
|
||||
content: JSON.stringify({
|
||||
world,
|
||||
currentScene: access.campaign.current_scene,
|
||||
heroKind: body.controller === 'ai' ? 'persistent AI companion' : 'human player character',
|
||||
requestedConcept: body.concept || 'Surprise me with a character who creates interesting choices for this party.',
|
||||
}),
|
||||
}],
|
||||
schemaName: 'character_draft',
|
||||
jsonSchema: CharacterDraftSchema.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,
|
||||
})
|
||||
} finally {
|
||||
clearTimeout(timeout)
|
||||
}
|
||||
if (!response.ok) throw createError({ statusCode: 502, statusMessage: 'The character coauthor is temporarily unavailable.' })
|
||||
|
||||
let character
|
||||
try {
|
||||
character = CharacterDraftSchema.parse(parseJsonCompletion(await response.json()))
|
||||
} catch {
|
||||
throw createError({ statusCode: 502, statusMessage: 'The coauthor returned an invalid character draft.' })
|
||||
}
|
||||
requireStageTwoSafeText(JSON.stringify(character))
|
||||
return { character }
|
||||
} catch (error) {
|
||||
stageTwoApiError(error)
|
||||
}
|
||||
})
|
||||
@@ -5,7 +5,7 @@ export default defineEventHandler(async (event) => {
|
||||
const user = await requireStageTwoUser(event)
|
||||
const id = stageTwoUuid(getRouterParam(event, 'id'))
|
||||
const sessions = await stageTwoDatabase<Array<Record<string, unknown>>>(
|
||||
`coauthor_sessions?select=id,status,messages,generated_world,confirmed_world_id,created_at,updated_at&id=eq.${id}&owner_id=eq.${user.id}&limit=1`,
|
||||
`coauthor_sessions?select=id,status,messages,current_question,generated_world,confirmed_world_id,created_at,updated_at&id=eq.${id}&owner_id=eq.${user.id}&limit=1`,
|
||||
)
|
||||
if (!sessions[0]) throw createError({ statusCode: 404, statusMessage: 'Coauthor session not found.' })
|
||||
return { session: { ...sessions[0], generatedWorld: sessions[0].generated_world ?? null } }
|
||||
|
||||
@@ -7,13 +7,18 @@ 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 {
|
||||
const user = await requireStageTwoUser(event)
|
||||
const sessionId = stageTwoUuid(getRouterParam(event, 'id'), 'session id')
|
||||
const sessions = await stageTwoDatabase<Array<{ status: string; messages: unknown }>>(
|
||||
`coauthor_sessions?select=status,messages&id=eq.${sessionId}&owner_id=eq.${user.id}&limit=1`,
|
||||
const sessions = await stageTwoDatabase<Array<{ status: string; messages: unknown; current_question: unknown }>>(
|
||||
`coauthor_sessions?select=status,messages,current_question&id=eq.${sessionId}&owner_id=eq.${user.id}&limit=1`,
|
||||
)
|
||||
const session = sessions[0]
|
||||
if (!session) throw createError({ statusCode: 404, statusMessage: 'Coauthor session not found.' })
|
||||
@@ -21,6 +26,9 @@ export default defineEventHandler(async (event) => {
|
||||
const messages = z.array(MessageSchema).min(1).max(12).parse(session.messages)
|
||||
const answerCount = Math.max(0, messages.filter(message => message.role === 'user').length - 1)
|
||||
if (answerCount >= 4) return { readyToGenerate: true }
|
||||
if (session.current_question) {
|
||||
return { readyToGenerate: false, question: StoredQuestionSchema.parse(session.current_question) }
|
||||
}
|
||||
|
||||
const 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+.`,
|
||||
@@ -41,12 +49,17 @@ export default defineEventHandler(async (event) => {
|
||||
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'))
|
||||
await stageTwoRpc('stage_two_append_coauthor_assistant_message', {
|
||||
p_session_id: sessionId, p_owner_id: user.id, p_content: question.question,
|
||||
const storedQuestion = StoredQuestionSchema.parse({
|
||||
id: `question-${answerCount + 1}`,
|
||||
label: question.question,
|
||||
options: question.options,
|
||||
})
|
||||
await stageTwoRpc('stage_four_set_coauthor_question', {
|
||||
p_session_id: sessionId, p_owner_id: user.id, p_question: storedQuestion,
|
||||
})
|
||||
return {
|
||||
readyToGenerate: false,
|
||||
question: { id: `question-${messages.length}`, label: question.question, options: question.options },
|
||||
question: storedQuestion,
|
||||
}
|
||||
} catch (error) {
|
||||
stageTwoApiError(error)
|
||||
|
||||
@@ -4,7 +4,7 @@ export default defineEventHandler(async (event) => {
|
||||
try {
|
||||
const user = await requireStageTwoUser(event)
|
||||
const sessions = await stageTwoDatabase<Array<Record<string, unknown>>>(
|
||||
`coauthor_sessions?select=id,status,messages,generated_world,confirmed_world_id,created_at,updated_at&owner_id=eq.${user.id}&order=updated_at.desc`,
|
||||
`coauthor_sessions?select=id,status,messages,current_question,generated_world,confirmed_world_id,created_at,updated_at&owner_id=eq.${user.id}&status=in.(collecting,generating,ready,failed)&order=updated_at.desc`,
|
||||
)
|
||||
return { sessions }
|
||||
} catch (error) {
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { CreateWorldRequestSchema, WorldStarterSchema, moderate13Plus } from '@dng/shared'
|
||||
import { buildJsonCompletion, parseJsonCompletion } from '../../utils/ai-provider'
|
||||
import { requireStageTwoUser } from '../../utils/stage-two-supabase'
|
||||
|
||||
export default defineEventHandler(async event => {
|
||||
await requireStageTwoUser(event)
|
||||
const raw = await readBody(event)
|
||||
const prompt = typeof raw?.prompt === 'string' ? raw.prompt : ''
|
||||
const answers = raw?.answers && typeof raw.answers === 'object' ? raw.answers : {}
|
||||
@@ -28,9 +30,14 @@ export default defineEventHandler(async event => {
|
||||
body: JSON.stringify(completion.body),
|
||||
})
|
||||
if (!response.ok) throw createError({ statusCode: 502, statusMessage: 'The coauthor is temporarily unavailable.' })
|
||||
let world
|
||||
try {
|
||||
return WorldStarterSchema.parse(parseJsonCompletion(await response.json()))
|
||||
world = WorldStarterSchema.parse(parseJsonCompletion(await response.json()))
|
||||
} catch {
|
||||
throw createError({ statusCode: 502, statusMessage: 'The coauthor returned an invalid world structure.' })
|
||||
}
|
||||
if (!moderate13Plus(JSON.stringify(world)).allowed) {
|
||||
throw createError({ statusCode: 422, statusMessage: 'The generated world falls outside the alpha’s 13+ content boundary.' })
|
||||
}
|
||||
return world
|
||||
})
|
||||
|
||||
@@ -18,4 +18,34 @@ describe('stage-two Supabase client', () => {
|
||||
body: '{}',
|
||||
})).resolves.toBeUndefined()
|
||||
})
|
||||
|
||||
it('supports new Supabase secret keys without using them as bearer tokens', async () => {
|
||||
vi.stubGlobal('useRuntimeConfig', () => ({
|
||||
supabaseUrl: 'https://example.supabase.co',
|
||||
supabaseServiceRoleKey: 'sb_secret_example',
|
||||
public: { supabaseAnonKey: 'sb_publishable_example' },
|
||||
}))
|
||||
const fetchMock = vi.fn(async (_url: URL, init?: RequestInit) => new Response('[]', { status: 200 }))
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
|
||||
await stageTwoDatabase('profiles?select=id&limit=1')
|
||||
expect(fetchMock).toHaveBeenCalledOnce()
|
||||
expect(fetchMock.mock.calls[0]?.[1]?.headers).toMatchObject({ apikey: 'sb_secret_example' })
|
||||
expect(fetchMock.mock.calls[0]?.[1]?.headers).not.toHaveProperty('Authorization')
|
||||
})
|
||||
|
||||
it('keeps legacy service-role JWT authorization support', async () => {
|
||||
vi.stubGlobal('useRuntimeConfig', () => ({
|
||||
supabaseUrl: 'https://example.supabase.co',
|
||||
supabaseServiceRoleKey: 'header.payload.signature',
|
||||
public: { supabaseAnonKey: 'anon-key' },
|
||||
}))
|
||||
const fetchMock = vi.fn(async (_url: URL, init?: RequestInit) => new Response('[]', { status: 200 }))
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
|
||||
await stageTwoDatabase('profiles?select=id&limit=1')
|
||||
expect(fetchMock.mock.calls[0]?.[1]?.headers).toMatchObject({
|
||||
Authorization: 'Bearer header.payload.signature',
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -13,6 +13,15 @@ interface RequestOptions extends RequestInit {
|
||||
prefer?: string
|
||||
}
|
||||
|
||||
function serviceAuthenticationHeaders(serviceKey: string): Record<string, string> {
|
||||
// Legacy service-role keys are JWTs and may be used as the PostgREST bearer.
|
||||
// New Supabase `sb_secret_…` keys are gateway API keys and must not be sent
|
||||
// as an Authorization token.
|
||||
return serviceKey.split('.').length === 3
|
||||
? { Authorization: `Bearer ${serviceKey}` }
|
||||
: {}
|
||||
}
|
||||
|
||||
export interface StageTwoUser {
|
||||
id: string
|
||||
email?: string
|
||||
@@ -51,7 +60,7 @@ export async function stageTwoDatabase<T>(path: string, options: RequestOptions
|
||||
...options,
|
||||
headers: {
|
||||
apikey: serviceKey,
|
||||
Authorization: `Bearer ${serviceKey}`,
|
||||
...serviceAuthenticationHeaders(serviceKey),
|
||||
'Content-Type': 'application/json',
|
||||
...(options.prefer ? { Prefer: options.prefer } : {}),
|
||||
...options.headers,
|
||||
|
||||
@@ -2,6 +2,17 @@ import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const originalEnv = { ...process.env }
|
||||
|
||||
const worldFixture = () => ({
|
||||
title: 'Test Reach', genre: 'Science fiction', tone: 'Tense', premise: 'A sufficiently long premise for a generated private campaign world.',
|
||||
contentBoundaries: ['13+'],
|
||||
startingLocation: { id: 'location', kind: 'location' as const, name: 'Gate', summary: 'A station beyond known space.', tags: [] as string[], secrets: [] as string[] },
|
||||
npcs: [1, 2, 3].map(index => ({ id: `npc-${index}`, kind: 'npc' as const, name: `NPC ${index}`, summary: 'A useful person with their own agenda.', tags: [], secrets: [] })),
|
||||
factions: [1, 2].map(index => ({ id: `faction-${index}`, kind: 'faction' as const, name: `Faction ${index}`, summary: 'An organization pursuing a hidden objective.', tags: [], secrets: [] })),
|
||||
hook: 'A sufficiently long hook that immediately gives the party something to investigate.',
|
||||
hiddenThreat: 'A hidden threat waits beyond the gate.',
|
||||
openingScene: 'The gate opens without warning, and an impossible signal calls every hero by name.',
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
process.env = { ...originalEnv }
|
||||
vi.unstubAllGlobals()
|
||||
@@ -12,16 +23,7 @@ describe('worker AI providers', () => {
|
||||
process.env.AI_PROVIDER = 'deepseek'
|
||||
process.env.DEEPSEEK_API_KEY = 'secret'
|
||||
process.env.DEEPSEEK_MODEL = 'deepseek-v4-flash'
|
||||
const world = {
|
||||
title: 'Test Reach', genre: 'Science fiction', tone: 'Tense', premise: 'A sufficiently long premise for a generated private campaign world.',
|
||||
contentBoundaries: ['13+'],
|
||||
startingLocation: { id: 'location', kind: 'location', name: 'Gate', summary: 'A station beyond known space.', tags: [], secrets: [] },
|
||||
npcs: [1, 2, 3].map(index => ({ id: `npc-${index}`, kind: 'npc', name: `NPC ${index}`, summary: 'A useful person with their own agenda.', tags: [], secrets: [] })),
|
||||
factions: [1, 2].map(index => ({ id: `faction-${index}`, kind: 'faction', name: `Faction ${index}`, summary: 'An organization pursuing a hidden objective.', tags: [], secrets: [] })),
|
||||
hook: 'A sufficiently long hook that immediately gives the party something to investigate.',
|
||||
hiddenThreat: 'A hidden threat waits beyond the gate.',
|
||||
openingScene: 'The gate opens without warning, and an impossible signal calls every hero by name.',
|
||||
}
|
||||
const world = worldFixture()
|
||||
const fetchMock = vi.fn(async (_url: string, init?: RequestInit) => {
|
||||
const body = JSON.parse(String(init?.body))
|
||||
expect(body.response_format).toEqual({ type: 'json_object' })
|
||||
@@ -35,4 +37,17 @@ describe('worker AI providers', () => {
|
||||
await expect(generateWorld([{ role: 'user', content: 'Create a science fiction frontier.' }])).resolves.toMatchObject({ title: 'Test Reach' })
|
||||
expect(fetchMock).toHaveBeenCalledWith('https://api.deepseek.com/chat/completions', expect.any(Object))
|
||||
})
|
||||
|
||||
it('rejects explicit content hidden inside generated entity fields', async () => {
|
||||
process.env.AI_PROVIDER = 'deepseek'
|
||||
process.env.DEEPSEEK_API_KEY = 'secret'
|
||||
const world = worldFixture()
|
||||
world.startingLocation.secrets = ['The requested reward is explicit sex.']
|
||||
vi.stubGlobal('fetch', vi.fn(async () => new Response(JSON.stringify({
|
||||
choices: [{ message: { content: JSON.stringify(world) } }],
|
||||
}), { status: 200 })))
|
||||
const { generateWorld } = await import('./ai')
|
||||
await expect(generateWorld([{ role: 'user', content: 'Create a science fiction frontier.' }]))
|
||||
.rejects.toThrow('Content policy rejected')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -92,15 +92,19 @@ export async function generateWorld(messages: Array<{ role: 'user' | 'assistant'
|
||||
{ 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.' },
|
||||
...messages,
|
||||
], value => WorldStarterSchema.parse(value))
|
||||
assert13Plus(world.title, world.premise, world.hook, world.hiddenThreat, world.openingScene)
|
||||
// Moderate the entire typed object, including entity summaries, secrets and
|
||||
// boundaries—not just the headline fields shown in the first preview.
|
||||
assert13Plus(JSON.stringify(world))
|
||||
return world
|
||||
}
|
||||
|
||||
export async function planRound(input: RoundContext): Promise<RoundPlan> {
|
||||
return 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: 'user', content: JSON.stringify(input) },
|
||||
], value => RoundPlanSchema.parse(value))
|
||||
assert13Plus(JSON.stringify(plan.aiActions), JSON.stringify(plan.proposedEvents))
|
||||
return plan
|
||||
}
|
||||
|
||||
export async function narrateRound(input: { context: RoundContext; actionSequence: Array<{ phase: 'human' | 'ai'; characterId: string; action: string }>; rolls: DiceRoll[]; permittedEvents: RoundPlan['proposedEvents'] }): Promise<RoundResolution> {
|
||||
@@ -108,7 +112,7 @@ export async function narrateRound(input: { context: RoundContext; actionSequenc
|
||||
{ role: 'system', content: 'Narrate the resolved TTRPG round in the exact supplied actionSequence order: all human actions first, then AI/delegated hero actions. Use the authoritative dice results without changing them. Keep player agency, maintain a 13+ rating, and end with a clear prompt for the next round.' },
|
||||
{ role: 'user', content: JSON.stringify(input) },
|
||||
], value => RoundResolutionSchema.parse(value))
|
||||
assert13Plus(resolution.narration, resolution.nextPrompt, resolution.memory?.summary ?? '')
|
||||
assert13Plus(JSON.stringify(resolution))
|
||||
return resolution
|
||||
}
|
||||
|
||||
|
||||
@@ -45,14 +45,14 @@ if (!supabaseUrl || !serviceKey) {
|
||||
}
|
||||
|
||||
async function databaseRequest<T>(path: string, init: RequestInit = {}): Promise<T> {
|
||||
const headers = new Headers(init.headers)
|
||||
headers.set('apikey', databaseServiceKey)
|
||||
headers.set('Content-Type', 'application/json')
|
||||
if (databaseServiceKey.split('.').length === 3) headers.set('Authorization', `Bearer ${databaseServiceKey}`)
|
||||
else headers.delete('Authorization')
|
||||
const response = await fetch(new URL(`/rest/v1/${path}`, databaseUrl), {
|
||||
...init,
|
||||
headers: {
|
||||
apikey: databaseServiceKey,
|
||||
Authorization: `Bearer ${databaseServiceKey}`,
|
||||
'Content-Type': 'application/json',
|
||||
...init.headers,
|
||||
},
|
||||
headers,
|
||||
})
|
||||
if (!response.ok) {
|
||||
const detail = await response.text()
|
||||
|
||||
31
packages/shared/src/character.test.ts
Normal file
31
packages/shared/src/character.test.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { CharacterDraftSchema } from './index'
|
||||
|
||||
const draft = {
|
||||
name: 'Iris Vale',
|
||||
concept: 'A patient relay mechanic who hears impossible harmonics.',
|
||||
abilities: { str: 9, dex: 13, con: 12, int: 16, wis: 14, cha: 10 },
|
||||
hp: 12,
|
||||
maxHp: 12,
|
||||
defense: 13,
|
||||
proficiency: 2,
|
||||
inventory: ['Signal lens', 'Field toolkit'],
|
||||
persona: {
|
||||
voice: 'Precise, with dry humor.',
|
||||
motivation: 'Prove the relay is speaking from the future.',
|
||||
flaw: 'Treats every warning as a puzzle.',
|
||||
bond: 'Will not abandon another crew member.',
|
||||
},
|
||||
}
|
||||
|
||||
describe('CharacterDraftSchema', () => {
|
||||
it('accepts a complete editable 13+ character draft', () => {
|
||||
expect(CharacterDraftSchema.parse(draft)).toEqual(draft)
|
||||
expect(CharacterDraftSchema.toJSONSchema()).toMatchObject({ type: 'object' })
|
||||
})
|
||||
|
||||
it('rejects invalid server-facing mechanics', () => {
|
||||
expect(() => CharacterDraftSchema.parse({ ...draft, hp: 20, maxHp: 10 })).toThrow()
|
||||
expect(() => CharacterDraftSchema.parse({ ...draft, defense: 99 })).toThrow()
|
||||
})
|
||||
})
|
||||
@@ -45,6 +45,30 @@ export const WorldStarterSchema = z.object({
|
||||
})
|
||||
export type WorldStarter = z.infer<typeof WorldStarterSchema>
|
||||
|
||||
export const CharacterPersonaSchema = z.object({
|
||||
voice: z.string().trim().min(1).max(240),
|
||||
motivation: z.string().trim().min(1).max(300),
|
||||
flaw: z.string().trim().min(1).max(300),
|
||||
bond: z.string().trim().min(1).max(300),
|
||||
})
|
||||
export type CharacterPersona = z.infer<typeof CharacterPersonaSchema>
|
||||
|
||||
export const CharacterDraftSchema = z.object({
|
||||
name: z.string().trim().min(1).max(80),
|
||||
concept: z.string().trim().min(3).max(600),
|
||||
abilities: AbilityScoresSchema,
|
||||
hp: z.number().int().min(1).max(100),
|
||||
maxHp: z.number().int().min(1).max(100),
|
||||
defense: z.number().int().min(1).max(40),
|
||||
proficiency: z.number().int().min(1).max(10),
|
||||
inventory: z.array(z.string().trim().min(1).max(120)).max(12),
|
||||
persona: CharacterPersonaSchema,
|
||||
}).strict().refine(value => value.hp <= value.maxHp, {
|
||||
message: 'hp must not exceed maxHp',
|
||||
path: ['hp'],
|
||||
})
|
||||
export type CharacterDraft = z.infer<typeof CharacterDraftSchema>
|
||||
|
||||
export const CharacterSchema = z.object({
|
||||
id: z.string().min(1),
|
||||
name: z.string().min(1).max(80),
|
||||
|
||||
@@ -8,6 +8,7 @@ const outputPath = join(root, 'supabase', 'bootstrap.sql')
|
||||
const migrationNames = (await readdir(migrationsDirectory))
|
||||
.filter(name => /^\d+_.+\.sql$/.test(name))
|
||||
.sort((left, right) => left.localeCompare(right))
|
||||
const latestSchemaVersion = Math.max(...migrationNames.map(name => Number(name.slice(0, 4))))
|
||||
|
||||
if (migrationNames.length === 0) throw new Error('No Supabase migrations were found.')
|
||||
|
||||
@@ -62,6 +63,12 @@ begin
|
||||
if to_regprocedure('public.dng_schema_version()') is null then
|
||||
raise exception 'D&G bootstrap verification failed: schema version RPC is missing';
|
||||
end if;
|
||||
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() <> ${latestSchemaVersion} then
|
||||
raise exception 'D&G bootstrap verification failed: unexpected schema version';
|
||||
end if;
|
||||
end;
|
||||
$$;
|
||||
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import { readFile, readdir } from 'node:fs/promises'
|
||||
import { dirname, join, resolve } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import postgres from 'postgres'
|
||||
|
||||
const root = resolve(dirname(fileURLToPath(import.meta.url)), '..')
|
||||
const migrationDirectory = join(root, 'supabase', 'migrations')
|
||||
const migrationNames = (await readdir(migrationDirectory))
|
||||
.filter(name => /^\d+_.+\.sql$/.test(name))
|
||||
.sort((left, right) => left.localeCompare(right))
|
||||
const latestSchemaVersion = Math.max(...migrationNames.map(name => Number(name.slice(0, 4))))
|
||||
|
||||
async function loadRootEnvironment() {
|
||||
let contents
|
||||
@@ -55,9 +60,13 @@ function assertMatchingProject(databaseUrl, supabaseUrl) {
|
||||
}
|
||||
}
|
||||
|
||||
async function dataApiHasSchema(supabaseUrl, serviceKey) {
|
||||
async function dataApiSchemaState(supabaseUrl, serviceKey) {
|
||||
const base = supabaseUrl.replace(/\/$/, '')
|
||||
const headers = { apikey: serviceKey, Authorization: `Bearer ${serviceKey}`, 'Content-Type': 'application/json' }
|
||||
const headers = {
|
||||
apikey: serviceKey,
|
||||
...(serviceKey.split('.').length === 3 ? { Authorization: `Bearer ${serviceKey}` } : {}),
|
||||
'Content-Type': 'application/json',
|
||||
}
|
||||
const [profiles, workerRpc, retryRpc, versionRpc] = await Promise.all([
|
||||
fetch(`${base}/rest/v1/profiles?select=id&limit=1`, { headers }),
|
||||
fetch(`${base}/rest/v1/rpc/claim_ai_job`, {
|
||||
@@ -78,13 +87,17 @@ async function dataApiHasSchema(supabaseUrl, serviceKey) {
|
||||
if ([profiles, workerRpc, retryRpc, versionRpc].some(response => response.status === 401 || response.status === 403)) {
|
||||
throw new Error('SUPABASE_SERVICE_ROLE_KEY was rejected by the configured project.')
|
||||
}
|
||||
if ([profiles, workerRpc, retryRpc, versionRpc].some(response => response.status === 404)) return false
|
||||
if ([profiles, workerRpc, retryRpc, versionRpc].some(response => response.status === 404)) {
|
||||
return { installed: false, version: 0 }
|
||||
}
|
||||
if (profiles.status !== 200 || workerRpc.status !== 400 || retryRpc.status !== 400 || versionRpc.status !== 200) {
|
||||
throw new Error(
|
||||
`Unexpected Supabase preflight response (profiles ${profiles.status}, claim_ai_job ${workerRpc.status}, retry_round ${retryRpc.status}, schema_version ${versionRpc.status}).`,
|
||||
)
|
||||
}
|
||||
return true
|
||||
const version = Number(await versionRpc.json())
|
||||
if (!Number.isInteger(version) || version < 1) throw new Error('Supabase returned an invalid D&G schema version.')
|
||||
return { installed: true, version }
|
||||
}
|
||||
|
||||
async function ensureSchema() {
|
||||
@@ -93,10 +106,15 @@ async function ensureSchema() {
|
||||
const serviceKey = required('SUPABASE_SERVICE_ROLE_KEY')
|
||||
|
||||
try {
|
||||
if (await dataApiHasSchema(supabaseUrl, serviceKey)) {
|
||||
console.log('[database] Supabase schema is ready.')
|
||||
const state = await dataApiSchemaState(supabaseUrl, serviceKey)
|
||||
if (state.installed && state.version === latestSchemaVersion) {
|
||||
console.log(`[database] Supabase schema v${state.version} is ready.`)
|
||||
return
|
||||
}
|
||||
if (state.version > latestSchemaVersion) {
|
||||
throw new Error(`Database schema v${state.version} is newer than this checkout (v${latestSchemaVersion}).`)
|
||||
}
|
||||
if (state.installed) console.log(`[database] Supabase schema v${state.version} needs upgrade to v${latestSchemaVersion}.`)
|
||||
} catch (error) {
|
||||
console.warn(`[database] Data API preflight failed: ${error instanceof Error ? error.message : String(error)}`)
|
||||
}
|
||||
@@ -104,7 +122,7 @@ async function ensureSchema() {
|
||||
const databaseUrl = process.env.SUPABASE_DB_URL?.trim()
|
||||
if (!databaseUrl) {
|
||||
throw new Error(
|
||||
'[database] The Supabase schema is missing. Add SUPABASE_DB_URL from Dashboard → Connect → Session pooler to the root .env; the next start will create it automatically.',
|
||||
`[database] The Supabase schema is missing or out of date (target v${latestSchemaVersion}). Add SUPABASE_DB_URL from Dashboard → Connect → Session pooler to the root .env; the next start will create or upgrade it automatically.`,
|
||||
)
|
||||
}
|
||||
assertMatchingProject(databaseUrl, supabaseUrl)
|
||||
@@ -129,25 +147,43 @@ async function ensureSchema() {
|
||||
to_regprocedure('public.dng_schema_version()')::text as version_rpc
|
||||
`)
|
||||
const baseEntries = Object.entries(state).filter(([name]) => !['retry_rpc', 'version_rpc'].includes(name))
|
||||
if (baseEntries.every(([, value]) => Boolean(value)) && state.retry_rpc && state.version_rpc) {
|
||||
if (baseEntries.every(([, value]) => Boolean(value))) {
|
||||
let currentVersion = 4
|
||||
if (state.version_rpc) {
|
||||
const [row] = await sql.unsafe('select public.dng_schema_version() as version')
|
||||
currentVersion = Number(row.version)
|
||||
} else if (state.retry_rpc) {
|
||||
currentVersion = 5
|
||||
}
|
||||
if (!Number.isInteger(currentVersion) || currentVersion < 1) throw new Error('[database] Invalid installed schema version.')
|
||||
if (currentVersion > latestSchemaVersion) {
|
||||
throw new Error(`[database] Schema v${currentVersion} is newer than this checkout (v${latestSchemaVersion}).`)
|
||||
}
|
||||
const pending = migrationNames.filter(name => Number(name.slice(0, 4)) > currentVersion)
|
||||
if (!pending.length) {
|
||||
await sql.unsafe("notify pgrst, 'reload schema'")
|
||||
console.log('[database] Supabase schema is ready; PostgREST cache reload requested.')
|
||||
console.log(`[database] Supabase schema v${currentVersion} is ready; PostgREST cache reload requested.`)
|
||||
return
|
||||
}
|
||||
if (baseEntries.every(([, value]) => Boolean(value)) && (!state.retry_rpc || !state.version_rpc)) {
|
||||
console.log('[database] Applying the pending failed-round recovery upgrade…')
|
||||
const migration = await readFile(join(root, 'supabase', 'migrations', '0006_retry_job_compatibility.sql'), 'utf8')
|
||||
console.log(`[database] Applying ${pending.length} migration(s): ${pending.join(', ')}…`)
|
||||
await sql.begin(async transaction => {
|
||||
for (const name of pending) {
|
||||
const migration = await readFile(join(migrationDirectory, name), 'utf8')
|
||||
await transaction.unsafe(migration)
|
||||
}
|
||||
})
|
||||
const [verified] = await sql.unsafe(`
|
||||
select
|
||||
to_regprocedure('public.stage_two_retry_failed_round(uuid,uuid)') is not null as retry_rpc,
|
||||
to_regprocedure('public.dng_schema_version()') is not null as version_rpc
|
||||
to_regprocedure('public.dng_schema_version()') is not null as version_rpc,
|
||||
to_regprocedure('public.stage_four_create_character(uuid,uuid,text,text,text,jsonb,integer,integer,integer,integer,jsonb,jsonb,jsonb)') is not null as character_rpc,
|
||||
public.dng_schema_version() as version
|
||||
`)
|
||||
if (!verified.retry_rpc || !verified.version_rpc) throw new Error('[database] Failed-round recovery upgrade verification failed.')
|
||||
if (!verified.retry_rpc || !verified.version_rpc || !verified.character_rpc || Number(verified.version) !== latestSchemaVersion) {
|
||||
throw new Error('[database] Schema upgrade verification failed.')
|
||||
}
|
||||
await sql.unsafe("notify pgrst, 'reload schema'")
|
||||
console.log('[database] Supabase schema upgraded successfully.')
|
||||
console.log(`[database] Supabase schema upgraded successfully to v${latestSchemaVersion}.`)
|
||||
return
|
||||
}
|
||||
if (baseEntries.some(([, value]) => Boolean(value))) {
|
||||
@@ -166,12 +202,16 @@ async function ensureSchema() {
|
||||
to_regprocedure('public.claim_ai_job(text)') is not null as worker_rpc,
|
||||
to_regprocedure('public.stage_two_create_campaign(uuid,uuid,text)') is not null as campaign_rpc,
|
||||
to_regprocedure('public.stage_two_retry_failed_round(uuid,uuid)') is not null as retry_rpc,
|
||||
to_regprocedure('public.dng_schema_version()') is not null as version_rpc
|
||||
to_regprocedure('public.stage_four_create_character(uuid,uuid,text,text,text,jsonb,integer,integer,integer,integer,jsonb,jsonb,jsonb)') is not null as character_rpc,
|
||||
to_regprocedure('public.dng_schema_version()') is not null as version_rpc,
|
||||
public.dng_schema_version() as version
|
||||
`)
|
||||
if (!Object.values(verified).every(Boolean)) {
|
||||
if (!verified.profiles || !verified.ai_jobs || !verified.worker_rpc || !verified.campaign_rpc
|
||||
|| !verified.retry_rpc || !verified.character_rpc || !verified.version_rpc
|
||||
|| Number(verified.version) !== latestSchemaVersion) {
|
||||
throw new Error('[database] Bootstrap completed but schema verification failed.')
|
||||
}
|
||||
console.log('[database] Dungeons & Ground schema created successfully.')
|
||||
console.log(`[database] Dungeons & Ground schema v${latestSchemaVersion} created successfully.`)
|
||||
} finally {
|
||||
await sql.end({ timeout: 5 })
|
||||
}
|
||||
|
||||
@@ -56,11 +56,14 @@ function appRequest(path, token, options = {}) {
|
||||
}
|
||||
|
||||
async function servicePatch(path, body) {
|
||||
const serviceAuthorization = serviceKey.split('.').length === 3
|
||||
? { Authorization: `Bearer ${serviceKey}` }
|
||||
: {}
|
||||
await jsonRequest(`${supabaseUrl}/rest/v1/${path}`, {
|
||||
method: 'PATCH',
|
||||
headers: {
|
||||
apikey: serviceKey,
|
||||
Authorization: `Bearer ${serviceKey}`,
|
||||
...serviceAuthorization,
|
||||
'Content-Type': 'application/json',
|
||||
Prefer: 'return=representation',
|
||||
},
|
||||
@@ -130,9 +133,22 @@ const playerCharacter = await appRequest(`/api/v1/campaigns/${campaignId}/charac
|
||||
method: 'POST', body: characterBody('Rowan Pike', 'A curious xenobotanist.'),
|
||||
})
|
||||
|
||||
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.' }),
|
||||
})
|
||||
if (!suggested?.character?.name || !suggested.character?.persona?.motivation) {
|
||||
throw new Error('[smoke] Character coauthor returned an incomplete draft.')
|
||||
}
|
||||
await appRequest(`/api/v1/campaigns/${campaignId}/characters`, owner.token, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ ...suggested.character, controller: 'ai', statuses: [] }),
|
||||
})
|
||||
|
||||
const initial = await appRequest(`/api/v1/campaigns/${campaignId}`, owner.token)
|
||||
if (initial.members.length !== 2) throw new Error(`[smoke] Expected 2 members, received ${initial.members.length}.`)
|
||||
if (initial.characters.length !== 2 || initial.round?.status !== 'open') throw new Error('[smoke] Multiplayer lobby did not expose two characters and an open round.')
|
||||
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.')
|
||||
const roundId = initial.round.id
|
||||
|
||||
console.log('[smoke] Verifying that the round waits for both humans…')
|
||||
@@ -167,4 +183,4 @@ while (Date.now() < deadline) {
|
||||
if (!completed) throw new Error('[smoke] Multiplayer round did not resolve within 120 seconds.')
|
||||
if (!completed.rounds.find(round => round.id === roundId)?.narration) throw new Error('[smoke] Resolved round has no narration.')
|
||||
|
||||
console.log(`[smoke] PASS — 2 players joined, shared readiness, worker resolution, and next round all work (campaign ${campaignId}).`)
|
||||
console.log(`[smoke] PASS — 2 players, AI character drafting, persistent companion, shared readiness, worker resolution, and next round all work (campaign ${campaignId}).`)
|
||||
|
||||
@@ -2030,6 +2030,151 @@ grant execute on function public.dng_schema_version() to service_role;
|
||||
|
||||
notify pgrst, 'reload schema';
|
||||
|
||||
-- ============================================================================
|
||||
-- 0007_stage_three_four_completion.sql
|
||||
-- ============================================================================
|
||||
|
||||
-- Complete the universe/character alpha slice: resumable coauthor questions and
|
||||
-- an atomic, server-only character creation path.
|
||||
|
||||
alter table public.coauthor_sessions
|
||||
add column if not exists current_question jsonb;
|
||||
|
||||
alter table public.coauthor_sessions
|
||||
drop constraint if exists coauthor_sessions_current_question_object;
|
||||
alter table public.coauthor_sessions
|
||||
add constraint coauthor_sessions_current_question_object check (
|
||||
current_question is null or jsonb_typeof(current_question) = 'object'
|
||||
);
|
||||
|
||||
create or replace function public.stage_two_append_coauthor_message(
|
||||
p_session_id uuid,
|
||||
p_owner_id uuid,
|
||||
p_content text
|
||||
) returns jsonb language plpgsql security definer set search_path = '' as $$
|
||||
declare
|
||||
v_session public.coauthor_sessions%rowtype;
|
||||
v_result jsonb;
|
||||
begin
|
||||
if p_content is null or char_length(btrim(p_content)) not between 1 and 5000 then
|
||||
raise exception 'message must be between 1 and 5000 characters';
|
||||
end if;
|
||||
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 in ('generating', 'confirmed') then
|
||||
raise exception 'coauthor session cannot be edited in status %', v_session.status;
|
||||
end if;
|
||||
update public.coauthor_sessions
|
||||
set messages = messages || jsonb_build_array(jsonb_build_object('role', 'user', 'content', btrim(p_content))),
|
||||
generated_world = null,
|
||||
current_question = null,
|
||||
status = 'collecting',
|
||||
updated_at = now()
|
||||
where id = p_session_id
|
||||
returning jsonb_build_object('id', id, 'status', status, 'messages', messages, 'updatedAt', updated_at)
|
||||
into v_result;
|
||||
return v_result;
|
||||
end;
|
||||
$$;
|
||||
|
||||
create or replace function public.stage_four_set_coauthor_question(
|
||||
p_session_id uuid,
|
||||
p_owner_id uuid,
|
||||
p_question jsonb
|
||||
) returns jsonb language plpgsql security definer set search_path = '' as $$
|
||||
declare
|
||||
v_result jsonb;
|
||||
begin
|
||||
if p_question is null
|
||||
or jsonb_typeof(p_question) <> 'object'
|
||||
or nullif(btrim(p_question->>'id'), '') is null
|
||||
or char_length(btrim(p_question->>'label')) not between 5 and 300
|
||||
or jsonb_typeof(p_question->'options') <> 'array'
|
||||
or jsonb_array_length(p_question->'options') <> 3 then
|
||||
raise exception 'invalid coauthor question';
|
||||
end if;
|
||||
|
||||
update public.coauthor_sessions
|
||||
set messages = messages || jsonb_build_array(jsonb_build_object(
|
||||
'role', 'assistant', 'content', btrim(p_question->>'label')
|
||||
)),
|
||||
current_question = p_question,
|
||||
updated_at = now()
|
||||
where id = p_session_id and owner_id = p_owner_id and status = 'collecting'
|
||||
returning current_question into v_result;
|
||||
if not found then raise exception 'collecting coauthor session not found'; end if;
|
||||
return v_result;
|
||||
end;
|
||||
$$;
|
||||
|
||||
create or replace function public.stage_four_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
|
||||
) returns uuid language plpgsql security definer set search_path = '' as $$
|
||||
declare
|
||||
v_character_id uuid;
|
||||
v_user_id uuid;
|
||||
begin
|
||||
if p_controller not in ('human', 'ai') then raise exception 'invalid character controller'; end if;
|
||||
if not exists (
|
||||
select 1 from public.campaign_members
|
||||
where campaign_id = p_campaign_id and user_id = p_actor_id and active
|
||||
) then raise exception 'active campaign membership is required'; end if;
|
||||
|
||||
if p_controller = 'ai' then
|
||||
if not exists (
|
||||
select 1 from public.campaigns where id = p_campaign_id and owner_id = p_actor_id
|
||||
) then raise exception 'only the campaign owner can add AI heroes'; end if;
|
||||
v_user_id := null;
|
||||
else
|
||||
v_user_id := p_actor_id;
|
||||
perform pg_catalog.pg_advisory_xact_lock(
|
||||
pg_catalog.hashtextextended(p_campaign_id::text || ':' || p_actor_id::text, 0)
|
||||
);
|
||||
if exists (
|
||||
select 1 from public.characters
|
||||
where campaign_id = p_campaign_id and user_id = p_actor_id
|
||||
) then raise exception 'this player already has a character'; end if;
|
||||
end if;
|
||||
|
||||
insert into public.characters(
|
||||
campaign_id, user_id, name, concept, controller, abilities, hp, max_hp,
|
||||
defense, proficiency, inventory, statuses, persona
|
||||
) values (
|
||||
p_campaign_id, v_user_id, btrim(p_name), btrim(p_concept),
|
||||
p_controller::public.character_controller, p_abilities, p_hp, p_max_hp,
|
||||
p_defense, p_proficiency, p_inventory, p_statuses, p_persona
|
||||
) returning id into v_character_id;
|
||||
return v_character_id;
|
||||
end;
|
||||
$$;
|
||||
|
||||
create or replace function public.dng_schema_version()
|
||||
returns integer language sql stable security definer set search_path = '' as $$
|
||||
select 7;
|
||||
$$;
|
||||
|
||||
revoke all on function public.stage_four_set_coauthor_question(uuid, uuid, jsonb) from public, anon, authenticated;
|
||||
revoke all on function public.stage_four_create_character(uuid, uuid, text, text, text, jsonb, integer, integer, integer, integer, jsonb, jsonb, jsonb) from public, anon, authenticated;
|
||||
revoke all on function public.dng_schema_version() from public, anon, authenticated;
|
||||
grant execute on function public.stage_four_set_coauthor_question(uuid, uuid, jsonb) to service_role;
|
||||
grant execute on function public.stage_four_create_character(uuid, uuid, text, text, text, jsonb, integer, integer, integer, integer, jsonb, jsonb, jsonb) to service_role;
|
||||
grant execute on function public.dng_schema_version() to service_role;
|
||||
|
||||
notify pgrst, 'reload schema';
|
||||
|
||||
|
||||
do $$
|
||||
begin
|
||||
@@ -2051,6 +2196,12 @@ begin
|
||||
if to_regprocedure('public.dng_schema_version()') is null then
|
||||
raise exception 'D&G bootstrap verification failed: schema version RPC is missing';
|
||||
end if;
|
||||
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() <> 7 then
|
||||
raise exception 'D&G bootstrap verification failed: unexpected schema version';
|
||||
end if;
|
||||
end;
|
||||
$$;
|
||||
|
||||
|
||||
140
supabase/migrations/0007_stage_three_four_completion.sql
Normal file
140
supabase/migrations/0007_stage_three_four_completion.sql
Normal file
@@ -0,0 +1,140 @@
|
||||
-- Complete the universe/character alpha slice: resumable coauthor questions and
|
||||
-- an atomic, server-only character creation path.
|
||||
|
||||
alter table public.coauthor_sessions
|
||||
add column if not exists current_question jsonb;
|
||||
|
||||
alter table public.coauthor_sessions
|
||||
drop constraint if exists coauthor_sessions_current_question_object;
|
||||
alter table public.coauthor_sessions
|
||||
add constraint coauthor_sessions_current_question_object check (
|
||||
current_question is null or jsonb_typeof(current_question) = 'object'
|
||||
);
|
||||
|
||||
create or replace function public.stage_two_append_coauthor_message(
|
||||
p_session_id uuid,
|
||||
p_owner_id uuid,
|
||||
p_content text
|
||||
) returns jsonb language plpgsql security definer set search_path = '' as $$
|
||||
declare
|
||||
v_session public.coauthor_sessions%rowtype;
|
||||
v_result jsonb;
|
||||
begin
|
||||
if p_content is null or char_length(btrim(p_content)) not between 1 and 5000 then
|
||||
raise exception 'message must be between 1 and 5000 characters';
|
||||
end if;
|
||||
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 in ('generating', 'confirmed') then
|
||||
raise exception 'coauthor session cannot be edited in status %', v_session.status;
|
||||
end if;
|
||||
update public.coauthor_sessions
|
||||
set messages = messages || jsonb_build_array(jsonb_build_object('role', 'user', 'content', btrim(p_content))),
|
||||
generated_world = null,
|
||||
current_question = null,
|
||||
status = 'collecting',
|
||||
updated_at = now()
|
||||
where id = p_session_id
|
||||
returning jsonb_build_object('id', id, 'status', status, 'messages', messages, 'updatedAt', updated_at)
|
||||
into v_result;
|
||||
return v_result;
|
||||
end;
|
||||
$$;
|
||||
|
||||
create or replace function public.stage_four_set_coauthor_question(
|
||||
p_session_id uuid,
|
||||
p_owner_id uuid,
|
||||
p_question jsonb
|
||||
) returns jsonb language plpgsql security definer set search_path = '' as $$
|
||||
declare
|
||||
v_result jsonb;
|
||||
begin
|
||||
if p_question is null
|
||||
or jsonb_typeof(p_question) <> 'object'
|
||||
or nullif(btrim(p_question->>'id'), '') is null
|
||||
or char_length(btrim(p_question->>'label')) not between 5 and 300
|
||||
or jsonb_typeof(p_question->'options') <> 'array'
|
||||
or jsonb_array_length(p_question->'options') <> 3 then
|
||||
raise exception 'invalid coauthor question';
|
||||
end if;
|
||||
|
||||
update public.coauthor_sessions
|
||||
set messages = messages || jsonb_build_array(jsonb_build_object(
|
||||
'role', 'assistant', 'content', btrim(p_question->>'label')
|
||||
)),
|
||||
current_question = p_question,
|
||||
updated_at = now()
|
||||
where id = p_session_id and owner_id = p_owner_id and status = 'collecting'
|
||||
returning current_question into v_result;
|
||||
if not found then raise exception 'collecting coauthor session not found'; end if;
|
||||
return v_result;
|
||||
end;
|
||||
$$;
|
||||
|
||||
create or replace function public.stage_four_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
|
||||
) returns uuid language plpgsql security definer set search_path = '' as $$
|
||||
declare
|
||||
v_character_id uuid;
|
||||
v_user_id uuid;
|
||||
begin
|
||||
if p_controller not in ('human', 'ai') then raise exception 'invalid character controller'; end if;
|
||||
if not exists (
|
||||
select 1 from public.campaign_members
|
||||
where campaign_id = p_campaign_id and user_id = p_actor_id and active
|
||||
) then raise exception 'active campaign membership is required'; end if;
|
||||
|
||||
if p_controller = 'ai' then
|
||||
if not exists (
|
||||
select 1 from public.campaigns where id = p_campaign_id and owner_id = p_actor_id
|
||||
) then raise exception 'only the campaign owner can add AI heroes'; end if;
|
||||
v_user_id := null;
|
||||
else
|
||||
v_user_id := p_actor_id;
|
||||
perform pg_catalog.pg_advisory_xact_lock(
|
||||
pg_catalog.hashtextextended(p_campaign_id::text || ':' || p_actor_id::text, 0)
|
||||
);
|
||||
if exists (
|
||||
select 1 from public.characters
|
||||
where campaign_id = p_campaign_id and user_id = p_actor_id
|
||||
) then raise exception 'this player already has a character'; end if;
|
||||
end if;
|
||||
|
||||
insert into public.characters(
|
||||
campaign_id, user_id, name, concept, controller, abilities, hp, max_hp,
|
||||
defense, proficiency, inventory, statuses, persona
|
||||
) values (
|
||||
p_campaign_id, v_user_id, btrim(p_name), btrim(p_concept),
|
||||
p_controller::public.character_controller, p_abilities, p_hp, p_max_hp,
|
||||
p_defense, p_proficiency, p_inventory, p_statuses, p_persona
|
||||
) returning id into v_character_id;
|
||||
return v_character_id;
|
||||
end;
|
||||
$$;
|
||||
|
||||
create or replace function public.dng_schema_version()
|
||||
returns integer language sql stable security definer set search_path = '' as $$
|
||||
select 7;
|
||||
$$;
|
||||
|
||||
revoke all on function public.stage_four_set_coauthor_question(uuid, uuid, jsonb) from public, anon, authenticated;
|
||||
revoke all on function public.stage_four_create_character(uuid, uuid, text, text, text, jsonb, integer, integer, integer, integer, jsonb, jsonb, jsonb) from public, anon, authenticated;
|
||||
revoke all on function public.dng_schema_version() from public, anon, authenticated;
|
||||
grant execute on function public.stage_four_set_coauthor_question(uuid, uuid, jsonb) to service_role;
|
||||
grant execute on function public.stage_four_create_character(uuid, uuid, text, text, text, jsonb, integer, integer, integer, integer, jsonb, jsonb, jsonb) 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