LUA port of the old python-based battle-engine
This repository has been archived on 2026-06-04. You can view files and clone it, but you cannot make any changes to its state, such as pushing and creating new issues, pull requests or comments.
  • Lua 99.8%
  • Makefile 0.2%
Find a file
McNieps d47e6b5a57 fix: resolve 3 doc-drift audit findings — validation claim, PRNG invariant, empty array JSON
- (#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>
2026-06-01 10:48:56 +02:00
.claude refactor: move FactionFilter to faction plugin, delete OnBelowTriggeredTrigger 2026-06-01 10:40:59 +02:00
bench perf: eliminate hot-path JIT barriers and add performance benchmark 2026-05-25 10:35:11 +02:00
spec perf: eliminate hot-path JIT barriers and add performance benchmark 2026-05-25 10:35:11 +02:00
src fix: resolve 3 doc-drift audit findings — validation claim, PRNG invariant, empty array JSON 2026-06-01 10:48:56 +02:00
tests fix: resolve 3 doc-drift audit findings — validation claim, PRNG invariant, empty array JSON 2026-06-01 10:48:56 +02:00
.busted Initial commit — LuaJIT battle engine port with busted test suite 2026-05-20 12:49:33 +02:00
.gitignore Initial commit — LuaJIT battle engine port with busted test suite 2026-05-20 12:49:33 +02:00
.luacheckrc Initial commit — LuaJIT battle engine port with busted test suite 2026-05-20 12:49:33 +02:00
CLAUDE.md fix: resolve 3 doc-drift audit findings — validation claim, PRNG invariant, empty array JSON 2026-06-01 10:48:56 +02:00
Makefile perf: eliminate hot-path JIT barriers and add performance benchmark 2026-05-25 10:35:11 +02:00
README.md fix: resolve 3 doc-drift audit findings — validation claim, PRNG invariant, empty array JSON 2026-06-01 10:48:56 +02:00
REMAINING_TASKS.md engine: rename TargetQuery→EntityQuery, add clause-succeeded/compare/SameEntity triggers and conditions, move faction to plugin system 2026-05-26 13:52:17 +02:00
URGENT_TODO.md fix: resolve 3 doc-drift audit findings — validation claim, PRNG invariant, empty array JSON 2026-06-01 10:48:56 +02:00

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

  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_stat" → dispatch to _handlers.alter_stat(ctx, effect, fire_ctx, cause_chain).

  3. EngineContext is the only state access point. All engine functions receive ctx and call ctx.effective_stat, ctx.resolve_targets, ctx.counter, 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 stats via matrix inversion. Anthem edges (trigger=nil clauses) and ConvertModifier cross-stat 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.