feat(combat): flanking bonus caps at +30, diagonal allies do not count
This commit is contained in:
@@ -408,6 +408,12 @@ final class CombatEngine
|
|||||||
}
|
}
|
||||||
|
|
||||||
private function flankingBonusFor(MatchState $match, UnitState $attacker, UnitState $target): int
|
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;
|
$count = 0;
|
||||||
foreach ($match->units as $other) {
|
foreach ($match->units as $other) {
|
||||||
@@ -421,12 +427,9 @@ final class CombatEngine
|
|||||||
$dy = abs($other->position->y - $target->position->y);
|
$dy = abs($other->position->y - $target->position->y);
|
||||||
if (($dx === 1 && $dy === 0) || ($dx === 0 && $dy === 1)) {
|
if (($dx === 1 && $dy === 0) || ($dx === 0 && $dy === 1)) {
|
||||||
$count += 1;
|
$count += 1;
|
||||||
if ($count >= 2) {
|
|
||||||
return 30;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
return $count;
|
||||||
return $count * 15;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private function applyDamage(int $base, bool $isCrit): int
|
private function applyDamage(int $base, bool $isCrit): int
|
||||||
|
|||||||
@@ -1509,4 +1509,151 @@ final class CombatEngineTest extends TestCase
|
|||||||
'alpha',
|
'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');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user