149 lines
4.2 KiB
PHP
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(...)));
|
|
}
|
|
}
|