Files
BattleForge/tests/Unit/Domain/CombatEngineTest.php
T

245 lines
8.2 KiB
PHP

<?php
declare(strict_types=1);
namespace BattleForge\Tests\Unit\Domain;
use BattleForge\Domain\Battlefield;
use BattleForge\Domain\CombatEngine;
use BattleForge\Domain\CombatException;
use BattleForge\Domain\MatchState;
use BattleForge\Domain\Position;
use BattleForge\Domain\Terrain;
use BattleForge\Domain\UnitState;
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));
}
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,
): UnitState {
return new UnitState($id, $team, $position, 10, $health, 4, 2, $speed, $actionsRemaining);
}
}