feat(combat): flanking bonus caps at +30, diagonal allies do not count

This commit is contained in:
Keith Solomon
2026-07-26 22:51:13 -05:00
parent 4cc457c231
commit 241195bbc5
2 changed files with 154 additions and 4 deletions
+147
View File
@@ -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');
}
}