diff --git a/src/Domain/CombatEngine.php b/src/Domain/CombatEngine.php new file mode 100644 index 0000000..b512e0b --- /dev/null +++ b/src/Domain/CombatEngine.php @@ -0,0 +1,58 @@ +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.'); + } + } +} diff --git a/src/Domain/CombatException.php b/src/Domain/CombatException.php new file mode 100644 index 0000000..d251218 --- /dev/null +++ b/src/Domain/CombatException.php @@ -0,0 +1,11 @@ +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);