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