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:
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user