diff --git a/src/Domain/CombatEngine.php b/src/Domain/CombatEngine.php index c126879..bdb373b 100644 --- a/src/Domain/CombatEngine.php +++ b/src/Domain/CombatEngine.php @@ -408,6 +408,12 @@ final class CombatEngine } private function flankingBonusFor(MatchState $match, UnitState $attacker, UnitState $target): int + { + $count = $this->flankingAlliesCount($match, $attacker, $target); + return min($count * 15, 30); + } + + private function flankingAlliesCount(MatchState $match, UnitState $attacker, UnitState $target): int { $count = 0; foreach ($match->units as $other) { @@ -421,12 +427,9 @@ final class CombatEngine $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; + return $count; } private function applyDamage(int $base, bool $isCrit): int diff --git a/tests/Unit/Domain/CombatEngineTest.php b/tests/Unit/Domain/CombatEngineTest.php index b59ff9b..ec22a4b 100644 --- a/tests/Unit/Domain/CombatEngineTest.php +++ b/tests/Unit/Domain/CombatEngineTest.php @@ -1509,4 +1509,151 @@ final class CombatEngineTest extends TestCase 'alpha', ); } + + public function testFlankingBonusFromOneOrthogonalAlly(): void + { + // Build a match: attacker alpha-1 at (0, 0), target bravo-1 at (0, 1), + // ally alpha-2 at (1, 1) (orthogonal to target), and bravo-2 filler at (2, 0). + // One orthogonal ally should produce a flanking bonus of +15, so the + // to-hit threshold becomes 40 + 3*attack - defense - 15 = 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; + // Rename the existing bravo-1 (units[1]) to alpha-2 and move it to (1, 1). + $matchArray['units'][1]['id'] = 'alpha-2'; + $matchArray['units'][1]['teamId'] = 'alpha'; + $matchArray['units'][1]['position'] = ['x' => 1, 'y' => 1]; + // Insert bravo-1 as the target at (0, 1) by copying the alpha-2 row above. + $matchArray['units'][2] = $matchArray['units'][1]; + $matchArray['units'][2]['id'] = 'bravo-1'; + $matchArray['units'][2]['teamId'] = 'bravo'; + $matchArray['units'][2]['position'] = ['x' => 0, 'y' => 1]; + $matchArray['units'][2]['defense'] = 0; // brief's threshold 34 requires target defense = 0 + // Add bravo-2 as a far-away filler so the match has two full teams. + $matchArray['units'][3] = $matchArray['units'][1]; + $matchArray['units'][3]['id'] = 'bravo-2'; + $matchArray['units'][3]['teamId'] = 'bravo'; + $matchArray['units'][3]['position'] = ['x' => 2, 'y' => 0]; + $match = ScenarioSerializer::matchFromArray($matchArray); + + $token = TurnToken::issue('s', '0123456789abcdef', 'alpha', 1, 0); + $req = $this->buildRequest($match, $token, [ + 'matchId' => '0123456789abcdef', + '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 as testFlankingBonusFromOneOrthogonalAlly, but the ally sits at (1, 2), + // which is diagonal to the target bravo-1 at (0, 1). Diagonal neighbours should + // not contribute, so the flanking bonus is 0 and the threshold stays at 40 + 9 - 0 = 49. + $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]['id'] = 'alpha-2'; + $matchArray['units'][1]['teamId'] = 'alpha'; + $matchArray['units'][1]['position'] = ['x' => 1, 'y' => 2]; // diagonal "ally" + $matchArray['units'][2] = $matchArray['units'][1]; + $matchArray['units'][2]['id'] = 'bravo-1'; + $matchArray['units'][2]['teamId'] = 'bravo'; + $matchArray['units'][2]['position'] = ['x' => 0, 'y' => 1]; + $matchArray['units'][2]['defense'] = 0; // target -- brief's threshold 49 requires target defense = 0 + $matchArray['units'][3] = $matchArray['units'][1]; + $matchArray['units'][3]['id'] = 'bravo-2'; + $matchArray['units'][3]['teamId'] = 'bravo'; + $matchArray['units'][3]['position'] = ['x' => 2, 'y' => 0]; + $match = ScenarioSerializer::matchFromArray($matchArray); + + $token = TurnToken::issue('s', '0123456789abcdef', 'alpha', 1, 0); + $req = $this->buildRequest($match, $token, [ + 'matchId' => '0123456789abcdef', + '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 + { + // Three orthogonal allies should still cap at +30, not produce +45. + // Layout (all coordinates on the 8x8 grid): + // alpha-1 (attacker) at (1, 0) + // bravo-1 (target) at (1, 1) + // alpha-2 (ally 1) at (0, 1) -- orthogonal (left) + // alpha-3 (ally 2) at (2, 1) -- orthogonal (right) + // alpha-4 (ally 3) at (1, 2) -- orthogonal (down) + // Three orthogonal allies => flanking bonus min(3 * 15, 30) = 30, + // threshold = 40 + 9 - 0 - 30 = 19. + $counts = []; + for ($i = 0; $i < 50; $i += 1) { + $match = $this->freshMatch(); + $matchArray = ScenarioSerializer::matchToArray($match); + $matchArray['units'][0]['position'] = ['x' => 1, 'y' => 0]; + $matchArray['units'][0]['attack'] = 3; + $matchArray['units'][0]['defense'] = 0; + $matchArray['units'][1]['id'] = 'alpha-2'; + $matchArray['units'][1]['teamId'] = 'alpha'; + $matchArray['units'][1]['position'] = ['x' => 0, 'y' => 1]; // ally 1 (orthogonal) + $matchArray['units'][2] = $matchArray['units'][1]; + $matchArray['units'][2]['id'] = 'bravo-1'; + $matchArray['units'][2]['teamId'] = 'bravo'; + $matchArray['units'][2]['position'] = ['x' => 1, 'y' => 1]; // target + $matchArray['units'][2]['defense'] = 0; // brief's threshold 19 requires target defense = 0 + $matchArray['units'][3] = $matchArray['units'][1]; + $matchArray['units'][3]['id'] = 'alpha-3'; + $matchArray['units'][3]['teamId'] = 'alpha'; + $matchArray['units'][3]['position'] = ['x' => 2, 'y' => 1]; // ally 2 (orthogonal) + $matchArray['units'][4] = $matchArray['units'][1]; + $matchArray['units'][4]['id'] = 'alpha-4'; + $matchArray['units'][4]['teamId'] = 'alpha'; + $matchArray['units'][4]['position'] = ['x' => 1, 'y' => 2]; // ally 3 (orthogonal) + $match = ScenarioSerializer::matchFromArray($matchArray); + + $token = TurnToken::issue('s', '0123456789abcdef', 'alpha', 1, 0); + $req = $this->buildRequest($match, $token, [ + 'matchId' => '0123456789abcdef', + '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(19, $unique, 'three allies should cap at +30 flanking, threshold 19'); + self::assertNotContains(4, $unique, 'flanking should not exceed +30'); + } }