Files
BattleForge/src/Domain/Battlefield.php
T

149 lines
4.2 KiB
PHP

<?php
declare(strict_types=1);
namespace BattleForge\Domain;
use InvalidArgumentException;
use SplQueue;
final readonly class Battlefield
{
/** @var array<string, Terrain> */
private array $terrain;
/**
* @param array<string, Terrain> $terrain
*/
public function __construct(
public int $width,
public int $height,
array $terrain = [],
) {
if ($width < 8 || $width > 16 || $height < 8 || $height > 16) {
throw new InvalidArgumentException('Battlefield dimensions must each be between 8 and 16.');
}
$validatedTerrain = [];
foreach ($terrain as $key => $terrainType) {
if (!self::isTerrain($terrainType)) {
throw new InvalidArgumentException("Invalid terrain value at coordinate {$key}.");
}
$position = self::positionFromTerrainKey($key);
if (!$this->contains($position)) {
throw new InvalidArgumentException("Terrain coordinate {$key} is outside the battlefield.");
}
$validatedTerrain[$key] = $terrainType;
}
$this->terrain = $validatedTerrain;
}
private static function isTerrain(mixed $terrain): bool
{
return $terrain instanceof Terrain;
}
private static function positionFromTerrainKey(mixed $key): Position
{
if (!is_string($key) || preg_match('/^(\d+):(\d+)$/', $key, $matches) !== 1) {
throw new InvalidArgumentException("Invalid terrain coordinate: {$key}.");
}
$position = new Position((int) $matches[1], (int) $matches[2]);
if ($key !== $position->key()) {
throw new InvalidArgumentException("Invalid terrain coordinate: {$key}.");
}
return $position;
}
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<Position> $occupied
* @return array<string, int>
*/
public function reachable(Position $start, int $budget, array $occupied): array
{
if (!$this->contains($start)) {
throw new InvalidArgumentException('Reachability start position is outside the battlefield.');
}
if ($budget < 0) {
throw new InvalidArgumentException('Reachability budget cannot be negative.');
}
$occupiedKeys = [];
foreach ($occupied as $position) {
if (!$this->contains($position)) {
throw new InvalidArgumentException('Occupied position is outside the battlefield.');
}
$occupiedKeys[$position->key()] = true;
}
$costs = [$start->key() => 0];
/** @var SplQueue<Position> $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<Position>
*/
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(...)));
}
}