Docs/@katforge/tactician

@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.