The first 2 weeks is ended.
Some checks failed
CI / validate (push) Failing after 14m50s

This commit is contained in:
2026-08-14 10:48:54 +05:00
commit 1774496cf9
48 changed files with 10825 additions and 0 deletions

113
apps/worker/src/ai.ts Normal file
View File

@@ -0,0 +1,113 @@
import { RoundPlanSchema, RoundResolutionSchema, WorldStarterSchema, moderate13Plus, type Character, type PlayerIntent, type RoundPlan, type RoundResolution, 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 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)
return world
}
export async function planRound(input: { scene: string; intents: PlayerIntent[]; characters: Character[]; memories: string[] }): Promise<RoundPlan> {
return structuredRequest('round_plan', RoundPlanSchema.toJSONSchema(), [
{ role: 'system', content: 'You plan one asynchronous TTRPG round. 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))
}
export async function narrateRound(input: { scene: string; intents: PlayerIntent[]; aiActions: RoundPlan['aiActions']; rolls: DiceRoll[]; permittedEvents: RoundPlan['proposedEvents'] }): Promise<RoundResolution> {
const resolution = await structuredRequest('round_resolution', RoundResolutionSchema.toJSONSchema(), [
{ role: 'system', content: 'Narrate the resolved TTRPG round using the supplied, authoritative dice results. Do not change 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 ?? '')
return resolution
}