This commit is contained in:
20
packages/game-engine/package.json
Normal file
20
packages/game-engine/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
59
packages/game-engine/src/index.test.ts
Normal file
59
packages/game-engine/src/index.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
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))
|
||||
}
|
||||
4
packages/game-engine/tsconfig.json
Normal file
4
packages/game-engine/tsconfig.json
Normal file
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"include": ["src/**/*.ts"]
|
||||
}
|
||||
20
packages/shared/package.json
Normal file
20
packages/shared/package.json
Normal file
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"name": "@dng/shared",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"engines": {
|
||||
"node": ">=18.17"
|
||||
},
|
||||
"exports": "./src/index.ts",
|
||||
"scripts": {
|
||||
"typecheck": "tsc --noEmit -p tsconfig.json"
|
||||
},
|
||||
"dependencies": {
|
||||
"zod": "^4.1.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^18.19.0",
|
||||
"typescript": "^5.9.2"
|
||||
}
|
||||
}
|
||||
157
packages/shared/src/index.ts
Normal file
157
packages/shared/src/index.ts
Normal file
@@ -0,0 +1,157 @@
|
||||
import { z } from 'zod'
|
||||
export * from './moderation'
|
||||
|
||||
export const abilityKeys = ['str', 'dex', 'con', 'int', 'wis', 'cha'] as const
|
||||
export const AbilityKeySchema = z.enum(abilityKeys)
|
||||
export type AbilityKey = z.infer<typeof AbilityKeySchema>
|
||||
|
||||
export const AbilityScoresSchema = z.object({
|
||||
str: z.number().int().min(1).max(30),
|
||||
dex: z.number().int().min(1).max(30),
|
||||
con: z.number().int().min(1).max(30),
|
||||
int: z.number().int().min(1).max(30),
|
||||
wis: z.number().int().min(1).max(30),
|
||||
cha: z.number().int().min(1).max(30),
|
||||
})
|
||||
export type AbilityScores = z.infer<typeof AbilityScoresSchema>
|
||||
|
||||
export const WorldEntitySchema = z.object({
|
||||
id: z.string().min(1),
|
||||
kind: z.enum(['location', 'npc', 'faction', 'quest']),
|
||||
name: z.string().min(1).max(120),
|
||||
summary: z.string().min(1).max(1200),
|
||||
tags: z.array(z.string().max(40)).max(12).default([]),
|
||||
secrets: z.array(z.string().max(500)).max(6).default([]),
|
||||
})
|
||||
export type WorldEntity = z.infer<typeof WorldEntitySchema>
|
||||
|
||||
export const WorldStarterSchema = z.object({
|
||||
title: z.string().min(3).max(100),
|
||||
genre: z.string().min(2).max(80),
|
||||
tone: z.string().min(2).max(160),
|
||||
premise: z.string().min(20).max(1800),
|
||||
contentBoundaries: z.array(z.string().max(120)).min(1).max(8),
|
||||
startingLocation: WorldEntitySchema.extend({ kind: z.literal('location') }),
|
||||
npcs: z.array(WorldEntitySchema.extend({ kind: z.literal('npc') })).length(3),
|
||||
factions: z.array(WorldEntitySchema.extend({ kind: z.literal('faction') })).length(2),
|
||||
hook: z.string().min(20).max(1200),
|
||||
hiddenThreat: z.string().min(10).max(1000),
|
||||
openingScene: z.string().min(40).max(2400),
|
||||
})
|
||||
export type WorldStarter = z.infer<typeof WorldStarterSchema>
|
||||
|
||||
export const CharacterSchema = z.object({
|
||||
id: z.string().min(1),
|
||||
name: z.string().min(1).max(80),
|
||||
concept: z.string().min(3).max(600),
|
||||
controller: z.enum(['human', 'ai', 'delegated']),
|
||||
userId: z.string().nullable().default(null),
|
||||
abilities: AbilityScoresSchema,
|
||||
hp: z.number().int().min(0),
|
||||
maxHp: z.number().int().min(1),
|
||||
defense: z.number().int().min(1).max(40),
|
||||
proficiency: z.number().int().min(1).max(10),
|
||||
inventory: z.array(z.string().max(120)).max(50).default([]),
|
||||
statuses: z.array(z.string().max(80)).max(12).default([]),
|
||||
})
|
||||
export type Character = z.infer<typeof CharacterSchema>
|
||||
|
||||
export const PlayerIntentSchema = z.object({
|
||||
id: z.string().min(1),
|
||||
roundId: z.string().min(1),
|
||||
memberId: z.string().min(1),
|
||||
characterId: z.string().min(1),
|
||||
action: z.string().trim().min(1).max(2000),
|
||||
ready: z.boolean().default(false),
|
||||
createdAt: z.string().datetime(),
|
||||
updatedAt: z.string().datetime(),
|
||||
})
|
||||
export type PlayerIntent = z.infer<typeof PlayerIntentSchema>
|
||||
|
||||
export const CheckRequestSchema = z.object({
|
||||
actorId: z.string().min(1),
|
||||
kind: z.enum(['ability', 'attack', 'initiative', 'damage', 'healing']),
|
||||
ability: AbilityKeySchema.optional(),
|
||||
difficulty: z.number().int().min(1).max(40).optional(),
|
||||
targetId: z.string().optional(),
|
||||
mode: z.enum(['normal', 'advantage', 'disadvantage']).default('normal'),
|
||||
dice: z.string().regex(/^\d+d\d+(?:[+-]\d+)?$/).optional(),
|
||||
reason: z.string().min(1).max(500),
|
||||
})
|
||||
export type CheckRequest = z.infer<typeof CheckRequestSchema>
|
||||
|
||||
export const DiceRollSchema = z.object({
|
||||
id: z.string(),
|
||||
checkKind: CheckRequestSchema.shape.kind,
|
||||
formula: z.string(),
|
||||
rolls: z.array(z.number().int()),
|
||||
kept: z.array(z.number().int()),
|
||||
modifier: z.number().int(),
|
||||
total: z.number().int(),
|
||||
difficulty: z.number().int().nullable(),
|
||||
success: z.boolean().nullable(),
|
||||
actorId: z.string(),
|
||||
targetId: z.string().nullable(),
|
||||
createdAt: z.string().datetime(),
|
||||
})
|
||||
export type DiceRoll = z.infer<typeof DiceRollSchema>
|
||||
|
||||
export const ProposedEventSchema = z.object({
|
||||
type: z.enum(['narrative', 'relationship', 'quest', 'inventory', 'status', 'damage', 'healing']),
|
||||
actorId: z.string().nullable().default(null),
|
||||
targetId: z.string().nullable().default(null),
|
||||
value: z.number().int().nullable().default(null),
|
||||
item: z.string().max(120).nullable().default(null),
|
||||
description: z.string().min(1).max(1000),
|
||||
})
|
||||
export type ProposedEvent = z.infer<typeof ProposedEventSchema>
|
||||
|
||||
export const RoundPlanSchema = z.object({
|
||||
checks: z.array(CheckRequestSchema).max(20),
|
||||
aiActions: z.array(z.object({
|
||||
characterId: z.string(),
|
||||
action: z.string().min(1).max(800),
|
||||
})).max(8),
|
||||
proposedEvents: z.array(ProposedEventSchema).max(30),
|
||||
relevantMemoryQueries: z.array(z.string().max(120)).max(8),
|
||||
})
|
||||
export type RoundPlan = z.infer<typeof RoundPlanSchema>
|
||||
|
||||
export const RoundResolutionSchema = z.object({
|
||||
narration: z.string().min(20).max(6000),
|
||||
events: z.array(ProposedEventSchema).max(30),
|
||||
memory: z.object({
|
||||
summary: z.string().min(10).max(1200),
|
||||
importance: z.number().int().min(1).max(5),
|
||||
tags: z.array(z.string().max(40)).max(12),
|
||||
entityIds: z.array(z.string()).max(20),
|
||||
}).nullable(),
|
||||
nextPrompt: z.string().min(3).max(500),
|
||||
})
|
||||
export type RoundResolution = z.infer<typeof RoundResolutionSchema>
|
||||
|
||||
export const CoauthorMessageSchema = z.object({
|
||||
role: z.enum(['user', 'assistant']),
|
||||
content: z.string().trim().min(1).max(5000),
|
||||
})
|
||||
|
||||
export const CreateWorldRequestSchema = z.object({
|
||||
messages: z.array(CoauthorMessageSchema).min(1).max(12),
|
||||
})
|
||||
|
||||
export const SubmitIntentRequestSchema = z.object({
|
||||
campaignId: z.string().min(1),
|
||||
characterId: z.string().min(1),
|
||||
action: z.string().trim().min(1).max(2000),
|
||||
ready: z.boolean().default(true),
|
||||
})
|
||||
|
||||
export type ID = string
|
||||
|
||||
export function makeId(prefix: string): string {
|
||||
const randomUuid = globalThis.crypto?.randomUUID?.()
|
||||
// IDs are identifiers, not authentication secrets. The fallback keeps the
|
||||
// shared browser/server contract usable in Node 18 VM contexts without Web Crypto.
|
||||
const id = randomUuid ?? `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}-${Math.random().toString(36).slice(2)}`
|
||||
return `${prefix}_${id}`
|
||||
}
|
||||
12
packages/shared/src/moderation.test.ts
Normal file
12
packages/shared/src/moderation.test.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { moderate13Plus } from './moderation'
|
||||
|
||||
describe('13+ moderation', () => {
|
||||
it('allows ordinary dark fantasy', () => {
|
||||
expect(moderate13Plus('A haunted knight fights skeletons beneath a ruined abbey.').allowed).toBe(true)
|
||||
})
|
||||
|
||||
it('rejects explicit material', () => {
|
||||
expect(moderate13Plus('Include explicit sex in the story.').allowed).toBe(false)
|
||||
})
|
||||
})
|
||||
21
packages/shared/src/moderation.ts
Normal file
21
packages/shared/src/moderation.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
export interface ModerationResult {
|
||||
allowed: boolean
|
||||
categories: string[]
|
||||
}
|
||||
|
||||
const explicitPatterns = [
|
||||
/\b(?:explicit sex|porn(?:ography)?|rape|sexual assault)\b/i,
|
||||
/\b(?:nude|naked)\s+(?:child|minor|teen)\b/i,
|
||||
/\b(?:child|minor|underage)\s+(?:sex|sexual|erotic)\b/i,
|
||||
]
|
||||
|
||||
const extremeGorePatterns = [
|
||||
/\b(?:graphic dismemberment|torture porn|extreme gore)\b/i,
|
||||
]
|
||||
|
||||
export function moderate13Plus(text: string): ModerationResult {
|
||||
const categories: string[] = []
|
||||
if (explicitPatterns.some(pattern => pattern.test(text))) categories.push('sexual_explicit')
|
||||
if (extremeGorePatterns.some(pattern => pattern.test(text))) categories.push('extreme_graphic_violence')
|
||||
return { allowed: categories.length === 0, categories }
|
||||
}
|
||||
4
packages/shared/tsconfig.json
Normal file
4
packages/shared/tsconfig.json
Normal file
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"include": ["src/**/*.ts"]
|
||||
}
|
||||
Reference in New Issue
Block a user