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

View File

@@ -0,0 +1,20 @@
{
"name": "@dng/game-engine",
"version": "0.1.0",
"private": true,
"type": "module",
"engines": {
"node": ">=18.17"
},
"exports": "./src/index.ts",
"scripts": {
"typecheck": "tsc --noEmit -p tsconfig.json"
},
"dependencies": {
"@dng/shared": "workspace:*"
},
"devDependencies": {
"@types/node": "^18.19.0",
"typescript": "^5.9.2"
}
}

View File

@@ -0,0 +1,59 @@
import { describe, expect, it } from 'vitest'
import { abilityModifier, applyMechanicalEvents, bindMechanicalEventsToRolls, resolveCheck, shouldCloseRound, type RandomSource } from './index'
import type { Character } from '@dng/shared'
const fixed: RandomSource = { integer: () => 12 }
const hero: Character = {
id: 'hero', name: 'Rook', concept: 'Relic runner', controller: 'human', userId: 'user',
abilities: { str: 14, dex: 16, con: 12, int: 10, wis: 8, cha: 13 },
hp: 10, maxHp: 12, defense: 14, proficiency: 2, inventory: [], statuses: [],
}
describe('game engine', () => {
it('calculates ability modifiers', () => {
expect(abilityModifier(8)).toBe(-1)
expect(abilityModifier(16)).toBe(3)
})
it('keeps dice server-owned and auditable', () => {
const roll = resolveCheck({ actorId: 'hero', kind: 'ability', ability: 'dex', difficulty: 15, mode: 'normal', reason: 'Leap' }, hero, fixed)
expect(roll.rolls).toEqual([12])
expect(roll.total).toBe(17)
expect(roll.success).toBe(true)
})
it('labels disadvantage rolls correctly', () => {
const values = [18, 4]
const roll = resolveCheck(
{ actorId: 'hero', kind: 'ability', ability: 'dex', difficulty: 10, mode: 'disadvantage', reason: 'Sneak' },
hero,
{ integer: () => values.shift()! },
)
expect(roll.formula).toBe('2d20kl1+5')
expect(roll.kept).toEqual([4])
})
it('replaces model-proposed damage with the authoritative roll total', () => {
const damage = resolveCheck(
{ actorId: 'hero', targetId: 'target', kind: 'damage', dice: '1d6+2', mode: 'normal', reason: 'Strike' },
hero,
{ integer: () => 4 },
)
const events = bindMechanicalEventsToRolls([
{ type: 'damage', actorId: 'hero', targetId: 'target', value: 999, item: null, description: 'Strike' },
], [damage])
expect(events[0]?.value).toBe(6)
})
it('clamps mechanical state', () => {
const result = applyMechanicalEvents([hero], [{ type: 'damage', actorId: null, targetId: 'hero', value: 99, item: null, description: 'Catastrophic hit' }])
expect(result.characters[0]?.hp).toBe(0)
expect(result.audit).toHaveLength(1)
})
it('closes only complete or forced rounds', () => {
expect(shouldCloseRound(['a', 'b'], ['a'])).toBe(false)
expect(shouldCloseRound(['a', 'b'], ['a', 'b'])).toBe(true)
expect(shouldCloseRound(['a', 'b'], [], true)).toBe(true)
})
})

View 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))
}

View File

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