From 189be5bc64fae42266a92e9aa74c1ab48e6d5747 Mon Sep 17 00:00:00 2001 From: Keith Solomon Date: Sat, 4 Jul 2026 15:08:40 -0500 Subject: [PATCH] feat: model battlefield terrain and movement costs --- src/Domain/Battlefield.php | 107 ++++++++++++++++++++++++++ src/Domain/Position.php | 24 ++++++ src/Domain/Terrain.php | 28 +++++++ tests/Unit/Domain/BattlefieldTest.php | 44 +++++++++++ 4 files changed, 203 insertions(+) create mode 100644 src/Domain/Battlefield.php create mode 100644 src/Domain/Position.php create mode 100644 src/Domain/Terrain.php create mode 100644 tests/Unit/Domain/BattlefieldTest.php diff --git a/src/Domain/Battlefield.php b/src/Domain/Battlefield.php new file mode 100644 index 0000000..201904f --- /dev/null +++ b/src/Domain/Battlefield.php @@ -0,0 +1,107 @@ + $terrain + */ + public function __construct( + public int $width, + public int $height, + private array $terrain = [], + ) { + if ($width < 8 || $width > 16 || $height < 8 || $height > 16) { + throw new InvalidArgumentException('Battlefield dimensions must each be between 8 and 16.'); + } + + foreach (array_keys($terrain) as $key) { + if (preg_match('/^(\d+):(\d+)$/', $key, $matches) !== 1) { + throw new InvalidArgumentException("Invalid terrain coordinate: {$key}."); + } + + $position = new Position((int) $matches[1], (int) $matches[2]); + + if (!$this->contains($position)) { + throw new InvalidArgumentException("Terrain coordinate {$key} is outside the battlefield."); + } + } + } + + public function contains(Position $position): bool + { + return $position->x >= 0 + && $position->x < $this->width + && $position->y >= 0 + && $position->y < $this->height; + } + + public function terrainAt(Position $position): Terrain + { + return $this->terrain[$position->key()] ?? Terrain::Open; + } + + /** + * @param list $occupied + * @return array + */ + public function reachable(Position $start, int $budget, array $occupied): array + { + $occupiedKeys = []; + + foreach ($occupied as $position) { + $occupiedKeys[$position->key()] = true; + } + + $costs = [$start->key() => 0]; + /** @var SplQueue $queue */ + $queue = new SplQueue(); + $queue->enqueue($start); + + while (!$queue->isEmpty()) { + $current = $queue->dequeue(); + $currentCost = $costs[$current->key()]; + + foreach ($this->neighbors($current) as $neighbor) { + $key = $neighbor->key(); + $movementCost = $this->terrainAt($neighbor)->movementCost(); + + if ($movementCost === null || isset($occupiedKeys[$key])) { + continue; + } + + $cost = $currentCost + $movementCost; + + if ($cost > $budget || (isset($costs[$key]) && $costs[$key] <= $cost)) { + continue; + } + + $costs[$key] = $cost; + $queue->enqueue($neighbor); + } + } + + return $costs; + } + + /** + * @return list + */ + private function neighbors(Position $position): array + { + $neighbors = [ + new Position($position->x + 1, $position->y), + new Position($position->x - 1, $position->y), + new Position($position->x, $position->y + 1), + new Position($position->x, $position->y - 1), + ]; + + return array_values(array_filter($neighbors, $this->contains(...))); + } +} diff --git a/src/Domain/Position.php b/src/Domain/Position.php new file mode 100644 index 0000000..8db6e49 --- /dev/null +++ b/src/Domain/Position.php @@ -0,0 +1,24 @@ +x . ':' . $this->y; + } + + public function distanceTo(self $position): int + { + return abs($this->x - $position->x) + abs($this->y - $position->y); + } +} diff --git a/src/Domain/Terrain.php b/src/Domain/Terrain.php new file mode 100644 index 0000000..ebb8e67 --- /dev/null +++ b/src/Domain/Terrain.php @@ -0,0 +1,28 @@ + 1, + self::Forest, self::Rough => 2, + self::Water, self::Blocking => null, + }; + } + + public function defenseBonus(): int + { + return $this === self::Forest ? 1 : 0; + } +} diff --git a/tests/Unit/Domain/BattlefieldTest.php b/tests/Unit/Domain/BattlefieldTest.php new file mode 100644 index 0000000..325edc8 --- /dev/null +++ b/tests/Unit/Domain/BattlefieldTest.php @@ -0,0 +1,44 @@ +expectException(InvalidArgumentException::class); + + new Battlefield(7, 16); + } + + public function testItFindsReachableTilesUsingTerrainCostsAndObstacles(): void + { + $battlefield = new Battlefield(8, 8, [ + '1:0' => Terrain::Forest, + '0:1' => Terrain::Rough, + '2:0' => Terrain::Water, + '1:1' => Terrain::Blocking, + ]); + + $reachable = $battlefield->reachable( + new Position(0, 0), + 2, + [new Position(0, 2)], + ); + + self::assertSame(0, $reachable['0:0']); + self::assertSame(2, $reachable['1:0']); + self::assertSame(2, $reachable['0:1']); + self::assertArrayNotHasKey('2:0', $reachable); + self::assertArrayNotHasKey('1:1', $reachable); + self::assertArrayNotHasKey('0:2', $reachable); + } +}