Compare commits
9 Commits
56bddafbe8
...
agent/code
| Author | SHA1 | Date | |
|---|---|---|---|
| 4e0c38c925 | |||
| ace1e47a6d | |||
| 0ade57b5a1 | |||
| 56b4586d1a | |||
| f6c0fbcfeb | |||
| 0b53ad0701 | |||
| 719cc738db | |||
| e430b4e773 | |||
| 9dd856ff8c |
3
PLAN.md
3
PLAN.md
@@ -27,12 +27,13 @@ Dungeons & Ground — англоязычная веб‑платформа дл
|
||||
- стартовая локация;
|
||||
- 3 значимых NPC;
|
||||
- 2 фракции;
|
||||
- 3 постоянных AI‑героя, созданных специально для этой вселенной;
|
||||
- сюжетная завязка;
|
||||
- скрытая угроза или цель;
|
||||
- начальная сцена.
|
||||
5. Владелец подтверждает мир, создаёт кампанию и приглашает игроков ссылкой.
|
||||
6. Каждый игрок создаёт персонажа вручную или с помощью AI.
|
||||
7. Владелец добавляет постоянных AI‑героев и определяет, чьи персонажи могут временно переходить под управление AI.
|
||||
7. Созданные вместе со вселенной постоянные AI‑герои автоматически входят в стартовую партию; владелец может добавлять других и определяет, чьи персонажи могут временно переходить под управление AI.
|
||||
8. Игроки отправляют действия в текущем раунде.
|
||||
9. AI‑DM обрабатывает раунд, когда:
|
||||
- ответили все активные реальные игроки; или
|
||||
|
||||
@@ -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. During world creation, the coauthor also creates three editable, universe-specific AI companions. A new campaign starts immediately with those companions and an owner-controlled hero. Additional players create a human character manually or ask the coauthor for an editable draft; owners can add more AI companions the same way.
|
||||
5. The owner creates an expiring private invite link. Signed-in email users join through `/join/:token`.
|
||||
6. Players save actions and mark them ready. The final ready action queues the round automatically; the owner can also continue without waiting.
|
||||
7. The worker resolves server-owned rolls and state, publishes narration, and opens the next round. Visible campaign tabs synchronize every two seconds and immediately when the tab regains focus.
|
||||
|
||||
@@ -1,8 +1,16 @@
|
||||
<script setup lang="ts">
|
||||
import type { WorldStarter } from '@dng/shared'
|
||||
import type { CharacterDraft, WorldStarter } from '@dng/shared'
|
||||
|
||||
const draft = defineModel<WorldStarter>({ required: true })
|
||||
defineEmits<{ revise: []; confirm: [] }>()
|
||||
|
||||
function updateInventory(companion: CharacterDraft, event: Event) {
|
||||
companion.inventory = (event.target as HTMLInputElement).value
|
||||
.split(',')
|
||||
.map(item => item.trim())
|
||||
.filter(Boolean)
|
||||
.slice(0, 12)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -21,6 +29,7 @@ defineEmits<{ revise: []; confirm: [] }>()
|
||||
<section class="wide private-truth"><label>PRIVATE GM TRUTH</label><textarea v-model="draft.hiddenThreat" rows="4" /><small>This hidden threat or goal is visible only to the owner and the Groundkeeper.</small></section>
|
||||
<section class="wide"><label>KEY PEOPLE</label><div class="entities"><article v-for="npc in draft.npcs" :key="npc.id"><small>NPC</small><input v-model="npc.name"><textarea v-model="npc.summary" rows="3" /></article></div></section>
|
||||
<section class="wide"><label>FACTIONS</label><div class="entities two"><article v-for="faction in draft.factions" :key="faction.id"><small>FACTION</small><input v-model="faction.name"><textarea v-model="faction.summary" rows="3" /></article></div></section>
|
||||
<section class="wide"><label>AI COMPANIONS</label><p class="section-note">These universe-specific heroes join the campaign automatically.</p><div class="entities companion-grid"><article v-for="(companion, index) in draft.companions" :key="index" class="companion-card"><small>AI HERO</small><input v-model="companion.name" maxlength="80" aria-label="Companion name"><textarea v-model="companion.concept" maxlength="600" rows="4" aria-label="Companion concept" /><div class="companion-stats"><label>HP<input v-model.number="companion.hp" type="number" min="1" :max="companion.maxHp"></label><label>MAX<input v-model.number="companion.maxHp" type="number" min="1" max="100"></label><label>AC<input v-model.number="companion.defense" type="number" min="1" max="40"></label><label>PROF<input v-model.number="companion.proficiency" type="number" min="1" max="10"></label><label v-for="ability in (['str','dex','con','int','wis','cha'] as const)" :key="ability">{{ ability }}<input v-model.number="companion.abilities[ability]" type="number" min="1" max="30"></label></div><label class="inventory-label">INVENTORY<input :value="companion.inventory.join(', ')" maxlength="800" @input="updateInventory(companion, $event)"></label><details><summary>VOICE & PERSONALITY</summary><input v-model="companion.persona.voice" maxlength="240" placeholder="Voice"><input v-model="companion.persona.motivation" maxlength="300" placeholder="Motivation"><input v-model="companion.persona.flaw" maxlength="300" placeholder="Flaw"><input v-model="companion.persona.bond" maxlength="300" placeholder="Bond"></details></article></div></section>
|
||||
<section><label>OPENING SCENE</label><textarea v-model="draft.openingScene" rows="6" /></section>
|
||||
<section><label>CONTENT BOUNDARIES</label><div v-for="(_, index) in draft.contentBoundaries" :key="index" class="boundary"><input v-model="draft.contentBoundaries[index]"><button type="button" aria-label="Remove boundary" @click="draft.contentBoundaries.splice(index, 1)">×</button></div><button class="add" type="button" @click="draft.contentBoundaries.push('')">+ ADD BOUNDARY</button></section>
|
||||
</div>
|
||||
@@ -29,6 +38,6 @@ defineEmits<{ revise: []; confirm: [] }>()
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.preview{max-width:1300px;width:100%;padding:clamp(40px,5vw,76px)}header{display:flex;justify-content:space-between;align-items:end;gap:20px}.kicker,label{font:600 8px var(--mono);letter-spacing:.15em;color:var(--acid)}header>div{flex:1}header input{border:0;border-bottom:1px solid var(--line);font:600 clamp(30px,4vw,54px) var(--display);letter-spacing:-.05em;padding:12px 0;background:transparent}.revise,.add{background:transparent;color:var(--muted);border:1px solid var(--line);padding:12px;font:500 8px var(--mono)}.edit-note{color:var(--muted);font-size:11px}.grid{display:grid;grid-template-columns:1fr 1fr;border-top:1px solid var(--line);border-left:1px solid var(--line);margin-top:28px}.grid>section{padding:26px;border-right:1px solid var(--line);border-bottom:1px solid var(--line)}.wide{grid-column:1/-1}input,textarea{box-sizing:border-box;width:100%;margin-top:12px;padding:13px;resize:vertical;background:#10100f;border:1px solid var(--line);color:var(--ink);font:12px/1.55 var(--body)}.entities{display:grid;grid-template-columns:repeat(3,1fr);gap:12px;margin-top:18px}.entities.two{grid-template-columns:repeat(2,1fr)}.entities article{padding:16px;border:1px solid var(--line);background:#0c0c0b}.entities small{font:500 7px var(--mono);color:var(--acid)}.boundary{display:flex}.boundary input{margin-top:8px}.boundary button{margin-top:8px;width:42px;border:1px solid var(--line);background:#151513;color:var(--muted)}.add{margin-top:12px}footer{position:sticky;bottom:0;display:flex;justify-content:space-between;align-items:center;padding:18px 22px;background:#11110f;border:1px solid var(--line);margin-top:24px}footer p{margin:0;display:flex;flex-direction:column;font:600 8px var(--mono);color:var(--acid)}footer p span{margin-top:5px;color:var(--muted);font-weight:400}footer>button{min-height:48px;border:0;background:var(--acid);color:#080808;padding:0 24px;font:600 9px var(--mono)}@media(max-width:760px){.preview{padding:36px 18px}header{align-items:stretch;flex-direction:column}.grid{grid-template-columns:1fr}.wide{grid-column:auto}.entities,.entities.two{grid-template-columns:1fr}footer{align-items:stretch;flex-direction:column;gap:14px}}
|
||||
.preview{max-width:1300px;width:100%;padding:clamp(40px,5vw,76px)}header{display:flex;justify-content:space-between;align-items:end;gap:20px}.kicker,label{font:600 8px var(--mono);letter-spacing:.15em;color:var(--acid)}header>div{flex:1}header input{border:0;border-bottom:1px solid var(--line);font:600 clamp(30px,4vw,54px) var(--display);letter-spacing:-.05em;padding:12px 0;background:transparent}.revise,.add{background:transparent;color:var(--muted);border:1px solid var(--line);padding:12px;font:500 8px var(--mono)}.edit-note,.section-note{color:var(--muted);font-size:11px}.section-note{margin:9px 0 0}.grid{display:grid;grid-template-columns:1fr 1fr;border-top:1px solid var(--line);border-left:1px solid var(--line);margin-top:28px}.grid>section{padding:26px;border-right:1px solid var(--line);border-bottom:1px solid var(--line)}.wide{grid-column:1/-1}input,textarea{box-sizing:border-box;width:100%;margin-top:12px;padding:13px;resize:vertical;background:#10100f;border:1px solid var(--line);color:var(--ink);font:12px/1.55 var(--body)}.entities{display:grid;grid-template-columns:repeat(3,1fr);gap:12px;margin-top:18px}.entities.two{grid-template-columns:repeat(2,1fr)}.entities article{padding:16px;border:1px solid var(--line);background:#0c0c0b}.entities small{font:500 7px var(--mono);color:var(--acid)}.companion-stats{display:grid;grid-template-columns:repeat(5,1fr);gap:5px;margin-top:12px}.companion-stats label{color:var(--muted);font-size:6px}.companion-stats input{margin-top:5px;padding:7px;text-align:center}.inventory-label{display:block;margin-top:12px;color:var(--muted)}.inventory-label input{margin-top:6px}.companion-card details{margin-top:12px;border-top:1px solid var(--line);padding-top:10px}.companion-card summary{cursor:pointer;color:var(--muted);font:600 7px var(--mono);letter-spacing:.1em}.companion-card details input{margin-top:7px}.boundary{display:flex}.boundary input{margin-top:8px}.boundary button{margin-top:8px;width:42px;border:1px solid var(--line);background:#151513;color:var(--muted)}.add{margin-top:12px}footer{position:sticky;bottom:0;display:flex;justify-content:space-between;align-items:center;padding:18px 22px;background:#11110f;border:1px solid var(--line);margin-top:24px}footer p{margin:0;display:flex;flex-direction:column;font:600 8px var(--mono);color:var(--acid)}footer p span{margin-top:5px;color:var(--muted);font-weight:400}footer>button{min-height:48px;border:0;background:var(--acid);color:#080808;padding:0 24px;font:600 9px var(--mono)}@media(max-width:760px){.preview{padding:36px 18px}header{align-items:stretch;flex-direction:column}.grid{grid-template-columns:1fr}.wide{grid-column:auto}.entities,.entities.two{grid-template-columns:1fr}.companion-stats{grid-template-columns:repeat(2,1fr)}footer{align-items:stretch;flex-direction:column;gap:14px}}
|
||||
.private-truth{background:linear-gradient(90deg,rgba(207,255,70,.035),transparent)}.private-truth small{display:block;margin-top:9px;color:var(--muted);font-size:9px}
|
||||
</style>
|
||||
|
||||
@@ -16,6 +16,11 @@ const defaultWorld: WorldStarter = {
|
||||
{ id: 'fac_heliograph', kind: 'faction', name: 'Heliograph Compact', summary: 'A mercantile coalition that owns the relay network.', tags: ['corporate'], secrets: [] },
|
||||
{ id: 'fac_quiet', kind: 'faction', name: 'The Quiet Choir', summary: 'Pilgrims who believe signals can remember the dead.', tags: ['mystic'], secrets: [] },
|
||||
],
|
||||
companions: [
|
||||
{ name: 'Rook-7', concept: 'A security intelligence in a weathered rescue frame.', abilities: { str: 16, dex: 11, con: 15, int: 10, wis: 12, cha: 8 }, hp: 15, maxHp: 15, defense: 15, proficiency: 2, inventory: ['Arc baton', 'Emergency beacon'], persona: { voice: 'Direct and protective.', motivation: 'Keep the crew alive long enough to learn the truth.', flaw: 'Treats uncertainty as a threat.', bond: 'The Orison crew gave Rook a second purpose.' } },
|
||||
{ name: 'Sable Thread', concept: 'A probabilistic navigator who reads possible futures as tangled routes.', abilities: { str: 8, dex: 14, con: 10, int: 16, wis: 13, cha: 11 }, hp: 10, maxHp: 10, defense: 13, proficiency: 2, inventory: ['Route prism', 'Vacuum line'], persona: { voice: 'Careful, elliptical, and precise.', motivation: 'Find the future in which everyone returns.', flaw: 'Can hesitate when too many paths look viable.', bond: 'Trusts the party to choose what prediction cannot.' } },
|
||||
{ name: 'Morrow Coil', concept: 'A former relay technician rebuilt around experimental signal hardware.', abilities: { str: 11, dex: 12, con: 13, int: 14, wis: 10, cha: 13 }, hp: 12, maxHp: 12, defense: 12, proficiency: 2, inventory: ['Signal probe', 'Insulated field coat'], persona: { voice: 'Warm humor under technical jargon.', motivation: 'Prove the relay can be understood instead of destroyed.', flaw: 'Cannot leave a broken machine alone.', bond: 'Believes this party is the last honest crew in the sector.' } },
|
||||
],
|
||||
hook: 'Dock with the silent relay, locate the source of the impossible distress call, and decide whether its warning should be believed.',
|
||||
hiddenThreat: 'A causality fracture is teaching the relay to choose which future becomes real.',
|
||||
openingScene: 'The Orison drifts beneath the relay’s vast black vanes. Every console shows the same countdown: 00:17:42. Then your own voice breaks through the static: “Do not open the central archive.”',
|
||||
@@ -36,7 +41,7 @@ export function useDemo() {
|
||||
const history = useState<Array<{ type: 'narration' | 'action' | 'roll'; author: string; body: string; meta?: string }>>('history', () => [
|
||||
{ type: 'narration', author: 'GROUNDKEEPER', body: defaultWorld.openingScene, meta: 'ROUND 03 · NOW' },
|
||||
{ type: 'action', author: 'MARA VALE', body: 'I ask the Orison to isolate the transmission and compare the voiceprint to mine.', meta: 'READY' },
|
||||
{ type: 'roll', author: 'SYSTEM CHECK', body: 'Intelligence check · 1d20 + 3 = 17', meta: 'SUCCESS · DC 14' },
|
||||
{ type: 'roll', author: 'SYSTEM CHECK', body: 'Intelligence check · 1d20 + 1 = 15', meta: 'SUCCESS · DC 14' },
|
||||
])
|
||||
const currentAction = useState('current-action', () => '')
|
||||
const ready = useState('ready', () => false)
|
||||
|
||||
@@ -74,7 +74,7 @@ const timeline = computed(() => {
|
||||
})),
|
||||
...payload.value.diceRolls.map(roll => ({
|
||||
id: `roll-${roll.id}`, type: 'roll', at: roll.created_at ?? '', label: 'SERVER ROLL',
|
||||
meta: roll.success === null || roll.success === undefined ? roll.check_kind : roll.success ? 'SUCCESS' : 'FAILED',
|
||||
meta: rollMeta(roll),
|
||||
body: `${roll.formula} · [${(roll.rolls ?? []).join(', ')}] ${signed(Number(roll.modifier ?? 0))} = ${roll.total}`,
|
||||
})),
|
||||
...payload.value.events.map(event => ({
|
||||
@@ -89,6 +89,29 @@ function signed(value: number) {
|
||||
return value >= 0 ? `+ ${value}` : `− ${Math.abs(value)}`
|
||||
}
|
||||
|
||||
function abilityModifier(score: number) {
|
||||
return Math.floor((Number(score) - 10) / 2)
|
||||
}
|
||||
|
||||
function abilityLabel(score: number) {
|
||||
const modifier = abilityModifier(score)
|
||||
return modifier >= 0 ? `+${modifier}` : String(modifier)
|
||||
}
|
||||
|
||||
function rollMeta(roll: Record<string, any>) {
|
||||
if (roll.success === null || roll.success === undefined) return String(roll.check_kind ?? 'ROLL').toUpperCase()
|
||||
const kept = Number(roll.kept?.[0])
|
||||
const outcome = roll.check_kind === 'attack' && kept === 20
|
||||
? 'CRITICAL HIT'
|
||||
: roll.check_kind === 'attack' && kept === 1
|
||||
? 'CRITICAL MISS'
|
||||
: roll.success ? 'SUCCESS' : 'FAILED'
|
||||
const target = roll.difficulty === null || roll.difficulty === undefined
|
||||
? ''
|
||||
: ` · ${roll.check_kind === 'attack' ? 'AC' : 'DC'} ${roll.difficulty}`
|
||||
return `${outcome}${target}`
|
||||
}
|
||||
|
||||
function initials(name: string) {
|
||||
return name.split(/\s+/).map(part => part[0]).join('').slice(0, 2).toUpperCase()
|
||||
}
|
||||
@@ -274,7 +297,7 @@ onBeforeUnmount(() => {
|
||||
<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>
|
||||
<div class="stat-editor"><label>HP <input v-model.number="characterForm.hp" type="number" min="1" :max="characterForm.maxHp"></label><label>MAX <input v-model.number="characterForm.maxHp" type="number" min="1"></label><label>AC <input v-model.number="characterForm.defense" type="number" min="1" max="40"></label></div>
|
||||
<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>
|
||||
@@ -285,8 +308,8 @@ onBeforeUnmount(() => {
|
||||
</article>
|
||||
<section v-if="myCharacter" class="sheet">
|
||||
<small>YOUR CHARACTER</small><h2>{{ myCharacter.name }}</h2><p>{{ myCharacter.concept }}</p>
|
||||
<div class="vitals"><span><b>{{ myCharacter.hp }}</b>/{{ myCharacter.max_hp }} HP</span><span><b>{{ myCharacter.defense }}</b> DEF</span></div>
|
||||
<div class="abilities"><span v-for="(score,key) in myCharacter.abilities" :key="key"><small>{{ key }}</small><b>{{ score }}</b></span></div>
|
||||
<div class="vitals"><span><b>{{ myCharacter.hp }}</b>/{{ myCharacter.max_hp }} HP</span><span><b>{{ myCharacter.defense }}</b> AC</span></div>
|
||||
<div class="abilities"><span v-for="(score,key) in myCharacter.abilities" :key="key"><small>{{ key }} {{ abilityLabel(Number(score)) }}</small><b>{{ score }}</b></span></div>
|
||||
</section>
|
||||
</aside>
|
||||
|
||||
@@ -320,7 +343,7 @@ onBeforeUnmount(() => {
|
||||
<section class="ai-order"><small>AI TURN ORDER</small><p v-if="!payload.characters.some(character=>character.controller!=='human')">No AI heroes in this party.</p><div v-for="(character,index) in payload.characters.filter(character=>character.controller!=='human')" :key="character.id"><b>{{ String(index+1).padStart(2,'0') }}</b><span>{{ character.name }}<small>{{ character.controller === 'delegated' ? 'Temporary stand-in' : 'Acts after all humans' }}</small></span></div></section>
|
||||
<button v-if="isOwner && currentRound?.status==='open'" class="force-button" :disabled="mutating" @click="forceRound">CONTINUE WITHOUT WAITING <span>↗</span></button>
|
||||
<button v-if="isOwner && currentRound?.status==='failed'" class="force-button" :disabled="mutating" @click="retryFailedRound">RETRY FAILED ROUND <span>↗</span></button>
|
||||
<div class="mechanics-note"><i /> Dice, HP and mechanical state are resolved by the server and recorded in the campaign log.</div>
|
||||
<div class="mechanics-note"><i /> D20 tests, AC, HP and mechanical state are resolved by the server and recorded in the campaign log.</div>
|
||||
</aside>
|
||||
</div>
|
||||
</AppShell>
|
||||
|
||||
@@ -5,6 +5,11 @@ const forceOpen = ref(false)
|
||||
const errorMessage = ref('')
|
||||
const submittedActionIndex = ref<number | null>(null)
|
||||
|
||||
function abilityLabel(score: number) {
|
||||
const modifier = Math.floor((Number(score) - 10) / 2)
|
||||
return modifier >= 0 ? `+${modifier}` : String(modifier)
|
||||
}
|
||||
|
||||
async function submitAction() {
|
||||
if (!currentAction.value.trim() || submitting.value || ready.value) return
|
||||
submitting.value = true
|
||||
@@ -67,8 +72,8 @@ async function continueRound() {
|
||||
</article>
|
||||
<div class="character-sheet">
|
||||
<small>ACTIVE CHARACTER</small><h2>{{ characters[0]?.name }}</h2><p>{{ characters[0]?.concept }}</p>
|
||||
<div class="vitals"><span><b>{{ characters[0]?.hp }}</b>/{{ characters[0]?.maxHp }} HP</span><span><b>{{ characters[0]?.defense }}</b> DEF</span></div>
|
||||
<div class="abilities"><span v-for="(score,key) in characters[0]?.abilities" :key="key"><small>{{ key }}</small><b>{{ score }}</b></span></div>
|
||||
<div class="vitals"><span><b>{{ characters[0]?.hp }}</b>/{{ characters[0]?.maxHp }} HP</span><span><b>{{ characters[0]?.defense }}</b> AC</span></div>
|
||||
<div class="abilities"><span v-for="(score,key) in characters[0]?.abilities" :key="key"><small>{{ key }} {{ abilityLabel(score) }}</small><b>{{ score }}</b></span></div>
|
||||
<details><summary>INVENTORY</summary><ul><li v-for="item in characters[0]?.inventory" :key="item">{{ item }}</li></ul></details>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -34,7 +34,7 @@ export default defineEventHandler(async (event) => {
|
||||
if (!world) throw createError({ statusCode: 404, statusMessage: 'Campaign world not found.' })
|
||||
|
||||
const completion = buildJsonCompletion(useRuntimeConfig(), {
|
||||
system: `You create an original, editable Dungeons & Ground character draft for a private 13+ TTRPG campaign. Adapt archetype, equipment and voice to the supplied genre, but always use STR, DEX, CON, INT, WIS and CHA. Keep an alpha hero grounded: ability scores 7–16, max HP 8–20, Defense 10–16, proficiency 2. Give 2–5 useful starting items and a distinctive persona. Never use protected franchise characters. Return only the requested JSON.`,
|
||||
system: `You create an original, editable Dungeons & Ground character draft for a private 13+ TTRPG campaign. Adapt archetype, equipment and voice to the supplied genre, but always use STR, DEX, CON, INT, WIS and CHA. Keep an alpha hero grounded: ability scores 7–16, max HP 8–20, Armor Class (AC) 10–16, proficiency 2. Give 2–5 useful starting items and a distinctive persona. Never use protected franchise characters. Return only the requested JSON.`,
|
||||
messages: [{
|
||||
role: 'user',
|
||||
content: JSON.stringify({
|
||||
|
||||
@@ -15,7 +15,7 @@ export default defineEventHandler(async event => {
|
||||
let completion
|
||||
try {
|
||||
completion = buildJsonCompletion(config, {
|
||||
system: 'Create an original 13+ TTRPG starting world. Return only valid JSON matching the supplied WorldStarter structure, with exactly 3 NPCs and 2 factions. Do not use protected franchises.',
|
||||
system: 'Create an original 13+ TTRPG starting world. Return only valid JSON matching the supplied WorldStarter structure, with exactly 3 NPCs, 2 factions, and 3 distinct persistent AI companion heroes designed specifically for this universe. Give every companion complete playable mechanics, useful genre-appropriate equipment, and a distinctive persona. Do not use protected franchises.',
|
||||
messages: request.messages,
|
||||
schemaName: 'world_starter',
|
||||
jsonSchema: WorldStarterSchema.toJSONSchema(),
|
||||
|
||||
@@ -8,6 +8,17 @@ const worldFixture = () => ({
|
||||
startingLocation: { id: 'location', kind: 'location' as const, name: 'Gate', summary: 'A station beyond known space.', tags: [] as string[], secrets: [] as string[] },
|
||||
npcs: [1, 2, 3].map(index => ({ id: `npc-${index}`, kind: 'npc' as const, name: `NPC ${index}`, summary: 'A useful person with their own agenda.', tags: [], secrets: [] })),
|
||||
factions: [1, 2].map(index => ({ id: `faction-${index}`, kind: 'faction' as const, name: `Faction ${index}`, summary: 'An organization pursuing a hidden objective.', tags: [], secrets: [] })),
|
||||
companions: [1, 2, 3].map(index => ({
|
||||
name: `Companion ${index}`,
|
||||
concept: `A universe-specific companion with role ${index}.`,
|
||||
abilities: { str: 10, dex: 12, con: 11, int: 13, wis: 12, cha: 10 },
|
||||
hp: 11,
|
||||
maxHp: 11,
|
||||
defense: 12,
|
||||
proficiency: 2,
|
||||
inventory: ['Field kit'],
|
||||
persona: { voice: 'Distinctive and clear.', motivation: 'Help the party.', flaw: 'Has a private agenda.', bond: 'Believes in the party.' },
|
||||
})),
|
||||
hook: 'A sufficiently long hook that immediately gives the party something to investigate.',
|
||||
hiddenThreat: 'A hidden threat waits beyond the gate.',
|
||||
openingScene: 'The gate opens without warning, and an impossible signal calls every hero by name.',
|
||||
@@ -34,7 +45,10 @@ describe('worker AI providers', () => {
|
||||
})
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
const { generateWorld } = await import('./ai')
|
||||
await expect(generateWorld([{ role: 'user', content: 'Create a science fiction frontier.' }])).resolves.toMatchObject({ title: 'Test Reach' })
|
||||
await expect(generateWorld([{ role: 'user', content: 'Create a science fiction frontier.' }])).resolves.toMatchObject({
|
||||
title: 'Test Reach',
|
||||
companions: [{ name: 'Companion 1' }, { name: 'Companion 2' }, { name: 'Companion 3' }],
|
||||
})
|
||||
expect(fetchMock).toHaveBeenCalledWith('https://api.deepseek.com/chat/completions', expect.any(Object))
|
||||
})
|
||||
|
||||
|
||||
@@ -89,7 +89,7 @@ async function structuredRequest<T>(name: string, schema: JsonSchema, messages:
|
||||
export async function generateWorld(messages: Array<{ role: 'user' | 'assistant'; content: string }>): Promise<WorldStarter> {
|
||||
assert13Plus(...messages.map(message => message.content))
|
||||
const world = await structuredRequest('world_starter', WorldStarterSchema.toJSONSchema(), [
|
||||
{ role: 'system', content: 'You are the Dungeons & Ground world coauthor. Create an original 13+ private TTRPG setting in any requested genre. Never use protected settings or characters. Return exactly three NPCs and two factions. Make the opening immediately playable.' },
|
||||
{ role: 'system', content: 'You are the Dungeons & Ground world coauthor. Create an original 13+ private TTRPG setting in any requested genre. Never use protected settings or characters. Return exactly three NPCs, two factions, and three distinct persistent AI companion heroes created specifically for this universe. Give each companion complete playable mechanics, useful genre-appropriate equipment, and a persona that produces interesting party choices without overriding the human player. Make the opening immediately playable.' },
|
||||
...messages,
|
||||
], value => WorldStarterSchema.parse(value))
|
||||
// Moderate the entire typed object, including entity summaries, secrets and
|
||||
@@ -100,7 +100,7 @@ export async function generateWorld(messages: Array<{ role: 'user' | 'assistant'
|
||||
|
||||
export async function planRound(input: RoundContext): Promise<RoundPlan> {
|
||||
const plan = await structuredRequest('round_plan', RoundPlanSchema.toJSONSchema(), [
|
||||
{ role: 'system', content: 'You plan one asynchronous TTRPG round. Human intents are authoritative and happen first. Then provide at most one action for each supplied aiCharacter, in the supplied order; never create AI actions for human-controlled characters. You may request checks and propose story events. Never invent dice results and never directly mutate mechanical state. The server owns rules, HP, inventory, statuses and randomness.' },
|
||||
{ role: 'system', content: 'You plan one asynchronous SRD 5.2.1 TTRPG round. Human intents are authoritative and happen first. Then provide at most one action for each supplied aiCharacter, in the supplied order; never create AI actions for human-controlled characters. Request a d20 test only when the outcome is uncertain: ability, skill, savingThrow, attack, or initiative. Skill checks require a skill and DC; saving throws require an ability and DC; attacks require an active targetId and use that target\'s server-owned Armor Class. Set proficient true for a skill or saving throw only when the character concept supports it; ordinary equipped-weapon attacks default to proficient. Request separate damage or healing dice only after an applicable action. Never invent dice results and never directly mutate mechanical state. The server owns rules, AC, HP, inventory, statuses and randomness.' },
|
||||
{ role: 'user', content: JSON.stringify(input) },
|
||||
], value => RoundPlanSchema.parse(value))
|
||||
assert13Plus(JSON.stringify(plan.aiActions), JSON.stringify(plan.proposedEvents))
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import './env'
|
||||
import { Worker } from 'bullmq'
|
||||
import IORedis from 'ioredis'
|
||||
import { applyMechanicalEvents, bindMechanicalEventsToRolls, resolveCheck } from '@dng/game-engine'
|
||||
import { applyMechanicalEvents, bindMechanicalEventsToRolls, resolveChecks } from '@dng/game-engine'
|
||||
import { CharacterSchema, PlayerIntentSchema, makeId } from '@dng/shared'
|
||||
import { generateWorld, narrateRound, planRound, summarizeStory } from './ai'
|
||||
import { buildActionSequence, buildRoundContext, sanitizeRoundMemory, shouldUpdateStorySummary, validateAndOrderRoundPlan } from './orchestration'
|
||||
@@ -153,11 +153,7 @@ if (!supabaseUrl || !serviceKey) {
|
||||
})),
|
||||
})
|
||||
const plan = validateAndOrderRoundPlan(await planRound(context), context)
|
||||
const rolls = plan.checks.map(check => {
|
||||
const actor = characters.find(character => character.id === check.actorId)
|
||||
if (!actor) throw new Error(`Unknown check actor ${check.actorId}`)
|
||||
return resolveCheck(check, actor)
|
||||
})
|
||||
const rolls = resolveChecks(plan.checks, characters)
|
||||
const permittedEvents = bindMechanicalEventsToRolls(plan.proposedEvents, rolls)
|
||||
const resolution = await narrateRound({ context, actionSequence: buildActionSequence(context, plan), rolls, permittedEvents })
|
||||
const safeEvents = resolution.events.filter(event => permittedEvents.some(permitted => JSON.stringify(permitted) === JSON.stringify(event)))
|
||||
|
||||
@@ -41,6 +41,21 @@ describe('round orchestration', () => {
|
||||
}, context)).toThrow('Unknown check actor outsider')
|
||||
})
|
||||
|
||||
it('requires complete SRD check inputs before rolling', () => {
|
||||
const context = buildRoundContext({
|
||||
scene: 'Scene', recentRounds: [], intents: [],
|
||||
characters: [character('hero', 'human'), character('foe', 'ai')], memories: [],
|
||||
})
|
||||
const basePlan = { aiActions: [], proposedEvents: [], relevantMemoryQueries: [] }
|
||||
|
||||
expect(() => validateAndOrderRoundPlan({
|
||||
...basePlan, checks: [{ actorId: 'hero', kind: 'skill', mode: 'normal', reason: 'Search' }],
|
||||
}, context)).toThrow('Skill checks require a skill')
|
||||
expect(() => validateAndOrderRoundPlan({
|
||||
...basePlan, checks: [{ actorId: 'hero', kind: 'attack', mode: 'normal', reason: 'Strike' }],
|
||||
}, context)).toThrow('Attack rolls require a target')
|
||||
})
|
||||
|
||||
it('schedules durable story summaries every third completed round', () => {
|
||||
expect([1, 2, 3, 4, 5, 6].filter(shouldUpdateStorySummary)).toEqual([3, 6])
|
||||
})
|
||||
|
||||
@@ -64,6 +64,13 @@ export function validateAndOrderRoundPlan(planInput: unknown, context: RoundCont
|
||||
for (const check of plan.checks) {
|
||||
if (!characterIds.has(check.actorId)) throw new Error(`Unknown check actor ${check.actorId}`)
|
||||
if (check.targetId && !characterIds.has(check.targetId)) throw new Error(`Unknown check target ${check.targetId}`)
|
||||
if (check.kind === 'skill' && !check.skill) throw new Error('Skill checks require a skill')
|
||||
if (['ability', 'savingThrow'].includes(check.kind) && !check.ability) throw new Error(`${check.kind} checks require an ability`)
|
||||
if (['ability', 'skill', 'savingThrow'].includes(check.kind) && check.difficulty === undefined) {
|
||||
throw new Error(`${check.kind} checks require a Difficulty Class`)
|
||||
}
|
||||
if (check.kind === 'attack' && !check.targetId) throw new Error('Attack rolls require a target')
|
||||
if (['damage', 'healing'].includes(check.kind) && !check.targetId) throw new Error(`${check.kind} rolls require a target`)
|
||||
}
|
||||
for (const event of plan.proposedEvents) {
|
||||
if (event.actorId && !characterIds.has(event.actorId)) throw new Error(`Unknown event actor ${event.actorId}`)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { abilityModifier, applyMechanicalEvents, bindMechanicalEventsToRolls, resolveCheck, shouldCloseRound, type RandomSource } from './index'
|
||||
import { abilityModifier, applyMechanicalEvents, bindMechanicalEventsToRolls, resolveCheck, resolveChecks, shouldCloseRound, type RandomSource } from './index'
|
||||
import type { Character } from '@dng/shared'
|
||||
|
||||
const fixed: RandomSource = { integer: () => 12 }
|
||||
@@ -18,7 +18,7 @@ describe('game engine', () => {
|
||||
it('keeps dice server-owned and auditable', () => {
|
||||
const roll = resolveCheck({ actorId: 'hero', kind: 'ability', ability: 'dex', difficulty: 15, mode: 'normal', reason: 'Leap' }, hero, fixed)
|
||||
expect(roll.rolls).toEqual([12])
|
||||
expect(roll.total).toBe(17)
|
||||
expect(roll.total).toBe(15)
|
||||
expect(roll.success).toBe(true)
|
||||
expect(roll.id).toMatch(/^[0-9a-f]{8}-(?:[0-9a-f]{4}-){3}[0-9a-f]{12}$/i)
|
||||
})
|
||||
@@ -30,10 +30,60 @@ describe('game engine', () => {
|
||||
hero,
|
||||
{ integer: () => values.shift()! },
|
||||
)
|
||||
expect(roll.formula).toBe('2d20kl1+5')
|
||||
expect(roll.formula).toBe('2d20kl1+3')
|
||||
expect(roll.kept).toEqual([4])
|
||||
})
|
||||
|
||||
it('applies proficiency only to proficient skills and saving throws', () => {
|
||||
const perception = resolveCheck({
|
||||
actorId: 'hero', kind: 'skill', skill: 'perception', proficient: true,
|
||||
difficulty: 12, mode: 'normal', reason: 'Spot an ambush',
|
||||
}, hero, fixed)
|
||||
const constitutionSave = resolveCheck({
|
||||
actorId: 'hero', kind: 'savingThrow', ability: 'con', proficient: false,
|
||||
difficulty: 12, mode: 'normal', reason: 'Endure poison',
|
||||
}, hero, fixed)
|
||||
|
||||
expect(perception.modifier).toBe(1)
|
||||
expect(constitutionSave.modifier).toBe(1)
|
||||
})
|
||||
|
||||
it('uses server-owned Armor Class and natural attack outcomes', () => {
|
||||
const target: Character = { ...hero, id: 'target', defense: 30 }
|
||||
const naturalTwenty = resolveCheck({
|
||||
actorId: 'hero', targetId: 'target', kind: 'attack', ability: 'str',
|
||||
difficulty: 1, mode: 'normal', reason: 'Sword strike',
|
||||
}, hero, { integer: () => 20 }, target)
|
||||
const naturalOne = resolveCheck({
|
||||
actorId: 'hero', targetId: 'target', kind: 'attack', ability: 'str',
|
||||
difficulty: 1, mode: 'normal', reason: 'Sword strike',
|
||||
}, hero, { integer: () => 1 }, { ...target, defense: 1 })
|
||||
|
||||
expect(naturalTwenty.difficulty).toBe(30)
|
||||
expect(naturalTwenty.success).toBe(true)
|
||||
expect(naturalOne.difficulty).toBe(1)
|
||||
expect(naturalOne.success).toBe(false)
|
||||
})
|
||||
|
||||
it('skips damage on a miss and doubles only critical damage dice', () => {
|
||||
const target: Character = { ...hero, id: 'target', defense: 14 }
|
||||
const checks = [
|
||||
{ actorId: 'hero', targetId: 'target', kind: 'attack' as const, ability: 'str' as const, mode: 'normal' as const, reason: 'Sword strike' },
|
||||
{ actorId: 'hero', targetId: 'target', kind: 'damage' as const, dice: '1d6+2', mode: 'normal' as const, reason: 'Sword damage' },
|
||||
]
|
||||
|
||||
const missed = resolveChecks(checks, [hero, target], { integer: () => 1 })
|
||||
expect(missed).toHaveLength(1)
|
||||
expect(missed[0]?.checkKind).toBe('attack')
|
||||
|
||||
const values = [20, 4, 5]
|
||||
const critical = resolveChecks(checks, [hero, target], { integer: () => values.shift()! })
|
||||
expect(critical).toHaveLength(2)
|
||||
expect(critical[1]?.formula).toBe('2d6+2')
|
||||
expect(critical[1]?.rolls).toEqual([4, 5])
|
||||
expect(critical[1]?.total).toBe(11)
|
||||
})
|
||||
|
||||
it('replaces model-proposed damage with the authoritative roll total', () => {
|
||||
const damage = resolveCheck(
|
||||
{ actorId: 'hero', targetId: 'target', kind: 'damage', dice: '1d6+2', mode: 'normal', reason: 'Strike' },
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { randomInt, randomUUID } from 'node:crypto'
|
||||
import type { AbilityKey, Character, CheckRequest, DiceRoll, ProposedEvent } from '@dng/shared'
|
||||
import type { AbilityKey, Character, CheckRequest, DiceRoll, ProposedEvent, SkillKey } from '@dng/shared'
|
||||
|
||||
export interface RandomSource {
|
||||
integer(min: number, max: number): number
|
||||
@@ -51,13 +51,47 @@ export function bindMechanicalEventsToRolls(events: ProposedEvent[], rolls: Dice
|
||||
})
|
||||
}
|
||||
|
||||
const skillAbilities: Record<SkillKey, AbilityKey> = {
|
||||
acrobatics: 'dex',
|
||||
animalHandling: 'wis',
|
||||
arcana: 'int',
|
||||
athletics: 'str',
|
||||
deception: 'cha',
|
||||
history: 'int',
|
||||
insight: 'wis',
|
||||
intimidation: 'cha',
|
||||
investigation: 'int',
|
||||
medicine: 'wis',
|
||||
nature: 'int',
|
||||
perception: 'wis',
|
||||
performance: 'cha',
|
||||
persuasion: 'cha',
|
||||
religion: 'int',
|
||||
sleightOfHand: 'dex',
|
||||
stealth: 'dex',
|
||||
survival: 'wis',
|
||||
}
|
||||
|
||||
function abilityForCheck(request: CheckRequest): AbilityKey {
|
||||
if (request.kind === 'skill' && request.skill) return skillAbilities[request.skill]
|
||||
if (request.ability) return request.ability
|
||||
if (request.kind === 'initiative') return 'dex'
|
||||
return 'str'
|
||||
}
|
||||
|
||||
export function resolveCheck(request: CheckRequest, actor: Character, random: RandomSource = secureRandom): DiceRoll {
|
||||
function usesProficiency(request: CheckRequest): boolean {
|
||||
// The alpha treats ordinary weapon attacks as proficient unless the planner
|
||||
// explicitly marks an improvised or unfamiliar attack otherwise.
|
||||
if (request.kind === 'attack') return request.proficient !== false
|
||||
return request.proficient === true
|
||||
}
|
||||
|
||||
export function resolveCheck(
|
||||
request: CheckRequest,
|
||||
actor: Character,
|
||||
random: RandomSource = secureRandom,
|
||||
target?: Character,
|
||||
): DiceRoll {
|
||||
if (request.kind === 'damage' || request.kind === 'healing') {
|
||||
const rolled = rollDice(request.dice ?? '1d6', random)
|
||||
return {
|
||||
@@ -82,9 +116,15 @@ export function resolveCheck(request: CheckRequest, actor: Character, random: Ra
|
||||
const diceCount = request.mode === 'normal' ? 1 : 2
|
||||
const rolls = Array.from({ length: diceCount }, () => random.integer(1, 20))
|
||||
const keptValue = request.mode === 'advantage' ? Math.max(...rolls) : request.mode === 'disadvantage' ? Math.min(...rolls) : rolls[0]!
|
||||
const modifier = abilityModifier(actor.abilities[ability]) + actor.proficiency
|
||||
const modifier = abilityModifier(actor.abilities[ability]) + (usesProficiency(request) ? actor.proficiency : 0)
|
||||
const total = keptValue + modifier
|
||||
const difficulty = request.difficulty ?? null
|
||||
if (request.kind === 'attack' && (!request.targetId || !target || target.id !== request.targetId)) {
|
||||
throw new Error('Attack rolls require the active target character')
|
||||
}
|
||||
const difficulty = request.kind === 'attack' ? target!.defense : request.difficulty ?? null
|
||||
const automaticAttackOutcome = request.kind === 'attack'
|
||||
? keptValue === 20 ? true : keptValue === 1 ? false : null
|
||||
: null
|
||||
return {
|
||||
id: randomUUID(),
|
||||
checkKind: request.kind,
|
||||
@@ -96,13 +136,58 @@ export function resolveCheck(request: CheckRequest, actor: Character, random: Ra
|
||||
modifier,
|
||||
total,
|
||||
difficulty,
|
||||
success: difficulty === null ? null : total >= difficulty,
|
||||
success: difficulty === null ? null : automaticAttackOutcome ?? total >= difficulty,
|
||||
actorId: actor.id,
|
||||
targetId: request.targetId ?? null,
|
||||
createdAt: new Date().toISOString(),
|
||||
}
|
||||
}
|
||||
|
||||
function criticalDamageFormula(formula: string): string {
|
||||
const match = /^(\d+)d(\d+)(?:([+-])(\d+))?$/.exec(formula)
|
||||
if (!match) throw new Error(`Unsupported dice formula: ${formula}`)
|
||||
const modifier = match[3] ? `${match[3]}${match[4]}` : ''
|
||||
return `${Number(match[1]) * 2}d${match[2]}${modifier}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves an ordered round plan while enforcing attack-to-damage rules.
|
||||
* A missed attack never rolls damage; a natural 20 doubles only the damage
|
||||
* dice and not the flat modifier.
|
||||
*/
|
||||
export function resolveChecks(
|
||||
requests: CheckRequest[],
|
||||
characters: Character[],
|
||||
random: RandomSource = secureRandom,
|
||||
): DiceRoll[] {
|
||||
const rolls: DiceRoll[] = []
|
||||
|
||||
for (const originalRequest of requests) {
|
||||
const actor = characters.find(character => character.id === originalRequest.actorId)
|
||||
if (!actor) throw new Error(`Unknown check actor ${originalRequest.actorId}`)
|
||||
const target = originalRequest.targetId
|
||||
? characters.find(character => character.id === originalRequest.targetId)
|
||||
: undefined
|
||||
|
||||
let request = originalRequest
|
||||
if (request.kind === 'damage') {
|
||||
const attack = [...rolls].reverse().find(roll =>
|
||||
roll.checkKind === 'attack'
|
||||
&& roll.actorId === request.actorId
|
||||
&& roll.targetId === request.targetId,
|
||||
)
|
||||
if (attack?.success === false) continue
|
||||
if (attack?.kept[0] === 20) {
|
||||
request = { ...request, dice: criticalDamageFormula(request.dice ?? '1d6') }
|
||||
}
|
||||
}
|
||||
|
||||
rolls.push(resolveCheck(request, actor, random, target))
|
||||
}
|
||||
|
||||
return rolls
|
||||
}
|
||||
|
||||
export interface AppliedState {
|
||||
characters: Character[]
|
||||
audit: Array<{ event: ProposedEvent; before: unknown; after: unknown }>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { PlayerIntentSchema } from './index'
|
||||
import { CheckRequestSchema, PlayerIntentSchema } from './index'
|
||||
|
||||
describe('shared database contracts', () => {
|
||||
it('accepts PostgREST timestamptz values with an explicit UTC offset', () => {
|
||||
@@ -16,4 +16,16 @@ describe('shared database contracts', () => {
|
||||
|
||||
expect(intent.ready).toBe(true)
|
||||
})
|
||||
|
||||
it('accepts typed SRD d20 tests', () => {
|
||||
expect(CheckRequestSchema.parse({
|
||||
actorId: 'hero', kind: 'skill', skill: 'perception', proficient: true,
|
||||
difficulty: 15, mode: 'advantage', reason: 'Search the trapped hall',
|
||||
})).toMatchObject({ kind: 'skill', skill: 'perception', proficient: true })
|
||||
|
||||
expect(CheckRequestSchema.parse({
|
||||
actorId: 'hero', kind: 'savingThrow', ability: 'wis', proficient: false,
|
||||
difficulty: 13, mode: 'normal', reason: 'Resist the whisper',
|
||||
})).toMatchObject({ kind: 'savingThrow', ability: 'wis' })
|
||||
})
|
||||
})
|
||||
|
||||
@@ -10,6 +10,14 @@ export const abilityKeys = ['str', 'dex', 'con', 'int', 'wis', 'cha'] as const
|
||||
export const AbilityKeySchema = z.enum(abilityKeys)
|
||||
export type AbilityKey = z.infer<typeof AbilityKeySchema>
|
||||
|
||||
export const skillKeys = [
|
||||
'acrobatics', 'animalHandling', 'arcana', 'athletics', 'deception', 'history',
|
||||
'insight', 'intimidation', 'investigation', 'medicine', 'nature', 'perception',
|
||||
'performance', 'persuasion', 'religion', 'sleightOfHand', 'stealth', 'survival',
|
||||
] as const
|
||||
export const SkillKeySchema = z.enum(skillKeys)
|
||||
export type SkillKey = z.infer<typeof SkillKeySchema>
|
||||
|
||||
export const AbilityScoresSchema = z.object({
|
||||
str: z.number().int().min(1).max(30),
|
||||
dex: z.number().int().min(1).max(30),
|
||||
@@ -30,21 +38,6 @@ export const WorldEntitySchema = z.object({
|
||||
})
|
||||
export type WorldEntity = z.infer<typeof WorldEntitySchema>
|
||||
|
||||
export const WorldStarterSchema = z.object({
|
||||
title: z.string().min(3).max(100),
|
||||
genre: z.string().min(2).max(80),
|
||||
tone: z.string().min(2).max(160),
|
||||
premise: z.string().min(20).max(1800),
|
||||
contentBoundaries: z.array(z.string().max(120)).min(1).max(8),
|
||||
startingLocation: WorldEntitySchema.extend({ kind: z.literal('location') }),
|
||||
npcs: z.array(WorldEntitySchema.extend({ kind: z.literal('npc') })).length(3),
|
||||
factions: z.array(WorldEntitySchema.extend({ kind: z.literal('faction') })).length(2),
|
||||
hook: z.string().min(20).max(1200),
|
||||
hiddenThreat: z.string().min(10).max(1000),
|
||||
openingScene: z.string().min(40).max(2400),
|
||||
})
|
||||
export type WorldStarter = z.infer<typeof WorldStarterSchema>
|
||||
|
||||
export const CharacterPersonaSchema = z.object({
|
||||
voice: z.string().trim().min(1).max(240),
|
||||
motivation: z.string().trim().min(1).max(300),
|
||||
@@ -69,6 +62,22 @@ export const CharacterDraftSchema = z.object({
|
||||
})
|
||||
export type CharacterDraft = z.infer<typeof CharacterDraftSchema>
|
||||
|
||||
export const WorldStarterSchema = z.object({
|
||||
title: z.string().min(3).max(100),
|
||||
genre: z.string().min(2).max(80),
|
||||
tone: z.string().min(2).max(160),
|
||||
premise: z.string().min(20).max(1800),
|
||||
contentBoundaries: z.array(z.string().max(120)).min(1).max(8),
|
||||
startingLocation: WorldEntitySchema.extend({ kind: z.literal('location') }),
|
||||
npcs: z.array(WorldEntitySchema.extend({ kind: z.literal('npc') })).length(3),
|
||||
factions: z.array(WorldEntitySchema.extend({ kind: z.literal('faction') })).length(2),
|
||||
companions: z.array(CharacterDraftSchema).length(3),
|
||||
hook: z.string().min(20).max(1200),
|
||||
hiddenThreat: z.string().min(10).max(1000),
|
||||
openingScene: z.string().min(40).max(2400),
|
||||
})
|
||||
export type WorldStarter = z.infer<typeof WorldStarterSchema>
|
||||
|
||||
export const CharacterSchema = z.object({
|
||||
id: z.string().min(1),
|
||||
name: z.string().min(1).max(80),
|
||||
@@ -120,8 +129,10 @@ export type RoundContext = z.infer<typeof RoundContextSchema>
|
||||
|
||||
export const CheckRequestSchema = z.object({
|
||||
actorId: z.string().min(1),
|
||||
kind: z.enum(['ability', 'attack', 'initiative', 'damage', 'healing']),
|
||||
kind: z.enum(['ability', 'skill', 'savingThrow', 'attack', 'initiative', 'damage', 'healing']),
|
||||
ability: AbilityKeySchema.optional(),
|
||||
skill: SkillKeySchema.optional(),
|
||||
proficient: z.boolean().optional(),
|
||||
difficulty: z.number().int().min(1).max(40).optional(),
|
||||
targetId: z.string().optional(),
|
||||
mode: z.enum(['normal', 'advantage', 'disadvantage']).default('normal'),
|
||||
|
||||
@@ -112,6 +112,17 @@ const world = {
|
||||
entity('faction', 'The Reclaimers', 'Salvagers who want the station stripped for parts.'),
|
||||
entity('faction', 'The Seed Vault', 'An automated network protecting the last viable specimens.'),
|
||||
],
|
||||
companions: [1, 2, 3].map(index => ({
|
||||
name: `Garden Companion ${index}`,
|
||||
concept: `A universe-specific caretaker hero assigned to garden sector ${index}.`,
|
||||
abilities: { str: 10, dex: 12, con: 11, int: 13, wis: 12, cha: 10 },
|
||||
hp: 11,
|
||||
maxHp: 11,
|
||||
defense: 12,
|
||||
proficiency: 2,
|
||||
inventory: ['Garden survey kit'],
|
||||
persona: { voice: 'Calm and observant.', motivation: 'Protect the living garden.', flaw: 'Trusts the station too much.', bond: 'Treats the party as new growth.' },
|
||||
})),
|
||||
hook: 'The airlock opens only after both visitors speak names the station already knows.',
|
||||
hiddenThreat: 'The garden predicts visitors by growing imperfect biological copies of them.',
|
||||
openingScene: 'The inner airlock opens on warm rain and rows of silver trees. A voice welcomes both salvagers by name, then asks why one of them has returned without the other.',
|
||||
@@ -125,9 +136,9 @@ const { campaignId } = await appRequest('/api/v1/campaigns', owner.token, {
|
||||
})
|
||||
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.')
|
||||
const automaticCompanions = starterParty.characters.filter(character => character.controller === 'ai')
|
||||
if (!ownerCharacter || automaticCompanions.length !== world.companions.length || automaticCompanions.some(character => character.name === 'Echo') || starterParty.round?.status !== 'open') {
|
||||
throw new Error('[smoke] A new campaign did not start with its actionable owner hero, generated world companions, and an open round.')
|
||||
}
|
||||
|
||||
console.log('[smoke] Joining the second player through an invite…')
|
||||
@@ -158,7 +169,7 @@ if (!suggested?.character?.name || !suggested.character?.persona?.motivation) {
|
||||
|
||||
const initial = await appRequest(`/api/v1/campaigns/${campaignId}`, owner.token)
|
||||
if (initial.members.length !== 2) throw new Error(`[smoke] Expected 2 members, received ${initial.members.length}.`)
|
||||
if (initial.characters.length !== 3 || initial.round?.status !== 'open') throw new Error('[smoke] Multiplayer lobby did not expose two humans, one AI companion, and an open round.')
|
||||
if (initial.characters.length !== 5 || initial.round?.status !== 'open') throw new Error('[smoke] Multiplayer lobby did not expose two humans, three generated AI companions, and an open round.')
|
||||
const roundId = initial.round.id
|
||||
|
||||
console.log('[smoke] Verifying that the round waits for both humans…')
|
||||
|
||||
@@ -2329,7 +2329,7 @@ notify pgrst, 'reload schema';
|
||||
-- ============================================================================
|
||||
|
||||
-- Make a newly launched campaign playable immediately: the owner receives a
|
||||
-- human-controlled starter and the party receives one persistent AI companion.
|
||||
-- human-controlled starter, and only actionable members hold the round open.
|
||||
|
||||
create or replace function public.stage_two_create_campaign(
|
||||
p_world_id uuid,
|
||||
@@ -2382,30 +2382,6 @@ begin
|
||||
)
|
||||
);
|
||||
|
||||
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;
|
||||
@@ -2502,6 +2478,269 @@ where campaign.status = 'active'
|
||||
and character.user_id = campaign.owner_id
|
||||
);
|
||||
|
||||
create or replace function public.dng_schema_version()
|
||||
returns integer language sql stable security definer set search_path = '' as $$
|
||||
select 10;
|
||||
$$;
|
||||
|
||||
revoke all on function public.dng_schema_version() from public, anon, authenticated;
|
||||
grant execute on function public.dng_schema_version() to service_role;
|
||||
|
||||
notify pgrst, 'reload schema';
|
||||
|
||||
-- ============================================================================
|
||||
-- 0011_universe_companions.sql
|
||||
-- ============================================================================
|
||||
|
||||
-- Persist the AI companions created with each universe and use that party when
|
||||
-- a campaign starts. This replaces the temporary hard-coded Echo companion.
|
||||
|
||||
alter table public.worlds
|
||||
add column if not exists starter_companions jsonb not null default '[]'::jsonb;
|
||||
|
||||
alter table public.worlds
|
||||
drop constraint if exists worlds_starter_companions_array;
|
||||
alter table public.worlds
|
||||
add constraint worlds_starter_companions_array check (
|
||||
jsonb_typeof(starter_companions) = 'array'
|
||||
and jsonb_array_length(starter_companions) <= 3
|
||||
);
|
||||
|
||||
-- Ready drafts created before companions were part of WorldStarter inherit
|
||||
-- three universe-specific people that the coauthor already generated.
|
||||
update public.coauthor_sessions session
|
||||
set generated_world = jsonb_set(
|
||||
session.generated_world,
|
||||
'{companions}',
|
||||
coalesce((
|
||||
select jsonb_agg(
|
||||
jsonb_build_object(
|
||||
'name', npc.value->>'name',
|
||||
'concept', left(npc.value->>'summary', 600),
|
||||
'abilities', case npc.position
|
||||
when 1 then '{"str":10,"dex":14,"con":11,"int":13,"wis":12,"cha":10}'::jsonb
|
||||
when 2 then '{"str":13,"dex":10,"con":14,"int":10,"wis":12,"cha":11}'::jsonb
|
||||
else '{"str":9,"dex":12,"con":11,"int":14,"wis":13,"cha":12}'::jsonb
|
||||
end,
|
||||
'hp', 10 + npc.position,
|
||||
'maxHp', 10 + npc.position,
|
||||
'defense', 11 + npc.position,
|
||||
'proficiency', 2,
|
||||
'inventory', jsonb_build_array('Universe field kit', 'Travel supplies'),
|
||||
'persona', jsonb_build_object(
|
||||
'voice', left(npc.value->>'summary', 240),
|
||||
'motivation', left('Pursue the goal behind: ' || (npc.value->>'summary'), 300),
|
||||
'flaw', 'Their personal agenda can complicate the party''s plans.',
|
||||
'bond', left('They belong to ' || (session.generated_world->>'title') || ' and choose to stand with the party.', 300)
|
||||
)
|
||||
) order by npc.position
|
||||
)
|
||||
from jsonb_array_elements(coalesce(session.generated_world->'npcs', '[]'::jsonb))
|
||||
with ordinality as npc(value, position)
|
||||
where npc.position <= 3
|
||||
), '[]'::jsonb),
|
||||
true
|
||||
),
|
||||
updated_at = now()
|
||||
where session.generated_world is not null
|
||||
and jsonb_typeof(session.generated_world->'npcs') = 'array'
|
||||
and coalesce(jsonb_array_length(session.generated_world->'companions'), 0) = 0;
|
||||
|
||||
-- Confirmed universes do not retain the original generation JSON. Build their
|
||||
-- starter companions from the AI-created NPC entities already stored for them.
|
||||
update public.worlds world
|
||||
set starter_companions = coalesce((
|
||||
select jsonb_agg(
|
||||
jsonb_build_object(
|
||||
'name', npc.name,
|
||||
'concept', left(npc.summary, 600),
|
||||
'abilities', case npc.position
|
||||
when 1 then '{"str":10,"dex":14,"con":11,"int":13,"wis":12,"cha":10}'::jsonb
|
||||
when 2 then '{"str":13,"dex":10,"con":14,"int":10,"wis":12,"cha":11}'::jsonb
|
||||
else '{"str":9,"dex":12,"con":11,"int":14,"wis":13,"cha":12}'::jsonb
|
||||
end,
|
||||
'hp', 10 + npc.position,
|
||||
'maxHp', 10 + npc.position,
|
||||
'defense', 11 + npc.position,
|
||||
'proficiency', 2,
|
||||
'inventory', jsonb_build_array('Universe field kit', 'Travel supplies'),
|
||||
'persona', jsonb_build_object(
|
||||
'voice', left(npc.summary, 240),
|
||||
'motivation', left('Pursue the goal behind: ' || npc.summary, 300),
|
||||
'flaw', 'Their personal agenda can complicate the party''s plans.',
|
||||
'bond', left('They belong to ' || world.title || ' and choose to stand with the party.', 300)
|
||||
)
|
||||
) order by npc.position
|
||||
)
|
||||
from (
|
||||
select entity.name, entity.summary,
|
||||
(row_number() over (order by entity.created_at, entity.id))::integer as position
|
||||
from public.world_entities entity
|
||||
where entity.world_id = world.id and entity.kind = 'npc'
|
||||
order by entity.created_at, entity.id
|
||||
limit 3
|
||||
) npc
|
||||
), '[]'::jsonb)
|
||||
where jsonb_array_length(world.starter_companions) = 0;
|
||||
|
||||
create or replace function public.stage_two_confirm_world(
|
||||
p_session_id uuid,
|
||||
p_owner_id uuid
|
||||
) returns uuid language plpgsql security definer set search_path = '' as $$
|
||||
declare
|
||||
v_session public.coauthor_sessions%rowtype;
|
||||
v_world jsonb;
|
||||
v_world_id uuid;
|
||||
v_entity jsonb;
|
||||
begin
|
||||
select * into v_session from public.coauthor_sessions
|
||||
where id = p_session_id and owner_id = p_owner_id for update;
|
||||
if not found then raise exception 'coauthor session not found'; end if;
|
||||
if v_session.status = 'confirmed' and v_session.confirmed_world_id is not null then
|
||||
return v_session.confirmed_world_id;
|
||||
end if;
|
||||
if v_session.status <> 'ready' or v_session.generated_world is null then
|
||||
raise exception 'coauthor session is not ready to confirm';
|
||||
end if;
|
||||
v_world := v_session.generated_world;
|
||||
if coalesce(v_world->>'title', '') = ''
|
||||
or coalesce(v_world->>'openingScene', '') = ''
|
||||
or jsonb_typeof(v_world->'companions') <> 'array'
|
||||
or jsonb_array_length(v_world->'companions') <> 3 then
|
||||
raise exception 'generated world is incomplete';
|
||||
end if;
|
||||
|
||||
insert into public.worlds(
|
||||
owner_id, title, genre, tone, premise, content_boundaries,
|
||||
hidden_threat, hook, opening_scene, starter_companions, status
|
||||
) values (
|
||||
p_owner_id, v_world->>'title', v_world->>'genre', v_world->>'tone', v_world->>'premise',
|
||||
coalesce(v_world->'contentBoundaries', '[]'::jsonb), v_world->>'hiddenThreat',
|
||||
v_world->>'hook', v_world->>'openingScene', v_world->'companions', 'confirmed'
|
||||
) returning id into v_world_id;
|
||||
|
||||
v_entity := v_world->'startingLocation';
|
||||
insert into public.world_entities(world_id, kind, name, summary, tags, secrets)
|
||||
values (v_world_id, 'location', v_entity->>'name', v_entity->>'summary',
|
||||
array(select jsonb_array_elements_text(coalesce(v_entity->'tags', '[]'::jsonb))),
|
||||
coalesce(v_entity->'secrets', '[]'::jsonb));
|
||||
for v_entity in select * from jsonb_array_elements(v_world->'npcs') loop
|
||||
insert into public.world_entities(world_id, kind, name, summary, tags, secrets)
|
||||
values (v_world_id, 'npc', v_entity->>'name', v_entity->>'summary',
|
||||
array(select jsonb_array_elements_text(coalesce(v_entity->'tags', '[]'::jsonb))),
|
||||
coalesce(v_entity->'secrets', '[]'::jsonb));
|
||||
end loop;
|
||||
for v_entity in select * from jsonb_array_elements(v_world->'factions') loop
|
||||
insert into public.world_entities(world_id, kind, name, summary, tags, secrets)
|
||||
values (v_world_id, 'faction', v_entity->>'name', v_entity->>'summary',
|
||||
array(select jsonb_array_elements_text(coalesce(v_entity->'tags', '[]'::jsonb))),
|
||||
coalesce(v_entity->'secrets', '[]'::jsonb));
|
||||
end loop;
|
||||
insert into public.world_entities(world_id, kind, name, summary, tags, secrets)
|
||||
values (v_world_id, 'quest', 'Opening Hook', v_world->>'hook', array['opening'], '[]'::jsonb);
|
||||
|
||||
update public.coauthor_sessions
|
||||
set status = 'confirmed', confirmed_world_id = v_world_id, updated_at = now()
|
||||
where id = p_session_id;
|
||||
return v_world_id;
|
||||
end;
|
||||
$$;
|
||||
|
||||
create or replace function public.stage_two_create_campaign(
|
||||
p_world_id uuid,
|
||||
p_owner_id uuid,
|
||||
p_title text
|
||||
) returns uuid language plpgsql security definer set search_path = '' as $$
|
||||
declare
|
||||
v_world public.worlds%rowtype;
|
||||
v_campaign_id uuid;
|
||||
v_owner_name text;
|
||||
v_companion jsonb;
|
||||
begin
|
||||
if p_title is null or char_length(btrim(p_title)) not between 3 and 100 then
|
||||
raise exception 'campaign title must be between 3 and 100 characters';
|
||||
end if;
|
||||
select * into v_world from public.worlds
|
||||
where id = p_world_id and owner_id = p_owner_id and status = 'confirmed' for share;
|
||||
if not found then raise exception 'confirmed world not found'; end if;
|
||||
if jsonb_typeof(v_world.starter_companions) <> 'array'
|
||||
or jsonb_array_length(v_world.starter_companions) = 0 then
|
||||
raise exception 'confirmed world has no AI companions';
|
||||
end if;
|
||||
|
||||
select nullif(btrim(display_name), '') into v_owner_name
|
||||
from public.profiles where id = p_owner_id;
|
||||
v_owner_name := left(coalesce(v_owner_name, 'Wayfinder'), 80);
|
||||
|
||||
insert into public.campaigns(world_id, owner_id, title, current_scene, next_prompt, status)
|
||||
values (p_world_id, p_owner_id, btrim(p_title), coalesce(v_world.opening_scene, v_world.premise), 'What do you do?', 'active')
|
||||
returning id into v_campaign_id;
|
||||
insert into public.campaign_members(campaign_id, user_id, role)
|
||||
values (v_campaign_id, p_owner_id, 'owner');
|
||||
|
||||
insert into public.characters(
|
||||
campaign_id, user_id, name, concept, controller, abilities, hp, max_hp,
|
||||
defense, proficiency, inventory, statuses, persona
|
||||
) values (
|
||||
v_campaign_id,
|
||||
p_owner_id,
|
||||
v_owner_name,
|
||||
left('An adaptable protagonist ready to confront the opening mystery of ' || v_world.title || '.', 600),
|
||||
'human'::public.character_controller,
|
||||
'{"str":10,"dex":12,"con":12,"int":11,"wis":13,"cha":10}'::jsonb,
|
||||
12,
|
||||
12,
|
||||
12,
|
||||
2,
|
||||
'["Field kit","Personal keepsake"]'::jsonb,
|
||||
'[]'::jsonb,
|
||||
jsonb_build_object(
|
||||
'voice', 'Defined by the player.',
|
||||
'motivation', 'Discover what the opening scene is hiding.',
|
||||
'flaw', 'Still learning what this world demands.',
|
||||
'bond', 'Protect the party through the first danger.'
|
||||
)
|
||||
);
|
||||
|
||||
for v_companion in select * from jsonb_array_elements(v_world.starter_companions) loop
|
||||
insert into public.characters(
|
||||
campaign_id, user_id, name, concept, controller, abilities, hp, max_hp,
|
||||
defense, proficiency, inventory, statuses, persona
|
||||
) values (
|
||||
v_campaign_id,
|
||||
null,
|
||||
v_companion->>'name',
|
||||
v_companion->>'concept',
|
||||
'ai'::public.character_controller,
|
||||
v_companion->'abilities',
|
||||
(v_companion->>'hp')::integer,
|
||||
(v_companion->>'maxHp')::integer,
|
||||
(v_companion->>'defense')::integer,
|
||||
(v_companion->>'proficiency')::integer,
|
||||
v_companion->'inventory',
|
||||
'[]'::jsonb,
|
||||
v_companion->'persona'
|
||||
);
|
||||
end loop;
|
||||
|
||||
insert into public.rounds(campaign_id, number) values (v_campaign_id, 1);
|
||||
return v_campaign_id;
|
||||
end;
|
||||
$$;
|
||||
|
||||
-- Remove only the exact temporary Echo inserted by schema v10, preserving any
|
||||
-- independently authored character that happens to share the name.
|
||||
delete from public.characters character
|
||||
where character.user_id is null
|
||||
and character.controller = 'ai'::public.character_controller
|
||||
and character.name = 'Echo'
|
||||
and character.inventory = '["Survey kit","Emergency supplies"]'::jsonb
|
||||
and character.persona->>'voice' = 'Observant, concise, and quietly curious.'
|
||||
and character.persona->>'motivation' = 'Help the party understand this unfamiliar world.'
|
||||
and character.concept like 'A persistent AI companion shaped by the %';
|
||||
|
||||
-- Existing active campaigns receive the companion roster belonging to their
|
||||
-- universe. Name matching keeps this migration idempotent around manual heroes.
|
||||
insert into public.characters(
|
||||
campaign_id, user_id, name, concept, controller, abilities, hp, max_hp,
|
||||
defense, proficiency, inventory, statuses, persona
|
||||
@@ -2509,34 +2748,31 @@ insert into public.characters(
|
||||
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),
|
||||
companion.value->>'name',
|
||||
companion.value->>'concept',
|
||||
'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,
|
||||
companion.value->'abilities',
|
||||
(companion.value->>'hp')::integer,
|
||||
(companion.value->>'maxHp')::integer,
|
||||
(companion.value->>'defense')::integer,
|
||||
(companion.value->>'proficiency')::integer,
|
||||
companion.value->'inventory',
|
||||
'[]'::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.'
|
||||
)
|
||||
companion.value->'persona'
|
||||
from public.campaigns campaign
|
||||
join public.worlds world on world.id = campaign.world_id
|
||||
cross join lateral jsonb_array_elements(world.starter_companions) companion(value)
|
||||
where campaign.status = 'active'
|
||||
and not exists (
|
||||
select 1 from public.characters character
|
||||
where character.campaign_id = campaign.id
|
||||
and character.controller = 'ai'::public.character_controller
|
||||
and lower(character.name) = lower(companion.value->>'name')
|
||||
);
|
||||
|
||||
create or replace function public.dng_schema_version()
|
||||
returns integer language sql stable security definer set search_path = '' as $$
|
||||
select 10;
|
||||
select 11;
|
||||
$$;
|
||||
|
||||
revoke all on function public.dng_schema_version() from public, anon, authenticated;
|
||||
@@ -2568,7 +2804,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() <> 11 then
|
||||
raise exception 'D&G bootstrap verification failed: unexpected schema version';
|
||||
end if;
|
||||
end;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
-- Make a newly launched campaign playable immediately: the owner receives a
|
||||
-- human-controlled starter and the party receives one persistent AI companion.
|
||||
-- human-controlled starter, and only actionable members hold the round open.
|
||||
|
||||
create or replace function public.stage_two_create_campaign(
|
||||
p_world_id uuid,
|
||||
@@ -52,30 +52,6 @@ begin
|
||||
)
|
||||
);
|
||||
|
||||
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;
|
||||
@@ -172,38 +148,6 @@ where campaign.status = 'active'
|
||||
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;
|
||||
|
||||
287
supabase/migrations/0011_universe_companions.sql
Normal file
287
supabase/migrations/0011_universe_companions.sql
Normal file
@@ -0,0 +1,287 @@
|
||||
-- Persist the AI companions created with each universe and use that party when
|
||||
-- a campaign starts. This replaces the temporary hard-coded Echo companion.
|
||||
|
||||
alter table public.worlds
|
||||
add column if not exists starter_companions jsonb not null default '[]'::jsonb;
|
||||
|
||||
alter table public.worlds
|
||||
drop constraint if exists worlds_starter_companions_array;
|
||||
alter table public.worlds
|
||||
add constraint worlds_starter_companions_array check (
|
||||
jsonb_typeof(starter_companions) = 'array'
|
||||
and jsonb_array_length(starter_companions) <= 3
|
||||
);
|
||||
|
||||
-- Ready drafts created before companions were part of WorldStarter inherit
|
||||
-- three universe-specific people that the coauthor already generated.
|
||||
update public.coauthor_sessions session
|
||||
set generated_world = jsonb_set(
|
||||
session.generated_world,
|
||||
'{companions}',
|
||||
coalesce((
|
||||
select jsonb_agg(
|
||||
jsonb_build_object(
|
||||
'name', npc.value->>'name',
|
||||
'concept', left(npc.value->>'summary', 600),
|
||||
'abilities', case npc.position
|
||||
when 1 then '{"str":10,"dex":14,"con":11,"int":13,"wis":12,"cha":10}'::jsonb
|
||||
when 2 then '{"str":13,"dex":10,"con":14,"int":10,"wis":12,"cha":11}'::jsonb
|
||||
else '{"str":9,"dex":12,"con":11,"int":14,"wis":13,"cha":12}'::jsonb
|
||||
end,
|
||||
'hp', 10 + npc.position,
|
||||
'maxHp', 10 + npc.position,
|
||||
'defense', 11 + npc.position,
|
||||
'proficiency', 2,
|
||||
'inventory', jsonb_build_array('Universe field kit', 'Travel supplies'),
|
||||
'persona', jsonb_build_object(
|
||||
'voice', left(npc.value->>'summary', 240),
|
||||
'motivation', left('Pursue the goal behind: ' || (npc.value->>'summary'), 300),
|
||||
'flaw', 'Their personal agenda can complicate the party''s plans.',
|
||||
'bond', left('They belong to ' || (session.generated_world->>'title') || ' and choose to stand with the party.', 300)
|
||||
)
|
||||
) order by npc.position
|
||||
)
|
||||
from jsonb_array_elements(coalesce(session.generated_world->'npcs', '[]'::jsonb))
|
||||
with ordinality as npc(value, position)
|
||||
where npc.position <= 3
|
||||
), '[]'::jsonb),
|
||||
true
|
||||
),
|
||||
updated_at = now()
|
||||
where session.generated_world is not null
|
||||
and jsonb_typeof(session.generated_world->'npcs') = 'array'
|
||||
and coalesce(jsonb_array_length(session.generated_world->'companions'), 0) = 0;
|
||||
|
||||
-- Confirmed universes do not retain the original generation JSON. Build their
|
||||
-- starter companions from the AI-created NPC entities already stored for them.
|
||||
update public.worlds world
|
||||
set starter_companions = coalesce((
|
||||
select jsonb_agg(
|
||||
jsonb_build_object(
|
||||
'name', npc.name,
|
||||
'concept', left(npc.summary, 600),
|
||||
'abilities', case npc.position
|
||||
when 1 then '{"str":10,"dex":14,"con":11,"int":13,"wis":12,"cha":10}'::jsonb
|
||||
when 2 then '{"str":13,"dex":10,"con":14,"int":10,"wis":12,"cha":11}'::jsonb
|
||||
else '{"str":9,"dex":12,"con":11,"int":14,"wis":13,"cha":12}'::jsonb
|
||||
end,
|
||||
'hp', 10 + npc.position,
|
||||
'maxHp', 10 + npc.position,
|
||||
'defense', 11 + npc.position,
|
||||
'proficiency', 2,
|
||||
'inventory', jsonb_build_array('Universe field kit', 'Travel supplies'),
|
||||
'persona', jsonb_build_object(
|
||||
'voice', left(npc.summary, 240),
|
||||
'motivation', left('Pursue the goal behind: ' || npc.summary, 300),
|
||||
'flaw', 'Their personal agenda can complicate the party''s plans.',
|
||||
'bond', left('They belong to ' || world.title || ' and choose to stand with the party.', 300)
|
||||
)
|
||||
) order by npc.position
|
||||
)
|
||||
from (
|
||||
select entity.name, entity.summary,
|
||||
(row_number() over (order by entity.created_at, entity.id))::integer as position
|
||||
from public.world_entities entity
|
||||
where entity.world_id = world.id and entity.kind = 'npc'
|
||||
order by entity.created_at, entity.id
|
||||
limit 3
|
||||
) npc
|
||||
), '[]'::jsonb)
|
||||
where jsonb_array_length(world.starter_companions) = 0;
|
||||
|
||||
create or replace function public.stage_two_confirm_world(
|
||||
p_session_id uuid,
|
||||
p_owner_id uuid
|
||||
) returns uuid language plpgsql security definer set search_path = '' as $$
|
||||
declare
|
||||
v_session public.coauthor_sessions%rowtype;
|
||||
v_world jsonb;
|
||||
v_world_id uuid;
|
||||
v_entity jsonb;
|
||||
begin
|
||||
select * into v_session from public.coauthor_sessions
|
||||
where id = p_session_id and owner_id = p_owner_id for update;
|
||||
if not found then raise exception 'coauthor session not found'; end if;
|
||||
if v_session.status = 'confirmed' and v_session.confirmed_world_id is not null then
|
||||
return v_session.confirmed_world_id;
|
||||
end if;
|
||||
if v_session.status <> 'ready' or v_session.generated_world is null then
|
||||
raise exception 'coauthor session is not ready to confirm';
|
||||
end if;
|
||||
v_world := v_session.generated_world;
|
||||
if coalesce(v_world->>'title', '') = ''
|
||||
or coalesce(v_world->>'openingScene', '') = ''
|
||||
or jsonb_typeof(v_world->'companions') <> 'array'
|
||||
or jsonb_array_length(v_world->'companions') <> 3 then
|
||||
raise exception 'generated world is incomplete';
|
||||
end if;
|
||||
|
||||
insert into public.worlds(
|
||||
owner_id, title, genre, tone, premise, content_boundaries,
|
||||
hidden_threat, hook, opening_scene, starter_companions, status
|
||||
) values (
|
||||
p_owner_id, v_world->>'title', v_world->>'genre', v_world->>'tone', v_world->>'premise',
|
||||
coalesce(v_world->'contentBoundaries', '[]'::jsonb), v_world->>'hiddenThreat',
|
||||
v_world->>'hook', v_world->>'openingScene', v_world->'companions', 'confirmed'
|
||||
) returning id into v_world_id;
|
||||
|
||||
v_entity := v_world->'startingLocation';
|
||||
insert into public.world_entities(world_id, kind, name, summary, tags, secrets)
|
||||
values (v_world_id, 'location', v_entity->>'name', v_entity->>'summary',
|
||||
array(select jsonb_array_elements_text(coalesce(v_entity->'tags', '[]'::jsonb))),
|
||||
coalesce(v_entity->'secrets', '[]'::jsonb));
|
||||
for v_entity in select * from jsonb_array_elements(v_world->'npcs') loop
|
||||
insert into public.world_entities(world_id, kind, name, summary, tags, secrets)
|
||||
values (v_world_id, 'npc', v_entity->>'name', v_entity->>'summary',
|
||||
array(select jsonb_array_elements_text(coalesce(v_entity->'tags', '[]'::jsonb))),
|
||||
coalesce(v_entity->'secrets', '[]'::jsonb));
|
||||
end loop;
|
||||
for v_entity in select * from jsonb_array_elements(v_world->'factions') loop
|
||||
insert into public.world_entities(world_id, kind, name, summary, tags, secrets)
|
||||
values (v_world_id, 'faction', v_entity->>'name', v_entity->>'summary',
|
||||
array(select jsonb_array_elements_text(coalesce(v_entity->'tags', '[]'::jsonb))),
|
||||
coalesce(v_entity->'secrets', '[]'::jsonb));
|
||||
end loop;
|
||||
insert into public.world_entities(world_id, kind, name, summary, tags, secrets)
|
||||
values (v_world_id, 'quest', 'Opening Hook', v_world->>'hook', array['opening'], '[]'::jsonb);
|
||||
|
||||
update public.coauthor_sessions
|
||||
set status = 'confirmed', confirmed_world_id = v_world_id, updated_at = now()
|
||||
where id = p_session_id;
|
||||
return v_world_id;
|
||||
end;
|
||||
$$;
|
||||
|
||||
create or replace function public.stage_two_create_campaign(
|
||||
p_world_id uuid,
|
||||
p_owner_id uuid,
|
||||
p_title text
|
||||
) returns uuid language plpgsql security definer set search_path = '' as $$
|
||||
declare
|
||||
v_world public.worlds%rowtype;
|
||||
v_campaign_id uuid;
|
||||
v_owner_name text;
|
||||
v_companion jsonb;
|
||||
begin
|
||||
if p_title is null or char_length(btrim(p_title)) not between 3 and 100 then
|
||||
raise exception 'campaign title must be between 3 and 100 characters';
|
||||
end if;
|
||||
select * into v_world from public.worlds
|
||||
where id = p_world_id and owner_id = p_owner_id and status = 'confirmed' for share;
|
||||
if not found then raise exception 'confirmed world not found'; end if;
|
||||
if jsonb_typeof(v_world.starter_companions) <> 'array'
|
||||
or jsonb_array_length(v_world.starter_companions) = 0 then
|
||||
raise exception 'confirmed world has no AI companions';
|
||||
end if;
|
||||
|
||||
select nullif(btrim(display_name), '') into v_owner_name
|
||||
from public.profiles where id = p_owner_id;
|
||||
v_owner_name := left(coalesce(v_owner_name, 'Wayfinder'), 80);
|
||||
|
||||
insert into public.campaigns(world_id, owner_id, title, current_scene, next_prompt, status)
|
||||
values (p_world_id, p_owner_id, btrim(p_title), coalesce(v_world.opening_scene, v_world.premise), 'What do you do?', 'active')
|
||||
returning id into v_campaign_id;
|
||||
insert into public.campaign_members(campaign_id, user_id, role)
|
||||
values (v_campaign_id, p_owner_id, 'owner');
|
||||
|
||||
insert into public.characters(
|
||||
campaign_id, user_id, name, concept, controller, abilities, hp, max_hp,
|
||||
defense, proficiency, inventory, statuses, persona
|
||||
) values (
|
||||
v_campaign_id,
|
||||
p_owner_id,
|
||||
v_owner_name,
|
||||
left('An adaptable protagonist ready to confront the opening mystery of ' || v_world.title || '.', 600),
|
||||
'human'::public.character_controller,
|
||||
'{"str":10,"dex":12,"con":12,"int":11,"wis":13,"cha":10}'::jsonb,
|
||||
12,
|
||||
12,
|
||||
12,
|
||||
2,
|
||||
'["Field kit","Personal keepsake"]'::jsonb,
|
||||
'[]'::jsonb,
|
||||
jsonb_build_object(
|
||||
'voice', 'Defined by the player.',
|
||||
'motivation', 'Discover what the opening scene is hiding.',
|
||||
'flaw', 'Still learning what this world demands.',
|
||||
'bond', 'Protect the party through the first danger.'
|
||||
)
|
||||
);
|
||||
|
||||
for v_companion in select * from jsonb_array_elements(v_world.starter_companions) loop
|
||||
insert into public.characters(
|
||||
campaign_id, user_id, name, concept, controller, abilities, hp, max_hp,
|
||||
defense, proficiency, inventory, statuses, persona
|
||||
) values (
|
||||
v_campaign_id,
|
||||
null,
|
||||
v_companion->>'name',
|
||||
v_companion->>'concept',
|
||||
'ai'::public.character_controller,
|
||||
v_companion->'abilities',
|
||||
(v_companion->>'hp')::integer,
|
||||
(v_companion->>'maxHp')::integer,
|
||||
(v_companion->>'defense')::integer,
|
||||
(v_companion->>'proficiency')::integer,
|
||||
v_companion->'inventory',
|
||||
'[]'::jsonb,
|
||||
v_companion->'persona'
|
||||
);
|
||||
end loop;
|
||||
|
||||
insert into public.rounds(campaign_id, number) values (v_campaign_id, 1);
|
||||
return v_campaign_id;
|
||||
end;
|
||||
$$;
|
||||
|
||||
-- Remove only the exact temporary Echo inserted by schema v10, preserving any
|
||||
-- independently authored character that happens to share the name.
|
||||
delete from public.characters character
|
||||
where character.user_id is null
|
||||
and character.controller = 'ai'::public.character_controller
|
||||
and character.name = 'Echo'
|
||||
and character.inventory = '["Survey kit","Emergency supplies"]'::jsonb
|
||||
and character.persona->>'voice' = 'Observant, concise, and quietly curious.'
|
||||
and character.persona->>'motivation' = 'Help the party understand this unfamiliar world.'
|
||||
and character.concept like 'A persistent AI companion shaped by the %';
|
||||
|
||||
-- Existing active campaigns receive the companion roster belonging to their
|
||||
-- universe. Name matching keeps this migration idempotent around manual heroes.
|
||||
insert into public.characters(
|
||||
campaign_id, user_id, name, concept, controller, abilities, hp, max_hp,
|
||||
defense, proficiency, inventory, statuses, persona
|
||||
)
|
||||
select
|
||||
campaign.id,
|
||||
null,
|
||||
companion.value->>'name',
|
||||
companion.value->>'concept',
|
||||
'ai'::public.character_controller,
|
||||
companion.value->'abilities',
|
||||
(companion.value->>'hp')::integer,
|
||||
(companion.value->>'maxHp')::integer,
|
||||
(companion.value->>'defense')::integer,
|
||||
(companion.value->>'proficiency')::integer,
|
||||
companion.value->'inventory',
|
||||
'[]'::jsonb,
|
||||
companion.value->'persona'
|
||||
from public.campaigns campaign
|
||||
join public.worlds world on world.id = campaign.world_id
|
||||
cross join lateral jsonb_array_elements(world.starter_companions) companion(value)
|
||||
where campaign.status = 'active'
|
||||
and not exists (
|
||||
select 1 from public.characters character
|
||||
where character.campaign_id = campaign.id
|
||||
and character.controller = 'ai'::public.character_controller
|
||||
and lower(character.name) = lower(companion.value->>'name')
|
||||
);
|
||||
|
||||
create or replace function public.dng_schema_version()
|
||||
returns integer language sql stable security definer set search_path = '' as $$
|
||||
select 11;
|
||||
$$;
|
||||
|
||||
revoke all on function public.dng_schema_version() from public, anon, authenticated;
|
||||
grant execute on function public.dng_schema_version() to service_role;
|
||||
|
||||
notify pgrst, 'reload schema';
|
||||
Reference in New Issue
Block a user