- Lua 99.9%
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> |
||
|---|---|---|
| bench | ||
| spec | ||
| src | ||
| tests | ||
| .busted | ||
| .gitignore | ||
| .luacheckrc | ||
| CLAUDE.md | ||
| further_optimization.md | ||
| Makefile | ||
| README.md | ||
| run_battle.lua | ||
| TODO.md | ||
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
-
Plain Lua tables, not classes. Type-checking is convention-based with
typediscriminator strings. No entry-boundary validation — the engine trusts callers pass well-formed data. Inside the engine, trust the types — no defensive nil-checks. -
Dispatch by
typestring, notinstanceof.effect.type == "alter_attribute"→ dispatch to_handlers.alter_attribute(ctx, effect, fire_ctx, cause_chain). -
EngineContext is the only state access point. All engine functions receive
ctxand callctx.effective_attribute,ctx.resolve_targets,ctx.attribute, etc. using dot syntax (not colon). Never readctx.statefields directly when a method exists. -
Deterministic iteration.
pairs()order is undefined. All iteration overattachedtables sorts keys first. PRNG is module-global insrc/prng.lua, re-seeded at the start of eachinit_battle; never callmath.random. -
Fixed-point arithmetic. All battle math uses
int64_twith 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. -
Circular attributes via matrix inversion. Anthem edges (trigger=nil clauses) and ConvertModifier cross-attribute edges feed into a single
(I-A)x=bsystem solved via Gauss-Jordan. Jacobi iteration does not exist in this engine. -
Cause chains as arrays. Appended to immutably per clause firing — append a copy, never mutate the caller's chain.
-
Effect algebra, not special-cased clause fields. A clause carries one
effect(a leaf, or aSequence/Pipeline/Gated/Repeat/Spendcomposite) instead of the oldpayment/times/flateffectslist — costs, ordering, and repetition are all ordinary effect nodes. See CLAUDE.md's Single Clause-Firing Path section.