1660 lines
62 KiB
PHP
1660 lines
62 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace BattleForge\Tests\Unit\Domain;
|
|
|
|
use BattleForge\Application\ScenarioSerializer;
|
|
use BattleForge\Application\TurnToken;
|
|
use BattleForge\Domain\Archetype;
|
|
use BattleForge\Domain\Battlefield;
|
|
use BattleForge\Domain\CombatEngine;
|
|
use BattleForge\Domain\CombatException;
|
|
use BattleForge\Domain\MatchState;
|
|
use BattleForge\Domain\ObjectiveMarker;
|
|
use BattleForge\Domain\Position;
|
|
use BattleForge\Domain\Terrain;
|
|
use BattleForge\Domain\UnitState;
|
|
use BattleForge\Domain\VictoryCondition;
|
|
use BattleForge\Http\CsrfToken;
|
|
use BattleForge\Http\Handlers\PostMatchAttack;
|
|
use BattleForge\Http\MatchActionSupport;
|
|
use InvalidArgumentException;
|
|
use PHPUnit\Framework\Attributes\DataProvider;
|
|
use PHPUnit\Framework\TestCase;
|
|
|
|
final class CombatEngineTest extends TestCase
|
|
{
|
|
public function testMatchAndUnitStateAreImmutable(): void
|
|
{
|
|
$unit = $this->unit('unit-1', 'alpha', new Position(0, 0));
|
|
$match = new MatchState(new Battlefield(8, 8), [$unit], 'alpha');
|
|
|
|
$moved = $unit->moveTo(new Position(1, 0))->spendAction();
|
|
$next = $match->withUnit($moved);
|
|
|
|
self::assertSame('0:0', $match->unit('unit-1')->position->key());
|
|
self::assertSame('1:0', $next->unit('unit-1')->position->key());
|
|
self::assertSame(1, $next->unit('unit-1')->actionsRemaining);
|
|
}
|
|
|
|
public function testMoveUpdatesUnitAndAppendsActionLogWithoutMutatingMatch(): void
|
|
{
|
|
$match = new MatchState(
|
|
new Battlefield(8, 8),
|
|
[
|
|
$this->unit('alpha-1', 'alpha', new Position(0, 0)),
|
|
$this->unit('bravo-1', 'bravo', new Position(7, 7)),
|
|
],
|
|
'alpha',
|
|
actionLog: ['match started'],
|
|
);
|
|
|
|
$next = (new CombatEngine())->move($match, 'alpha-1', new Position(2, 0));
|
|
|
|
self::assertSame('0:0', $match->unit('alpha-1')->position->key());
|
|
self::assertSame(2, $match->unit('alpha-1')->actionsRemaining);
|
|
self::assertSame(['match started'], $match->actionLog);
|
|
self::assertSame('2:0', $next->unit('alpha-1')->position->key());
|
|
self::assertSame(1, $next->unit('alpha-1')->actionsRemaining);
|
|
self::assertSame(['match started', 'alpha-1 moved to 2:0'], $next->actionLog);
|
|
}
|
|
|
|
public function testMoveTranslatesUnknownUnitAtCommandBoundary(): void
|
|
{
|
|
$match = $this->match();
|
|
|
|
try {
|
|
(new CombatEngine())->move($match, 'missing', new Position(1, 0));
|
|
self::fail('Expected an unknown unit exception.');
|
|
} catch (CombatException $exception) {
|
|
self::assertSame('Unknown unit: missing.', $exception->getMessage());
|
|
self::assertInstanceOf(InvalidArgumentException::class, $exception->getPrevious());
|
|
self::assertSame('Unknown unit id: missing.', $exception->getPrevious()->getMessage());
|
|
}
|
|
}
|
|
|
|
public function testMoveRejectsCompletedMatchBeforeResolvingUnit(): void
|
|
{
|
|
$match = $this->match()->withWinner('alpha');
|
|
|
|
$this->expectException(CombatException::class);
|
|
$this->expectExceptionMessage('The match is already complete.');
|
|
|
|
(new CombatEngine())->move($match, 'missing', new Position(1, 0));
|
|
}
|
|
|
|
public function testMoveRejectsDefeatedActor(): void
|
|
{
|
|
$match = new MatchState(
|
|
new Battlefield(8, 8),
|
|
[
|
|
$this->unit('alpha-1', 'alpha', new Position(0, 0), health: 0),
|
|
$this->unit('bravo-1', 'bravo', new Position(7, 7)),
|
|
],
|
|
'alpha',
|
|
);
|
|
|
|
$this->expectException(CombatException::class);
|
|
$this->expectExceptionMessage('Defeated units cannot act.');
|
|
|
|
(new CombatEngine())->move($match, 'alpha-1', new Position(1, 0));
|
|
}
|
|
|
|
public function testMoveRejectsExhaustedActor(): void
|
|
{
|
|
$match = new MatchState(
|
|
new Battlefield(8, 8),
|
|
[
|
|
$this->unit('alpha-1', 'alpha', new Position(0, 0), actionsRemaining: 0),
|
|
$this->unit('bravo-1', 'bravo', new Position(7, 7)),
|
|
],
|
|
'alpha',
|
|
);
|
|
|
|
$this->expectException(CombatException::class);
|
|
$this->expectExceptionMessage('Unit has no actions remaining.');
|
|
|
|
(new CombatEngine())->move($match, 'alpha-1', new Position(1, 0));
|
|
}
|
|
|
|
public function testMoveRejectsCurrentPosition(): void
|
|
{
|
|
$match = $this->match();
|
|
|
|
$this->expectException(CombatException::class);
|
|
$this->expectExceptionMessage('Destination is not reachable.');
|
|
|
|
(new CombatEngine())->move($match, 'alpha-1', new Position(0, 0));
|
|
}
|
|
|
|
/**
|
|
* @param array<string, Terrain> $terrain
|
|
*/
|
|
#[DataProvider('unreachableDestinationProvider')]
|
|
public function testMoveRejectsUnreachableDestination(array $terrain, Position $destination): void
|
|
{
|
|
$match = new MatchState(
|
|
new Battlefield(8, 8, $terrain),
|
|
[
|
|
$this->unit('alpha-1', 'alpha', new Position(0, 0)),
|
|
$this->unit('bravo-1', 'bravo', new Position(7, 7)),
|
|
],
|
|
'alpha',
|
|
);
|
|
|
|
$this->expectException(CombatException::class);
|
|
$this->expectExceptionMessage('Destination is not reachable.');
|
|
|
|
(new CombatEngine())->move($match, 'alpha-1', $destination);
|
|
}
|
|
|
|
/**
|
|
* @return iterable<string, array{array<string, Terrain>, Position}>
|
|
*/
|
|
public static function unreachableDestinationProvider(): iterable
|
|
{
|
|
yield 'outside battlefield' => [[], new Position(8, 0)];
|
|
yield 'outside speed budget' => [[], new Position(5, 0)];
|
|
yield 'impassable terrain' => [['1:0' => Terrain::Blocking], new Position(1, 0)];
|
|
}
|
|
|
|
public function testDefeatedUnitDoesNotBlockItsPosition(): void
|
|
{
|
|
$match = new MatchState(
|
|
new Battlefield(8, 8),
|
|
[
|
|
$this->unit('alpha-1', 'alpha', new Position(0, 0)),
|
|
$this->unit('alpha-2', 'alpha', new Position(1, 0), health: 0),
|
|
$this->unit('bravo-1', 'bravo', new Position(7, 7)),
|
|
],
|
|
'alpha',
|
|
);
|
|
|
|
$next = (new CombatEngine())->move($match, 'alpha-1', new Position(1, 0));
|
|
|
|
self::assertSame('1:0', $next->unit('alpha-1')->position->key());
|
|
}
|
|
|
|
public function testMoveHonorsTerrainCostWithinSpeedBudget(): void
|
|
{
|
|
$match = new MatchState(
|
|
new Battlefield(8, 8, ['1:0' => Terrain::Forest]),
|
|
[
|
|
$this->unit('alpha-1', 'alpha', new Position(0, 0), speed: 3),
|
|
$this->unit('bravo-1', 'bravo', new Position(7, 7)),
|
|
],
|
|
'alpha',
|
|
);
|
|
|
|
$next = (new CombatEngine())->move($match, 'alpha-1', new Position(2, 0));
|
|
|
|
self::assertSame('2:0', $next->unit('alpha-1')->position->key());
|
|
}
|
|
|
|
public function testMoveRejectsUnitFromInactiveTeam(): void
|
|
{
|
|
$match = new MatchState(
|
|
new Battlefield(8, 8),
|
|
[
|
|
$this->unit('alpha-1', 'alpha', new Position(0, 0)),
|
|
$this->unit('bravo-1', 'bravo', new Position(7, 7)),
|
|
],
|
|
'alpha',
|
|
);
|
|
|
|
$this->expectException(CombatException::class);
|
|
$this->expectExceptionMessage("It is not this unit's turn.");
|
|
|
|
(new CombatEngine())->move($match, 'bravo-1', new Position(6, 7));
|
|
}
|
|
|
|
public function testMoveRejectsOccupiedDestination(): void
|
|
{
|
|
$match = new MatchState(
|
|
new Battlefield(8, 8),
|
|
[
|
|
$this->unit('alpha-1', 'alpha', new Position(0, 0)),
|
|
$this->unit('alpha-2', 'alpha', new Position(1, 0)),
|
|
$this->unit('bravo-1', 'bravo', new Position(7, 7)),
|
|
],
|
|
'alpha',
|
|
);
|
|
|
|
$this->expectException(CombatException::class);
|
|
$this->expectExceptionMessage('Destination is not reachable.');
|
|
|
|
(new CombatEngine())->move($match, 'alpha-1', new Position(1, 0));
|
|
}
|
|
|
|
public function testAttackAppliesForestDefenseAndAppendsLogWithoutMutatingMatch(): void
|
|
{
|
|
$attacker = $this->unit('alpha-1', 'alpha', new Position(0, 0));
|
|
$target = $this->unit('bravo-1', 'bravo', new Position(1, 0));
|
|
$match = new MatchState(
|
|
new Battlefield(8, 8, ['1:0' => Terrain::Forest]),
|
|
[$attacker, $target],
|
|
'alpha',
|
|
actionLog: ['match started'],
|
|
);
|
|
|
|
$next = (new CombatEngine())->attack($match, 'alpha-1', 'bravo-1');
|
|
|
|
self::assertSame(10, $target->health);
|
|
self::assertSame(2, $attacker->actionsRemaining);
|
|
self::assertFalse($attacker->hasAttacked);
|
|
self::assertSame(10, $match->unit('bravo-1')->health);
|
|
self::assertSame(2, $match->unit('alpha-1')->actionsRemaining);
|
|
self::assertFalse($match->unit('alpha-1')->hasAttacked);
|
|
self::assertSame(['match started'], $match->actionLog);
|
|
self::assertGreaterThanOrEqual(9, $next->unit('bravo-1')->health);
|
|
self::assertLessThanOrEqual(10, $next->unit('bravo-1')->health);
|
|
self::assertSame(1, $next->unit('alpha-1')->actionsRemaining);
|
|
self::assertTrue($next->unit('alpha-1')->hasAttacked);
|
|
// With attack=4, defense=2, +1 forest defense: base = max(1, 4-2-1) = 1.
|
|
// On hit, damage = 1 (variance leaves it at 1). On miss, damage = 0.
|
|
$log = $next->actionLog[1];
|
|
self::assertMatchesRegularExpression(
|
|
'/^alpha-1 attacked bravo-1 \(rolled \d+ \/ needed \d+\) for \d+ damage( — (miss|crit|concealed)( .+)?)?$/',
|
|
$log,
|
|
);
|
|
}
|
|
|
|
public function testAttackRejectsTargetOutsideRange(): void
|
|
{
|
|
$match = new MatchState(
|
|
new Battlefield(8, 8),
|
|
[
|
|
$this->unit('alpha-1', 'alpha', new Position(0, 0)),
|
|
$this->unit('bravo-1', 'bravo', new Position(2, 0)),
|
|
],
|
|
'alpha',
|
|
);
|
|
|
|
$this->expectException(CombatException::class);
|
|
$this->expectExceptionMessage('Target is outside attack range.');
|
|
|
|
(new CombatEngine())->attack($match, 'alpha-1', 'bravo-1');
|
|
}
|
|
|
|
public function testAttackDefeatingLastEnemyAwardsVictory(): void
|
|
{
|
|
$buildMatch = static function (): MatchState {
|
|
return new MatchState(
|
|
new Battlefield(8, 8),
|
|
[
|
|
new UnitState(
|
|
'alpha-1',
|
|
'alpha',
|
|
new Position(0, 0),
|
|
10,
|
|
10,
|
|
20,
|
|
2,
|
|
4,
|
|
2,
|
|
false,
|
|
),
|
|
new UnitState(
|
|
'bravo-1',
|
|
'bravo',
|
|
new Position(1, 0),
|
|
10,
|
|
10,
|
|
3,
|
|
2,
|
|
4,
|
|
2,
|
|
false,
|
|
),
|
|
],
|
|
'alpha',
|
|
);
|
|
};
|
|
|
|
$match = $buildMatch();
|
|
|
|
// The new to-hit roll can miss; loop until we find a hit that defeats the target.
|
|
$next = $match;
|
|
for ($i = 0; $i < 200; $i += 1) {
|
|
$fresh = $buildMatch();
|
|
$next = (new CombatEngine())->attack($fresh, 'alpha-1', 'bravo-1');
|
|
if ($next->unit('bravo-1')->health === 0) {
|
|
break;
|
|
}
|
|
}
|
|
self::assertSame(0, $next->unit('bravo-1')->health);
|
|
self::assertSame('alpha', $next->winnerTeamId);
|
|
self::assertNull($match->winnerTeamId);
|
|
}
|
|
|
|
public function testAttackRejectsUnitThatAlreadyAttacked(): void
|
|
{
|
|
$match = new MatchState(
|
|
new Battlefield(8, 8),
|
|
[
|
|
$this->unit('alpha-1', 'alpha', new Position(0, 0), hasAttacked: true),
|
|
$this->unit('bravo-1', 'bravo', new Position(1, 0)),
|
|
],
|
|
'alpha',
|
|
);
|
|
|
|
$this->expectException(CombatException::class);
|
|
$this->expectExceptionMessage('Unit has already attacked this turn.');
|
|
|
|
(new CombatEngine())->attack($match, 'alpha-1', 'bravo-1');
|
|
}
|
|
|
|
public function testAttackTranslatesUnknownAttackerAtCommandBoundary(): void
|
|
{
|
|
$match = $this->match();
|
|
|
|
try {
|
|
(new CombatEngine())->attack($match, 'missing', 'bravo-1');
|
|
self::fail('Expected an unknown unit exception.');
|
|
} catch (CombatException $exception) {
|
|
self::assertSame('Unknown unit: missing.', $exception->getMessage());
|
|
self::assertInstanceOf(InvalidArgumentException::class, $exception->getPrevious());
|
|
self::assertSame('Unknown unit id: missing.', $exception->getPrevious()->getMessage());
|
|
}
|
|
}
|
|
|
|
public function testAttackTranslatesUnknownTargetAtCommandBoundary(): void
|
|
{
|
|
$match = $this->match();
|
|
|
|
try {
|
|
(new CombatEngine())->attack($match, 'alpha-1', 'missing');
|
|
self::fail('Expected an unknown unit exception.');
|
|
} catch (CombatException $exception) {
|
|
self::assertSame('Unknown unit: missing.', $exception->getMessage());
|
|
self::assertInstanceOf(InvalidArgumentException::class, $exception->getPrevious());
|
|
self::assertSame('Unknown unit id: missing.', $exception->getPrevious()->getMessage());
|
|
}
|
|
}
|
|
|
|
public function testAttackRejectsCompletedMatchBeforeResolvingAttacker(): void
|
|
{
|
|
$match = $this->match()->withWinner('alpha');
|
|
|
|
$this->expectException(CombatException::class);
|
|
$this->expectExceptionMessage('The match is already complete.');
|
|
|
|
(new CombatEngine())->attack($match, 'missing', 'also-missing');
|
|
}
|
|
|
|
/**
|
|
* @param array{team?: string, health?: int, actionsRemaining?: int, hasAttacked?: bool} $overrides
|
|
*/
|
|
#[DataProvider('invalidAttackActorProvider')]
|
|
public function testAttackRejectsInvalidActor(array $overrides, string $message): void
|
|
{
|
|
$attacker = $this->unit(
|
|
'attacker',
|
|
$overrides['team'] ?? 'alpha',
|
|
new Position(0, 0),
|
|
health: $overrides['health'] ?? 10,
|
|
actionsRemaining: $overrides['actionsRemaining'] ?? 2,
|
|
hasAttacked: $overrides['hasAttacked'] ?? false,
|
|
);
|
|
$match = new MatchState(
|
|
new Battlefield(8, 8),
|
|
[
|
|
$attacker,
|
|
$this->unit('bravo-1', 'bravo', new Position(1, 0)),
|
|
$this->unit('alpha-2', 'alpha', new Position(7, 7)),
|
|
],
|
|
'alpha',
|
|
);
|
|
|
|
$this->expectException(CombatException::class);
|
|
$this->expectExceptionMessage($message);
|
|
|
|
(new CombatEngine())->attack($match, 'attacker', 'missing');
|
|
}
|
|
|
|
/**
|
|
* @return iterable<string, array{
|
|
* array{team?: string, health?: int, actionsRemaining?: int, hasAttacked?: bool},
|
|
* string
|
|
* }>
|
|
*/
|
|
public static function invalidAttackActorProvider(): iterable
|
|
{
|
|
yield 'inactive and already attacked' => [
|
|
['team' => 'bravo', 'hasAttacked' => true],
|
|
"It is not this unit's turn.",
|
|
];
|
|
yield 'defeated and already attacked' => [
|
|
['health' => 0, 'hasAttacked' => true],
|
|
'Defeated units cannot act.',
|
|
];
|
|
yield 'exhausted and already attacked' => [
|
|
['actionsRemaining' => 0, 'hasAttacked' => true],
|
|
'Unit has no actions remaining.',
|
|
];
|
|
}
|
|
|
|
#[DataProvider('invalidAttackTargetProvider')]
|
|
public function testAttackRejectsInvalidTarget(string $targetTeam, int $targetHealth): void
|
|
{
|
|
$match = new MatchState(
|
|
new Battlefield(8, 8),
|
|
[
|
|
$this->unit('alpha-1', 'alpha', new Position(0, 0)),
|
|
$this->unit('target', $targetTeam, new Position(1, 0), health: $targetHealth),
|
|
$this->unit('bravo-2', 'bravo', new Position(7, 7)),
|
|
],
|
|
'alpha',
|
|
);
|
|
|
|
$this->expectException(CombatException::class);
|
|
$this->expectExceptionMessage('Target must be an active enemy unit.');
|
|
|
|
(new CombatEngine())->attack($match, 'alpha-1', 'target');
|
|
}
|
|
|
|
/**
|
|
* @return iterable<string, array{string, int}>
|
|
*/
|
|
public static function invalidAttackTargetProvider(): iterable
|
|
{
|
|
yield 'friendly' => ['alpha', 10];
|
|
yield 'defeated enemy' => ['bravo', 0];
|
|
}
|
|
|
|
public function testAttackDealsCalculatedOpenTerrainDamage(): void
|
|
{
|
|
$buildMatch = static function (): MatchState {
|
|
$target = new UnitState(
|
|
'bravo-1',
|
|
'bravo',
|
|
new Position(1, 0),
|
|
10,
|
|
10,
|
|
3,
|
|
3,
|
|
4,
|
|
2,
|
|
false,
|
|
);
|
|
return new MatchState(
|
|
new Battlefield(8, 8),
|
|
[
|
|
new UnitState(
|
|
'alpha-1',
|
|
'alpha',
|
|
new Position(0, 0),
|
|
10,
|
|
10,
|
|
8,
|
|
2,
|
|
4,
|
|
2,
|
|
false,
|
|
),
|
|
$target,
|
|
],
|
|
'alpha',
|
|
);
|
|
};
|
|
|
|
// Loop until a non-crit hit lands; the test verifies the base variance on a hit.
|
|
$match = $buildMatch();
|
|
$next = $match;
|
|
for ($i = 0; $i < 500; $i += 1) {
|
|
$fresh = $buildMatch();
|
|
$next = (new CombatEngine())->attack($fresh, 'alpha-1', 'bravo-1');
|
|
$entry = $next->actionLog[0];
|
|
if (str_contains($entry, 'for ') && !str_contains($entry, ' — crit') && !str_contains($entry, ' — miss')) {
|
|
break;
|
|
}
|
|
}
|
|
$damage = 10 - $next->unit('bravo-1')->health;
|
|
// Base = max(1, 8-3) = 5. Variance 85-115% = 4-6 (rounded).
|
|
self::assertGreaterThanOrEqual(4, $damage);
|
|
self::assertLessThanOrEqual(6, $damage);
|
|
}
|
|
|
|
public function testAttackAlwaysDealsAtLeastOneDamage(): void
|
|
{
|
|
$buildMatch = static function (): MatchState {
|
|
return new MatchState(
|
|
new Battlefield(8, 8, ['1:0' => Terrain::Forest]),
|
|
[
|
|
new UnitState(
|
|
'alpha-1',
|
|
'alpha',
|
|
new Position(0, 0),
|
|
10,
|
|
10,
|
|
0,
|
|
2,
|
|
4,
|
|
2,
|
|
false,
|
|
),
|
|
new UnitState(
|
|
'bravo-1',
|
|
'bravo',
|
|
new Position(1, 0),
|
|
10,
|
|
10,
|
|
3,
|
|
20,
|
|
4,
|
|
2,
|
|
false,
|
|
),
|
|
],
|
|
'alpha',
|
|
);
|
|
};
|
|
|
|
// Loop until a non-crit hit lands; then verify the damage is at least 1.
|
|
$match = $buildMatch();
|
|
$next = $match;
|
|
for ($i = 0; $i < 500; $i += 1) {
|
|
$fresh = $buildMatch();
|
|
$next = (new CombatEngine())->attack($fresh, 'alpha-1', 'bravo-1');
|
|
$entry = $next->actionLog[0];
|
|
if (str_contains($entry, 'for ') && !str_contains($entry, ' — crit') && !str_contains($entry, ' — miss')) {
|
|
break;
|
|
}
|
|
}
|
|
// Base = max(1, 0-20-1) = 1. Variance 85-115% rounds to 1.
|
|
self::assertSame(9, $next->unit('bravo-1')->health);
|
|
self::assertMatchesRegularExpression(
|
|
'/^alpha-1 attacked bravo-1 \(rolled \d+ \/ needed \d+\) for 1 damage$/',
|
|
$next->actionLog[0],
|
|
);
|
|
}
|
|
|
|
public function testAttackDoesNotAwardVictoryWhileAnotherEnemyLives(): void
|
|
{
|
|
$buildMatch = static function (): MatchState {
|
|
return new MatchState(
|
|
new Battlefield(8, 8),
|
|
[
|
|
new UnitState(
|
|
'alpha-1',
|
|
'alpha',
|
|
new Position(0, 0),
|
|
10,
|
|
10,
|
|
20,
|
|
2,
|
|
4,
|
|
2,
|
|
false,
|
|
),
|
|
new UnitState(
|
|
'bravo-1',
|
|
'bravo',
|
|
new Position(1, 0),
|
|
10,
|
|
10,
|
|
3,
|
|
2,
|
|
4,
|
|
2,
|
|
false,
|
|
),
|
|
new UnitState(
|
|
'bravo-2',
|
|
'bravo',
|
|
new Position(7, 7),
|
|
10,
|
|
10,
|
|
3,
|
|
2,
|
|
4,
|
|
2,
|
|
false,
|
|
),
|
|
],
|
|
'alpha',
|
|
);
|
|
};
|
|
|
|
// Loop until we find a hit that defeats bravo-1.
|
|
$match = $buildMatch();
|
|
$next = $match;
|
|
for ($i = 0; $i < 200; $i += 1) {
|
|
$fresh = $buildMatch();
|
|
$next = (new CombatEngine())->attack($fresh, 'alpha-1', 'bravo-1');
|
|
if ($next->unit('bravo-1')->health === 0) {
|
|
break;
|
|
}
|
|
}
|
|
self::assertSame(0, $next->unit('bravo-1')->health);
|
|
self::assertSame(10, $next->unit('bravo-2')->health);
|
|
self::assertNull($next->winnerTeamId);
|
|
}
|
|
|
|
public function testEndTurnPassesControlToBravoAndRefreshesItsLivingUnits(): void
|
|
{
|
|
$match = new MatchState(
|
|
new Battlefield(8, 8),
|
|
[
|
|
$this->unit(
|
|
'alpha-1',
|
|
'alpha',
|
|
new Position(0, 0),
|
|
actionsRemaining: 1,
|
|
hasAttacked: true,
|
|
),
|
|
$this->unit(
|
|
'bravo-1',
|
|
'bravo',
|
|
new Position(7, 7),
|
|
actionsRemaining: 0,
|
|
hasAttacked: true,
|
|
),
|
|
],
|
|
'alpha',
|
|
actionLog: ['match started'],
|
|
);
|
|
|
|
$next = (new CombatEngine())->endTurn($match);
|
|
|
|
self::assertSame('alpha', $match->activeTeamId);
|
|
self::assertSame(1, $match->round);
|
|
self::assertSame(1, $match->unit('alpha-1')->actionsRemaining);
|
|
self::assertTrue($match->unit('alpha-1')->hasAttacked);
|
|
self::assertSame(0, $match->unit('bravo-1')->actionsRemaining);
|
|
self::assertTrue($match->unit('bravo-1')->hasAttacked);
|
|
self::assertSame(['match started'], $match->actionLog);
|
|
self::assertSame('bravo', $next->activeTeamId);
|
|
self::assertSame(1, $next->round);
|
|
self::assertSame(1, $next->unit('alpha-1')->actionsRemaining);
|
|
self::assertTrue($next->unit('alpha-1')->hasAttacked);
|
|
self::assertSame(2, $next->unit('bravo-1')->actionsRemaining);
|
|
self::assertFalse($next->unit('bravo-1')->hasAttacked);
|
|
self::assertSame(['match started', 'alpha ended turn'], $next->actionLog);
|
|
}
|
|
|
|
public function testEndTurnPassesControlToAlphaAndStartsANewRound(): void
|
|
{
|
|
$match = new MatchState(
|
|
new Battlefield(8, 8),
|
|
[
|
|
$this->unit(
|
|
'alpha-1',
|
|
'alpha',
|
|
new Position(0, 0),
|
|
actionsRemaining: 0,
|
|
hasAttacked: true,
|
|
),
|
|
$this->unit('bravo-1', 'bravo', new Position(7, 7), actionsRemaining: 1),
|
|
],
|
|
'bravo',
|
|
round: 1,
|
|
actionLog: ['alpha ended turn'],
|
|
);
|
|
|
|
$next = (new CombatEngine())->endTurn($match);
|
|
|
|
self::assertSame('alpha', $next->activeTeamId);
|
|
self::assertSame(2, $next->round);
|
|
self::assertSame(2, $next->unit('alpha-1')->actionsRemaining);
|
|
self::assertFalse($next->unit('alpha-1')->hasAttacked);
|
|
self::assertSame(1, $next->unit('bravo-1')->actionsRemaining);
|
|
self::assertSame(['alpha ended turn', 'bravo ended turn'], $next->actionLog);
|
|
self::assertSame('bravo', $match->activeTeamId);
|
|
self::assertSame(1, $match->round);
|
|
self::assertSame(0, $match->unit('alpha-1')->actionsRemaining);
|
|
self::assertTrue($match->unit('alpha-1')->hasAttacked);
|
|
self::assertSame(['alpha ended turn'], $match->actionLog);
|
|
}
|
|
|
|
public function testEndTurnRejectsCompletedMatchBeforeValidatingTeams(): void
|
|
{
|
|
$match = new MatchState(
|
|
new Battlefield(8, 8),
|
|
[$this->unit('alpha-1', 'alpha', new Position(0, 0))],
|
|
'alpha',
|
|
winnerTeamId: 'alpha',
|
|
);
|
|
|
|
$this->expectException(CombatException::class);
|
|
$this->expectExceptionMessage('The match is already complete.');
|
|
|
|
(new CombatEngine())->endTurn($match);
|
|
}
|
|
|
|
/**
|
|
* @param list<string> $teamIds
|
|
*/
|
|
#[DataProvider('invalidEndTurnTeamProvider')]
|
|
public function testEndTurnRejectsMatchesWithoutExactlyAlphaAndBravoTeams(array $teamIds): void
|
|
{
|
|
$units = [];
|
|
|
|
foreach ($teamIds as $index => $teamId) {
|
|
$units[] = $this->unit("{$teamId}-{$index}", $teamId, new Position($index, 0));
|
|
}
|
|
|
|
$match = new MatchState(
|
|
new Battlefield(8, 8),
|
|
$units,
|
|
'alpha',
|
|
);
|
|
|
|
$this->expectException(CombatException::class);
|
|
$this->expectExceptionMessage('Matches require exactly two teams.');
|
|
|
|
(new CombatEngine())->endTurn($match);
|
|
}
|
|
|
|
/**
|
|
* @return iterable<string, array{list<string>}>
|
|
*/
|
|
public static function invalidEndTurnTeamProvider(): iterable
|
|
{
|
|
yield 'only alpha' => [['alpha']];
|
|
yield 'alpha, bravo, and charlie' => [['alpha', 'bravo', 'charlie']];
|
|
}
|
|
|
|
#[DataProvider('eliminatedEndTurnTeamProvider')]
|
|
public function testEndTurnRejectsEliminatedTeam(string $activeTeamId, string $eliminatedTeamId): void
|
|
{
|
|
$match = new MatchState(
|
|
new Battlefield(8, 8),
|
|
[
|
|
$this->unit(
|
|
'alpha-1',
|
|
'alpha',
|
|
new Position(0, 0),
|
|
health: $eliminatedTeamId === 'alpha' ? 0 : 10,
|
|
),
|
|
$this->unit(
|
|
'bravo-1',
|
|
'bravo',
|
|
new Position(7, 7),
|
|
health: $eliminatedTeamId === 'bravo' ? 0 : 10,
|
|
),
|
|
],
|
|
$activeTeamId,
|
|
);
|
|
|
|
$this->expectException(CombatException::class);
|
|
$this->expectExceptionMessage('Cannot end turn while a team is eliminated.');
|
|
|
|
(new CombatEngine())->endTurn($match);
|
|
}
|
|
|
|
/**
|
|
* @return iterable<string, array{string, string}>
|
|
*/
|
|
public static function eliminatedEndTurnTeamProvider(): iterable
|
|
{
|
|
yield 'incoming team' => ['alpha', 'bravo'];
|
|
yield 'active team' => ['alpha', 'alpha'];
|
|
}
|
|
|
|
public function testEndTurnRejectsEliminatedTeamBeforeRoundLimit(): void
|
|
{
|
|
$match = new MatchState(
|
|
new Battlefield(8, 8),
|
|
[
|
|
$this->unit('alpha-1', 'alpha', new Position(0, 0), health: 0),
|
|
$this->unit('bravo-1', 'bravo', new Position(7, 7)),
|
|
],
|
|
'bravo',
|
|
round: PHP_INT_MAX,
|
|
);
|
|
|
|
$this->expectException(CombatException::class);
|
|
$this->expectExceptionMessage('Cannot end turn while a team is eliminated.');
|
|
|
|
(new CombatEngine())->endTurn($match);
|
|
}
|
|
|
|
public function testEndTurnRejectsRoundIncrementBeyondIntegerLimit(): void
|
|
{
|
|
$match = new MatchState(
|
|
new Battlefield(8, 8),
|
|
[
|
|
$this->unit('alpha-1', 'alpha', new Position(0, 0)),
|
|
$this->unit('bravo-1', 'bravo', new Position(7, 7)),
|
|
],
|
|
'bravo',
|
|
round: PHP_INT_MAX,
|
|
);
|
|
|
|
$this->expectException(CombatException::class);
|
|
$this->expectExceptionMessage('Match round limit reached.');
|
|
|
|
(new CombatEngine())->endTurn($match);
|
|
}
|
|
|
|
public function testEndTurnAllowsAlphaToPassAtIntegerRoundLimit(): void
|
|
{
|
|
$match = new MatchState(
|
|
new Battlefield(8, 8),
|
|
[
|
|
$this->unit('alpha-1', 'alpha', new Position(0, 0)),
|
|
$this->unit('bravo-1', 'bravo', new Position(7, 7), actionsRemaining: 0),
|
|
],
|
|
'alpha',
|
|
round: PHP_INT_MAX,
|
|
);
|
|
|
|
$next = (new CombatEngine())->endTurn($match);
|
|
|
|
self::assertSame('bravo', $next->activeTeamId);
|
|
self::assertSame(PHP_INT_MAX, $next->round);
|
|
self::assertSame(2, $next->unit('bravo-1')->actionsRemaining);
|
|
self::assertSame(['alpha ended turn'], $next->actionLog);
|
|
}
|
|
|
|
public function testEndTurnDoesNotRefreshDefeatedUnitsOnIncomingTeam(): void
|
|
{
|
|
$match = new MatchState(
|
|
new Battlefield(8, 8),
|
|
[
|
|
$this->unit('alpha-1', 'alpha', new Position(0, 0)),
|
|
$this->unit(
|
|
'bravo-defeated',
|
|
'bravo',
|
|
new Position(6, 7),
|
|
health: 0,
|
|
actionsRemaining: 0,
|
|
hasAttacked: true,
|
|
),
|
|
$this->unit(
|
|
'bravo-living',
|
|
'bravo',
|
|
new Position(7, 7),
|
|
actionsRemaining: 0,
|
|
hasAttacked: true,
|
|
),
|
|
],
|
|
'alpha',
|
|
);
|
|
|
|
$next = (new CombatEngine())->endTurn($match);
|
|
|
|
self::assertSame(0, $next->unit('bravo-defeated')->actionsRemaining);
|
|
self::assertTrue($next->unit('bravo-defeated')->hasAttacked);
|
|
self::assertSame(2, $next->unit('bravo-living')->actionsRemaining);
|
|
self::assertFalse($next->unit('bravo-living')->hasAttacked);
|
|
}
|
|
|
|
private function match(): MatchState
|
|
{
|
|
return new MatchState(
|
|
new Battlefield(8, 8),
|
|
[
|
|
$this->unit('alpha-1', 'alpha', new Position(0, 0)),
|
|
$this->unit('bravo-1', 'bravo', new Position(7, 7)),
|
|
],
|
|
'alpha',
|
|
);
|
|
}
|
|
|
|
private function unit(
|
|
string $id,
|
|
string $team,
|
|
Position $position,
|
|
int $health = 10,
|
|
int $speed = 4,
|
|
int $actionsRemaining = 2,
|
|
int $attack = 4,
|
|
int $defense = 2,
|
|
bool $hasAttacked = false,
|
|
int $attackBonus = 0,
|
|
): UnitState {
|
|
return new UnitState(
|
|
$id,
|
|
$team,
|
|
$position,
|
|
10,
|
|
$health,
|
|
$attack,
|
|
$defense,
|
|
$speed,
|
|
$actionsRemaining,
|
|
$hasAttacked,
|
|
attackBonus: $attackBonus,
|
|
);
|
|
}
|
|
|
|
public function testHealAbilityRestoresAlliedHealthAndConsumesOneAction(): void
|
|
{
|
|
$caster = new UnitState(
|
|
id: 'support-1',
|
|
teamId: 'alpha',
|
|
position: new Position(0, 0),
|
|
maxHealth: 8,
|
|
health: 8,
|
|
attack: 2,
|
|
defense: 2,
|
|
speed: 3,
|
|
actionsRemaining: 2,
|
|
hasAttacked: false,
|
|
archetype: Archetype::Support,
|
|
abilities: ['heal'],
|
|
attackBonus: 0,
|
|
hasUsedAbility: false,
|
|
);
|
|
$wounded = new UnitState(
|
|
id: 'alpha-1',
|
|
teamId: 'alpha',
|
|
position: new Position(0, 1),
|
|
maxHealth: 10,
|
|
health: 3,
|
|
attack: 4,
|
|
defense: 2,
|
|
speed: 4,
|
|
actionsRemaining: 2,
|
|
hasAttacked: false,
|
|
);
|
|
$bravo = $this->unit('bravo-1', 'bravo', new Position(7, 7));
|
|
$match = new MatchState(
|
|
new Battlefield(8, 8),
|
|
[$caster, $wounded, $bravo],
|
|
'alpha',
|
|
);
|
|
|
|
$next = (new CombatEngine())->useAbility($match, 'support-1', 'heal', new Position(0, 1));
|
|
|
|
self::assertSame(8, $next->unit('alpha-1')->health);
|
|
self::assertSame(1, $next->unit('support-1')->actionsRemaining);
|
|
self::assertTrue($next->unit('support-1')->hasUsedAbility);
|
|
self::assertSame(['support-1 used Heal on alpha-1 for 5'], $next->actionLog);
|
|
}
|
|
|
|
public function testAreaDamageAbilityHitsEveryEnemyInsideTheRadius(): void
|
|
{
|
|
$caster = new UnitState(
|
|
id: 'striker-1',
|
|
teamId: 'alpha',
|
|
position: new Position(0, 0),
|
|
maxHealth: 8,
|
|
health: 8,
|
|
attack: 5,
|
|
defense: 1,
|
|
speed: 3,
|
|
actionsRemaining: 2,
|
|
hasAttacked: false,
|
|
archetype: Archetype::Striker,
|
|
abilities: ['area_damage'],
|
|
attackBonus: 0,
|
|
hasUsedAbility: false,
|
|
);
|
|
$alpha = $this->unit('alpha-2', 'alpha', new Position(2, 0));
|
|
$bravoA = $this->unit('bravo-1', 'bravo', new Position(1, 1), defense: 1);
|
|
$bravoB = $this->unit('bravo-2', 'bravo', new Position(2, 1), defense: 1);
|
|
$bravoC = $this->unit('bravo-3', 'bravo', new Position(3, 1), defense: 1);
|
|
$bravoD = $this->unit('bravo-4', 'bravo', new Position(5, 5));
|
|
$match = new MatchState(
|
|
new Battlefield(8, 8),
|
|
[$caster, $alpha, $bravoA, $bravoB, $bravoC, $bravoD],
|
|
'alpha',
|
|
);
|
|
|
|
$next = (new CombatEngine())->useAbility($match, 'striker-1', 'area_damage', new Position(2, 1));
|
|
|
|
self::assertSame(8, $next->unit('bravo-1')->health);
|
|
self::assertSame(8, $next->unit('bravo-2')->health);
|
|
self::assertSame(8, $next->unit('bravo-3')->health);
|
|
self::assertSame(10, $next->unit('bravo-4')->health);
|
|
self::assertSame(10, $next->unit('alpha-2')->health);
|
|
self::assertSame(1, $next->unit('striker-1')->actionsRemaining);
|
|
}
|
|
|
|
public function testBuffAbilityReplacesTheAttackBonusOnAnAlly(): void
|
|
{
|
|
$caster = new UnitState(
|
|
id: 'support-1',
|
|
teamId: 'alpha',
|
|
position: new Position(0, 0),
|
|
maxHealth: 8,
|
|
health: 8,
|
|
attack: 2,
|
|
defense: 2,
|
|
speed: 3,
|
|
actionsRemaining: 2,
|
|
hasAttacked: false,
|
|
archetype: Archetype::Support,
|
|
abilities: ['buff'],
|
|
attackBonus: 0,
|
|
hasUsedAbility: false,
|
|
);
|
|
$alpha = $this->unit('alpha-2', 'alpha', new Position(0, 1), attackBonus: 1);
|
|
$bravo = $this->unit('bravo-1', 'bravo', new Position(7, 7));
|
|
$match = new MatchState(
|
|
new Battlefield(8, 8),
|
|
[$caster, $alpha, $bravo],
|
|
'alpha',
|
|
);
|
|
|
|
$next = (new CombatEngine())->useAbility($match, 'support-1', 'buff', new Position(0, 1));
|
|
|
|
self::assertSame(2, $next->unit('alpha-2')->attackBonus);
|
|
}
|
|
|
|
public function testUseAbilityRejectsAUnitThatHasAlreadyUsedOne(): void
|
|
{
|
|
$caster = new UnitState(
|
|
id: 'support-1',
|
|
teamId: 'alpha',
|
|
position: new Position(0, 0),
|
|
maxHealth: 8,
|
|
health: 8,
|
|
attack: 2,
|
|
defense: 2,
|
|
speed: 3,
|
|
actionsRemaining: 2,
|
|
hasAttacked: false,
|
|
archetype: Archetype::Support,
|
|
abilities: ['heal'],
|
|
attackBonus: 0,
|
|
hasUsedAbility: true,
|
|
);
|
|
$alpha = $this->unit('alpha-2', 'alpha', new Position(0, 1));
|
|
$bravo = $this->unit('bravo-1', 'bravo', new Position(7, 7));
|
|
$match = new MatchState(
|
|
new Battlefield(8, 8),
|
|
[$caster, $alpha, $bravo],
|
|
'alpha',
|
|
);
|
|
|
|
$this->expectException(CombatException::class);
|
|
$this->expectExceptionMessage('Unit has already used an ability this turn.');
|
|
|
|
(new CombatEngine())->useAbility($match, 'support-1', 'heal', new Position(0, 1));
|
|
}
|
|
|
|
public function testUseAbilityRejectsTargetsOutsideRange(): void
|
|
{
|
|
$caster = new UnitState(
|
|
id: 'support-1',
|
|
teamId: 'alpha',
|
|
position: new Position(0, 0),
|
|
maxHealth: 8,
|
|
health: 8,
|
|
attack: 2,
|
|
defense: 2,
|
|
speed: 3,
|
|
actionsRemaining: 2,
|
|
hasAttacked: false,
|
|
archetype: Archetype::Support,
|
|
abilities: ['heal'],
|
|
attackBonus: 0,
|
|
hasUsedAbility: false,
|
|
);
|
|
$alpha = $this->unit('alpha-2', 'alpha', new Position(5, 5));
|
|
$bravo = $this->unit('bravo-1', 'bravo', new Position(7, 7));
|
|
$match = new MatchState(
|
|
new Battlefield(8, 8),
|
|
[$caster, $alpha, $bravo],
|
|
'alpha',
|
|
);
|
|
|
|
$this->expectException(CombatException::class);
|
|
$this->expectExceptionMessage('Ability target is out of range.');
|
|
|
|
(new CombatEngine())->useAbility($match, 'support-1', 'heal', new Position(5, 5));
|
|
}
|
|
|
|
public function testUseAbilityRejectsAnAbilityTheArchetypeForbids(): void
|
|
{
|
|
$caster = new UnitState(
|
|
id: 'scout-1',
|
|
teamId: 'alpha',
|
|
position: new Position(0, 0),
|
|
maxHealth: 6,
|
|
health: 6,
|
|
attack: 3,
|
|
defense: 1,
|
|
speed: 5,
|
|
actionsRemaining: 2,
|
|
hasAttacked: false,
|
|
archetype: Archetype::Scout,
|
|
abilities: [],
|
|
attackBonus: 0,
|
|
hasUsedAbility: false,
|
|
);
|
|
$alpha = $this->unit('alpha-2', 'alpha', new Position(0, 1));
|
|
$bravo = $this->unit('bravo-1', 'bravo', new Position(7, 7));
|
|
$match = new MatchState(
|
|
new Battlefield(8, 8),
|
|
[$caster, $alpha, $bravo],
|
|
'alpha',
|
|
);
|
|
|
|
$this->expectException(CombatException::class);
|
|
$this->expectExceptionMessage('Unit archetype forbids that ability.');
|
|
|
|
(new CombatEngine())->useAbility($match, 'scout-1', 'heal', new Position(0, 1));
|
|
}
|
|
|
|
public function testEndTurnTalliesObjectiveControlAfterAFullRound(): void
|
|
{
|
|
$alpha = $this->unit('alpha-1', 'alpha', new Position(4, 4));
|
|
$bravo = $this->unit('bravo-1', 'bravo', new Position(7, 7));
|
|
$match = new MatchState(
|
|
new Battlefield(8, 8),
|
|
[$alpha, $bravo],
|
|
'alpha',
|
|
objectives: ['objective-1' => new ObjectiveMarker('objective-1', new Position(4, 4))],
|
|
victoryCondition: VictoryCondition::HoldObjective,
|
|
holdRoundsRequired: 2,
|
|
);
|
|
|
|
$afterAlphaEnd = (new CombatEngine())->endTurn($match);
|
|
$afterBravoEnd = (new CombatEngine())->endTurn($afterAlphaEnd);
|
|
|
|
self::assertSame(['alpha' => 1], $afterAlphaEnd->objectiveControl);
|
|
self::assertSame(['alpha' => 1], $afterBravoEnd->objectiveControl);
|
|
self::assertNull($afterBravoEnd->winnerTeamId);
|
|
}
|
|
|
|
public function testEndTurnDeclaresWinnerWhenHoldRoundsReached(): void
|
|
{
|
|
$alpha = $this->unit('alpha-1', 'alpha', new Position(4, 4));
|
|
$bravo = $this->unit('bravo-1', 'bravo', new Position(7, 7));
|
|
$match = new MatchState(
|
|
new Battlefield(8, 8),
|
|
[$alpha, $bravo],
|
|
'alpha',
|
|
objectives: ['objective-1' => new ObjectiveMarker('objective-1', new Position(4, 4))],
|
|
victoryCondition: VictoryCondition::HoldObjective,
|
|
holdRoundsRequired: 1,
|
|
objectiveControl: ['alpha' => 0],
|
|
);
|
|
|
|
$afterAlphaEnd = (new CombatEngine())->endTurn($match);
|
|
$afterBravoEnd = (new CombatEngine())->endTurn($afterAlphaEnd);
|
|
|
|
self::assertSame('alpha', $afterBravoEnd->winnerTeamId);
|
|
self::assertSame(['alpha' => 1], $afterBravoEnd->objectiveControl);
|
|
}
|
|
|
|
public function testEndTurnDoesNotAwardControlWhenNoTeamStandsOnTheObjective(): void
|
|
{
|
|
$alpha = $this->unit('alpha-1', 'alpha', new Position(0, 0));
|
|
$bravo = $this->unit('bravo-1', 'bravo', new Position(7, 7));
|
|
$match = new MatchState(
|
|
new Battlefield(8, 8),
|
|
[$alpha, $bravo],
|
|
'alpha',
|
|
objectives: ['objective-1' => new ObjectiveMarker('objective-1', new Position(4, 4))],
|
|
victoryCondition: VictoryCondition::HoldObjective,
|
|
holdRoundsRequired: 1,
|
|
);
|
|
|
|
$next = (new CombatEngine())->endTurn($match);
|
|
|
|
self::assertSame([], $next->objectiveControl);
|
|
self::assertNull($next->winnerTeamId);
|
|
}
|
|
|
|
public function testAttackRollsAboveThresholdAndDamageStaysWithinVariance(): void
|
|
{
|
|
$buildPayload = static function (): array {
|
|
$match = new MatchState(
|
|
new Battlefield(8, 8),
|
|
[
|
|
new UnitState(
|
|
'alpha-1',
|
|
'alpha',
|
|
new Position(0, 0),
|
|
10,
|
|
10,
|
|
10,
|
|
0,
|
|
4,
|
|
2,
|
|
false,
|
|
),
|
|
new UnitState(
|
|
'bravo-1',
|
|
'bravo',
|
|
new Position(1, 0),
|
|
10,
|
|
10,
|
|
3,
|
|
0,
|
|
4,
|
|
2,
|
|
false,
|
|
),
|
|
],
|
|
'alpha',
|
|
);
|
|
$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);
|
|
|
|
return [
|
|
'match' => $match,
|
|
'matchArray' => $matchArray,
|
|
];
|
|
};
|
|
|
|
$token = TurnToken::issue('s', '0123456789abcdef', 'alpha', 1, 0);
|
|
$log = null;
|
|
// Loop until we find a hit; the to-hit roll can miss.
|
|
for ($i = 0; $i < 200; $i += 1) {
|
|
$payload = $buildPayload();
|
|
$req = $this->buildRequest($payload['match'], $token, $this->attackBody($payload['matchArray']));
|
|
$result = (new PostMatchAttack('s'))->handle($req, []);
|
|
self::assertSame(200, $result->status);
|
|
$body = json_decode($result->body, true);
|
|
$log = $body['match']['actionLog'][0];
|
|
if (!str_contains($log, ' — miss')) {
|
|
break;
|
|
}
|
|
}
|
|
self::assertNotNull($log);
|
|
$pattern = '/^alpha-1 attacked bravo-1 \(rolled \d+ \/ needed \d+\) for \d+ damage( — crit)?$/';
|
|
self::assertMatchesRegularExpression($pattern, $log);
|
|
preg_match('/rolled (\d+) \/ needed (\d+)\) for (\d+)/', $log, $m);
|
|
$threshold = (int) ($m[2] ?? 0);
|
|
$roll = (int) ($m[1] ?? 0);
|
|
$damage = (int) ($m[3] ?? 0);
|
|
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', '0123456789abcdef', 'alpha', 1, 0);
|
|
$req = $this->buildRequest($match, $token, $this->attackBody($matchArray));
|
|
$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"
|
|
$pattern = '/^alpha-1 attacked bravo-1 \(rolled \d+ \/ needed \d+\) for \d+ damage( — .+)?$/';
|
|
self::assertMatchesRegularExpression($pattern, $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 100 times against an isolated target, recording damage each time.
|
|
$damages = [];
|
|
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'] = 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', '0123456789abcdef', 'alpha', 1, 0);
|
|
$req = $this->buildRequest($match, $token, $this->attackBody($matchArray));
|
|
$result = (new PostMatchAttack('s'))->handle($req, []);
|
|
$body = json_decode($result->body, true);
|
|
$log = $body['match']['actionLog'];
|
|
// Skip misses and crits; this test verifies the base variance only.
|
|
if (str_contains($log[0], ' — miss') || str_contains($log[0], ' — crit')) {
|
|
continue;
|
|
}
|
|
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 100 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', '0123456789abcdef', 'alpha', 1, 0);
|
|
$req = $this->buildRequest($match, $token, $this->attackBody($matchArray));
|
|
$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', '0123456789abcdef', 'alpha', 1, 0);
|
|
$req = $this->buildRequest($match, $token, $this->attackBody($matchArray));
|
|
$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', '0123456789abcdef', 'alpha', 1, 0);
|
|
$req = $this->buildRequest($match, $token, $this->attackBody($matchArray));
|
|
$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;
|
|
}
|
|
|
|
/** @param array<string, mixed> $body */
|
|
private function buildRequest(MatchState $match, string $token, array $body): \BattleForge\Http\Request
|
|
{
|
|
[$csrf, $cookie] = CsrfToken::issue('s');
|
|
return new \BattleForge\Http\Request(
|
|
get: [],
|
|
post: [],
|
|
files: [],
|
|
cookies: ['__csrf' => $cookie, '__csrf_token' => $token],
|
|
server: [
|
|
'HTTP_X_CSRF_TOKEN' => $csrf,
|
|
'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),
|
|
);
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $matchArray
|
|
* @return array<string, mixed>
|
|
*/
|
|
private function attackBody(array $matchArray): array
|
|
{
|
|
return [
|
|
'matchId' => '0123456789abcdef',
|
|
'match' => $matchArray,
|
|
'attackerId' => 'alpha-1',
|
|
'targetId' => 'bravo-1',
|
|
];
|
|
}
|
|
|
|
private function freshMatch(): MatchState
|
|
{
|
|
return new MatchState(
|
|
new Battlefield(8, 8),
|
|
[
|
|
$this->unit('alpha-1', 'alpha', new Position(0, 0)),
|
|
$this->unit('bravo-1', 'bravo', new Position(1, 0)),
|
|
],
|
|
'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');
|
|
}
|
|
}
|