Files
BattleForge/docs/superpowers/plans/2026-07-26-deeper-combat.md
2026-07-26 18:39:54 -05:00

54 KiB
Raw Permalink Blame History

Plan 5: Deeper Combat Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Add hit/miss randomness, damage variance, crits, flanking, forest concealment, and water traversal to the rules engine, without changing the JSON shape, the storage model, or the JS renderer beyond reading longer log strings.

Architecture: All changes are inside src/Domain/. The combat engine is the single source of truth; the action log carries the roll and threshold so the JS renderer can show the to-hit mechanic without changing its render code. random_int(1, 100) calls are made at the attack site; the concealment roll is a separate independent roll when the target is on forest. Damage variance is a uniform ±15% on the deterministic base. Water traversal ends the unit's turn on entry via a new wadedThisTurn flag on UnitState, reset by startTurn().

Tech Stack: PHP 8.3, vanilla ESM JavaScript (no new dependencies), PHPUnit 11, PHPStan level 6, PHP_CodeSniffer with the repository ruleset.

Global Constraints

These apply to every task and are copied verbatim from the spec.

  • To-hit threshold: 40 + (attacker.attack * 3) - (target.defense + terrain.defenseBonus) - flankingBonus; floor 5, ceiling 95.
  • Flanking bonus: +15 per ally in any of the 4 orthogonally-adjacent tiles (N/S/E/W) of the target, capped at +30 (3+ allies = +30). Diagonals do not count. Defeated allies do not count.
  • Crit: roll >= 95. Crits always hit (the roll >= any threshold up to 95). Crit damage is doubled before the variance multiplier. Crit is a 5% window.
  • Damage variance: random_int(85, 115) / 100 multiplier on the deterministic damage floor, rounded. Floor on hit: 1 damage.
  • Forest concealment: independent second roll random_int(1, 100) > 15. Failure (rolls 115) → miss-with-concealment, damage 0.
  • Water: cost 3 movement; entering sets wadedThisTurn = true and actionsRemaining = 0. startTurn() resets both. Standing on water does not restrict actions after the turn boundary.
  • Rough: cost 2 movement; no defense bonus, no concealment, no crit window.
  • Action log format: "{attackerId} attacked {targetId} (rolled X / needed Y) for Z damage" plus — miss (to-hit fail), — miss — concealed (concealment fail), — crit (roll >= 95).
  • The five to-hit constants (40, 3, 15, 5, 95) are named constants in CombatEngine, not magic numbers, so they're easy to retune.
  • random_int(1, 100) calls use PHP's CSPRNG. No seeding; no reproducibility.
  • No new PHPCS warnings. random_int and to-hit math are short; the log format string is < 200 chars.
  • No new stat axis, no new terrain type, no new archetype, no new ability, no new victory condition. Plan 5 is engine-only.
  • The Application layer (ScenarioSerializer) is extended to round-trip the new wadedThisTurn flag in the UnitState. No other Application-layer change.
  • The JS renderer is unchanged structurally. It already iterates match.actionLog and renders the strings. The log strings just get longer.
  • The bundled scenarios (skirmish.json, hold-the-line.json, last-stand.json) are unchanged on disk. Plan 5 doesn't require new fixtures; existing scenarios exercise the new mechanics naturally.

Task 1: To-hit + damage variance + crits + log format

Files:

  • Modify: src/Domain/CombatEngine.php (add the to-hit math, the variance roll, the crit path, the new log format)
  • Modify: tests/Unit/Domain/CombatEngineTest.php (extend with 8 new test cases)

Interfaces:

  • Consumes: existing CombatEngine::attack(MatchState, string $attackerId, string $targetId): MatchState signature; existing UnitState, Terrain, Battlefield.
  • Produces: action log entries of the new format, with the same array<string, string> shape.

Constants in CombatEngine (private static):

  • TO_HIT_BASE = 40

  • TO_HIT_PER_ATTACK = 3

  • TO_HIT_FLOOR = 5

  • TO_HIT_CEILING = 95

  • CRIT_THRESHOLD = 95

  • DAMAGE_VARIANCE_MIN_PCT = 85

  • DAMAGE_VARIANCE_MAX_PCT = 115

  • Step 1: Add named constants to CombatEngine

Add at the top of src/Domain/CombatEngine.php (just below the use statements, above the class declaration):

final class CombatEngine
{
    private const TO_HIT_BASE = 40;
    private const TO_HIT_PER_ATTACK = 3;
    private const TO_HIT_FLOOR = 5;
    private const TO_HIT_CEILING = 95;
    private const CRIT_THRESHOLD = 95;
    private const DAMAGE_VARIANCE_MIN_PCT = 85;
    private const DAMAGE_VARIANCE_MAX_PCT = 115;
  • Step 2: Write the failing tests

Append the following test methods to tests/Unit/Domain/CombatEngineTest.php (just before the closing brace of the class):

    public function testAttackRollsAboveThresholdAndDamageStaysWithinVariance(): void
    {
        $match = $this->freshMatch();
        $matchArray = ScenarioSerializer::matchToArray($match);
        $matchArray['units'][0]['position'] = ['x' => 0, 'y' => 0]; // alpha-1
        $matchArray['units'][0]['attack'] = 10;
        $matchArray['units'][0]['defense'] = 0;
        $matchArray['units'][1]['position'] = ['x' => 1, 'y' => 0]; // bravo-1 (the target)
        $matchArray['units'][1]['attack'] = 3;
        $matchArray['units'][1]['defense'] = 0;
        $match = ScenarioSerializer::matchFromArray($matchArray);

        $token = TurnToken::issue('s', 'm', 'alpha', 1, 0);
        $support = new MatchActionSupport('s');
        $req = $this->buildRequest($match, $token, ['matchId' => 'm', 'match' => $matchArray, 'attackerId' => 'alpha-1', 'targetId' => 'bravo-1']);
        $result = (new PostMatchAttack('s'))->handle($req, []);
        self::assertSame(200, $result->status);

        $body = json_decode($result->body, true);
        $log = $body['match']['actionLog'];
        self::assertCount(1, $log);
        self::assertMatchesRegularExpression('/^alpha-1 attacked bravo-1 \(rolled \d+ \/ needed \d+\) for \d+ damage$/', $log[0]);
        preg_match('/rolled (\d+) \/ needed (\d+) for (\d+)/', $log[0], $m);
        $threshold = (int) $m[2];
        $roll = (int) $m[1];
        $damage = (int) $m[3];
        self::assertGreaterThanOrEqual(5, $threshold, 'threshold should be at least the floor of 5');
        self::assertLessThanOrEqual(95, $threshold, 'threshold should be at most the ceiling of 95');
        self::assertGreaterThanOrEqual(1, $damage);
    }

    public function testAttackActionLogUsesNewFormat(): void
    {
        $match = $this->freshMatch();
        $matchArray = ScenarioSerializer::matchToArray($match);
        $matchArray['units'][0]['position'] = ['x' => 0, 'y' => 0];
        $matchArray['units'][1]['position'] = ['x' => 1, 'y' => 0];
        $match = ScenarioSerializer::matchFromArray($matchArray);

        $token = TurnToken::issue('s', 'm', 'alpha', 1, 0);
        $req = $this->buildRequest($match, $token, ['matchId' => 'm', 'match' => $matchArray, 'attackerId' => 'alpha-1', 'targetId' => 'bravo-1']);
        $result = (new PostMatchAttack('s'))->handle($req, []);
        self::assertSame(200, $result->status);

        $body = json_decode($result->body, true);
        $log = $body['match']['actionLog'];
        $entry = $log[0];
        // Has the new shape: "{attacker} attacked {target} (rolled X / needed Y) for Z damage"
        self::assertMatchesRegularExpression('/^alpha-1 attacked bravo-1 \(rolled \d+ \/ needed \d+\) for \d+ damage( — .+)?$/', $entry);
    }

    public function testAttackThresholdFloorsAt5AndCeilingsAt95(): void
    {
        // Very low attack vs very high defense: threshold should clamp to 5.
        $low = $this->buildAttackWithStats(attackerAttack: 1, targetDefense: 20, flanking: 0, terrain: 'open');
        self::assertGreaterThanOrEqual(5, $low);
        self::assertLessThanOrEqual(95, $low);

        // Very high attack vs very low defense: threshold should clamp to 95.
        $high = $this->buildAttackWithStats(attackerAttack: 50, targetDefense: 0, flanking: 0, terrain: 'open');
        self::assertGreaterThanOrEqual(5, $high);
        self::assertLessThanOrEqual(95, $high);
    }

    public function testAttackDamageRollsWithinVarianceBounds(): void
    {
        // Run the attack 50 times against an isolated target, recording damage each time.
        $damages = [];
        for ($i = 0; $i < 50; $i += 1) {
            $match = $this->freshMatch();
            $matchArray = ScenarioSerializer::matchToArray($match);
            $matchArray['units'][0]['position'] = ['x' => 0, 'y' => 0];
            $matchArray['units'][0]['attack'] = 5;
            $matchArray['units'][0]['defense'] = 1;
            $matchArray['units'][1]['position'] = ['x' => 1, 'y' => 0];
            $matchArray['units'][1]['attack'] = 3;
            $matchArray['units'][1]['defense'] = 1;
            $match = ScenarioSerializer::matchFromArray($matchArray);

            $token = TurnToken::issue('s', 'm', 'alpha', 1, 0);
            $req = $this->buildRequest($match, $token, ['matchId' => 'm', 'match' => $matchArray, 'attackerId' => 'alpha-1', 'targetId' => 'bravo-1']);
            $result = (new PostMatchAttack('s'))->handle($req, []);
            $body = json_decode($result->body, true);
            $log = $body['match']['actionLog'];
            preg_match('/for (\d+) damage/', $log[0], $m);
            if (isset($m[1])) {
                $damages[] = (int) $m[1];
            }
        }
        // Filter out misses (damage 0).
        $damages = array_filter($damages, static fn (int $d): bool => $d > 0);
        self::assertGreaterThan(0, count($damages), 'at least one hit expected across 50 rolls');
        // Floor damage: max(1, 5-1) = 4. Variance: 85%-115% of 4 = 3-5.
        foreach ($damages as $d) {
            self::assertGreaterThanOrEqual(3, $d);
            self::assertLessThanOrEqual(5, $d);
        }
    }

    public function testAttackCritLogCarriesCritMarker(): void
    {
        // We can't easily force a crit from a test (it's a 5% event), so this test
        // asserts the log-format contract: any attack log entry whose damage is
        // >= 2x the base expectation is a crit and must carry " — crit".
        // We force by directly inspecting the CombatEngine internals via a
        // test helper: roll with a high attack and many trials.
        $critsSeen = false;
        for ($i = 0; $i < 200; $i += 1) {
            $match = $this->freshMatch();
            $matchArray = ScenarioSerializer::matchToArray($match);
            $matchArray['units'][0]['position'] = ['x' => 0, 'y' => 0];
            $matchArray['units'][0]['attack'] = 50;
            $matchArray['units'][0]['defense'] = 0;
            $matchArray['units'][1]['position'] = ['x' => 1, 'y' => 0];
            $matchArray['units'][1]['attack'] = 1;
            $matchArray['units'][1]['defense'] = 0;
            $match = ScenarioSerializer::matchFromArray($matchArray);

            $token = TurnToken::issue('s', 'm', 'alpha', 1, 0);
            $req = $this->buildRequest($match, $token, ['matchId' => 'm', 'match' => $matchArray, 'attackerId' => 'alpha-1', 'targetId' => 'bravo-1']);
            $result = (new PostMatchAttack('s'))->handle($req, []);
            $body = json_decode($result->body, true);
            $log = $body['match']['actionLog'];
            if (str_contains($log[0], ' — crit')) {
                $critsSeen = true;
                self::assertStringContainsString('(rolled', $log[0]);
                self::assertStringContainsString('/ needed', $log[0]);
                break;
            }
        }
        self::assertTrue($critsSeen, 'expected at least one crit in 200 trials of high-attack vs low-defense');
    }

    public function testAttackCritDamageIsDoubledBeforeVariance(): void
    {
        // A crit doubles damage before the variance roll. For attack=10 vs defense=0,
        // base damage = max(1, 10-0) = 10. Crit damage before variance = 20.
        // After ±15% variance, the range is 17-23.
        $crits = [];
        for ($i = 0; $i < 200; $i += 1) {
            $match = $this->freshMatch();
            $matchArray = ScenarioSerializer::matchToArray($match);
            $matchArray['units'][0]['position'] = ['x' => 0, 'y' => 0];
            $matchArray['units'][0]['attack'] = 10;
            $matchArray['units'][0]['defense'] = 0;
            $matchArray['units'][1]['position'] = ['x' => 1, 'y' => 0];
            $matchArray['units'][1]['attack'] = 1;
            $matchArray['units'][1]['defense'] = 0;
            $match = ScenarioSerializer::matchFromArray($matchArray);

            $token = TurnToken::issue('s', 'm', 'alpha', 1, 0);
            $req = $this->buildRequest($match, $token, ['matchId' => 'm', 'match' => $matchArray, 'attackerId' => 'alpha-1', 'targetId' => 'bravo-1']);
            $result = (new PostMatchAttack('s'))->handle($req, []);
            $body = json_decode($result->body, true);
            $log = $body['match']['actionLog'];
            if (str_contains($log[0], ' — crit')) {
                preg_match('/for (\d+) damage/', $log[0], $m);
                $crits[] = (int) $m[1];
                if (count($crits) >= 5) {
                    break;
                }
            }
        }
        self::assertGreaterThan(0, count($crits), 'expected crits in 200 trials');
        foreach ($crits as $d) {
            // Base 10 doubled = 20, ±15% = 17-23.
            self::assertGreaterThanOrEqual(17, $d);
            self::assertLessThanOrEqual(23, $d);
        }
    }

    public function testAttackMissCarriesMissMarker(): void
    {
        // Run 200 attacks with low attack vs high defense; expect at least
        // one miss. The miss log entry must include " — miss".
        $missSeen = false;
        for ($i = 0; $i < 200; $i += 1) {
            $match = $this->freshMatch();
            $matchArray = ScenarioSerializer::matchToArray($match);
            $matchArray['units'][0]['position'] = ['x' => 0, 'y' => 0];
            $matchArray['units'][0]['attack'] = 1;
            $matchArray['units'][0]['defense'] = 0;
            $matchArray['units'][1]['position'] = ['x' => 1, 'y' => 0];
            $matchArray['units'][1]['attack'] = 1;
            $matchArray['units'][1]['defense'] = 20;
            $match = ScenarioSerializer::matchFromArray($matchArray);

            $token = TurnToken::issue('s', 'm', 'alpha', 1, 0);
            $req = $this->buildRequest($match, $token, ['matchId' => 'm', 'match' => $matchArray, 'attackerId' => 'alpha-1', 'targetId' => 'bravo-1']);
            $result = (new PostMatchAttack('s'))->handle($req, []);
            $body = json_decode($result->body, true);
            $log = $body['match']['actionLog'];
            if (str_contains($log[0], ' — miss') && !str_contains($log[0], ' — crit')) {
                $missSeen = true;
                preg_match('/for (\d+) damage/', $log[0], $m);
                self::assertSame('0', $m[1], 'miss should record 0 damage');
                break;
            }
        }
        self::assertTrue($missSeen, 'expected at least one miss in 200 trials of low-attack vs high-defense');
    }

    /** @return int */
    private function buildAttackWithStats(int $attackerAttack, int $targetDefense, int $flanking, string $terrain): int
    {
        // Returns the to-hit threshold the engine computes for the given stats.
        // We compute it locally and assert it's in the [5, 95] range.
        $threshold = CombatEngine::TO_HIT_BASE
            + ($attackerAttack * CombatEngine::TO_HIT_PER_ATTACK)
            - $targetDefense
            - $flanking;
        if ($threshold < CombatEngine::TO_HIT_FLOOR) {
            $threshold = CombatEngine::TO_HIT_FLOOR;
        }
        if ($threshold > CombatEngine::TO_HIT_CEILING) {
            $threshold = CombatEngine::TO_HIT_CEILING;
        }
        return $threshold;
    }

The test file also needs three small helper methods. Append these just before the closing brace:

    /** @param array<string, mixed> $body */
    private function buildRequest(MatchState $match, string $token, array $body): \BattleForge\Http\Request
    {
        return new \BattleForge\Http\Request(
            get: [],
            post: [],
            files: [],
            cookies: ['__csrf' => 'unused', '__csrf_token' => $token],
            server: [
                'HTTP_X_CSRF_TOKEN' => $token,
                'HTTP_X_TURN_TOKEN' => $token,
                '__csrf_secret' => 's',
                'CONTENT_TYPE' => 'application/json',
            ],
            queryString: '',
            method: 'POST',
            path: '/matches/current/attack',
            contentType: 'application/json',
            rawBody: json_encode($body, JSON_THROW_ON_ERROR),
        );
    }

(Replace the existing buildRequest if it exists; the brief requires this exact body shape.)

  • Step 3: Run the new tests to confirm they fail

Run: vendor/bin/phpunit tests/Unit/Domain/CombatEngineTest.php Expected: the 8 new tests fail with errors like Undefined constant CombatEngine::TO_HIT_BASE and Call to undefined method CombatEngine::buildAttackWithStats.

  • Step 4: Implement the constants and the to-hit math in CombatEngine::attack

Open src/Domain/CombatEngine.php. Add a private function thresholdFor(UnitState $attacker, UnitState $target, int $terrainDefense, int $flankingBonus): int method:

    private function thresholdFor(UnitState $attacker, UnitState $target, int $terrainDefense, int $flankingBonus): int
    {
        $raw = self::TO_HIT_BASE
            + ($attacker->attack * self::TO_HIT_PER_ATTACK)
            - ($target->defense + $terrainDefense)
            - $flankingBonus;
        if ($raw < self::TO_HIT_FLOOR) {
            return self::TO_HIT_FLOOR;
        }
        if ($raw > self::TO_HIT_CEILING) {
            return self::TO_HIT_CEILING;
        }
        return $raw;
    }

Add a private function flankingBonusFor(MatchState $match, UnitState $attacker, UnitState $target): int method:

    private function flankingBonusFor(MatchState $match, UnitState $attacker, UnitState $target): int
    {
        $count = 0;
        foreach ($match->units as $other) {
            if ($other->id === $attacker->id || $other->id === $target->id) {
                continue;
            }
            if ($other->isDefeated() || $other->teamId !== $attacker->teamId) {
                continue;
            }
            $dx = abs($other->position->x - $target->position->x);
            $dy = abs($other->position->y - $target->position->y);
            if (($dx === 1 && $dy === 0) || ($dx === 0 && $dy === 1)) {
                $count += 1;
                if ($count >= 2) {
                    return 30;
                }
            }
        }
        return $count * 15;
    }

Add a private function applyDamage(UnitState $target, int $base): int method that returns the final damage after variance (and doubling on crit):

    private function applyDamage(int $base, bool $isCrit): int
    {
        $doubled = $isCrit ? $base * 2 : $base;
        $pct = random_int(self::DAMAGE_VARIANCE_MIN_PCT, self::DAMAGE_VARIANCE_MAX_PCT);
        return (int) round($doubled * $pct / 100);
    }

Now rewrite attack() to use these. Replace the entire attack method with:

    public function attack(MatchState $match, string $attackerId, string $targetId): MatchState
    {
        $attacker = $this->unitOrFail($match, $attackerId);
        $this->assertCanAct($match, $attacker);

        if ($attacker->hasAttacked) {
            throw new CombatException('Unit has already attacked this turn.');
        }

        $target = $this->unitOrFail($match, $targetId);

        if ($target->teamId === $attacker->teamId || $target->isDefeated()) {
            throw new CombatException('Target must be an active enemy unit.');
        }

        if ($attacker->position->distanceTo($target->position) !== 1) {
            throw new CombatException('Target is outside attack range.');
        }

        $terrainDefense = $match->battlefield->terrainAt($target->position)->defenseBonus();
        $flanking = $this->flankingBonusFor($match, $attacker, $target);
        $threshold = $this->thresholdFor($attacker, $target, $terrainDefense, $flanking);
        $roll = random_int(1, 100);

        $logParts = [
            "{$attacker->id} attacked {$target->id}",
            "(rolled {$roll} / needed {$threshold})",
        ];

        $updatedAttacker = $attacker->markAttacked()->spendAction();

        if ($roll < $threshold) {
            // To-hit miss; log and return without applying damage.
            $logParts[] = 'for 0 damage';
            $logParts[] = '— miss';
            $next = $match->withUnit($updatedAttacker);
            return $next->copy(actionLog: [...$next->actionLog, implode(' ', $logParts)]);
        }

        // Forest concealment: independent 15% miss roll.
        if ($match->battlefield->terrainAt($target->position)->movementCost() === 2) {
            // Forest and rough both have cost 2; only forest has concealment. Distinguish by name.
            $terrainName = $match->battlefield->terrainAt($target->position)->name;
            if ($terrainName === 'Forest') {
                $concealmentRoll = random_int(1, 100);
                if ($concealmentRoll <= 15) {
                    $logParts[] = 'for 0 damage';
                    $logParts[] = '— miss — concealed';
                    $next = $match->withUnit($updatedAttacker);
                    return $next->copy(actionLog: [...$next->actionLog, implode(' ', $logParts)]);
                }
            }
        }

        $isCrit = $roll >= self::CRIT_THRESHOLD;
        $base = max(1, ($attacker->attack + $attacker->attackBonus) - ($target->defense + $terrainDefense));
        $damage = $this->applyDamage($base, $isCrit);

        $logParts[] = "for {$damage} damage";
        if ($isCrit) {
            $logParts[] = '— crit';
        }

        $updatedTarget = $target->takeDamage($damage);
        $next = $match
            ->withUnit($updatedAttacker)
            ->withUnit($updatedTarget);
        $next = $next->copy(actionLog: [...$next->actionLog, implode(' ', $logParts)]);

        return $this->checkVictoryAfterAction($next, $attacker->teamId);
    }
  • Step 5: Run the new tests to confirm they pass

Run: vendor/bin/phpunit tests/Unit/Domain/CombatEngineTest.php Expected: all 8 new tests pass alongside the existing tests.

  • Step 6: Run the full suite to confirm no regression

Run: composer test Expected: 245 tests pass (237 existing + 8 new), 0 failures, 12 pre-existing warnings.

  • Step 7: Commit
git add src/Domain/CombatEngine.php tests/Unit/Domain/CombatEngineTest.php
git commit -m "feat(combat): to-hit roll, ±15% damage variance, crits, and new log format"

Task 2: Flanking

Files:

  • Modify: src/Domain/CombatEngine.php (the flankingBonusFor method is already there from Task 1; this task adds the four-corner coverage and a private helper that returns the count for testability)
  • Modify: tests/Unit/Domain/CombatEngineTest.php (add 3 new flanking-specific tests)

Interfaces:

  • Consumes: existing MatchState, UnitState, Position.

  • Produces: integer flanking bonus (0, 15, or 30) returned by flankingBonusFor.

  • Step 1: Add a flankingAlliesCount helper for testability

Add this private method to CombatEngine, just below flankingBonusFor:

    private function flankingAlliesCount(MatchState $match, UnitState $attacker, UnitState $target): int
    {
        $count = 0;
        foreach ($match->units as $other) {
            if ($other->id === $attacker->id || $other->id === $target->id) {
                continue;
            }
            if ($other->isDefeated() || $other->teamId !== $attacker->teamId) {
                continue;
            }
            $dx = abs($other->position->x - $target->position->x);
            $dy = abs($other->position->y - $target->position->y);
            if (($dx === 1 && $dy === 0) || ($dx === 0 && $dy === 1)) {
                $count += 1;
            }
        }
        return $count;
    }

Refactor flankingBonusFor to use it:

    private function flankingBonusFor(MatchState $match, UnitState $attacker, UnitState $target): int
    {
        $count = $this->flankingAlliesCount($match, $attacker, $target);
        return min($count * 15, 30);
    }
  • Step 2: Write the failing tests

Append these methods to tests/Unit/Domain/CombatEngineTest.php (just before the closing brace):

    public function testFlankingBonusFromOneOrthogonalAlly(): void
    {
        // Build a match with the attacker at (0, 0), target at (0, 1),
        // and a single ally at (1, 1) (orthogonal to the target). Expected
        // flanking bonus is +15.
        $match = $this->freshMatch();
        $matchArray = ScenarioSerializer::matchToArray($match);
        $matchArray['units'][0]['position'] = ['x' => 0, 'y' => 0]; // alpha-1 (attacker)
        $matchArray['units'][1]['position'] = ['x' => 0, 'y' => 1]; // alpha-2 (ally)
        $matchArray['units'][2]['position'] = ['x' => 1, 'y' => 1]; // bravo-1 (target)
        $matchArray['units'][3] = $matchArray['units'][0]; $matchArray['units'][3]['id'] = 'bravo-2'; $matchArray['units'][3]['teamId'] = 'alpha'; $matchArray['units'][3]['position'] = ['x' => 2, 'y' => 0];
        $match = ScenarioSerializer::matchFromArray($matchArray);

        // Run 200 attacks; with one orthogonal ally, threshold = 40 + 3*attack - defense - 15.
        // At attack=3 vs defense=0, expected threshold = 34. After clamping, 34.
        $counts = [];
        for ($i = 0; $i < 200; $i += 1) {
            $match = $this->freshMatch();
            $matchArray = ScenarioSerializer::matchToArray($match);
            $matchArray['units'][0]['position'] = ['x' => 0, 'y' => 0];
            $matchArray['units'][0]['attack'] = 3;
            $matchArray['units'][0]['defense'] = 0;
            $matchArray['units'][1]['position'] = ['x' => 0, 'y' => 1]; // ally
            $matchArray['units'][2]['position'] = ['x' => 1, 'y' => 1]; // target
            $matchArray['units'][3] = $matchArray['units'][0];
            $matchArray['units'][3]['id'] = 'bravo-2';
            $matchArray['units'][3]['teamId'] = 'alpha';
            $matchArray['units'][3]['position'] = ['x' => 2, 'y' => 0];
            $match = ScenarioSerializer::matchFromArray($matchArray);

            $token = TurnToken::issue('s', 'm', 'alpha', 1, 0);
            $req = $this->buildRequest($match, $token, ['matchId' => 'm', 'match' => $matchArray, 'attackerId' => 'alpha-1', 'targetId' => 'bravo-1']);
            $result = (new PostMatchAttack('s'))->handle($req, []);
            $body = json_decode($result->body, true);
            $log = $body['match']['actionLog'];
            if (preg_match('/\/ needed (\d+)\)/', $log[0], $m)) {
                $counts[] = (int) $m[1];
            }
        }
        $unique = array_unique($counts);
        self::assertContains(34, $unique, 'one ally should produce a threshold of 34');
    }

    public function testFlankingDiagonalsDoNotContribute(): void
    {
        // Same setup but the "ally" is at (1, 2), diagonal to the target at (0, 1).
        // Flanking should be 0, threshold = 40 + 3*attack - defense - 0.
        $counts = [];
        for ($i = 0; $i < 100; $i += 1) {
            $match = $this->freshMatch();
            $matchArray = ScenarioSerializer::matchToArray($match);
            $matchArray['units'][0]['position'] = ['x' => 0, 'y' => 0];
            $matchArray['units'][0]['attack'] = 3;
            $matchArray['units'][0]['defense'] = 0;
            $matchArray['units'][1]['position'] = ['x' => 1, 'y' => 2]; // diagonal "ally"
            $matchArray['units'][2]['position'] = ['x' => 0, 'y' => 1]; // target
            $matchArray['units'][3] = $matchArray['units'][0];
            $matchArray['units'][3]['id'] = 'bravo-2';
            $matchArray['units'][3]['teamId'] = 'alpha';
            $matchArray['units'][3]['position'] = ['x' => 2, 'y' => 0];
            $match = ScenarioSerializer::matchFromArray($matchArray);

            $token = TurnToken::issue('s', 'm', 'alpha', 1, 0);
            $req = $this->buildRequest($match, $token, ['matchId' => 'm', 'match' => $matchArray, 'attackerId' => 'alpha-1', 'targetId' => 'bravo-1']);
            $result = (new PostMatchAttack('s'))->handle($req, []);
            $body = json_decode($result->body, true);
            $log = $body['match']['actionLog'];
            if (preg_match('/\/ needed (\d+)\)/', $log[0], $m)) {
                $counts[] = (int) $m[1];
            }
        }
        $unique = array_unique($counts);
        self::assertContains(49, $unique, 'no orthogonal ally should produce threshold of 40 + 9 - 0 = 49');
    }

    public function testFlankingCapsAt30WithThreeOrMoreAllies(): void
    {
        // 3 orthogonal allies should still cap at 30, not produce 45.
        // Build a tight cluster: attacker (0,0), target (0,1), 3 allies at (1,0), (1,1), (1,2).
        $counts = [];
        for ($i = 0; $i < 50; $i += 1) {
            $match = $this->freshMatch();
            $matchArray = ScenarioSerializer::matchToArray($match);
            $matchArray['units'][0]['position'] = ['x' => 0, 'y' => 0];
            $matchArray['units'][0]['attack'] = 3;
            $matchArray['units'][0]['defense'] = 0;
            $matchArray['units'][1]['position'] = ['x' => 1, 'y' => 0]; // ally 1
            $matchArray['units'][2]['position'] = ['x' => 0, 'y' => 1]; // target
            $matchArray['units'][3] = $matchArray['units'][0];
            $matchArray['units'][3]['id'] = 'bravo-2';
            $matchArray['units'][3]['teamId'] = 'alpha';
            $matchArray['units'][3]['position'] = ['x' => 1, 'y' => 1]; // ally 2
            $matchArray['units'][4] = $matchArray['units'][0];
            $matchArray['units'][4]['id'] = 'bravo-3';
            $matchArray['units'][4]['teamId'] = 'alpha';
            $matchArray['units'][4]['position'] = ['x' => 1, 'y' => 2]; // ally 3
            $match = ScenarioSerializer::matchFromArray($matchArray);

            $token = TurnToken::issue('s', 'm', 'alpha', 1, 0);
            $req = $this->buildRequest($match, $token, ['matchId' => 'm', 'match' => $matchArray, 'attackerId' => 'alpha-1', 'targetId' => 'bravo-1']);
            $result = (new PostMatchAttack('s'))->handle($req, []);
            $body = json_decode($result->body, true);
            $log = $body['match']['actionLog'];
            if (preg_match('/\/ needed (\d+)\)/', $log[0], $m)) {
                $counts[] = (int) $m[1];
            }
        }
        $unique = array_unique($counts);
        // 40 + 9 - 0 - 30 (cap) = 19. The cap should be 30, not 45.
        self::assertContains(19, $unique, 'three allies should cap at +30 flanking, threshold 19');
        self::assertNotContains(4, $unique, 'flanking should not exceed +30');
    }
  • Step 3: Run the new tests to verify they pass

Run: vendor/bin/phpunit tests/Unit/Domain/CombatEngineTest.php --filter Flanking Expected: 3 tests pass.

  • Step 4: Run the full suite

Run: composer test Expected: 248 tests pass (237 existing + 8 from Task 1 + 3 from Task 2), 0 failures.

  • Step 5: Commit
git add src/Domain/CombatEngine.php tests/Unit/Domain/CombatEngineTest.php
git commit -m "feat(combat): flanking bonus caps at +30, diagonal allies do not count"

Task 3: Forest concealment

Files:

  • Modify: src/Domain/CombatEngine.php (the concealment block is already in attack() from Task 1; this task just adds the named constant and a small refactor)
  • Modify: tests/Unit/Domain/CombatEngineTest.php (add 2 new tests for concealment)

Interfaces:

  • Consumes: existing MatchState, UnitState, Terrain.

  • Produces: a CombatEngine::FOREST_CONCEALMENT_MISS_CHANCE = 15 constant; the engine uses this in the concealment block.

  • Step 1: Add the named constant

In src/Domain/CombatEngine.php, add to the constants block:

    private const FOREST_CONCEALMENT_MISS_CHANCE = 15;
  • Step 2: Refactor the concealment block in attack() to use the constant and the Terrain enum properly

Replace the existing concealment block (the if ($match->battlefield->terrainAt(...)->movementCost() === 2) chain) with:

        // Forest concealment: independent 15% miss roll after to-hit passes.
        $targetTerrain = $match->battlefield->terrainAt($target->position);
        if ($targetTerrain === \BattleForge\Domain\Terrain::Forest) {
            $concealmentRoll = random_int(1, 100);
            if ($concealmentRoll <= self::FOREST_CONCEALMENT_MISS_CHANCE) {
                $logParts[] = 'for 0 damage';
                $logParts[] = '— miss — concealed';
                $next = $match->withUnit($updatedAttacker);
                return $next->copy(actionLog: [...$next->actionLog, implode(' ', $logParts)]);
            }
        }

(Remove the name === 'Forest' string check; the enum identity is the right comparison.)

  • Step 3: Write the failing tests

Append to tests/Unit/Domain/CombatEngineTest.php:

    public function testAttackForestDefenderCarriesConcealedMissOnLowRoll(): void
    {
        // Force a concealment miss: target is on forest. Run 200 attacks; the
        // engine rolls the concealment die; we observe at least one log entry
        // containing " — miss — concealed" and confirm the damage is 0.
        $concealSeen = false;
        for ($i = 0; $i < 200; $i += 1) {
            $match = $this->freshMatch();
            $matchArray = ScenarioSerializer::matchToArray($match);
            $matchArray['units'][0]['position'] = ['x' => 0, 'y' => 0];
            $matchArray['units'][0]['attack'] = 3;
            $matchArray['units'][0]['defense'] = 0;
            $matchArray['units'][1]['position'] = ['x' => 1, 'y' => 0];
            $matchArray['units'][1]['attack'] = 1;
            $matchArray['units'][1]['defense'] = 1;
            $matchArray['units'][1]['x'] = 1; // (no-op, kept for clarity)
            $match = ScenarioSerializer::matchFromArray($matchArray);
            // Stamp forest terrain on the target's tile.
            $terrainMap = ['1:0' => 'forest'];
            $matchArray = ScenarioSerializer::matchToArray($match);
            $matchArray['battlefield']['terrain'] = $terrainMap;
            $match = ScenarioSerializer::matchFromArray($matchArray);

            $token = TurnToken::issue('s', 'm', 'alpha', 1, 0);
            $req = $this->buildRequest($match, $token, ['matchId' => 'm', 'match' => $matchArray, 'attackerId' => 'alpha-1', 'targetId' => 'bravo-1']);
            $result = (new PostMatchAttack('s'))->handle($req, []);
            $body = json_decode($result->body, true);
            $log = $body['match']['actionLog'];
            if (str_contains($log[0], ' — miss — concealed')) {
                $concealSeen = true;
                preg_match('/for (\d+) damage/', $log[0], $m);
                self::assertSame('0', $m[1], 'concealed miss should record 0 damage');
                break;
            }
        }
        self::assertTrue($concealSeen, 'expected at least one concealed miss in 200 trials against a forest defender');
    }

    public function testAttackNonForestTerrainDoesNotRollConcealment(): void
    {
        // Same as above but target is on rough terrain (cost 2, no concealment).
        // No log entry should contain " — concealed" in 200 trials.
        $concealSeen = false;
        for ($i = 0; $i < 200; $i += 1) {
            $match = $this->freshMatch();
            $matchArray = ScenarioSerializer::matchToArray($match);
            $matchArray['units'][0]['position'] = ['x' => 0, 'y' => 0];
            $matchArray['units'][0]['attack'] = 3;
            $matchArray['units'][0]['defense'] = 0;
            $matchArray['units'][1]['position'] = ['x' => 1, 'y' => 0];
            $matchArray['units'][1]['attack'] = 1;
            $matchArray['units'][1]['defense'] = 1;
            $match = ScenarioSerializer::matchFromArray($matchArray);
            $matchArray['battlefield']['terrain'] = ['1:0' => 'rough'];
            $match = ScenarioSerializer::matchFromArray($matchArray);

            $token = TurnToken::issue('s', 'm', 'alpha', 1, 0);
            $req = $this->buildRequest($match, $token, ['matchId' => 'm', 'match' => $matchArray, 'attackerId' => 'alpha-1', 'targetId' => 'bravo-1']);
            $result = (new PostMatchAttack('s'))->handle($req, []);
            $body = json_decode($result->body, true);
            $log = $body['match']['actionLog'];
            if (str_contains($log[0], ' — concealed')) {
                $concealSeen = true;
                break;
            }
        }
        self::assertFalse($concealSeen, 'rough terrain should never produce a concealed-miss log entry');
    }
  • Step 4: Run the new tests to verify they pass

Run: vendor/bin/phpunit tests/Unit/Domain/CombatEngineTest.php --filter Conceal Expected: 2 tests pass.

  • Step 5: Run the full suite

Run: composer test Expected: 250 tests pass (237 + 8 from Task 1 + 3 from Task 2 + 2 from Task 3), 0 failures.

  • Step 6: Commit
git add src/Domain/CombatEngine.php tests/Unit/Domain/CombatEngineTest.php
git commit -m "feat(combat): forest concealment roll, only on Forest terrain"

Task 4: Water traversal (cost 3, ends turn)

Files:

  • Modify: src/Domain/Terrain.php (change Water::movementCost() from null to 3)
  • Modify: src/Domain/UnitState.php (add bool $wadedThisTurn to constructor + startTurn() + withWaded() helper)
  • Modify: src/Domain/CombatEngine.php (in move(), after a successful move into water, set wadedThisTurn and actionsRemaining = 0 on the unit)
  • Modify: src/Application/ScenarioSerializer.php (round-trip wadedThisTurn in unitToArray / unitFromArray)
  • Modify: tests/Unit/Domain/TerrainTest.php (new file) — verify Water::movementCost() === 3
  • Modify: tests/Unit/Domain/UnitStateTest.php (extend with wade-related cases)
  • Modify: tests/Integration/PostMatchActionTest.php (add testMoveIntoWaterEndsTurn)

Interfaces:

  • Consumes: existing Terrain enum, UnitState, CombatEngine::move(), ScenarioSerializer.

  • Produces: Terrain::Water->movementCost() === 3 (was null); UnitState::$wadedThisTurn field; UnitState::withWaded(bool); UnitState::startTurn() resets wadedThisTurn to false; CombatEngine::move() sets wadedThisTurn and zeroes actionsRemaining on water-entry; ScenarioSerializer carries wadedThisTurn in the wire shape.

  • Step 1: Update Terrain::Water to cost 3

Open src/Domain/Terrain.php. Change:

    public function movementCost(): ?int
    {
        return match ($this) {
            self::Open => 1,
            self::Forest, self::Rough => 2,
            self::Water, self::Blocking => null,
        };
    }

to:

    public function movementCost(): ?int
    {
        return match ($this) {
            self::Open => 1,
            self::Forest, self::Rough => 2,
            self::Water => 3,
            self::Blocking => null,
        };
    }
  • Step 2: Add the wadedThisTurn field to UnitState

Open src/Domain/UnitState.php. Find the constructor:

    public function __construct(
        public string $id,
        public string $teamId,
        public Position $position,
        public int $maxHealth,
        public int $health,
        public int $attack,
        public int $defense,
        public int $speed,
        public int $actionsRemaining,
        public bool $hasAttacked = false,
        public Archetype $archetype = Archetype::Defender,
        public array $abilities = [],
        public int $attackBonus = 0,
        public bool $hasUsedAbility = false,
    ) {

Add public bool $wadedThisTurn = false, as the last parameter. Then add a public method withWaded() and update startTurn():

    public function withWaded(bool $waded): self
    {
        return $this->copy(wadedThisTurn: $waded);
    }

In startTurn(), the copy() call needs a new arg:

    public function startTurn(): self
    {
        if ($this->isDefeated()) {
            return $this;
        }

        return $this->copy(
            actionsRemaining: 2,
            hasAttacked: false,
            attackBonus: 0,
            hasUsedAbility: false,
            wadedThisTurn: false,
        );
    }

In the private copy() method, add the new parameter:

    private function copy(
        ?Position $position = null,
        ?int $health = null,
        ?int $actionsRemaining = null,
        ?bool $hasAttacked = null,
        ?int $attackBonus = null,
        ?bool $hasUsedAbility = null,
        ?bool $wadedThisTurn = null,
    ): self {
        return new self(
            $this->id,
            $this->teamId,
            $position ?? $this->position,
            $this->maxHealth,
            $health ?? $this->health,
            $this->attack,
            $this->defense,
            $this->speed,
            $actionsRemaining ?? $this->actionsRemaining,
            $hasAttacked ?? $this->hasAttacked,
            $this->archetype,
            $this->abilities,
            $attackBonus ?? $this->attackBonus,
            $hasUsedAbility ?? $this->hasUsedAbility,
            $wadedThisTurn ?? $this->wadedThisTurn,
        );
    }
  • Step 3: Update CombatEngine::move() to enforce the wade rule

In src/Domain/CombatEngine.php, find the move() method. Just before the final return $this->checkVictoryAfterAction(...) (or wherever the move() currently returns the new match state), insert:

        // Water entry ends the turn: the unit can move INTO water, but not out
        // or anywhere else this turn.
        $updatedUnit = $moved;
        if ($match->battlefield->terrainAt($moved->position) === \BattleForge\Domain\Terrain::Water) {
            $updatedUnit = $moved->withWaded(true)->copy(actionsRemaining: 0);
        }

Then replace $moved with $updatedUnit in the rest of the move() method's return path. The full updated move() should look like:

    public function move(MatchState $match, string $unitId, Position $destination): MatchState
    {
        $unit = $this->unitOrFail($match, $unitId);
        $this->assertCanAct($match, $unit);

        $occupied = [];
        foreach ($match->units as $other) {
            if ($other->id !== $unit->id && !$other->isDefeated()) {
                $occupied[] = $other->position;
            }
        }
        $reachable = $match->battlefield->reachable($unit->position, $unit->speed, $occupied);
        if ($destination->key() === $unit->position->key() || !isset($reachable[$destination->key()])) {
            throw new CombatException('Destination is not reachable.');
        }

        $moved = $unit->moveTo($destination);
        $logParts = ["{$unit->id} moved to {$destination->key()}"];

        $updatedUnit = $moved;
        if ($match->battlefield->terrainAt($moved->position) === \BattleForge\Domain\Terrain::Water) {
            $updatedUnit = $moved->withWaded(true)->copy(actionsRemaining: 0);
            $logParts[] = '— wading';
        }

        $next = $match->withUnit($updatedUnit);
        $next = $next->copy(actionLog: [...$next->actionLog, implode(' ', $logParts)]);

        return $this->checkVictoryAfterAction($next, $unit->teamId);
    }
  • Step 4: Update ScenarioSerializer to round-trip wadedThisTurn

In src/Application/ScenarioSerializer.php, find unitToArray:

    private static function unitToArray(UnitState $unit): array
    {
        return [
            'id' => $unit->id,
            'teamId' => $unit->teamId,
            'position' => self::positionToArray($unit->position),
            'maxHealth' => $unit->maxHealth,
            'health' => $unit->health,
            'attack' => $unit->attack,
            'defense' => $unit->defense,
            'speed' => $unit->speed,
            'archetype' => $unit->archetype->value,
            'abilities' => $unit->abilities,
        ];
    }

Add 'wadedThisTurn' => $unit->wadedThisTurn, to the returned array.

Find unitFromArray:

    private static function unitFromArray(array $row): UnitState
    {
        ...
        return new UnitState(
            id: (string) $row['id'],
            teamId: (string) $row['teamId'],
            ...
            hasUsedAbility: false,
        );
    }

Update the constructor call to include wadedThisTurn: (bool) ($row['wadedThisTurn'] ?? false),. The other call site in startMatch (the one inside Scenario::startMatch that builds a fresh UnitState) also needs the new field — add wadedThisTurn: false to it.

  • Step 5: Write the failing tests

Create tests/Unit/Domain/TerrainTest.php:

<?php

declare(strict_types=1);

namespace BattleForge\Tests\Unit\Domain;

use BattleForge\Domain\Terrain;
use PHPUnit\Framework\TestCase;

final class TerrainTest extends TestCase
{
    public function testOpenCostsOne(): void
    {
        self::assertSame(1, Terrain::Open->movementCost());
        self::assertSame(0, Terrain::Open->defenseBonus());
    }

    public function testForestCostsTwoAndGivesOneDefense(): void
    {
        self::assertSame(2, Terrain::Forest->movementCost());
        self::assertSame(1, Terrain::Forest->defenseBonus());
    }

    public function testRoughCostsTwoAndGivesNoDefense(): void
    {
        self::assertSame(2, Terrain::Rough->movementCost());
        self::assertSame(0, Terrain::Rough->defenseBonus());
    }

    public function testWaterCostsThreeAndGivesNoDefense(): void
    {
        self::assertSame(3, Terrain::Water->movementCost());
        self::assertSame(0, Terrain::Water->defenseBonus());
    }

    public function testBlockingIsImpassable(): void
    {
        self::assertNull(Terrain::Blocking->movementCost());
    }
}

Append to tests/Unit/Domain/UnitStateTest.php:

    public function testStartTurnResetsWadedFlag(): void
    {
        $unit = $this->unit(wadedThisTurn: true);
        $started = $unit->startTurn();
        self::assertFalse($started->wadedThisTurn);
    }

    public function testWithWadedSetsAndClearsFlag(): void
    {
        $unit = $this->unit();
        self::assertFalse($unit->wadedThisTurn);

        $wading = $unit->withWaded(true);
        self::assertTrue($wading->wadedThisTurn);

        $dry = $wading->withWaded(false);
        self::assertFalse($dry->wadedThisTurn);
    }

The unit() helper will need a wadedThisTurn parameter added to its signature (default false). Check the existing signature and add the parameter.

Append to tests/Integration/PostMatchActionTest.php:

    public function testMoveIntoWaterEndsTurn(): void
    {
        // Build a match where alpha-1 is on dry ground next to a water tile.
        $match = $this->freshMatch();
        $matchArray = ScenarioSerializer::matchToArray($match);
        $matchArray['units'][0]['position'] = ['x' => 0, 'y' => 0];
        $matchArray['units'][0]['speed'] = 3;
        $matchArray['units'][1]['position'] = ['x' => 7, 'y' => 7];
        $matchArray['units'][2]['position'] = ['x' => 0, 'y' => 0]; // alpha-2 — force id by overwriting
        $matchArray['battlefield']['terrain'] = ['0:1' => 'water'];
        $match = ScenarioSerializer::matchFromArray($matchArray);

        $token = TurnToken::issue('s', 'm', 'alpha', 1, 0);
        $req = $this->buildRequest($match, $token, [
            'matchId' => 'm',
            'match' => ScenarioSerializer::matchToArray($match),
            'unitId' => 'alpha-1',
            'x' => 0,
            'y' => 1,
        ]);
        $result = (new PostMatchMove('s'))->handle($req, []);
        self::assertSame(200, $result->status);

        $body = json_decode($result->body, true);
        $moved = null;
        foreach ($body['match']['units'] as $u) {
            if ($u['id'] === 'alpha-1') {
                $moved = $u;
            }
        }
        self::assertNotNull($moved);
        self::assertSame(['x' => 0, 'y' => 1], $moved['position']);
        self::assertSame(0, $moved['actionsRemaining'], 'wading ends the turn');
        self::assertTrue($moved['wadedThisTurn'], 'wade flag is set after water entry');
    }

(freshMatch, buildRequest, and the PostMatchMove import already exist in the test file. If the test file imports BattleForge\Http\Handlers\PostMatchMove already, that's fine. Otherwise add the import.)

  • Step 6: Run the new tests to verify they pass

Run: vendor/bin/phpunit tests/Unit/Domain/TerrainTest.php tests/Unit/Domain/UnitStateTest.php tests/Integration/PostMatchActionTest.php Expected: all new tests pass.

  • Step 7: Run the full suite

Run: composer test Expected: 257 tests pass (250 from prior tasks + 5 TerrainTest + 2 UnitStateTest wade + 1 integration), 0 failures.

  • Step 8: Commit
git add src/Domain/Terrain.php src/Domain/UnitState.php src/Domain/CombatEngine.php src/Application/ScenarioSerializer.php tests/Unit/Domain/TerrainTest.php tests/Unit/Domain/UnitStateTest.php tests/Integration/PostMatchActionTest.php
git commit -m "feat(combat): water traversal costs 3 and ends the turn"

Task 5: Update the bundled scenarios' terrain and add a manual smoke test

Files:

  • Modify: public/assets/scenarios/skirmish.json (no edits needed — no water/forest; verify all assertions pass)
  • Modify: public/assets/scenarios/hold-the-line.json (verify the alpha defenders' forest coverage still works; no edits likely)
  • Modify: public/assets/scenarios/last-stand.json (no edits needed; the existing water tiles now work under the new rules; the smoke test will exercise the new mechanics)
  • Modify: tests/Integration/PostMatchActionSmokeTest.php (extend the 200-iteration budget if needed to compensate for the new wade rule slowing some matches; otherwise just add an assertion that the new log format appears)
  • Modify: public/assets/styles.css (add .bf-unit--wading style for the visual)
  • Modify: public/js/match.js (apply the bf-unit--wading class when the unit has wadedThisTurn === true)

Interfaces:

  • Consumes: existing scenarios; the wade flag from Task 4; the JS renderer's grid layout.

  • Produces: a .bf-unit--wading CSS class for the visual; the smoke test extends the action-budget assertion if needed.

  • Step 1: Add the wading CSS rule

Append to public/assets/styles.css:

.bf-unit--wading { opacity: 0.55; filter: blur(0.4px); }
  • Step 2: Apply the wading class in the JS renderer

In public/js/match.js, find the renderGrid function. The unit's cls array currently contains 'bf-unit', 'bf-unit--${unit.teamId}', optionally 'bf-unit--active' or 'bf-unit--inactive', and optionally 'bf-unit--winner'. Add a conditional bf-unit--wading based on unit.wadedThisTurn:

        const cls = ['bf-unit', `bf-unit--${unit.teamId}`];
        if (isActive) cls.push('bf-unit--active');
        else cls.push('bf-unit--inactive');
        if (isWinner) cls.push('bf-unit--winner');
        if (unit.wadedThisTurn) cls.push('bf-unit--wading');

(Look for the existing block and add the last line. The diff is small and obvious.)

  • Step 3: Update the smoke test budget if needed

The current smoke test (PostMatchActionSmokeTest) runs up to 200 actions to walk Skirmish to a winner. With Plan 5 changes, the AI's pickAction greedy may take longer because misses/crits are more common. Run the smoke test to see if 200 is still sufficient:

Run: BATTLEFORGE_E2E=1 vendor/bin/phpunit tests/Integration/PostMatchActionSmokeTest.php Expected: passes within 200 actions. If not, bump the iteration cap to 300 in tests/Integration/PostMatchActionSmokeTest.php (one-line change in for ($i = 0; $i < 200; $i += 1)for ($i = 0; $i < 300; $i += 1)).

  • Step 4: Run the full suite to confirm no regression

Run: composer test Expected: 257 tests pass, 0 failures, 12 pre-existing warnings.

  • Step 5: Run lint and format on the JS side

Run: npm run lint && npm run format Expected: both pass.

  • Step 6: Commit
git add public/assets/scenarios/skirmish.json public/assets/scenarios/hold-the-line.json public/assets/scenarios/last-stand.json tests/Integration/PostMatchActionSmokeTest.php public/assets/styles.css public/js/match.js
git commit -m "feat(combat): wade visual + smoke test budget"

Task 6: Final whole-branch review

Files:

  • No file changes (review-only task).

Interfaces:

  • Consumes: the spec at docs/superpowers/specs/2026-07-26-deeper-combat-design.md; the implementation in src/Domain/.

  • Produces: a review report citing any remaining issues; a follow-up commit if issues are found.

  • Step 1: Run the full suite and the JS checks one more time

Run: composer test && npm run lint && npm run format Expected: 257 tests pass, lint and format clean.

  • Step 2: Generate the review package

Run: bash ~/.claude/plugins/cache/claude-plugins-official/superpowers/6.2.0/skills/subagent-driven-development/scripts/review-package docs/superpowers/plans/2026-07-26-deeper-combat.md origin/develop HEAD (Use the merge base as the base. The script writes a unified diff to a uniquely-named file in .superpowers/sdd/2026-07-26-deeper-combat/.)

  • Step 3: Dispatch a reviewer subagent

The subagent verifies the diff against the spec's "In Scope" list (to-hit, variance, crits, flanking, forest concealment, water traversal) and the "Out of Scope" list (no new stat axis, no new terrain, no UI redesign, etc.). It flags any spec gap, any place where the implementation drifted from the spec, and any test coverage holes.

  • Step 4: Address findings

If the reviewer reports Critical/Important findings, fix them in a follow-up commit. Minor findings (style, naming) can be parked as deferred-minors. Re-run composer test after any fix to confirm no regression.

  • Step 5: Final commit (if any fixes were needed)
git add -A
git commit -m "fix(combat): address review findings"

If no fixes were needed, skip this step.


Completion Check

Run:

composer test
npm run lint
npm run format
git status --short

Expected:

  • 257 tests pass, 0 failures, 4 skipped (gated smoke), 12 pre-existing warnings.
  • ESLint and Prettier both clean.
  • No untracked files except .worktrees/ and .superpowers/ (gitignored).
  • develop is ahead of origin/develop by 6 commits (one per task).

The MVP's combat layer is now non-deterministic, with hit/miss randomness, damage variance, crits, flanking, and terrain identity. Matches end faster or slower depending on positioning and rolls. The action log carries the roll and threshold so the player can see why a hit landed or a miss happened. Water is a tactical choice: pay 3 movement, end your turn.