Files
Dungeons-Ground/apps/worker/src/ai.ts
pavel444-byte 6c39188027
Some checks failed
CI / validate (push) Failing after 3m36s
CI / validate (pull_request) Failing after 11s
feat(gameplay): add versioned SRD rules migration
Co-authored-by: multica-agent <github@multica.ai>
2026-08-26 08:45:50 +05:00

127 lines
7.7 KiB
TypeScript

import { RoundPlanSchema, RoundResolutionSchema, StorySummarySchema, WorldStarterSchema, moderate13Plus, type RoundContext, type RoundPlan, type RoundResolution, type StorySummary, type WorldStarter } from '@dng/shared'
import type { DiceRoll } from '@dng/shared'
type JsonSchema = Record<string, unknown>
interface AiProvider {
name: 'openrouter' | 'deepseek'
endpoint: string
apiKey: string
model: string
headers: Record<string, string>
providerOptions?: Record<string, unknown>
}
function getProvider(): AiProvider {
const provider = (process.env.AI_PROVIDER ?? 'openrouter').toLowerCase()
if (provider === 'deepseek') {
const apiKey = process.env.DEEPSEEK_API_KEY
if (!apiKey) throw new Error('DEEPSEEK_API_KEY is not configured')
return {
name: 'deepseek',
endpoint: process.env.DEEPSEEK_API_ENDPOINT ?? 'https://api.deepseek.com/chat/completions',
apiKey,
model: process.env.DEEPSEEK_MODEL ?? 'deepseek-v4-flash',
headers: {},
}
}
if (provider !== 'openrouter') throw new Error(`Unsupported AI_PROVIDER: ${provider}`)
const apiKey = process.env.OPENROUTER_API_KEY
if (!apiKey) throw new Error('OPENROUTER_API_KEY is not configured')
return {
name: 'openrouter',
endpoint: process.env.OPENROUTER_API_ENDPOINT ?? 'https://openrouter.ai/api/v1/chat/completions',
apiKey,
model: process.env.OPENROUTER_MODEL ?? 'deepseek/deepseek-v4-flash',
headers: {
'HTTP-Referer': process.env.APP_URL ?? 'http://localhost:3000',
'X-Title': 'Dungeons & Ground',
},
providerOptions: { require_parameters: true, data_collection: 'deny', allow_fallbacks: true },
}
}
function assert13Plus(...texts: string[]): void {
const result = moderate13Plus(texts.join('\n'))
if (!result.allowed) throw new Error(`Content policy rejected: ${result.categories.join(', ')}`)
}
async function structuredRequest<T>(name: string, schema: JsonSchema, messages: Array<{ role: string; content: string }>, parse: (value: unknown) => T): Promise<T> {
const provider = getProvider()
const controller = new AbortController()
const timeout = setTimeout(() => controller.abort(), 45_000)
try {
const response = await fetch(provider.endpoint, {
method: 'POST',
headers: {
Authorization: `Bearer ${provider.apiKey}`,
'Content-Type': 'application/json',
...provider.headers,
},
body: JSON.stringify({
model: provider.model,
messages: provider.name === 'deepseek'
? [
{ role: 'system', content: `${messages[0]?.content ?? ''}\nReturn only valid JSON matching this JSON Schema: ${JSON.stringify(schema)}` },
...messages.slice(1),
]
: messages,
temperature: 0.65,
max_tokens: 4000,
...(provider.providerOptions ? { provider: provider.providerOptions } : {}),
...(provider.name === 'deepseek' ? { thinking: { type: 'disabled' } } : {}),
response_format: provider.name === 'deepseek'
? { type: 'json_object' }
: { type: 'json_schema', json_schema: { name, strict: true, schema } },
}),
signal: controller.signal,
})
if (!response.ok) throw new Error(`AI provider returned ${response.status}`)
const payload = await response.json() as { choices?: Array<{ message?: { content?: string } }>; usage?: unknown }
const content = payload.choices?.[0]?.message?.content
if (!content) throw new Error('AI provider returned an empty response')
return parse(JSON.parse(content))
} finally {
clearTimeout(timeout)
}
}
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 SRD 5.2.1 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, exactly four fitting skill proficiencies, exactly two saving throw proficiencies, no more than one expertise chosen from their proficient skills, 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
// 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> {
const plan = await structuredRequest('round_plan', RoundPlanSchema.toJSONSchema(), [
{ 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, deathSavingThrow, 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. Character rules profiles—not you—own proficiency, expertise, temporary HP, death saves, exhaustion, resistances and limited resources. Request a deathSavingThrow only for an unstable actor at 0 HP. Request separate damage or healing dice only after an applicable action; every damage event needs its SRD damageType. A resource event may consume only a named resource present on the character. A rest event uses item short or long. Never invent dice results and never directly mutate mechanical state. The server applies conditions, resistance, vulnerability, immunity, critical damage, HP 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> {
const resolution = await structuredRequest('round_resolution', RoundResolutionSchema.toJSONSchema(), [
{ 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(JSON.stringify(resolution))
return resolution
}
export async function summarizeStory(input: { previousSummary: string | null; rounds: Array<{ number: number; narration: string }> }): Promise<StorySummary> {
const result = await structuredRequest('story_summary', StorySummarySchema.toJSONSchema(), [
{ role: 'system', content: 'Update a durable TTRPG campaign summary. Preserve established facts, goals, relationships and unresolved consequences. Use only the supplied previous summary and round narrations. Be concise and do not invent details.' },
{ role: 'user', content: JSON.stringify(input) },
], value => StorySummarySchema.parse(value))
assert13Plus(result.summary)
return result
}