feat(gameplay): add versioned SRD rules migration
Some checks failed
CI / validate (push) Failing after 3m36s
CI / validate (pull_request) Failing after 11s

Co-authored-by: multica-agent <github@multica.ai>
This commit is contained in:
2026-08-26 08:45:50 +05:00
parent 89fd1fd984
commit 6c39188027
18 changed files with 1143 additions and 42 deletions

View File

@@ -17,6 +17,9 @@ const worldFixture = () => ({
defense: 12,
proficiency: 2,
inventory: ['Field kit'],
skillProficiencies: ['arcana', 'investigation', 'perception', 'survival'],
skillExpertise: ['investigation'],
savingThrowProficiencies: ['int', 'wis'],
persona: { voice: 'Distinctive and clear.', motivation: 'Help the party.', flaw: 'Has a private agenda.', bond: 'Believes in the party.' },
})),
hook: 'A sufficiently long hook that immediately gives the party something to investigate.',

View File

@@ -89,7 +89,7 @@ async function structuredRequest<T>(name: string, schema: JsonSchema, messages:
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, two factions, and three distinct persistent AI companion heroes created specifically for this universe. Give each companion complete playable mechanics, useful genre-appropriate equipment, and a persona that produces interesting party choices without overriding the human player. Make the opening immediately playable.' },
{ 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
@@ -100,7 +100,7 @@ export async function generateWorld(messages: Array<{ role: 'user' | 'assistant'
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, 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. Set proficient true for a skill or saving throw only when the character concept supports it; ordinary equipped-weapon attacks default to proficient. Request separate damage or healing dice only after an applicable action. Never invent dice results and never directly mutate mechanical state. The server owns rules, AC, HP, inventory, statuses and randomness.' },
{ 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))

View File

@@ -135,7 +135,8 @@ if (!supabaseUrl || !serviceKey) {
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, persona: row.persona,
defense: row.defense, proficiency: row.proficiency, inventory: row.inventory, statuses: row.statuses,
persona: row.persona, rules: row.rules_state,
}))
const intents = rawIntents.map(row => PlayerIntentSchema.parse({
id: row.id, roundId: row.round_id, memberId: row.member_id, characterId: row.character_id,
@@ -157,7 +158,7 @@ if (!supabaseUrl || !serviceKey) {
const permittedEvents = bindMechanicalEventsToRolls(plan.proposedEvents, rolls)
const resolution = await narrateRound({ context, actionSequence: buildActionSequence(context, plan), rolls, permittedEvents })
const safeEvents = resolution.events.filter(event => permittedEvents.some(permitted => JSON.stringify(permitted) === JSON.stringify(event)))
const applied = applyMechanicalEvents(characters, safeEvents)
const applied = applyMechanicalEvents(characters, safeEvents, rolls)
const shouldSummarize = shouldUpdateStorySummary(Number(round.number))
const summary = shouldSummarize
? await summarizeStory({
@@ -166,7 +167,7 @@ if (!supabaseUrl || !serviceKey) {
})
: null
await databaseRequest(job.claimToken ? 'rpc/commit_claimed_round_resolution' : 'rpc/commit_round_resolution', {
await databaseRequest(job.claimToken ? 'rpc/commit_claimed_srd_round_resolution' : 'rpc/commit_srd_round_resolution', {
method: 'POST',
body: JSON.stringify({
p_round_id: round.id,
@@ -174,7 +175,13 @@ if (!supabaseUrl || !serviceKey) {
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_character_states: applied.characters.map(character => ({
id: character.id,
hp: character.hp,
inventory: character.inventory,
statuses: character.statuses,
rulesState: character.rules,
})),
p_memory: sanitizeRoundMemory(resolution.memory),
p_idempotency_key: job.id,
...(job.claimToken ? { p_worker_id: job.claimToken } : {}),

View File

@@ -69,15 +69,19 @@ export function validateAndOrderRoundPlan(planInput: unknown, context: RoundCont
if (['ability', 'skill', 'savingThrow'].includes(check.kind) && check.difficulty === undefined) {
throw new Error(`${check.kind} checks require a Difficulty Class`)
}
if (check.kind === 'deathSavingThrow' && check.targetId) throw new Error('Death saving throws cannot target another character')
if (check.kind === 'attack' && !check.targetId) throw new Error('Attack rolls require a target')
if (['damage', 'healing'].includes(check.kind) && !check.targetId) throw new Error(`${check.kind} rolls require a target`)
}
for (const event of plan.proposedEvents) {
if (event.actorId && !characterIds.has(event.actorId)) throw new Error(`Unknown event actor ${event.actorId}`)
if (event.targetId && !characterIds.has(event.targetId)) throw new Error(`Unknown event target ${event.targetId}`)
if (['damage', 'healing', 'inventory', 'status'].includes(event.type) && !event.targetId) {
if (['damage', 'healing', 'inventory', 'status', 'temporaryHp', 'resource', 'rest'].includes(event.type) && !event.targetId) {
throw new Error(`${event.type} event requires a target`)
}
if (event.type === 'damage' && !event.damageType) throw new Error('damage events require a damage type')
if (event.type === 'resource' && !event.item) throw new Error('resource events require a resource name')
if (event.type === 'rest' && !['short', 'long'].includes(String(event.item))) throw new Error('rest events require short or long')
}
const aiActions = plan.aiActions