No description
  • Lua 99.9%
  • Makefile 0.1%
Find a file
McNieps df8d060c16 Add per-firing fire_id to disambiguate same-tick repeat clause firings
cause_chain alone can't tell two same-tick firings of the same clause
apart, which blocks the client from batching their effects separately
(client TODO.md #23). fire_id is allocated once per fire_clause() call,
ctx-scoped rather than module-level so it can't leak across battles once
the engine runs in-process for more than one, and threaded through every
effect handler and event constructor that produces events for that firing.

Also logs an event-construction envelope refactor as a deferred backlog
item, noticed but deliberately not bundled with this fix.
2026-07-18 21:21:14 +02:00
bench Convert EngineContext methods to plain module functions for JIT tracing 2026-07-07 21:05:28 +02:00
spec Rewrite: replace Python engine with LuaJIT implementation 2026-06-04 11:51:17 +02:00
src Add per-firing fire_id to disambiguate same-tick repeat clause firings 2026-07-18 21:21:14 +02:00
tests Implement AdvanceClockEffect, clocked-trigger-only (TODO.md #21) 2026-07-16 13:40:53 +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 Merge permanent modify_stat into alter_stat; add PaymentSpent, CandidateRef, THIS_WEAPON, CounterName typing, and cross-battle stat persistence 2026-07-07 13:01:44 +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 Actualiser README.md 2026-06-04 09:57:57 +00:00
run_battle.lua Consolidate faction plugin into data/battle/plugins/faction 2026-07-02 17:28:08 +02:00
TODO.md Add per-firing fire_id to disambiguate same-tick repeat clause firings 2026-07-18 21:21:14 +02:00

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.