From 9eac4981f4486efbbd874073fad6619f18671a3e Mon Sep 17 00:00:00 2001 From: Keith Solomon Date: Sun, 26 Jul 2026 18:39:54 -0500 Subject: [PATCH] docs: add Plan 5 implementation plan (deeper combat) --- .../plans/2026-07-26-deeper-combat.md | 1260 +++++++++++++++++ 1 file changed, 1260 insertions(+) create mode 100644 docs/superpowers/plans/2026-07-26-deeper-combat.md diff --git a/docs/superpowers/plans/2026-07-26-deeper-combat.md b/docs/superpowers/plans/2026-07-26-deeper-combat.md new file mode 100644 index 0000000..105044b --- /dev/null +++ b/docs/superpowers/plans/2026-07-26-deeper-combat.md @@ -0,0 +1,1260 @@ +# 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 1–15) → 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` 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): + +```php +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): + +```php + 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: + +```php + /** @param array $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: + +```php + 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: + +```php + 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): + +```php + 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: + +```php + 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** + +```bash +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`: + +```php + 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: + +```php + 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): + +```php + 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** + +```bash +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: + +```php + 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: + +```php + // 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`: + +```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** + +```bash +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: + +```php + public function movementCost(): ?int + { + return match ($this) { + self::Open => 1, + self::Forest, self::Rough => 2, + self::Water, self::Blocking => null, + }; + } +``` + +to: + +```php + 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: + +```php + 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()`: + +```php + public function withWaded(bool $waded): self + { + return $this->copy(wadedThisTurn: $waded); + } +``` + +In `startTurn()`, the `copy()` call needs a new arg: + +```php + 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: + +```php + 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: + +```php + // 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: + +```php + 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`: + +```php + 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`: + +```php + 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 +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`: + +```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`: + +```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** + +```bash +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`: + +```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`: + +```js + 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** + +```bash +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)** + +```bash +git add -A +git commit -m "fix(combat): address review findings" +``` + +If no fixes were needed, skip this step. + +--- + +## Completion Check + +Run: +```bash +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.