- Lua 99.8%
- Makefile 0.2%
- (#6) CLAUDE.md and README.md claimed models are "validated at the run_battle() entry boundary." No such validation exists. Corrected both docs to state the engine trusts callers to pass well-formed data. - (#7) CLAUDE.md and README.md claimed PRNG state lives in BattleState.rng_state. Reality: prng.lua uses a module-global state, re-seeded at init_battle start; rng_state is an unused snapshot. Corrected both docs to describe the actual behavior. - (#8) _sorted_json returned "{}" for empty tables including empty cause_chain arrays. Changed to "[]". Added regression test. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> |
||
|---|---|---|
| .claude | ||
| bench | ||
| spec | ||
| src | ||
| tests | ||
| .busted | ||
| .gitignore | ||
| .luacheckrc | ||
| CLAUDE.md | ||
| Makefile | ||
| README.md | ||
| REMAINING_TASKS.md | ||
| URGENT_TODO.md | ||
lua_battle_engine
A deterministic, game-agnostic battle simulation engine — LuaJIT port of the
Python battle_engine. 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 stat values, multipliers, and effectiveness 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
lua_battle_engine/
├── README.md -- This file
├── 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, ChargePayment
│ │ ├── effects.lua -- 13 effect types
│ │ ├── triggers.lua -- 11 trigger types
│ │ ├── conditions.lua -- 9 condition types
│ │ ├── value_source.lua -- FlatValue, CounterValue, StatValue, PayloadValue
│ │ ├── target_ref.lua -- SelfRef, SourceRef, WalkRef, etc.
│ │ ├── entity_query.lua -- EntityQuery + structural/faction/filter/sort
│ │ ├── 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 -- BeforeStatChange/BeforeCounterChange pipeline
│ ├── stat_resolver.lua -- Stat resolution + dirty-bit cache
│ ├── subscriptions.lua -- OnEntityTriggeredTrigger dispatch
│ ├── anthem.lua -- Anthem edge extractor (feeds circular stat solver)
│ ├── initializer.lua -- init_battle (tree → ref assignment)
│ └── log.lua -- build_log, log_to_json
├── data/ -- Data plugins (status effects, intercepts)
│ ├── status/ -- stun, root, silence, poison
│ └── intercept/ -- nullify, redirect
├── tests/ -- Test suite (busted or custom runner)
│ ├── init.lua
│ ├── test_fixed_point.lua
│ ├── test_scenarios.lua -- 10 canonical parity scenarios
│ ├── test_hooks.lua -- 27 hook & plugin system tests
│ └── ...
├── spec/ -- busted configuration
│ └── init.lua
└── Makefile
Build & Test
# Install dependencies (LuaRocks)
make deps
# Run tests
make test
# Run a single test file
busted tests/test_scenarios.lua
Status
Feature-complete. 309 tests passing.
| Area | File(s) | Status |
|---|---|---|
| Models (entities, state, refs, clause, effects, triggers…) | models/ |
✅ |
| BattleConfig, init_battle | models/config.lua, engine/initializer.lua |
✅ |
| Stat resolver + dirty-bit cache | engine/stat_resolver.lua |
✅ |
| EngineContext (navigation, stat, target, value, condition) | engine/context.lua |
✅ |
| Effect executor + change pipeline | engine/effects.lua, engine/pipeline.lua |
✅ |
| Tick loop + run_battle | engine/tick.lua |
✅ |
Circular stat solver (Tarjan SCC + (I-A)^-1) |
engine/stat_solver.lua |
✅ |
| Anthem edge extraction (feeds stat solver) | engine/anthem.lua |
✅ |
| Non-circular effectiveness constants | engine/context.lua |
✅ |
| Non-circular non-effectiveness anthem constants | engine/context.lua, engine/stat_resolver.lua |
✅ |
| OnEntityTriggeredTrigger watcher 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 |
✅ |
| Data plugins (status effects, intercepts) | data/ |
✅ |
| Performance benchmark + O(N²) watcher fix | bench/bench_battle.lua, engine/context.lua, engine/effects.lua |
✅ |
| 10 canonical scenario tests | tests/test_scenarios.lua |
✅ |
| 27 hook system tests | tests/test_hooks.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_stat"→ dispatch to_handlers.alter_stat(ctx, effect, fire_ctx, cause_chain). -
EngineContext is the only state access point. All engine functions receive
ctxand callctx.effective_stat,ctx.resolve_targets,ctx.counter, 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 stats via matrix inversion. Anthem edges (trigger=nil clauses) and ConvertModifier cross-stat 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.