This commit is contained in:
139
packages/game-engine/src/index.ts
Normal file
139
packages/game-engine/src/index.ts
Normal file
@@ -0,0 +1,139 @@
|
||||
import { randomInt } from 'node:crypto'
|
||||
import type { AbilityKey, Character, CheckRequest, DiceRoll, ProposedEvent } from '@dng/shared'
|
||||
import { makeId } from '@dng/shared'
|
||||
|
||||
export interface RandomSource {
|
||||
integer(min: number, max: number): number
|
||||
}
|
||||
|
||||
export const secureRandom: RandomSource = {
|
||||
integer(min, max) {
|
||||
if (!Number.isInteger(min) || !Number.isInteger(max) || max < min) throw new Error('Invalid random range')
|
||||
return randomInt(min, max + 1)
|
||||
},
|
||||
}
|
||||
|
||||
export function abilityModifier(score: number): number {
|
||||
return Math.floor((score - 10) / 2)
|
||||
}
|
||||
|
||||
export function rollDice(formula: string, random: RandomSource = secureRandom) {
|
||||
const match = /^(\d+)d(\d+)(?:([+-])(\d+))?$/.exec(formula)
|
||||
if (!match) throw new Error(`Unsupported dice formula: ${formula}`)
|
||||
const count = Number(match[1])
|
||||
const sides = Number(match[2])
|
||||
const modifier = match[3] ? Number(`${match[3]}${match[4]}`) : 0
|
||||
if (count < 1 || count > 100 || sides < 2 || sides > 1000) throw new Error('Dice formula outside safe limits')
|
||||
const rolls = Array.from({ length: count }, () => random.integer(1, sides))
|
||||
return { rolls, modifier, total: rolls.reduce((sum, value) => sum + value, modifier) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts model-proposed damage/healing events into server-authoritative events.
|
||||
* The model may decide that a roll is needed, but it cannot choose its result.
|
||||
*/
|
||||
export function bindMechanicalEventsToRolls(events: ProposedEvent[], rolls: DiceRoll[]): ProposedEvent[] {
|
||||
const unusedRollIds = new Set(rolls.map(roll => roll.id))
|
||||
|
||||
return events.flatMap(event => {
|
||||
if (event.type !== 'damage' && event.type !== 'healing') return [event]
|
||||
if (!event.targetId) return []
|
||||
|
||||
const roll = rolls.find(candidate =>
|
||||
unusedRollIds.has(candidate.id)
|
||||
&& candidate.checkKind === event.type
|
||||
&& candidate.actorId === event.actorId
|
||||
&& candidate.targetId === event.targetId,
|
||||
)
|
||||
if (!roll) return []
|
||||
|
||||
unusedRollIds.delete(roll.id)
|
||||
return [{ ...event, value: Math.max(0, roll.total) }]
|
||||
})
|
||||
}
|
||||
|
||||
function abilityForCheck(request: CheckRequest): AbilityKey {
|
||||
if (request.ability) return request.ability
|
||||
if (request.kind === 'initiative') return 'dex'
|
||||
return 'str'
|
||||
}
|
||||
|
||||
export function resolveCheck(request: CheckRequest, actor: Character, random: RandomSource = secureRandom): DiceRoll {
|
||||
if (request.kind === 'damage' || request.kind === 'healing') {
|
||||
const rolled = rollDice(request.dice ?? '1d6', random)
|
||||
return {
|
||||
id: makeId('roll'),
|
||||
checkKind: request.kind,
|
||||
formula: request.dice ?? '1d6',
|
||||
rolls: rolled.rolls,
|
||||
kept: rolled.rolls,
|
||||
modifier: rolled.modifier,
|
||||
total: rolled.total,
|
||||
difficulty: null,
|
||||
success: null,
|
||||
actorId: actor.id,
|
||||
targetId: request.targetId ?? null,
|
||||
createdAt: new Date().toISOString(),
|
||||
}
|
||||
}
|
||||
|
||||
const ability = abilityForCheck(request)
|
||||
const diceCount = request.mode === 'normal' ? 1 : 2
|
||||
const rolls = Array.from({ length: diceCount }, () => random.integer(1, 20))
|
||||
const keptValue = request.mode === 'advantage' ? Math.max(...rolls) : request.mode === 'disadvantage' ? Math.min(...rolls) : rolls[0]!
|
||||
const modifier = abilityModifier(actor.abilities[ability]) + actor.proficiency
|
||||
const total = keptValue + modifier
|
||||
const difficulty = request.difficulty ?? null
|
||||
return {
|
||||
id: makeId('roll'),
|
||||
checkKind: request.kind,
|
||||
formula: request.mode === 'normal'
|
||||
? `1d20${modifier >= 0 ? '+' : ''}${modifier}`
|
||||
: `2d20${request.mode === 'advantage' ? 'kh1' : 'kl1'}${modifier >= 0 ? '+' : ''}${modifier}`,
|
||||
rolls,
|
||||
kept: [keptValue],
|
||||
modifier,
|
||||
total,
|
||||
difficulty,
|
||||
success: difficulty === null ? null : total >= difficulty,
|
||||
actorId: actor.id,
|
||||
targetId: request.targetId ?? null,
|
||||
createdAt: new Date().toISOString(),
|
||||
}
|
||||
}
|
||||
|
||||
export interface AppliedState {
|
||||
characters: Character[]
|
||||
audit: Array<{ event: ProposedEvent; before: unknown; after: unknown }>
|
||||
}
|
||||
|
||||
export function applyMechanicalEvents(characters: Character[], events: ProposedEvent[]): AppliedState {
|
||||
const next = characters.map(character => ({ ...character, inventory: [...character.inventory], statuses: [...character.statuses] }))
|
||||
const audit: AppliedState['audit'] = []
|
||||
|
||||
for (const event of events) {
|
||||
if (!['damage', 'healing', 'inventory', 'status'].includes(event.type)) continue
|
||||
const target = next.find(character => character.id === event.targetId)
|
||||
if (!target) throw new Error(`Unknown event target: ${event.targetId}`)
|
||||
const before = structuredClone(target)
|
||||
|
||||
if (event.type === 'damage') target.hp = Math.max(0, target.hp - Math.max(0, event.value ?? 0))
|
||||
if (event.type === 'healing') target.hp = Math.min(target.maxHp, target.hp + Math.max(0, event.value ?? 0))
|
||||
if (event.type === 'inventory' && event.item) {
|
||||
if ((event.value ?? 1) >= 0 && !target.inventory.includes(event.item)) target.inventory.push(event.item)
|
||||
if ((event.value ?? 1) < 0) target.inventory = target.inventory.filter(item => item !== event.item)
|
||||
}
|
||||
if (event.type === 'status' && event.item) {
|
||||
if ((event.value ?? 1) >= 0 && !target.statuses.includes(event.item)) target.statuses.push(event.item)
|
||||
if ((event.value ?? 1) < 0) target.statuses = target.statuses.filter(status => status !== event.item)
|
||||
}
|
||||
audit.push({ event, before, after: structuredClone(target) })
|
||||
}
|
||||
|
||||
return { characters: next, audit }
|
||||
}
|
||||
|
||||
export function shouldCloseRound(activeHumanMemberIds: string[], readyMemberIds: string[], forcedByOwner = false): boolean {
|
||||
if (forcedByOwner) return true
|
||||
return activeHumanMemberIds.length > 0 && activeHumanMemberIds.every(id => readyMemberIds.includes(id))
|
||||
}
|
||||
Reference in New Issue
Block a user