No description
Find a file
McNieps 9170154e4d Fix Heartsteel's init crash: plugins register before the anthem solve; same-entity anthem reads don't re-enter the rebuild
Two bugs, both hit by Heartsteel (team max_hp += team vitality):

1. EngineContext solved anthems in its constructor, before run_battle let
   plugins register, so an anthem on a plugin-owned query step (the faction
   filter behind ALLIED_TEAM) raised 'Unknown filter step type faction'.
   EngineContext now takes the plugin list and registers it right before the
   solve; run_battle passes it. Callers without plugins are unchanged.

2. _recompute_stats(team) reads an anthem edge's source through
   effective_attribute; a same-entity source off the fast path (absent at
   battle start) re-entered _recompute_stats on the same entity forever.
   A re-entrancy guard serves that read from base + flat modifiers + flat
   anthem contributions while the rebuild is in flight. Regression test
   (fails without the guard).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 18:52:07 +02:00
bench Weapon clocks and catch-up Advance Clock (data TODO #40) 2026-10-02 16:27:10 +02:00
spec Add design doc for effective tags: clause-level tags aggregated onto their carrier 2026-08-16 15:53:44 +02:00
src Fix Heartsteel's init crash: plugins register before the anthem solve; same-entity anthem reads don't re-enter the rebuild 2026-10-02 18:52:07 +02:00
tests Fix Heartsteel's init crash: plugins register before the anthem solve; same-entity anthem reads don't re-enter the rebuild 2026-10-02 18:52:07 +02:00
.busted Rewrite: replace Python engine with LuaJIT implementation 2026-06-04 11:51:17 +02:00
.gitignore Rewrite: replace Python engine with LuaJIT implementation 2026-06-04 11:51:17 +02:00
.luacheckrc Rewrite: replace Python engine with LuaJIT implementation 2026-06-04 11:51:17 +02:00
CLAUDE.md Mark the attributes-unification spec superseded; fix stale paths 2026-08-12 14:03:05 +02:00
further_optimization.md Convert EngineContext methods to plain module functions for JIT tracing 2026-07-07 21:05:28 +02:00
Makefile Rewrite: replace Python engine with LuaJIT implementation 2026-06-04 11:51:17 +02:00
README.md Mark the attributes-unification spec superseded; fix stale paths 2026-08-12 14:03:05 +02:00
run_battle.lua Wire the keen plugin into the CLI and the test runner 2026-08-09 10:26:13 +02:00
TODO.md display: repoint spec refs to client_rendering_spec.md; commit pending #37 engine half 2026-08-22 16:59:21 +02:00

battle_engine

A deterministic, game-agnostic battle simulation engine — LuaJIT, runs inside the Godot client via LuaJIT/GDNative.

local engine = require("src.init")
local log = engine.run_battle(root_entity, config)
print(engine.log_to_json(log))

The engine is a pure function: same seed + same entity tree → byte-identical BattleLog. No I/O, no global state, no floats in battle math.

Numeric Strategy: FFI int64_t Fixed-Point

Decision Rationale
LuaJIT FFI int64_t LuaJIT has no native integer type (Lua 5.1 lineage). FFI int64_t gives true 64-bit integers that JIT-compile to native CPU instructions.
Fixed-point scale: 1,000,000 1.0 = 1,000,000 internal units. 6 decimal places of precision; fp.ratio(2, 3) → (2 * SCALE) // 3.
No floats in state All attribute values and modifiers use int64_t scaled integers. Truncation to int happens only at read sites via fp.to_int(). Float64 is used only in Phase 1 matrix inversion ((I-A)^-1), then rounded back to fixed-point.

Stat resolution formula with fixed-point:

-- Python:  int((base + bonus) * mult)   where mult = Fraction(2, 3)
-- Lua:     ((base + bonus) * mult) // SCALE   where mult = (2 * SCALE) // 3

Project Structure

battle_engine/
├── README.md                 -- This file
├── CLAUDE.md                 -- Full architecture reference for Claude Code
├── src/
│   ├── init.lua              -- Public API (mirrors battle_engine/__init__.py)
│   ├── fixed_point.lua       -- int64_t fixed-point math library
│   ├── prng.lua              -- Seeded PRNG (xoshiro128**)
│   ├── models/               -- Passive data types (plain Lua tables)
│   │   ├── init.lua
│   │   ├── entity.lua        -- EntityDefinition, EntityInstance
│   │   ├── state.lua         -- BattleState, EntityState
│   │   ├── refs.lua          -- EntityRef
│   │   ├── clause.lua        -- Clause (single `effect` field), EngineClause
│   │   ├── effects.lua       -- leaf effect types + 5 composite nodes (Sequence, Pipeline, Gated, Repeat, Spend)
│   │   ├── triggers.lua      -- trigger types
│   │   ├── conditions.lua    -- condition types
│   │   ├── value_source.lua  -- FlatValue, AttributeValue, PayloadValue, PaymentSpentValue
│   │   ├── query.lua         -- Query + structural/filter/sort steps
│   │   ├── clause_query.lua  -- ClauseQuery
│   │   ├── stats.lua         -- StatLayers, FlatModifier, MultModifier, ConvertModifier
│   │   ├── charge.lua        -- ChargeConfig
│   │   ├── config.lua        -- BattleConfig, TickOrderPolicy
│   │   └── battle_log.lua    -- BattleLog + all event types
│   └── engine/                    -- Active engine code (all mutation lives here)
│       ├── init.lua
│       ├── context.lua            -- EngineContext (single access point for all state)
│       ├── tick.lua               -- run_tick, run_battle
│       ├── fire_clause.lua        -- Single clause-firing path
│       ├── effects.lua            -- Effect executor (dispatch by type string)
│       ├── pipeline.lua           -- BeforeAttributeChangeTrigger pipeline
│       ├── attribute_resolver.lua -- Attribute resolution + dirty-bit cache
│       ├── attribute_solver.lua   -- Circular-attribute matrix solver (Tarjan SCC + Gauss-Jordan)
│       ├── subscriptions.lua      -- OnEntityTriggeredTrigger / OnClauseSucceededTrigger dispatch
│       ├── anthem.lua             -- Anthem edge extractor (feeds circular attribute solver)
│       ├── plugin_loader.lua      -- Plugin registration, reserved-hook-name checks
│       ├── initializer.lua        -- init_battle (tree → ref assignment)
│       └── log.lua                -- build_log, log_to_json
├── tests/                    -- Custom test runner (no busted required)
│   ├── run.lua                -- Entry point; `test_files` table lists every suite
│   ├── test_scenarios.lua     -- Canonical parity scenarios
│   ├── test_hooks.lua         -- Hook & plugin system tests
│   └── ...
├── bench/                    -- Performance benchmarks
└── Makefile

Game-specific behaviour (layout, faction, status effects, …) lives entirely in plugins under data/battle/plugins/ in the content repo, never in src/ — see CLAUDE.md's Plugin System section.

Build & Test

# Run all tests (custom runner, no external deps beyond LuaJIT)
luajit tests/run.lua

# Run a single test file or filter by name
luajit tests/run.lua test_effects

make test / make bench remain available as thin wrappers (see Makefile) but the canonical, actively-maintained entry point is tests/run.lua.

Status

Feature-complete.

Area File(s) Status
Models (entities, state, refs, clause, effects, triggers…) models/ ✅
BattleConfig, init_battle models/config.lua, engine/initializer.lua ✅
Attribute resolver + dirty-bit cache engine/attribute_resolver.lua ✅
EngineContext (navigation, attribute, target, value, condition) engine/context.lua ✅
Effect executor + change pipeline (leaf + composite effect algebra) engine/effects.lua, engine/pipeline.lua ✅
Single clause-firing path (Clause.effect tree) engine/fire_clause.lua ✅
Tick loop + run_battle engine/tick.lua ✅
Circular attribute solver (Tarjan SCC + (I-A)^-1) engine/attribute_solver.lua ✅
Anthem edge extraction (feeds attribute solver) engine/anthem.lua ✅
OnEntityTriggeredTrigger / OnClauseSucceededTrigger dispatch engine/subscriptions.lua ✅
BattleLog event emission + JSON serialisation engine/log.lua ✅
Hook & Plugin System (HookTrigger, injected clauses, control-flow effects) engine/tick.lua, engine/fire_clause.lua, engine/effects.lua, engine/context.lua, engine/plugin_loader.lua ✅
Performance benchmark bench/bench_battle.lua ✅

Key Design Decisions

  1. Plain Lua tables, not classes. Type-checking is convention-based with type discriminator strings. No entry-boundary validation — the engine trusts callers pass well-formed data. Inside the engine, trust the types — no defensive nil-checks.

  2. Dispatch by type string, not instanceof. effect.type == "alter_attribute" → dispatch to _handlers.alter_attribute(ctx, effect, fire_ctx, cause_chain).

  3. EngineContext is the only state access point. All engine functions receive ctx and call ctx.effective_attribute, ctx.resolve_targets, ctx.attribute, etc. using dot syntax (not colon). Never read ctx.state fields directly when a method exists.

  4. Deterministic iteration. pairs() order is undefined. All iteration over attached tables sorts keys first. PRNG is module-global in src/prng.lua, re-seeded at the start of each init_battle; never call math.random.

  5. Fixed-point arithmetic. All battle math uses int64_t with SCALE=1,000,000. fp.ratio(2, 3) = (2 * SCALE) // 3. Float64 is used only in Phase 1 matrix inversion, then rounded back to fixed-point.

  6. Circular attributes via matrix inversion. Anthem edges (trigger=nil clauses) and ConvertModifier cross-attribute edges feed into a single (I-A)x=b system solved via Gauss-Jordan. Jacobi iteration does not exist in this engine.

  7. Cause chains as arrays. Appended to immutably per clause firing — append a copy, never mutate the caller's chain.

  8. Effect algebra, not special-cased clause fields. A clause carries one effect (a leaf, or a Sequence/Pipeline/Gated/Repeat/Spend composite) instead of the old payment/times/flat effects list — costs, ordering, and repetition are all ordinary effect nodes. See CLAUDE.md's Single Clause-Firing Path section.