Add AI-assisted world and character drafting flows
All checks were successful
CI / validate (push) Successful in 14m3s

This commit is contained in:
2026-08-17 09:25:37 +05:00
parent 830aa5dc1f
commit cdeea8653c
23 changed files with 811 additions and 92 deletions

View File

@@ -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')
})
})

View File

@@ -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
}

View File

@@ -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()