fix: enforce executable movement state
This commit is contained in:
@@ -4,13 +4,19 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace BattleForge\Domain;
|
namespace BattleForge\Domain;
|
||||||
|
|
||||||
|
use InvalidArgumentException;
|
||||||
|
|
||||||
final class CombatEngine
|
final class CombatEngine
|
||||||
{
|
{
|
||||||
public function move(MatchState $match, string $unitId, Position $destination): MatchState
|
public function move(MatchState $match, string $unitId, Position $destination): MatchState
|
||||||
{
|
{
|
||||||
$this->assertMatchActive($match);
|
$this->assertMatchActive($match);
|
||||||
|
|
||||||
$unit = $match->unit($unitId);
|
try {
|
||||||
|
$unit = $match->unit($unitId);
|
||||||
|
} catch (InvalidArgumentException $exception) {
|
||||||
|
throw new CombatException("Unknown unit: {$unitId}.", previous: $exception);
|
||||||
|
}
|
||||||
$this->assertCanAct($match, $unit);
|
$this->assertCanAct($match, $unit);
|
||||||
|
|
||||||
$occupied = [];
|
$occupied = [];
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ final readonly class MatchState
|
|||||||
|
|
||||||
$unitIds = [];
|
$unitIds = [];
|
||||||
$teamIds = [];
|
$teamIds = [];
|
||||||
|
$occupiedPositions = [];
|
||||||
|
|
||||||
foreach ($units as $unit) {
|
foreach ($units as $unit) {
|
||||||
if (!self::isUnitState($unit)) {
|
if (!self::isUnitState($unit)) {
|
||||||
@@ -40,6 +41,24 @@ final readonly class MatchState
|
|||||||
throw new InvalidArgumentException("Duplicate unit id: {$unit->id}.");
|
throw new InvalidArgumentException("Duplicate unit id: {$unit->id}.");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!$battlefield->contains($unit->position)) {
|
||||||
|
throw new InvalidArgumentException("Unit {$unit->id} is outside the battlefield.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!$unit->isDefeated() && $battlefield->terrainAt($unit->position)->movementCost() === null) {
|
||||||
|
throw new InvalidArgumentException("Living unit {$unit->id} must stand on passable terrain.");
|
||||||
|
}
|
||||||
|
|
||||||
|
$positionKey = $unit->position->key();
|
||||||
|
|
||||||
|
if (!$unit->isDefeated() && isset($occupiedPositions[$positionKey])) {
|
||||||
|
throw new InvalidArgumentException("Living units cannot share position {$positionKey}.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!$unit->isDefeated()) {
|
||||||
|
$occupiedPositions[$positionKey] = true;
|
||||||
|
}
|
||||||
|
|
||||||
$unitIds[$unit->id] = true;
|
$unitIds[$unit->id] = true;
|
||||||
$teamIds[$unit->teamId] = true;
|
$teamIds[$unit->teamId] = true;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,7 +9,10 @@ use BattleForge\Domain\CombatEngine;
|
|||||||
use BattleForge\Domain\CombatException;
|
use BattleForge\Domain\CombatException;
|
||||||
use BattleForge\Domain\MatchState;
|
use BattleForge\Domain\MatchState;
|
||||||
use BattleForge\Domain\Position;
|
use BattleForge\Domain\Position;
|
||||||
|
use BattleForge\Domain\Terrain;
|
||||||
use BattleForge\Domain\UnitState;
|
use BattleForge\Domain\UnitState;
|
||||||
|
use InvalidArgumentException;
|
||||||
|
use PHPUnit\Framework\Attributes\DataProvider;
|
||||||
use PHPUnit\Framework\TestCase;
|
use PHPUnit\Framework\TestCase;
|
||||||
|
|
||||||
final class CombatEngineTest extends TestCase
|
final class CombatEngineTest extends TestCase
|
||||||
@@ -36,14 +39,149 @@ final class CombatEngineTest extends TestCase
|
|||||||
$this->unit('bravo-1', 'bravo', new Position(7, 7)),
|
$this->unit('bravo-1', 'bravo', new Position(7, 7)),
|
||||||
],
|
],
|
||||||
'alpha',
|
'alpha',
|
||||||
|
actionLog: ['match started'],
|
||||||
);
|
);
|
||||||
|
|
||||||
$next = (new CombatEngine())->move($match, 'alpha-1', new Position(2, 0));
|
$next = (new CombatEngine())->move($match, 'alpha-1', new Position(2, 0));
|
||||||
|
|
||||||
self::assertSame('0:0', $match->unit('alpha-1')->position->key());
|
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('2:0', $next->unit('alpha-1')->position->key());
|
||||||
self::assertSame(1, $next->unit('alpha-1')->actionsRemaining);
|
self::assertSame(1, $next->unit('alpha-1')->actionsRemaining);
|
||||||
self::assertSame(['alpha-1 moved to 2:0'], $next->actionLog);
|
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
|
public function testMoveRejectsUnitFromInactiveTeam(): void
|
||||||
@@ -81,8 +219,26 @@ final class CombatEngineTest extends TestCase
|
|||||||
(new CombatEngine())->move($match, 'alpha-1', new Position(1, 0));
|
(new CombatEngine())->move($match, 'alpha-1', new Position(1, 0));
|
||||||
}
|
}
|
||||||
|
|
||||||
private function unit(string $id, string $team, Position $position): UnitState
|
private function match(): MatchState
|
||||||
{
|
{
|
||||||
return new UnitState($id, $team, $position, 10, 10, 4, 2, 4, 2);
|
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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,8 +7,10 @@ namespace BattleForge\Tests\Unit\Domain;
|
|||||||
use BattleForge\Domain\Battlefield;
|
use BattleForge\Domain\Battlefield;
|
||||||
use BattleForge\Domain\MatchState;
|
use BattleForge\Domain\MatchState;
|
||||||
use BattleForge\Domain\Position;
|
use BattleForge\Domain\Position;
|
||||||
|
use BattleForge\Domain\Terrain;
|
||||||
use BattleForge\Domain\UnitState;
|
use BattleForge\Domain\UnitState;
|
||||||
use InvalidArgumentException;
|
use InvalidArgumentException;
|
||||||
|
use PHPUnit\Framework\Attributes\DataProvider;
|
||||||
use PHPUnit\Framework\TestCase;
|
use PHPUnit\Framework\TestCase;
|
||||||
|
|
||||||
final class MatchStateTest extends TestCase
|
final class MatchStateTest extends TestCase
|
||||||
@@ -24,6 +26,67 @@ final class MatchStateTest extends TestCase
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function testItRejectsAUnitOutsideTheBattlefield(): void
|
||||||
|
{
|
||||||
|
$this->expectException(InvalidArgumentException::class);
|
||||||
|
$this->expectExceptionMessage('Unit alpha-1 is outside the battlefield.');
|
||||||
|
|
||||||
|
new MatchState(
|
||||||
|
new Battlefield(8, 8),
|
||||||
|
[$this->unit('alpha-1', 'alpha', new Position(8, 0))],
|
||||||
|
'alpha',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[DataProvider('impassableTerrainProvider')]
|
||||||
|
public function testItRejectsALivingUnitOnImpassableTerrain(Terrain $terrain): void
|
||||||
|
{
|
||||||
|
$this->expectException(InvalidArgumentException::class);
|
||||||
|
$this->expectExceptionMessage('Living unit alpha-1 must stand on passable terrain.');
|
||||||
|
|
||||||
|
new MatchState(
|
||||||
|
new Battlefield(8, 8, ['0:0' => $terrain]),
|
||||||
|
[$this->unit('alpha-1', 'alpha')],
|
||||||
|
'alpha',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return iterable<string, array{Terrain}>
|
||||||
|
*/
|
||||||
|
public static function impassableTerrainProvider(): iterable
|
||||||
|
{
|
||||||
|
yield 'water' => [Terrain::Water];
|
||||||
|
yield 'blocking' => [Terrain::Blocking];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testItRejectsLivingUnitsAtTheSamePosition(): void
|
||||||
|
{
|
||||||
|
$this->expectException(InvalidArgumentException::class);
|
||||||
|
$this->expectExceptionMessage('Living units cannot share position 0:0.');
|
||||||
|
|
||||||
|
new MatchState(
|
||||||
|
new Battlefield(8, 8),
|
||||||
|
[$this->unit('alpha-1', 'alpha'), $this->unit('bravo-1', 'bravo')],
|
||||||
|
'alpha',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testItAcceptsLivingAndDefeatedUnitsAtTheSamePosition(): void
|
||||||
|
{
|
||||||
|
$match = new MatchState(
|
||||||
|
new Battlefield(8, 8),
|
||||||
|
[
|
||||||
|
$this->unit('alpha-1', 'alpha'),
|
||||||
|
$this->unit('bravo-1', 'bravo', health: 0),
|
||||||
|
],
|
||||||
|
'alpha',
|
||||||
|
);
|
||||||
|
|
||||||
|
self::assertSame('0:0', $match->unit('alpha-1')->position->key());
|
||||||
|
self::assertSame('0:0', $match->unit('bravo-1')->position->key());
|
||||||
|
}
|
||||||
|
|
||||||
public function testItRejectsAnUnknownUnitLookup(): void
|
public function testItRejectsAnUnknownUnitLookup(): void
|
||||||
{
|
{
|
||||||
$match = $this->match();
|
$match = $this->match();
|
||||||
@@ -169,14 +232,21 @@ final class MatchStateTest extends TestCase
|
|||||||
{
|
{
|
||||||
return new MatchState(
|
return new MatchState(
|
||||||
new Battlefield(8, 8),
|
new Battlefield(8, 8),
|
||||||
[$this->unit('alpha-1', 'alpha'), $this->unit('bravo-1', 'bravo')],
|
[
|
||||||
|
$this->unit('alpha-1', 'alpha'),
|
||||||
|
$this->unit('bravo-1', 'bravo', new Position(7, 7)),
|
||||||
|
],
|
||||||
'alpha',
|
'alpha',
|
||||||
actionLog: $actionLog,
|
actionLog: $actionLog,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
private function unit(string $id, string $teamId): UnitState
|
private function unit(
|
||||||
{
|
string $id,
|
||||||
return new UnitState($id, $teamId, new Position(0, 0), 10, 10, 4, 2, 4, 2);
|
string $teamId,
|
||||||
|
?Position $position = null,
|
||||||
|
int $health = 10,
|
||||||
|
): UnitState {
|
||||||
|
return new UnitState($id, $teamId, $position ?? new Position(0, 0), 10, $health, 4, 2, 4, 2);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user