diff --git a/src/Domain/CombatEngine.php b/src/Domain/CombatEngine.php index bdb373b..88434fc 100644 --- a/src/Domain/CombatEngine.php +++ b/src/Domain/CombatEngine.php @@ -15,6 +15,7 @@ final class CombatEngine public const CRIT_THRESHOLD = 95; public const DAMAGE_VARIANCE_MIN_PCT = 85; public const DAMAGE_VARIANCE_MAX_PCT = 115; + public const FOREST_CONCEALMENT_MISS_CHANCE = 15; public function move(MatchState $match, string $unitId, Position $destination): MatchState { @@ -85,18 +86,15 @@ final class CombatEngine 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)]); - } + // 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)]); } } diff --git a/tests/Unit/Domain/CombatEngineTest.php b/tests/Unit/Domain/CombatEngineTest.php index ec22a4b..913303f 100644 --- a/tests/Unit/Domain/CombatEngineTest.php +++ b/tests/Unit/Domain/CombatEngineTest.php @@ -1656,4 +1656,85 @@ final class CombatEngineTest extends TestCase self::assertContains(19, $unique, 'three allies should cap at +30 flanking, threshold 19'); self::assertNotContains(4, $unique, 'flanking should not exceed +30'); } + + 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; + // (no-op, kept for clarity) + $match = ScenarioSerializer::matchFromArray($matchArray); + // Stamp forest terrain on the target's tile. + $matchArray['battlefield']['terrain'] = ['1:0' => 'forest']; + $match = ScenarioSerializer::matchFromArray($matchArray); + + // Brief uses 'm' for matchId; TurnToken requires 16+ lowercase hex chars. + // Use a valid matchId (the smallest deviation). + $matchId = '0123456789abcdef'; + $token = TurnToken::issue('s', $matchId, 'alpha', 1, 0); + $req = $this->buildRequest($match, $token, [ + 'matchId' => $matchId, + '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); + + $matchId = '0123456789abcdef'; + $token = TurnToken::issue('s', $matchId, 'alpha', 1, 0); + $req = $this->buildRequest($match, $token, [ + 'matchId' => $matchId, + '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'); + } }