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

27
apps/worker/package.json Normal file
View File

@@ -0,0 +1,27 @@
{
"name": "@dng/worker",
"version": "0.1.0",
"private": true,
"type": "module",
"engines": {
"node": ">=18.17"
},
"scripts": {
"dev": "tsx watch src/index.ts",
"start": "tsx src/index.ts",
"typecheck": "tsc --noEmit -p tsconfig.json"
},
"dependencies": {
"@dng/game-engine": "workspace:*",
"@dng/shared": "workspace:*",
"bullmq": "^5.58.5",
"dotenv": "16.6.1",
"ioredis": "^5.7.0",
"zod": "^4.1.5"
},
"devDependencies": {
"@types/node": "^18.19.0",
"tsx": "^4.20.5",
"typescript": "^5.9.2"
}
}

View File

@@ -0,0 +1,38 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
const originalEnv = { ...process.env }
afterEach(() => {
process.env = { ...originalEnv }
vi.unstubAllGlobals()
})
describe('worker AI providers', () => {
it('uses official DeepSeek JSON mode and validates the parsed response', async () => {
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 fetchMock = vi.fn(async (_url: string, init?: RequestInit) => {
const body = JSON.parse(String(init?.body))
expect(body.response_format).toEqual({ type: 'json_object' })
expect(body.thinking).toEqual({ type: 'disabled' })
expect(body.model).toBe('deepseek-v4-flash')
expect(body.messages[0].content).toContain('JSON Schema')
return new Response(JSON.stringify({ choices: [{ message: { content: JSON.stringify(world) } }] }), { status: 200 })
})
vi.stubGlobal('fetch', fetchMock)
const { generateWorld } = await import('./ai')
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))
}, 15_000)
})

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
}

12
apps/worker/src/env.ts Normal file
View File

@@ -0,0 +1,12 @@
import { config } from 'dotenv'
import { fileURLToPath } from 'node:url'
// pnpm runs filtered workspace scripts with apps/worker as the current working
// directory. Resolve from this module so the monorepo root .env is found in
// dev, while real process variables supplied by Railway/CI keep precedence.
const rootEnvPath = fileURLToPath(new URL('../../../.env', import.meta.url))
const result = config({ path: rootEnvPath })
if (result.error && 'code' in result.error && result.error.code !== 'ENOENT') {
throw result.error
}

241
apps/worker/src/index.ts Normal file
View File

@@ -0,0 +1,241 @@
import './env'
import { Worker } from 'bullmq'
import IORedis from 'ioredis'
import { applyMechanicalEvents, bindMechanicalEventsToRolls, resolveCheck } from '@dng/game-engine'
import { CharacterSchema, PlayerIntentSchema, makeId } from '@dng/shared'
import { generateWorld, narrateRound, planRound } from './ai'
const redisUrl = process.env.REDIS_URL
const supabaseUrl = process.env.SUPABASE_URL
const serviceKey = process.env.SUPABASE_SERVICE_ROLE_KEY
if (!supabaseUrl || !serviceKey) {
throw new Error('[worker] SUPABASE_URL and SUPABASE_SERVICE_ROLE_KEY are required.')
} else {
const databaseUrl = supabaseUrl
const databaseServiceKey = serviceKey
type JobType = 'generate-world' | 'resolve-round'
interface JobPayload {
id: string
name: JobType
entityId: string
messages?: Array<{ role: 'user' | 'assistant'; content: string }>
claimToken?: string
}
interface ClaimedJob {
id: string
job_type: JobType
entity_id: string
}
class SupabaseRequestError extends Error {
constructor(
readonly status: number,
readonly path: string,
detail: string,
) {
const rpcName = path.startsWith('rpc/') ? path.slice('rpc/'.length) : null
const message = status === 404 && rpcName
? `Supabase RPC "${rpcName}" was not found. Apply supabase/migrations/0002_supabase_ai_queue.sql in the Supabase SQL Editor, then restart the worker.`
: `Supabase returned ${status} for ${path}: ${detail}`
super(message)
this.name = 'SupabaseRequestError'
}
}
async function databaseRequest<T>(path: string, init: RequestInit = {}): Promise<T> {
const response = await fetch(new URL(`/rest/v1/${path}`, databaseUrl), {
...init,
headers: {
apikey: databaseServiceKey,
Authorization: `Bearer ${databaseServiceKey}`,
'Content-Type': 'application/json',
...init.headers,
},
})
if (!response.ok) {
const detail = await response.text()
throw new SupabaseRequestError(response.status, path, detail)
}
if (response.status === 204) return undefined as T
return response.json() as Promise<T>
}
async function selectRows<T>(table: string, query: Record<string, string>): Promise<T[]> {
return databaseRequest<T[]>(`${table}?${new URLSearchParams(query)}`)
}
async function processWorld(job: JobPayload) {
let messages = job.messages
if (!messages) {
const [session] = await selectRows<{ messages: Array<{ role: 'user' | 'assistant'; content: string }> }>('coauthor_sessions', {
select: 'messages', id: `eq.${job.entityId}`, limit: '1',
})
if (!session) throw new Error('Coauthor session not found')
messages = session.messages
}
const world = await generateWorld(messages)
await databaseRequest(`coauthor_sessions?id=eq.${encodeURIComponent(job.entityId)}`, {
method: 'PATCH',
body: JSON.stringify({ status: 'ready', generated_world: world, updated_at: new Date().toISOString() }),
})
await databaseRequest('rpc/complete_ai_job', {
method: 'POST', body: JSON.stringify({ p_job_id: job.id, p_worker_id: job.claimToken ?? null }),
})
return world
}
async function processRound(job: JobPayload) {
const [round] = await selectRows<Record<string, any>>('rounds', {
select: '*,campaigns!inner(*)',
id: `eq.${job.entityId}`,
limit: '1',
})
if (!round) throw new Error('Round not found')
if (round.status === 'resolved') return { duplicate: true }
const [rawCharacters, rawIntents, rawMemories] = await Promise.all([
selectRows<Record<string, any>>('characters', { select: '*', campaign_id: `eq.${round.campaign_id}` }),
selectRows<Record<string, any>>('player_intents', { select: '*', round_id: `eq.${round.id}` }),
selectRows<{ summary: string }>('memories', { select: 'summary', campaign_id: `eq.${round.campaign_id}`, order: 'importance.desc', limit: '12' }),
])
const characters = rawCharacters.map(row => CharacterSchema.parse({
id: row.id, name: row.name, concept: row.concept, controller: row.controller,
userId: row.user_id, abilities: row.abilities, hp: row.hp, maxHp: row.max_hp,
defense: row.defense, proficiency: row.proficiency, inventory: row.inventory, statuses: row.statuses,
}))
const intents = rawIntents.map(row => PlayerIntentSchema.parse({
id: row.id, roundId: row.round_id, memberId: row.member_id, characterId: row.character_id,
action: row.action, ready: row.ready, createdAt: row.created_at, updatedAt: row.updated_at,
}))
const scene = String(round.campaigns.current_scene ?? '')
const plan = await planRound({ scene, intents, characters, memories: rawMemories.map(row => String(row.summary)) })
const rolls = plan.checks.map(check => {
const actor = characters.find(character => character.id === check.actorId)
if (!actor) throw new Error(`Unknown check actor ${check.actorId}`)
return resolveCheck(check, actor)
})
const permittedEvents = bindMechanicalEventsToRolls(plan.proposedEvents, rolls)
const resolution = await narrateRound({ scene, intents, aiActions: plan.aiActions, rolls, permittedEvents })
const safeEvents = resolution.events.filter(event => permittedEvents.some(permitted => JSON.stringify(permitted) === JSON.stringify(event)))
const applied = applyMechanicalEvents(characters, safeEvents)
await databaseRequest(job.claimToken ? 'rpc/commit_claimed_round_resolution' : 'rpc/commit_round_resolution', {
method: 'POST',
body: JSON.stringify({
p_round_id: round.id,
p_narration: resolution.narration,
p_next_prompt: resolution.nextPrompt,
p_rolls: rolls,
p_events: safeEvents,
p_character_states: applied.characters.map(character => ({ id: character.id, hp: character.hp, inventory: character.inventory, statuses: character.statuses })),
p_memory: resolution.memory,
p_idempotency_key: job.id,
...(job.claimToken ? { p_worker_id: job.claimToken } : {}),
}),
})
return { narration: resolution.narration, rolls: rolls.length }
}
async function processJob(job: JobPayload) {
if (job.name === 'generate-world') return processWorld(job)
if (job.name === 'resolve-round') return processRound(job)
throw new Error(`Unknown job type: ${job.name}`)
}
async function withLeaseHeartbeat<T>(jobId: string, workerId: string, task: () => Promise<T>): Promise<T> {
let heartbeatError: unknown
let renewing = false
const heartbeat = setInterval(() => {
if (renewing) return
renewing = true
void databaseRequest('rpc/renew_ai_job_lease', {
method: 'POST', body: JSON.stringify({ p_job_id: jobId, p_worker_id: workerId }),
}).catch(error => { heartbeatError = error }).finally(() => { renewing = false })
}, 60_000)
heartbeat.unref()
try {
const result = await task()
if (heartbeatError) throw heartbeatError
return result
} finally {
clearInterval(heartbeat)
}
}
if (redisUrl) {
const connection = new IORedis(redisUrl, { maxRetriesPerRequest: null })
const workerId = process.env.RAILWAY_REPLICA_ID ?? makeId('worker')
const worker = new Worker('dng-ai', async job => {
if (!job.id) throw new Error('BullMQ job id must match an ai_jobs id')
const [claimed] = await databaseRequest<ClaimedJob[]>('rpc/claim_ai_job_by_id', {
method: 'POST', body: JSON.stringify({ p_job_id: String(job.id), p_worker_id: workerId }),
})
if (!claimed) throw new Error(`AI job ${job.id} is not claimable`)
try {
return await withLeaseHeartbeat(claimed.id, workerId, () => processJob({
id: claimed.id,
name: claimed.job_type,
entityId: claimed.entity_id,
messages: job.data.messages,
claimToken: workerId,
}))
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
await databaseRequest('rpc/retry_ai_job', {
method: 'POST', body: JSON.stringify({ p_job_id: claimed.id, p_worker_id: workerId, p_error: message.slice(0, 2_000) }),
})
throw error
}
}, { connection, concurrency: 4, lockDuration: 120_000 })
console.info('[worker] Dungeons & Ground worker ready (BullMQ)')
const close = async () => {
await worker.close()
await connection.quit()
}
process.once('SIGTERM', () => void close())
process.once('SIGINT', () => void close())
} else {
const pollInterval = Math.max(250, Number(process.env.AI_JOB_POLL_INTERVAL_MS ?? 1_000))
const workerId = process.env.RAILWAY_REPLICA_ID ?? makeId('worker')
let stopped = false
const stop = () => { stopped = true }
process.once('SIGTERM', stop)
process.once('SIGINT', stop)
console.info(`[worker] Dungeons & Ground worker ready (Supabase polling every ${pollInterval}ms)`)
while (!stopped) {
try {
const claimed = await databaseRequest<ClaimedJob[]>('rpc/claim_ai_job', {
method: 'POST', body: JSON.stringify({ p_worker_id: workerId }),
})
const job = claimed[0]
if (!job) {
await new Promise(resolve => setTimeout(resolve, pollInterval))
continue
}
try {
await withLeaseHeartbeat(job.id, workerId, () => processJob({ id: job.id, name: job.job_type, entityId: job.entity_id, claimToken: workerId }))
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
console.error(`[worker] ${job.job_type} ${job.id} failed: ${message}`)
await databaseRequest('rpc/retry_ai_job', {
method: 'POST',
body: JSON.stringify({ p_job_id: job.id, p_worker_id: workerId, p_error: message.slice(0, 2_000) }),
})
}
} catch (error) {
if (error instanceof SupabaseRequestError && error.status === 404 && error.path.startsWith('rpc/')) {
console.error(`[worker] ${error.message}`)
process.exitCode = 1
break
}
console.error('[worker] polling failed:', error)
await new Promise(resolve => setTimeout(resolve, pollInterval))
}
}
}
}

View File

@@ -0,0 +1,4 @@
{
"extends": "../../tsconfig.json",
"include": ["src/**/*.ts"]
}