- Lua 99.9%
- Makefile 0.1%
known_gaps.md §7: a non-circular coefficient anthem edge (target += coeff * source) was extracted correctly but silently dropped by the matrix build, which only ever indexes circular nodes. Adds a live-read fallback (context.lua's _anthem_edge_map/_anthem_edge_reverse) mirroring the existing flat-constant fallback, with transitive invalidation on every write path (direct writes, modifier add/expiry, and matrix-solved sources feeding a non-circular target). Also lands the anthem-edge coefficient-scaling seam (register_anthem_edge_scaler, separate from the fire-time register_amount_scaler since anthem edges are never fired), gated to non-circular edges with a hard error if a scaler is registered while any anthem edge is fully circular — the option-B rollout data/TODO.md #34 calls for, deferring the circular case's dirty-tracking until real content needs it. 614/618 -> 618/618 across this session's incremental runs; final: 618 passed, 0 failed. |
||
|---|---|---|
| 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.