Skip to content

Conversation

@sudo-owen
Copy link
Collaborator

@sudo-owen sudo-owen commented Jan 10, 2026

Changelog

Double Battles Implementation

This document summarizes all changes made to implement double battles support.

Core Data Structure Changes

src/Enums.sol

  • Added GameMode enum: Singles, Doubles

src/Structs.sol

  • BattleArgs and Battle: Added GameMode gameMode field
  • BattleData: Added slotSwitchFlagsAndGameMode (packed field: lower 4 bits = per-slot switch flags, bit 4 = game mode)
  • BattleContext / BattleConfigView: Added:
    • p0ActiveMonIndex2, p1ActiveMonIndex2 (slot 1 active mons)
    • slotSwitchFlags (per-slot switch requirements)
    • gameMode

src/Constants.sol

  • Added GAME_MODE_BIT = 0x10 (bit 4 for doubles mode)
  • Added SWITCH_FLAGS_MASK = 0x0F (lower 4 bits for per-slot flags)
  • Added ACTIVE_MON_INDEX_MASK = 0x0F (4 bits per slot in packed active index)

New Files Added

src/DoublesCommitManager.sol

Commit/reveal manager for doubles that handles 2 moves per player per turn:

  • commitMoves(battleKey, moveHash) - Single hash for both moves
  • revealMoves(battleKey, moveIndex0, extraData0, moveIndex1, extraData1, salt, autoExecute) - Reveal both slot moves
  • Validates both moves are legal via IValidator.validatePlayerMoveForSlot
  • Prevents both slots from switching to same mon (BothSlotsSwitchToSameMon error)
  • Accounts for cross-slot switch claiming when validating

test/DoublesCommitManagerTest.sol

Basic integration tests for doubles commit/reveal flow.

test/DoublesValidationTest.sol

Comprehensive test suite (30 tests) covering:

  • Turn 0 switch requirements
  • KO'd slot handling with/without valid switch targets
  • Both slots KO'd scenarios (0, 1, or 2 reserves)
  • Single-player switch turns
  • Force-switch moves
  • Storage reuse between singles↔doubles transitions

test/mocks/DoublesTargetedAttack.sol

Mock attack move that targets a specific slot in doubles.

test/mocks/DoublesForceSwitchMove.sol

Mock move that forces opponent to switch a specific slot (uses switchActiveMonForSlot).


Modified Interfaces

src/IEngine.sol

New functions:

// Get active mon index for a specific slot (0 or 1)
function getActiveMonIndexForSlot(bytes32 battleKey, uint256 playerIndex, uint256 slotIndex)
    external view returns (uint256);

// Get game mode (Singles or Doubles)
function getGameMode(bytes32 battleKey) external view returns (GameMode);

// Force-switch a specific slot (for moves like Roar in doubles)
function switchActiveMonForSlot(uint256 playerIndex, uint256 slotIndex, uint256 monToSwitchIndex) external;

src/IValidator.sol

New functions:

// Validate a move for a specific slot in doubles
function validatePlayerMoveForSlot(
    bytes32 battleKey, uint256 moveIndex, uint256 playerIndex,
    uint256 slotIndex, uint240 extraData
) external returns (bool);

// Validate accounting for what the other slot is switching to
function validatePlayerMoveForSlotWithClaimed(
    bytes32 battleKey, uint256 moveIndex, uint256 playerIndex,
    uint256 slotIndex, uint240 extraData, uint256 claimedByOtherSlot
) external returns (bool);

src/Engine.sol

Key changes:

  • startBattle accepts gameMode and initializes doubles-specific storage packing
  • execute dispatches to _executeDoubles when in doubles mode
  • _executeDoubles handles 4 moves per turn (2 per player), speed ordering, KO detection
  • _handleSwitchForSlot updates slot-specific active mon (4-bit packed storage)
  • _checkForGameOverOrKO_Doubles checks both slots for each player
  • Slot switch flags track which slots need to switch after KOs

src/DefaultValidator.sol

  • validateSwitch now checks both slots when in doubles mode
  • validatePlayerMoveForSlot validates moves for a specific slot
  • validatePlayerMoveForSlotWithClaimed accounts for cross-slot switch claiming
  • _hasValidSwitchTargetForSlot / _hasValidSwitchTargetForSlotWithClaimed check available mons

Client Usage Guide

Starting a Doubles Battle

Battle memory battle = Battle({
    p0: alice,
    p1: bob,
    validator: validator,
    rngOracle: rngOracle,
    p0TeamHash: keccak256(abi.encode(teams[0])),
    p1TeamHash: keccak256(abi.encode(teams[1])),
    moveManager: address(doublesCommitManager),  // Use DoublesCommitManager
    matchmaker: matchmaker,
    engineHooks: hooks,
    gameMode: GameMode.Doubles  // Set to Doubles
});

bytes32 battleKey = engine.startBattle(battleArgs);

Turn 0: Initial Switch (Both Slots)

// Alice commits moves for both slots
bytes32 moveHash = keccak256(abi.encodePacked(
    SWITCH_MOVE_INDEX, uint240(0),   // Slot 0 switches to mon 0
    SWITCH_MOVE_INDEX, uint240(1),   // Slot 1 switches to mon 1
    salt
));
doublesCommitManager.commitMoves(battleKey, moveHash);

// Alice reveals
doublesCommitManager.revealMoves(
    battleKey,
    SWITCH_MOVE_INDEX, 0,    // Slot 0: switch to mon 0
    SWITCH_MOVE_INDEX, 1,    // Slot 1: switch to mon 1
    salt,
    true  // autoExecute
);

Regular Turns: Attacks/Switches

// Commit hash of both moves
bytes32 moveHash = keccak256(abi.encodePacked(
    uint8(0), uint240(targetSlot),     // Slot 0: move 0 targeting slot X
    uint8(1), uint240(targetSlot2),    // Slot 1: move 1 targeting slot Y
    salt
));
doublesCommitManager.commitMoves(battleKey, moveHash);

// Reveal
doublesCommitManager.revealMoves(
    battleKey,
    0, uint240(targetSlot),      // Slot 0 move
    1, uint240(targetSlot2),     // Slot 1 move
    salt,
    true
);

Handling KO'd Slots

  • If a slot is KO'd and has valid switch targets → must SWITCH
  • If a slot is KO'd and no valid switch targets → must NO_OP (NO_OP_MOVE_INDEX)
  • If both slots are KO'd with one reserve → slot 0 switches, slot 1 NO_OPs

Future Work / Suggested Changes

Target Redirection (Not Yet Implemented)

When a target slot is KO'd mid-turn, moves targeting that slot should redirect or fail. Currently, this can be handled by individual move implementations via an abstract base class.

Move Targeting System

  • Moves need clear targeting semantics (self, ally slot, opponent slot 0, opponent slot 1, both opponents, etc.)
  • Consider adding TargetType enum and standardizing extraData encoding for slot targeting

Speed Tie Handling

  • Currently uses basic speed comparison
  • May need explicit tie-breaking rules (random, player advantage, etc.)

Timeout Handling

  • Doubles timeout logic in DefaultValidator.validateTimeout needs review
  • Should account for per-slot switch requirements

Mixed Switch + Attack Turns

  • Partially implemented: single-player switch turns work
  • Edge cases around mid-turn KOs creating new switch requirements need testing

Ability/Effect Integration

  • Abilities that affect both slots (e.g., Intimidate affecting both opponents)
  • Weather/terrain affecting 4 mons instead of 2
  • Spread moves (hitting multiple targets)

UI/Client Considerations

  • Clients need to track 4 active mons instead of 2
  • Move selection UI needs slot-based targeting
  • Battle log should indicate which slot acted

claude and others added 20 commits January 8, 2026 16:28
Milestone 1 of doubles refactor:
- Add GameMode enum (Singles, Doubles)
- Extend BattleData with slotSwitchFlagsAndGameMode (packed uint8)
- Add p0Move2, p1Move2 to BattleConfig for slot 1 moves
- Add constants for activeMonIndex 4-bit packing and switch flags
- Update Engine startBattle to initialize doubles fields
- Update getBattleContext/getCommitContext to extract game mode
- Add gameMode field to Battle, ProposedBattle structs
- Update matchmaker and CPU to pass gameMode
- Update all tests with gameMode: GameMode.Singles

All changes are backwards compatible - singles mode works unchanged.
Fixes compilation error from Milestone 1 - BattleConfigView
was missing the new doubles move fields.
Milestone 2 progress:
- Add getGameMode(battleKey) - returns GameMode enum
- Add getActiveMonIndexForSlot(battleKey, playerIndex, slotIndex)
  - For doubles: uses 4-bit packing per slot
  - For singles: falls back to existing 8-bit packing

These getters allow external contracts to query the game mode
and active mons for specific battle slots.
Milestone 3: Doubles Commit Manager

- Create DoublesCommitManager.sol with:
  - commitMoves(): commits hash of both slot moves
  - revealMoves(): reveals and validates both moves at once
  - Same alternating commit scheme as singles

- Update Engine.setMove() to handle slot 1 moves:
  - playerIndex 0-1: slot 0 moves (existing behavior)
  - playerIndex 2-3: slot 1 moves (stored in p0Move2/p1Move2)

Hash format: keccak256(moveIndex0, extraData0, moveIndex1, extraData1, salt)
- Add helper functions for doubles-specific active mon index packing
- Add _computeMoveOrderForDoubles for 4-move priority sorting
- Add _handleMoveForSlot for executing moves per slot
- Add _handleSwitchForSlot for handling slot-specific switches
- Add _checkForGameOverOrKO_Doubles for doubles KO tracking
- Add _executeDoubles as main execution function for doubles mode
- Add comprehensive tests for doubles commit/reveal/execute flow
- Fix getActiveMonIndexForBattleState to be doubles-aware
- Fix getDamageCalcContext to use correct slot unpacking in doubles
- Add DoublesTargetedAttack mock for testing slot-specific targeting
- Add comprehensive doubles boundary condition tests:
  - test_doublesFasterSpeedExecutesFirst
  - test_doublesFasterPriorityExecutesFirst
  - test_doublesPositionTiebreaker
  - test_doublesPartialKOContinuesBattle
  - test_doublesGameOverWhenAllMonsKOed
  - test_doublesSwitchPriorityBeforeAttacks
  - test_doublesNonKOSubsequentMoves
- Add _handleSwitchCore for shared switch-out effects logic
- Add _completeSwitchIn for shared switch-in effects logic
- Add _checkForGameOver for shared game over detection
- Refactor _handleSwitch to use shared functions
- Refactor _handleSwitchForSlot to use shared functions
- Refactor _checkForGameOverOrKO to use _checkForGameOver
- Refactor _checkForGameOverOrKO_Doubles to use _checkForGameOver

This reduces code duplication between singles and doubles execution paths.
Add validatePlayerMoveForSlot to IValidator and DefaultValidator:
- Validates moves for specific slots in doubles mode
- Enforces switch for KO'd mons, allows NO_OP if no valid targets
- Prevents switching to mon already active in other slot
- Update DoublesCommitManager to use new validation
Test scenarios for validatePlayerMoveForSlot:
- Turn 0 only allows SWITCH_MOVE_INDEX
- After turn 0, attacks are allowed for non-KO'd mons
- Can't switch to same mon or other slot's active mon
- One player with 1 KO'd mon (with/without valid switch targets)
- Both players with 1 KO'd mon each (both/neither have targets)
- Integration test for validation after KO
- Reveal revert test for invalid moves on KO'd slot
When a player has a KO'd mon with valid switch targets, only they act
next turn. Changes include:

- Add _playerNeedsSwitchTurn helper to check if player needs switch turn
- Update _checkForGameOverOrKO_Doubles to set playerSwitchForTurnFlag
- Fix _handleMoveForSlot to allow SWITCH on KO'd slots
- Fix _computeMoveOrderForDoubles to handle unset moves in single-player turns
- Update tests for new turn flow behavior
Add tests covering all combinations of switch turn scenarios:
- P1-only switch turns (mirrors of P0 tests)
- Asymmetric switch targets (one player has target, other doesn't)
- Slot 1 KO'd scenarios
- Both slots KO'd with reserves
- Game over when all mons KO'd
- Continuing play with one mon after KO with no valid target

Key principle tested: Switch turns trigger when a player has a KO'd mon
AND a valid switch target. If no valid target, NO_OP is allowed.
When both slots are KO'd and only one reserve exists, both slots
attempt to switch to the same mon. Previously this resulted in
both slots having the same mon. Now the second switch is treated
as NO_OP at execution time - the slot keeps its KO'd mon and the
player continues playing with just one active mon.

This handles the edge case without additional storage by checking
if the target mon is already active in the other slot before
executing the switch.
- Update validateSwitch to check both slots in doubles mode
  When a move forces a switch, it now correctly rejects targets
  that are already active in either slot for the player.

- Add tests for forced switch validation in doubles:
  - test_forceSwitchMove_cannotSwitchToOtherSlotActiveMon
  - test_forceSwitchMove_cannotSwitchToSlot0ActiveMon
  - test_validateSwitch_allowsKOdMonReplacement

These tests verify that validateSwitch (used by switchActiveMon
for move-initiated switches) properly handles doubles mode.
Add two tests to verify MappingAllocator storage reuse works correctly
when transitioning between different game modes:

- test_doublesThenSingles_storageReuse: Complete a doubles battle, then
  verify a singles battle can reuse the freed storage key correctly
- test_singlesThenDoubles_storageReuse: Complete a singles battle, then
  verify a doubles battle can reuse the freed storage key correctly

Both tests execute actual combat with damage to ensure storage is
properly written to and that mode transitions don't corrupt state.

Also adds singles battle helper functions:
- _startSinglesBattle: Creates and starts a singles battle
- _singlesInitialSwitch: Handles turn 0 initial switch for singles
- _singlesCommitRevealExecute: Commit/reveal flow for singles turns
- _singlesSwitchTurn: Single-player switch turn handler for singles
Add a new Engine function `switchActiveMonForSlot` that correctly handles
forced switches in doubles mode by using the slot-aware storage format.

The existing `switchActiveMon` uses singles-style storage packing which
corrupts the activeMonIndex in doubles mode. The new function:
- Takes a slotIndex parameter to specify which slot to switch
- Uses `_handleSwitchForSlot` for correct doubles storage handling
- Uses `_checkForGameOverOrKO_Doubles` for proper KO detection

Also adds:
- DoublesForceSwitchMove mock for testing force-switch in doubles
- Import for the mock in DoublesValidationTest
Add tests verifying the new switchActiveMonForSlot function works correctly:
- test_switchActiveMonForSlot_correctlyUpdatesSingleSlot: Verifies force-
  switching slot 0 updates only that slot without corrupting slot 1
- test_switchActiveMonForSlot_slot1_doesNotAffectSlot0: Verifies force-
  switching slot 1 doesn't affect slot 0's active mon

Also fixes DoublesForceSwitchMove to use Type.None instead of Type.Normal.
When both slots are KO'd and there's only one reserve mon, slot 0 may
claim that reserve, leaving slot 1 with no valid switch target. Added
validatePlayerMoveForSlotWithClaimed to IValidator to account for what
the other slot is switching to when validating moves.

This allows slot 1 to NO_OP when slot 0 is claiming the last available
reserve, rather than incorrectly rejecting the NO_OP because the
validator didn't account for the pending switch.

Also adds tests verifying:
- Both slots can't switch to same mon (reverts)
- KO'd mon's moves don't execute
- Both opponent slots KO'd mid-turn handled correctly
Summarizes all changes made for doubles support including:
- Core data structure changes
- New files added (DoublesCommitManager, tests, mocks)
- Modified interfaces (IEngine, IValidator, Engine, DefaultValidator)
- Client usage guide with code examples
- Future work and suggested improvements
@sudo-owen sudo-owen changed the title Enumerate double battle switch combinations feat: Add Double Battles Jan 11, 2026
Adds test_singlePlayerSwitchTurn_withAttack to verify that during a
single-player switch turn (when one slot is KO'd), the alive slot can
attack while the KO'd slot switches. This confirms the implementation
handles mixed switch + attack correctly.
AttackCalculator._calculateDamageFromContext was returning bytes32(0)
when the accuracy check failed, but should return MOVE_MISS_EVENT_TYPE
so callers can properly detect and handle misses.
…tions

- Create BaseCommitManager.sol with shared commit/reveal logic
- DefaultCommitManager and DoublesCommitManager now extend BaseCommitManager
- Unify _hasValidSwitchTargetForSlot with optional claimedByOtherSlot param
- Create _validatePlayerMoveForSlotImpl to share logic between slot validators
- Add _getActiveMonIndexFromContext helper to reduce ENGINE calls
- Add Engine.setMoveForSlot for clean slot-based move setting
- Fix AttackCalculator to return MOVE_MISS_EVENT_TYPE on miss
- Update CHANGELOG with refactoring details and known inconsistencies
- Net reduction of ~200 lines of duplicated code
The _runEffects function was using _unpackActiveMonIndex which only
returns slot 0's mon index. In doubles battles, effects on slot 1's
mons would incorrectly look up effects for slot 0's mon instead.

Changes:
- Add _runEffectsForMon with explicit monIndex parameter
- _runEffects now delegates to _runEffectsForMon with sentinel value
- Update _executeDoubles to pass correct monIndex for each slot
- Simplify baseSlot calculation (both branches did the same thing)

Test:
- Add DoublesEffectAttack mock for targeting specific slots
- Add test_effectsRunOnBothSlots verifying effects run for both slots
Singles now uses the same 4-bit-per-slot packing as doubles, defaulting
to slot 0. This removes redundant functions and simplifies the codebase:

- Remove deprecated _packActiveMonIndices, _unpackActiveMonIndex,
  _setActiveMonIndex functions
- Have _handleSwitch delegate to _handleSwitchForSlot with slot 0
- Update all singles code to use _unpackActiveMonIndexForSlot(..., 0)
- Simplify external getters by removing mode-specific branching
claude and others added 6 commits January 16, 2026 00:36
Singles code now calls _handleSwitchForSlot directly with slot 0,
eliminating the unnecessary _handleSwitch wrapper function.
This commit fixes several critical issues in the doubles implementation:

Issue #1: Switch effects now pass explicit monIndex
- _handleSwitchCore passes currentActiveMonIndex to switch-out effects
- _completeSwitchIn passes monToSwitchIndex to switch-in effects

Issue #2: Move validation now receives slotIndex
- validateSpecificMoveSelection accepts slotIndex parameter
- Uses _getActiveMonIndexFromContext to get correct mon for validation

Issue #3: AfterDamage effects pass explicit monIndex
- dealDamage now passes monIndex to effect execution

Issue #4: OnUpdateMonState effects pass explicit monIndex
- updateMonState now passes monIndex to effect execution

Also adds:
- Overloaded _runEffects that accepts explicit monIndex
- EffectApplyingAttack mock for testing
- MonIndexTrackingEffect mock for testing
- Tests: test_afterDamageEffectsRunOnCorrectMon,
         test_moveValidationUsesCorrectSlotMon
- p0ActiveMonIndex → p0ActiveMonIndex0
- p1ActiveMonIndex → p1ActiveMonIndex0
- p0ActiveMonIndex2 → p0ActiveMonIndex1
- p1ActiveMonIndex2 → p1ActiveMonIndex1

This maintains consistent 0-indexed naming across the codebase.
Add attackerSlotIndex and defenderSlotIndex parameters to:
- getDamageCalcContext (IEngine and Engine)
- AttackCalculator._calculateDamage
- AttackCalculator._calculateDamageView

This fixes a bug where damage calculations in doubles mode always used
slot 0's mon stats regardless of which slot was attacking or being
attacked. All callers updated to pass slot indices (singles use 0, 0).

Adds DoublesSlotAttack mock and tests to verify:
- Attacking slot 1 uses slot 1's defense stats
- Attacking from slot 1 uses slot 1's attack stats
@sudo-owen sudo-owen force-pushed the claude/double-battle-switch-combos-jvJGO branch from 101bb6c to ef54e4d Compare January 21, 2026 06:25
…rclock

These tests validate two bugs where global effects only affect slot 0 mons
in doubles mode:

1. StaminaRegen.onRoundEnd() - uses getActiveMonIndexForBattleState() which
   only returns slot 0 mons, so slot 1 never gets stamina regen

2. Overclock.onApply() - same issue, only applies stat boost to slot 0 mon

Both tests are expected to FAIL until the bugs are fixed.
Both effects were using getActiveMonIndexForBattleState() which only
returns slot 0 mons. Fixed to iterate over both slots in doubles mode:

- StaminaRegen.onRoundEnd(): Now regenerates stamina for all active mons
  (both slots in doubles, slot 0 only in singles)

- Overclock.onApply(): Now applies stat boosts to all active mons of the
  player who summoned Overclock

- Overclock.onRemove(): Now removes stat boosts from all active mons when
  the effect expires

Uses getGameMode() and getActiveMonIndexForSlot() for proper slot handling.
- Move StaminaRegen doubles test to DoublesValidationTest.sol
- Move Overclock doubles test to VolthareTest.sol
- Add doubles helper functions to BattleHelper.sol for reuse
- Delete DoublesEffectBugsTest.sol (tests now in appropriate files)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants