Combat Simulator — Design
Date: 2026-09-05 · Status: implemented as v1 in this repo
Purpose
A deterministic implementation of the rulebook (docs/rulebook.md) that can (a) fight speculative assembled mechs against each other thousands of times per second for tuning, and (b) show a single fight die by die. It is the source of truth for the rules as we move toward the real game: any engine that ships later must reproduce this engine's results on the same seeds and the same part data.
Stack and why
TypeScript, single pnpm package, matching the battlecon browser-prototype pattern.
engine/pure, side-effect free, seeded RNG, zero dependencies. One language for CLI and UI means one implementation of the rules.data/*.jsonthe part, pilot and kit catalog. JSON so a future engine in any language consumes the same catalog unchanged.cli/Node entry (pnpm sim) for batches, single verbose games, catalog and build inspection.web/Vite + React bench: assemble rosters, step through a game, run batches in a Web Worker with charts.
Engine architecture
| Module | Rules | Responsibility |
|---|---|---|
rng.ts |
A1 | mulberry32 seeded RNG, scriptedRng for tests, dice notation 2d6+2 |
modifiers.ts |
F2 | [+]/[−] cancel, roll remainder as d6, take highest |
hex.ts |
H | axial hex math (distance, line, odd-r offset, step toward) |
board.ts |
H2–H7 | hand-authored MapDef (offset coords, ordered features), terrain and elevation, movement cost and Dijkstra reachability, line of sight with interpolated sight height, hard/soft cover, high ground, zones |
maps.ts |
H1 | map registry: flat (open field) and quarry (data/maps/quarry.json) |
assembly.ts |
B, C7, F4 | resolve builds, derived stats, largest-remainder hit bands, team validation |
combat.ts |
F, G | mech runtime state, resolveFire (hit → location → pen → damage → crit → heat), stress |
battle.ts |
E, I | GameState: deployment, initiative, alternating activations, scoring, event log, snapshots |
ai.ts |
— | positional policy: scores every reachable hex by modifier-aware expected damage, incoming threat, cover, high ground and zones, then Charge/Strike/Grapple/Barrage/Fire/Aimed/Lock On/Prepare/Overwatch/Vent/Brace/Stabilize. One knob per side, aggression 0..1 (Scenario.aiA/aiB, default 0.5): it scales the weight on incoming threat (1.4 → 0), the pull toward enemies, the reward for closing (negative below 0.5, so a cautious bot kites) and the value of strikes. 0.5 is the v1 policy; 0 is the cautious policy; 1 brawls |
sweep.ts |
— | runSweep: one batch per value of a scenario field, rule, bot knob or catalog path; applyVary |
sim.ts |
— | runBatch: seeds seed..seed+n-1, aggregates weapon/part/stress/build/melee/reaction stats |
The base dice order inside resolveFire is fixed and documented because the UI replays it and the worked example (rulebook J) is a unit test: modifier d6s once, then per shot hit d20, location d20 (unless aimed), pen d6, damage dice, crit d6, on-hit heat dice; weapon heat after the last shot. The accepted E9 rule and optional command-reserve experiment conditionally insert replacement hit d20s, with distinct eligibility and ordering specified in the 2026-09-09 contracts below.
Every game emits typed events (GameEvent). activation_end carries a snapshot of every mech so viewers can scrub through a fight without re-simulating.
Scope
In (v1, 2026-09-05): full combat pipeline, per-part armor/durability/destruction, crit tables (crit at Armor + 4), heat and stress, pilots with stats, two-axis ability activation (D3) and five of the six example abilities, bonds (D4 proximity bonus), alternating activation with initiative, round cap, scoring by kills plus zone points with Hull tiebreak, roster validation. Terrain, elevation, movement costs, line of sight, cover and high ground from geometry (H2–H6), zone control (H7), hand-authored maps, deployment columns. On the open field, static per-side cover and elevation toggles stand in for terrain. The web bench draws the board, each activation's path, and each shot's line.
Added later on 2026-09-05: melee system (F11: strikes, Charge with push and falls, knockdown, Grapple/Throw/Break), reactions with Prepare (E6: Overwatch, Backup Fire, Disengage strike, riposte), rule overrides and sweeps (--rule, sweep --vary), the bot aggression knob (--aggression, --aggression-a/-b, aiA.aggression in sweeps), ammunition loads (F12: Load lists on ballistic weapons, loads on builds, load on attacks, Limited counts on mech state, per-load batch rows, kit[slot=load] and part[load] team specs; Overdrive profiles on energy weapons with an overdrive flag on attacks), load and Overdrive editing in the bench and Tuner.
Added 2026-09-06: Guard (E5), the smoke launcher (C6, Board.placeSmoke/clearSmoke, clouds on GameState.clouds), Indirect fire (C6, canFireIndirect), all with bot use and batch counters (stats.support).
Added 2026-09-06: best-of-three matches with a fourth-mech sideboard (engine/match.ts, pnpm sim match): games rotate through a map list, the loser swaps its worst performer for its bench mech when the team stays legal.
Historical initial-v1 exclusions (tracked in liste at the time): Overcharge, heat planning in the bot, falls (no map then had a two-level edge by a zone), geometric partial cover, multi-hex footprints. Later sections and the current rulebook supersede this old scope line.
Determinism contract
- Same scenario + seed ⇒ identical events. Tests enforce it for
runGameandrunBatch. - All randomness flows through
GameState.rng. Sorting uses explicit tie-breakers. - Batch game i uses
seed + i, so any game in a batch can be replayed withpnpm sim play --seed <seed+i>.
Testing
Vitest, 84 tests. TDD throughout: tests were written first and watched to fail. Coverage of note: worked example J reproduced die for die with scriptedRng; penetration thresholds; overflow and transfer; cover; range bands; stress table; hit-band rounding (including the float tie that integer remainders fixed); roster rules; determinism.
React components are not unit-tested (no DOM test environment installed); the model behind them (web/src/model.ts) is.
Interfaces
CLI:
pnpm sim catalog
pnpm sim build courier bastion
pnpm sim play --a reaver --b bastion --seed 9 --distance 4
pnpm sim batch --a courier,foreman,reaver --b bastion,foreman,courier -n 5000 --json out.json
Team specs accept kit ids, inline chassis:head:rightArm:leftArm:legs:back:pilot, or a .json file of builds.
Web (pnpm dev): Bench (assemble, weight, stats, hit tape), Battle (seeded game, stepper, dice log), Play (hotseat: one activation at a time through engine/turn.ts, either side human or bot, click-to-act with the E3 budget enforced), Batch (worker-backed, outcome bar, histograms, weapon and build tables, JSON export). Both interfaces print the equivalent CLI command so a finding in the browser is reproducible in a shell.
Performance
The charging AI ran about 10k games/second. The positional AI evaluates sight from every reachable hex to every enemy and back, and runs at 80–130 games/second (1000 Quarry games in 13s). Enough for thousand-game batches; the planned sweep tool will want per-activation sight caching.
2026-09-08: independent equipment and interactive melee
B2–B4 now separate structural arms from held equipment. Build.equipment names the held item per hand; null means an empty or reserved hand. A two-handed item counts once. ArmPart.defaultEquipment imports old builds without an equipment field. No weapon adds a hit location. Build Weight and arm Capacity are hard limits, even with team composition checks disabled.
MeleeExchange is a seeded decision state machine. It rolls both weapon pools before declarations. Condition d6s roll once per weapon pool before its d20s. A strike spends one successful source-tagged opportunity and selects a body part. The defender then chooses a parry or accepts the hit. Resolution uses the saved hit roll and total; it rolls only penetration, damage, critical and heat dice in the existing order. Ranged resolveFire dice order and the worked example remain unchanged.
GameState.fight emits melee_start, melee_declare, melee_response and melee_end events. Accepted strikes use existing fire/shot/damage events. TurnController spends the initiator's Full action, pauses all normal actions during decisions, and stops bot play whenever a human must respond. Both-bot play uses the same deterministic policy as CLI and batch games. Every new exchange starts fresh pools; participation does not spend the defender's activation or reaction.
The Workshop stores incomplete drafts separately from playable rosters. Catalog serialization, the Tuner, rulebook tables, snapshots and CLI output include independent equipment. Inline CLI builds accept two optional fields after Pilot: rightEquipment:leftEquipment. A dash means an empty or reserved hand. The browser's equivalent CLI command includes those fields, so independent loadouts are reproducible.
Explicit numeric defaults and review instructions: docs/design/2026-09-08-prototype-implementation.md.
Local prototype contract update — 2026-09-08
GameSession pins the catalog, rules and tuning. Commands record the expected decision and actor. A rejected command leaves live state and RNG untouched. A plain reaction window includes the committed path, current path index and legal response IDs. Accepted responses record their exact trigger identity. Reconstruction supplies them to the same synchronous engine action, including a bot action that pauses before the first human command. Only the dedicated reaction suspension is caught; rule and dice-script errors remain errors.
Movement emits one event per committed hex. Reaction attacks occur at their chosen path point. A wipe stops further steps and follow-ups. Under current paid vertical movement, an ordinary-leg edge costs destination terrain entry plus absolute elevation change; working Jump legs pay only destination terrain entry. An elevation change has no special damage, Prone, or movement-ending effect of its own; Throw, collisions and other actions keep their explicit effects. Reduced Speed changes the remaining allowance, not the length of the originally selected route. Legacy saves retain their embedded climb, Heat and fall handling.
Ranged resolveFire keeps its RNG order. Stage labels identify initiative, modifiers, hits, locations, penetration, damage, crits, Heat, stress and melee pool rolls. A scripted override still consumes the corresponding seeded draw. Script exhaustion is an error. ShotResult.durabilityLost is a read-only total of actual durability removed by the shot and its critical, excluding overkill and subsequent Heat stress; it consumes no dice.
Active rules are in the current rulebook. This update supersedes older descriptions of per-round initiative, Overcharge, uncontrolled reactions, mandatory BO3, copy limits and action clocks. Historical tuning reports retain their original setup and are not evidence for the new melee rules.
Enraged RNG and state contract — 2026-09-09
E9 is the accepted permanent offensive rule. New games resolve DEFAULT_RULES.enraged: true and DEFAULT_RULES.enragedDuration: 'game'; omitted reroll mode means offensive. Partial scenario overrides retain those defaults unless explicitly changed. An enemy-caused allied destruction qualifies each survivor for the rest of the game, beginning at its next own activation. Every eligible own activation adds +2 ordinary movement allowance without changing effective Speed, Boost or Charge allowance, and refreshes one shared offensive reroll. Multiple losses cannot stack or replenish an active reroll. Eligibility is not conditional on remaining outnumbered. Pending, active and reroll-available state are separate booleans.
Ranged dice order: roll the Fire's modifier d6s once, then each projectile's hit d20. If that hit fails while the attacker is in its own Enraged activation and still has the shared reroll, immediately consume one additional d20 from the hit stage, before any location/penetration/damage rolls or the next projectile. Keep this replacement face and recalculate the hit total and natural 1/20 result using the same frozen modifier result. Do not reroll modifier dice. Successful replacement hits then follow the ordinary location → penetration → damage → critical → on-hit Heat pipeline. Failed replacements remain misses and still spend the reroll. Weapon Heat and ammunition use remain once per Fire. Reactions and melee/opportunity strike resolution cannot use this ranged reroll.
Melee pool dice order: each source rolls its condition modifier d6s once, followed by its opportunity d20s. For the initiating mech only, when the first eligible non-defense-only opportunity fails, immediately draw its replacement from melee-pool before the next opportunity. Reuse the source's frozen condition dice and modifier value; keep the replacement, preserve pool size, and spend the same activation-wide reroll used by ranged fire. Defender and shield/parry-only dice are excluded. The automatic first-failure selection happens before player choices and adds no prompt. Declaration and strike resolution use the final saved success without another hit roll.
ShotResult.enragedReroll and Opportunity.enragedReroll retain { from, to }; their ordinary face/total fields contain the replacement result. No eligible failure means no additional RNG draw and no reroll consumption. A consumed stage-script replacement still advances the corresponding seeded draw; exhaustion remains an error, not a fallback. Ordinary unbuffed attacks keep their prior ordering.
enraged_granted { mech, by, victim } follows credited destruction and sets pending. enraged_started { mech } follows the next own activation_start, clears pending, and enables active/reroll state before the movement budget is returned. enraged_ended { mech, continuing: true } precedes the ending snapshot, clears active/available state, and queues a living permanent recipient again. The next own start refreshes the benefits; no offensive bonus remains available between activations. Temporary comparison sessions instead emit an end without continuing and preserve only any new pending casualty grant. Replay applies these events and the shot/pool reroll metadata at their event positions; it does not reroll or infer a future snapshot's status. Enemy Heat and lethal pushes count; self/environmental deaths do not.
Accepted permanent duration and saved games
An enemy-caused allied death unlocks permanent eligibility through rules.enragedDuration: 'game', now the default for new games. It grants nothing before the next own activation, cannot stack after further casualties, and grants no reaction rerolls. At activation end a living recipient is queued with continuing: true; replay sets pending at that event, before the snapshot. Explicit 'activation' retains the earlier temporary rule for comparisons.
New sessions and local matches use the session engine string local-prototype-5. It is one plain string. Every session uses it, whatever rules the session selects. There are no capability markers. Their resolved duration is pinned in the saved setup, including matches still choosing rosters or deployment.
A save carries every key that DEFAULT_RULES carries. Experimental keys that DEFAULT_RULES omits stay optional. Restore reads those rules from the save. Restore refuses a save that is missing a default key. It fills nothing from DEFAULT_RULES. It then replays the accepted commands. Restore refuses a save that no longer replays. The tuning fingerprint that guards a save covers the prototype tuning, the melee tuning, the weapon skill catalog, DEFAULT_RULES and the default AI; a change to any of them refuses every earlier save.
The prototype keeps no save compatibility (owner decision, 2026-09-16). A save from an earlier engine string does not load. There is no migration.
Regression coverage includes casualty attribution, replacement order and exclusions, per-activation refresh, segmented movement, exact command reconstruction, event-time replay, and save/resume. Pre-promotion temporary and permanent-offensive full-history/future-RNG goldens remain covered using explicit durations. The original worked example remains an unbuffed case.
Optional defensive-reroll experiment
rules.enragedReroll: 'defensive' requires game duration; temporary defensive combinations are rejected. It replaces the offensive reroll with one defensive use, while retaining the casualty trigger and +2 ordinary movement on subsequent own activations. The charge is first earned at enraged_started { mech, defensive: true }; enragedDefenseAvailable is absent until then and in ordinary modes. This start sets defensive availability true and offensive availability false. The defensive charge survives activation end and enemy turns, refreshes at the next own Enraged activation, and is cleared when used or the mech dies. Another casualty never replenishes or stacks it.
For an incoming hit, automatically replace the first successful hit d20 before location, penetration, or damage, retaining the existing accuracy and modifier result. This includes incoming ranged reactions and direct legacy melee reaction hits. ShotResult.enragedDefenseReroll { from, to, by: defenderId } records the replacement; ordinary hit face/total/natural fields contain the final result. The target spends its charge even if the replacement still hits. An offensive reroll, if present in a controlled mixed state, resolves before the defensive replacement; neither restarts the other.
Interactive melee instead rerolls the first incoming declared strike opportunity after melee_declare and before a parry decision. It consumes an additional melee-pool d20 at declaration time, reuses the opportunity's original modifiers, and records melee_defense_reroll { mech: attackerId, target: defenderId, opportunity, from, to, total, threshold, success }. A failed replacement cancels the spent strike and passes the exchange turn normally, without spending parry opportunities. A successful replacement proceeds to the normal response. The updated opportunity retains enragedDefenseReroll { from, to, by }; accepted-strike resolution does not apply the defense again. Shield and parry-pool dice are unchanged.
Replay consumes the target's defensive budget on the incoming shot or melee-defense event and preserves it through enraged_ended; the next defensive start refreshes it. Unit status/inspection shows ready or spent even between activations, and incoming-hit previews label their odds as before defensive reroll. This experiment needs no engine string of its own. The save carries rules.enragedReroll: 'defensive', and restore reads it from the save. Defensive rerolls remain experimental and are not the adopted rule; the current default is permanent offensive Enraged.
Current map symmetry contract — 2026-09-09
All authored maps are 32×20 and must have exact 180° rotational symmetry: resolved terrain and elevation, both deployment regions, and complete objective footprints. The odd-r offset transform is (col, row) → (31 − col, 19 − row); it preserves hex adjacency. Quarry, Foundry and Causeway each contain four non-overlapping seven-hex objective flowers arranged in rotational pairs. Flat remains the open control with no objectives. H7 requires a living mech in each controlled zone. With three deployed mechs and non-overlapping flowers, each team can control at most three zones per scoring boundary. Over seven normal rounds, the theoretical ceilings are 21 zone points per team and 28 across both teams combined, ignoring travel and contestation. A fourth zone increases the combined board opportunity, not the per-team ceiling. The existing scoring rule is unchanged.
engine/map-symmetry.ts and its tests enforce the geometry, resolved cells, deployment rotation and whole flowers. TASK-091 records the structural fix. The completed paired old/fixed diagnostic uses six same-roster mirrors, both first sides, 64 seed blocks and all three objective maps: 4,608 executions forming 2,304 paired comparisons. Fixed-map physical-A payoffs are 51.8% on Quarry, 50.1% on Foundry and 49.1% on Causeway; first-side payoffs remain 40.4%, 45.4% and 40.1%. Geometry symmetry therefore does not establish initiative neutrality. This measures the combined terrain/objective repair, including three to four zones. See the completed mirror results and limits. Historical reports retain their original map evidence.
Optional command-reserve contract — 2026-09-09
Rules.commandReserve is an experimental shared team resource. It is absent from DEFAULT_RULES; ordinary games retain permanent offensive Enraged and receive no CP state, events or additional draws. The experiment uses automatic spending through a shared GameState callback, including human-controlled sessions. It adds no player spending prompt or setup control.
CommandReserveConfig supplies initial, cap, perRound, perCasualty and spend. All four numeric fields must be nonnegative safe integers, with initial <= cap; spending must be 'offense', 'defense' or 'adaptive'. Each side begins with the same initial balance, capped grants discard excess, and each accepted spend costs one point. Offense/defense policies accept only their named effect. Adaptive accepts either effect only when the relevant mech's current total body durability is at most half its maximum: the enemy target for offense, the protected target for defense. Empty pools, policy mismatches and healthy adaptive targets decline without a draw or spend.
Income and authoritative state
command_reserve_initialized { pools } precedes game_start. After round_end scoring, both sides receive perRound only if another round will occur and neither side is wiped; this includes an earned tied bonus round, but not an unusable final refill. An enemy-credited mech_destroyed grants perCasualty to the victim's side, once per victim. Self/environmental deaths do not qualify, and a duplicate event cannot reattribute an already-recorded death. A terminal casualty can still grant points; final balances expose any unspent remainder. With Enraged also enabled, the casualty CP grant is emitted before that destruction's Enraged grants.
command_point_granted records side, round/casualty reason, requested/accepted/wasted amounts, remaining balance and the casualty victim when applicable. Eligible combat requests emit command_point_attempt with allowed status, reason (spend, empty, policy or healthy), balance, relevant body fraction, round, alive counts, scores and actor/target IDs. Acceptance then emits command_point_spent with the new balance and situation. A declined eligible request can be followed by another request on a later die or damage application. These events count actual requests, not every rolled die or every theoretical spending opportunity.
command_reserve_ended { pools } precedes game_end. CP belongs to GameState.commandReserve, not individual MechState or MechSnapshot objects. Mech snapshots retain actual resulting damage/status; team balances are recorded by the dedicated events and reconstructed from the frozen setup and accepted command history. Rejected commands and suspended reaction reconstruction must preserve the shared balance and future RNG, without a second spend from replaying the interrupted action. Tests cover shared budgets across mechs, Barrage, incoming reactions, accepted melee strikes, casualty deduplication, round income and save reconstruction.
Offensive RNG insertion and defensive mitigation
For proactive ranged Fire during the attacker's own activation, CP can replace one eligible failed hit per Fire. Apply the normal offensive Enraged check first. A die already replaced by Enraged cannot also receive CP, even if its replacement fails; a later failed projectile remains eligible. On an accepted CP spend, immediately draw one replacement d20 from hit, before location/penetration/damage or the next projectile. Keep the replacement and recalculate total/natural outcome with the same frozen accuracy and modifier result. ShotResult.commandReroll { from, to } preserves both faces; the ordinary hit fields contain the final result. Successful incoming hits can subsequently receive the separate optional defensive-Enraged replacement. No correction restarts a previous correction.
Interactive melee allows one CP replacement across the initiator's entire upfront pool, during its own activation. It applies only to the first eligible failed non-defense-only opportunity for which spending succeeds, after the offensive Enraged check. Draw from melee-pool immediately before the next opportunity, preserving that source's condition dice/modifier and accuracy. Opportunity.commandReroll { from, to } records the replacement. Defender pools and shield-only sources cannot receive offensive CP. Reactions, direct legacy melee hit rolls and accepted opportunity-strike hit resolution cannot receive offensive CP. A new Fire or newly initiated exchange starts its own CP limit; Enraged remains shared over the activation. Barrage comprises two Fires, so can consume two offensive points if available.
Defensive CP reduces the first eligible incoming damage amount by one, at most once per Fire resolution and never below one. Eligibility is checked after penetration and damage dice (including glance rounding), requires an enemy attacker and an amount of at least two, and precedes applyDamage and ordinary critical effects. ShotResult.commandMitigation: 1 records the nominal reduction. Critical effects and Heat/stress are not separately reduced. Reactions and accepted interactive melee strikes are eligible; each accepted strike is its own one-shot resolution, so the defensive cap is per strike rather than per exchange. Barrage's two Fires likewise have separate caps, sharing the same team pool. This is nominal damage mitigation, not a claim of exactly one actual Hull saved after overflow, destruction and critical effects. Mitigation adds no random draw; changed damage can still affect subsequent conditional resolution.
Optional goal-field policy contract — 2026-09-09
The three routing flags are experimental, absent from DEFAULT_RULES, and affect bot position scoring rather than movement legality, player path planning or combat dice. With flags omitted, the existing policy and its seeded histories remain unchanged. The study's corrected controller is not automatically the playable default.
| Rule flag | Implemented effect |
|---|---|
goalDirectedPathing: true |
Use safe walking cost to enemy/objective goals instead of the historical field measured from sources |
objectiveFootprintPathing: true |
Seed all passable hexes of each currently unheld scoring flower, instead of its center alone |
unreachableGoalPenalty: true |
Preserve a missing safe route to existing goals as a large finite cost in no-target approach scoring |
Each of the latter two flags requires goalDirectedPathing: true; invalid combinations throw during GameState construction. They do not require one another at the engine level. Study goalpath enables only directed fields; zonegoals adds objective footprints; safeapproach enables all three.
Board.distanceField retains its original forward semantics. Board.distanceToGoals uses reverse-edge Dijkstra: at goalward cell u, predecessor v pays the movement cost of v → u, including terrain entry at u. Under paid vertical movement, ordinary legs add the absolute elevation change and can cross any affordable adjacent height; working Jump legs add no elevation surcharge in either direction. It ignores occupancy. Blocked/out-of-bounds goals are omitted, duplicate goals are harmless, and each returned cost is to the cheapest reachable goal. This is a terrain heuristic, not a guaranteed immediately executable path through other mechs. Legacy sessions use their prior directed-edge restrictions.
The directed cache has a separate to| namespace from forward fields, keyed by goal coordinates and effective Jump capability. Each Board has one immutable vertical-movement mode, so caches cannot cross movement modes. Existing move, push, grapple, displacement, smoke and round-end events invalidate the shared field cache. Destroyed Jump legs use the ordinary-walking key.
goalDistanceEstimate distinguishes three states: a present field entry supplies its exact cost (including zero); a nonempty field missing the candidate cell supplies the finite penalty when enabled; an absent or empty field retains the geometric fallback. Thus having no usable goals is not treated as being trapped away from existing goals. The finite global bound is (boardCells − 1) × (maximum finite terrain-entry cost + vertical allowance) + 1. A paid-mode board uses the map's maximum elevation span as a conservative vertical allowance for every leg type. It exceeds every simple route under the board's resolved movement mode and avoids infinite scores when all candidate positions lack a route.
That penalty affects enemy/objective approach terms only when the candidate position has no attack target. Positions with a useful immediate attack retain their normal offense, threat, range and closing evaluation; the flag does not categorically forbid an isolated firing perch. Full objective flowers prevent a blocked center marker from erasing otherwise accessible scoring goals. Geometry tests, actual roof-avoidance/perch cases and unchanged baseline/prior-goal history/RNG goldens protect these distinctions. The routing diagnosis and roof-trap evidence document why these remain separately measured mechanisms.
Combined study saves
GameSession exports its complete resolved rule setup, catalog, map and accepted commands. The session engine string is local-prototype-5. It is one plain string for every session. It does not change with the rules that the session selects, so there is no capability marker and no priority order.
The save carries every key that DEFAULT_RULES carries. Every flag that a study selects sits in the exported setup, with its complete configuration. Safe approach plus CP is an example: the save holds both, and neither one hides the other.
Restore reads the rules from the save. Restore refuses a save that is missing a default key. It fills nothing from DEFAULT_RULES. It validates the rule dependencies while it reconstructs the session. It then replays the accepted commands. Restore refuses a save that no longer replays.
The prototype keeps no save compatibility (owner decision, 2026-09-16). A save from an earlier engine string does not load. There is no migration. The flags and the CP configuration stay absent from ordinary defaults, so an ordinary session selects none of them. These optional contracts introduce no new player-rulebook mechanic.
TASK-107 opt-in melee and AI controls: 2026-09-12
The experiment protocol defines the controlled study for these options.
These options are experimental support. They remain absent or false by default and are not adopted gameplay.
The three Rules fields are absent from DEFAULT_RULES. Both AI flags are absent from DEFAULT_AI.
Omitted options preserve the default event stream, state, RNG state, and replay result.
F11 melee pool controls
Rules.meleeOpportunityTarget supplies one common offensive opportunity target for F11.1. A supplied value must be an integer from 1 through 20.
Validation occurs before any pool die. The default is 10. DEFAULT_RULES has carried that value since the adoption on 2026-09-15. The key stays omittable. A scenario that sets the key to undefined restores the pre-adoption behavior: F11.1 then rolls against current effective Evasion. Use that setting for experiments only.
The offensive threshold is the common target plus current effective Evasion, minus the target's base derived Evasion.
This calculation preserves the F9 Prone penalty of 3. It also preserves the F7 destroyed Legs penalty of 2.
F11.6 shield dice keep their current threshold. Natural 1 and natural 20 keep their F11.1 effects.
Rules.meleePilotingDice: false removes only floor(Piloting / 2) bonus pool dice from F11.1.
Weapon dice, Handling, chassis dice, improvised dice, shield dice, Accuracy, and strike order remain unchanged.
D3 and D6 Momentum melee order
Rules.meleeMomentum: true applies the D3 and D6 Momentum Ability inside F11.1 upfront pools.
The fight caller must mark that fighter eligible during its own activation. Its D3 Ability must be active.
The fighter must have moved at least three hexes.
An F11.3 Charge can supply that movement. An ordinary own-activation move before Fight can also qualify.
Reactions and the defending fighter are never eligible. The engine does not infer eligibility from stored movement.
The first offensive source with at least one die applies Momentum to its first actual die.
Resolve that die's normal condition modifiers with one added 1[+]. Apply normal cancellation, then roll its d20.
Check E9 Enraged first. Then check command-reserve replacement under its existing eligibility.
Each replacement reuses the same modifier and resolves before the next pool die.
If that source has more dice, roll its ordinary condition modifier once after the Momentum die and its replacements.
Then roll the source's remaining d20s. Later sources, defender sources, and F11.6 shield sources keep their existing order.
Opportunity.momentum: true marks only the affected die. Omitted options add no event or opportunity keys.
This is an exchange-specific experimental order. It does not change resolveFire or its fixed ranged dice contract.
Paid form policy
AiOptions.paidForms: true lets the bot evaluate alternate held-weapon forms under E3 and E4.
GameState.changeWeaponMode validates and applies the form. TurnController and the bot own the action Budget.
Each successful change spends one Quick action and removes Full eligibility. An invalid change spends nothing.
The bot evaluates the current form with its current Budget. It evaluates each alternate after paying one Quick.
The alternate also loses Full eligibility. It must strictly improve the combined score.
The score uses the best remaining current-turn attack. It adds half the best next-activation melee preparation value.
A tie keeps the current form. Later ties use right hand, left hand, then catalog form order.
The bot changes each hand at most once per activation. A switch never enables a second Fire from that hand.
The policy reads no future RNG and does not use an attack outcome.
Prospective melee threat policy
AiOptions.prospectiveMeleeThreat: true adds legal enemy contact reach to bot position threat.
The evaluator uses movement, obstacles, elevation costs, occupancy, part destruction, critical effects, forms, and usable melee sources.
Each scored candidate vacates the acting mech's old hex. Its candidate hex then blocks enemy movement.
With two Quick remaining, a full-melee-capable projection includes a legal adjacent Fight or a legal moved Charge.
With one Quick remaining, the projection permits Move-to-contact only. It does not permit a projected Full Fight or Charge.
With zero Quick remaining, the evaluator adds no movement projection. Existing current-position and adjacent threat remain unchanged.
The evaluator keeps the larger current threat and reachable melee threat for each enemy. It applies the existing aggression weight once.
Results cache by enemy, candidate hex, and relevant state for one activation. Shared terrain work can use the same activation cache.
Different candidate hexes receive occupancy-sensitive evaluation. Every prospective cache expires before the next activation.
Diagnostics label projections as full-melee-capable or contact-only. They record scored candidates without consuming combat RNG.
Both AI options use the legacy single-profile attack valuation. It does not model common-TN or Piloting-pool experiment toggles.
Actual F11 exchanges apply those toggles. Experiment reports must retain this limitation.
These policies are bounded deterministic diagnostics. They do not establish optimal AI play or human counterplay.
Experimental declaration targeting
MeleeExchange.auto(targetSelector?) accepts one optional declaration target selector.
The selector receives the current attacker, defender, and already selected opportunity. It returns only the declaration Slot.
It cannot select an opportunity or change the automatic parry policy. Opportunity source selection stays unchanged.
The engine calls the selector again for each automatic declaration.
Omission targets Core exactly as before. Tests compare omission with an explicit Core selector.
Those tests require equal sources, declarations, parries, state, events, and future RNG.
The repeated-combat study uses the selector to reevaluate the attacker's disarm location before each strike.
The defender retains automatic Core declarations and the existing strike and parry choices.
Experimental decision observation
positionDecisionDiagnostics(game, mech, moveBudget?) records supplemental reachable-position proposals.
The study passes the live movement Budget. Standing from Prone therefore supplies zero movement instead of effective Speed.
Omitting moveBudget retains the existing effective Speed preview.
Each candidate records its hex, total score, score terms, and prospective-threat inputs. The result also records the proposed choice.
The study passes the same live Budget to paidFormDecision. These values are proposals, not the bot's selected action.
runActivation(game, mech, begun?, onDecision?) accepts an optional ActivationDecisionObserver.
The observer receives the chosen action branch immediately before that action executes. It records applicable actor, target, position, and source fields.
The observer does not choose or change an action. Omission preserves decisions, Budget, state, events, and future RNG.
Opening diagnostics hash the live state before supplemental inspection. They reject any state change or combat RNG request.
Actual action inspection runs only on a reconstructed state. Reconstruction uses the frozen Scenario and prior autoplay actor commands.
The observer records a platform move when one precedes the mech action. The disposable reconstruction can then apply that deterministic move.
It stops before the first selected non-platform action executes. Combat RNG is disabled throughout this inspection.
Each counterfactual starts from another reconstruction with the same state hash. It changes only the named experimental factor.
The report keeps the actual selected action separate from supplemental position and form proposals.
These records show deterministic bot choices under the existing policy. They do not certify an optimal policy or human behavior.
Experimental lifecycle and event observers
Repeated trials use normal beginActivation and endActivation calls for the attacker and passive target.
They call public Fire, Vent, Fight, damage, and melee exchange paths. They do not copy combat resolution into study code.
Heat, damage, ammunition, parts, and statuses persist. The target takes no bot action, and the fixture performs no round scoring.
Each attacker activation records its actual attack, Vent, pass reason, and action cost. No unavailable attack receives a fallback.
The trial stops at either destruction or the fixed attacker-activation horizon. Determinism tests compare complete rows and future RNG.
limbLossAgency(events) starts at the first part_destroyed event for rightArm, leftArm, or legs per mech.
It excludes the activation that contains the loss. It then counts later own activations and their observed action categories.
Only active-mech events count. Off-turn reactions do not count as the mech's own activity.
Attack, movement, and support can overlap once per activation. Maintenance-only requires Vent or Stabilize without those categories.
The observer distinguishes survival to the next own activation, destruction before it, and game-end censoring.
explicitMechanisms(events) also counts only explicit mechanism events. Unsupported ability-use measures remain null.
State and event observers consume no RNG and do not mutate the game. Tests enforce finite initial and final state values.
These APIs support experimental measurement only. They do not adopt a rule, establish balance, or certify human counterplay.