feat: validate and resolve unit movement
This commit is contained in:
@@ -0,0 +1,58 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace BattleForge\Domain;
|
||||
|
||||
final class CombatEngine
|
||||
{
|
||||
public function move(MatchState $match, string $unitId, Position $destination): MatchState
|
||||
{
|
||||
$this->assertMatchActive($match);
|
||||
|
||||
$unit = $match->unit($unitId);
|
||||
$this->assertCanAct($match, $unit);
|
||||
|
||||
$occupied = [];
|
||||
|
||||
foreach ($match->units as $otherUnit) {
|
||||
if ($otherUnit->id !== $unit->id && !$otherUnit->isDefeated()) {
|
||||
$occupied[] = $otherUnit->position;
|
||||
}
|
||||
}
|
||||
|
||||
$reachable = $match->battlefield->reachable($unit->position, $unit->speed, $occupied);
|
||||
|
||||
if ($destination->key() === $unit->position->key() || !isset($reachable[$destination->key()])) {
|
||||
throw new CombatException('Destination is not reachable.');
|
||||
}
|
||||
|
||||
$movedUnit = $unit->moveTo($destination)->spendAction();
|
||||
$next = $match->withUnit($movedUnit);
|
||||
$actionLog = [...$match->actionLog, "{$unit->id} moved to {$destination->key()}"];
|
||||
|
||||
return $next->copy(actionLog: $actionLog);
|
||||
}
|
||||
|
||||
private function assertMatchActive(MatchState $match): void
|
||||
{
|
||||
if ($match->winnerTeamId !== null) {
|
||||
throw new CombatException('The match is already complete.');
|
||||
}
|
||||
}
|
||||
|
||||
private function assertCanAct(MatchState $match, UnitState $unit): void
|
||||
{
|
||||
if ($unit->teamId !== $match->activeTeamId) {
|
||||
throw new CombatException("It is not this unit's turn.");
|
||||
}
|
||||
|
||||
if ($unit->isDefeated()) {
|
||||
throw new CombatException('Defeated units cannot act.');
|
||||
}
|
||||
|
||||
if ($unit->actionsRemaining === 0) {
|
||||
throw new CombatException('Unit has no actions remaining.');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace BattleForge\Domain;
|
||||
|
||||
use DomainException;
|
||||
|
||||
final class CombatException extends DomainException
|
||||
{
|
||||
}
|
||||
@@ -5,6 +5,8 @@ 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\UnitState;
|
||||
@@ -25,6 +27,60 @@ final class CombatEngineTest extends TestCase
|
||||
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',
|
||||
);
|
||||
|
||||
$next = (new CombatEngine())->move($match, 'alpha-1', new Position(2, 0));
|
||||
|
||||
self::assertSame('0:0', $match->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(['alpha-1 moved to 2:0'], $next->actionLog);
|
||||
}
|
||||
|
||||
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 unit(string $id, string $team, Position $position): UnitState
|
||||
{
|
||||
return new UnitState($id, $team, $position, 10, 10, 4, 2, 4, 2);
|
||||
|
||||
Reference in New Issue
Block a user