@katforge/tactician
A deterministic combat engine. Given the same configuration and RNG seed, the same encounter always produces the same result. Used by Gear Goblins for all PvE combat.
shell
npm install @katforge/tactician
Simulate an encounter
TypeScript
import { simulate } from '@katforge/tactician';
const sim = simulate ({
party: resolveParty (partyBuild, codex),
enemies: resolveMonster ('id.monster.goblin_chief', codex),
seed: encounterSeed,
maxTurns: 100
});
// Step through
while (!sim.done) {
sim.tick ();
}
// Or run to completion
const result = sim.run ();
result.won // boolean
result.turns // number
result.loot // LootEntry[]
result.events // DamageEvent[], HealEvent[], DeathEvent[], ...
Why deterministic
The same seed produces the same outcome on the server and the client. Benefits:
- Replay: store just the seed + config, reconstruct the full combat log
- Anti-cheat: server validates the client's claimed result by re-simulating
- Idle mode: skip the animation, compute the outcome instantly
Encounter flow
stateDiagram-v2
[*] --> Setup: configure party + enemies
Setup --> TurnLoop: init
TurnLoop --> TurnLoop: tick (resolve spells, apply damage, check deaths)
TurnLoop --> Victory: all enemies dead
TurnLoop --> Defeat: all party dead
TurnLoop --> Timeout: maxTurns exceeded
Victory --> LootRoll: roll loot table
LootRoll --> [*]
Defeat --> [*]
Timeout --> [*]Resolve helpers
Build encounter configs from Codex definitions:
TypeScript
import { resolve, resolveSpell, resolveMonster, resolveParty } from '@katforge/tactician';
// Resolve a party build (character + equipped spells + gear stats)
const party = resolveParty ({
stats: { hp: 500, attack: 45, defense: 20, speed: 12 },
spells: [
resolveSpell ('id.spell.fireball', codex),
resolveSpell ('id.spell.heal', codex)
]
}, codex);
// Resolve a monster (stats, abilities, loot table from Codex)
const enemies = resolveMonster ('id.monster.goblin_chief', codex);
Event log
Every action in the simulation emits a typed event:
TypeScript
type Events = {
damage: { source: number, target: number, amount: number, spell?: string };
heal: { source: number, target: number, amount: number };
death: { unit: number };
cast: { caster: number, spell: string };
aggro: { unit: number, target: number, amount: number };
};
A finished result.events array tells a complete story of the encounter:
TypeScript
[
{ turn: 1, type: 'cast', caster: 'p1', spell: 'id.spell.fireball' },
{ turn: 1, type: 'damage', source: 'p1', target: 'e1', amount: 84, spell: 'id.spell.fireball' },
{ turn: 1, type: 'damage', source: 'p1', target: 'e2', amount: 84, spell: 'id.spell.fireball' },
{ turn: 1, type: 'death', unit: 'e2' },
{ turn: 2, type: 'aggro', unit: 'e1', target: 'p1', amount: 35 },
{ turn: 2, type: 'damage', source: 'e1', target: 'p1', amount: 22 },
{ turn: 3, type: 'cast', caster: 'p1', spell: 'id.spell.heal' },
{ turn: 3, type: 'heal', source: 'p1', target: 'p1', amount: 60 }
]
The client replays these events as animations. The server validates them for integrity by re-simulating from the seed and comparing the event stream.
Threat / aggro
Monsters target the party member with the highest threat. Healing generates threat. Taunts spike threat. The aggro system is inside aggro.ts and runs deterministically per-tick.