No description
  • Lua 99.9%
  • Makefile 0.1%
Find a file
McNieps 05c0029b16 Fix cross-attribute anthem edges; add anthem-edge keen-scaling seam
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.
2026-08-17 16:15:25 +02:00
bench Convert EngineContext methods to plain module functions for JIT tracing 2026-07-07 21:05:28 +02:00
spec Add design doc for effective tags: clause-level tags aggregated onto their carrier 2026-08-16 15:53:44 +02:00
src Fix cross-attribute anthem edges; add anthem-edge keen-scaling seam 2026-08-17 16:15:25 +02:00
tests Fix cross-attribute anthem edges; add anthem-edge keen-scaling seam 2026-08-17 16:15:25 +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 Mark the attributes-unification spec superseded; fix stale paths 2026-08-12 14:03:05 +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 Mark the attributes-unification spec superseded; fix stale paths 2026-08-12 14:03:05 +02:00
run_battle.lua Wire the keen plugin into the CLI and the test runner 2026-08-09 10:26:13 +02:00
TODO.md Widen the amount-scaler seam; drop the intent_type pre-filter (#30) 2026-08-09 20:32:41 +02:00

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

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

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

  8. Effect algebra, not special-cased clause fields. A clause carries one effect (a leaf, or a Sequence/Pipeline/Gated/Repeat/Spend composite) instead of the old payment/times/flat effects list — costs, ordering, and repetition are all ordinary effect nodes. See CLAUDE.md's Single Clause-Firing Path section.