feat(gameplay): add versioned SRD rules migration
Co-authored-by: multica-agent <github@multica.ai>
This commit is contained in:
@@ -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.',
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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 } : {}),
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user