feat: validate scenario deployment zones and objectives
This commit is contained in:
@@ -0,0 +1,82 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace BattleForge\Domain;
|
||||
|
||||
final class ScenarioValidator
|
||||
{
|
||||
/**
|
||||
* @return list<string>
|
||||
*/
|
||||
public static function validate(Scenario $scenario): array
|
||||
{
|
||||
$errors = [];
|
||||
|
||||
$occupied = [];
|
||||
|
||||
foreach ($scenario->deploymentZones as $teamId => $zone) {
|
||||
foreach ($zone->positions as $position) {
|
||||
if (!$scenario->battlefield->contains($position)) {
|
||||
$errors[] = "Deployment zone for {$teamId} includes a position outside the battlefield.";
|
||||
continue;
|
||||
}
|
||||
if ($scenario->battlefield->terrainAt($position)->movementCost() === null) {
|
||||
$errors[] = "Deployment zone for {$teamId} sits on impassable terrain at {$position->key()}.";
|
||||
}
|
||||
if (isset($occupied[$position->key()])) {
|
||||
$errors[] = "Deployment zones overlap at {$position->key()}.";
|
||||
}
|
||||
$occupied[$position->key()] = $teamId;
|
||||
}
|
||||
}
|
||||
|
||||
$reachableFromDeployment = self::reachableFromDeployment($scenario, $occupied);
|
||||
|
||||
foreach ($scenario->objectives as $objective) {
|
||||
if (!$scenario->battlefield->contains($objective->position)) {
|
||||
$errors[] = "Objective {$objective->id} is outside the battlefield.";
|
||||
continue;
|
||||
}
|
||||
if ($scenario->battlefield->terrainAt($objective->position)->movementCost() === null) {
|
||||
$errors[] = "Objective {$objective->id} sits on impassable terrain.";
|
||||
}
|
||||
if (!isset($reachableFromDeployment[$objective->position->key()])) {
|
||||
$errors[] = "Objective {$objective->id} is unreachable from any deployment zone.";
|
||||
}
|
||||
}
|
||||
|
||||
return $errors;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, string> $occupied
|
||||
* @return array<string, true>
|
||||
*/
|
||||
private static function reachableFromDeployment(Scenario $scenario, array $occupied): array
|
||||
{
|
||||
$merged = $scenario->units;
|
||||
$allReachable = [];
|
||||
|
||||
foreach ($merged as $unit) {
|
||||
$others = [];
|
||||
|
||||
foreach ($merged as $other) {
|
||||
if ($other->id === $unit->id || $other->isDefeated()) {
|
||||
continue;
|
||||
}
|
||||
$others[] = $other->position;
|
||||
}
|
||||
|
||||
$reachable = $scenario->battlefield->reachable($unit->position, $unit->speed, $others);
|
||||
|
||||
foreach ($reachable as $key => $_cost) {
|
||||
if (!isset($occupied[$key])) {
|
||||
$allReachable[$key] = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $allReachable;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace BattleForge\Tests\Unit\Domain;
|
||||
|
||||
use BattleForge\Domain\Archetype;
|
||||
use BattleForge\Domain\Battlefield;
|
||||
use BattleForge\Domain\DeploymentZone;
|
||||
use BattleForge\Domain\ObjectiveMarker;
|
||||
use BattleForge\Domain\Position;
|
||||
use BattleForge\Domain\Scenario;
|
||||
use BattleForge\Domain\ScenarioValidator;
|
||||
use BattleForge\Domain\Terrain;
|
||||
use BattleForge\Domain\UnitState;
|
||||
use BattleForge\Domain\VictoryCondition;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
final class ScenarioValidatorTest extends TestCase
|
||||
{
|
||||
public function testItReturnsAnEmptyListForAValidScenario(): void
|
||||
{
|
||||
$scenario = $this->validScenario();
|
||||
|
||||
self::assertSame([], ScenarioValidator::validate($scenario));
|
||||
}
|
||||
|
||||
public function testItRejectsAnObjectiveOnImpassableTerrain(): void
|
||||
{
|
||||
$scenario = $this->validScenario(
|
||||
terrain: ['4:4' => Terrain::Water],
|
||||
objectivePosition: new Position(4, 4),
|
||||
);
|
||||
|
||||
$errors = ScenarioValidator::validate($scenario);
|
||||
|
||||
self::assertNotEmpty($errors);
|
||||
self::assertTrue((bool) array_filter($errors, static fn (string $error): bool => str_contains($error, 'impassable')));
|
||||
}
|
||||
|
||||
public function testItRejectsAnObjectiveUnreachableFromAnyDeploymentZone(): void
|
||||
{
|
||||
$scenario = $this->validScenario(
|
||||
terrain: [
|
||||
'3:4' => Terrain::Blocking,
|
||||
'4:3' => Terrain::Blocking,
|
||||
'5:4' => Terrain::Blocking,
|
||||
'4:5' => Terrain::Blocking,
|
||||
],
|
||||
objectivePosition: new Position(4, 4),
|
||||
);
|
||||
|
||||
$errors = ScenarioValidator::validate($scenario);
|
||||
|
||||
self::assertTrue((bool) array_filter($errors, static fn (string $error): bool => str_contains($error, 'unreachable')));
|
||||
}
|
||||
|
||||
public function testItRejectsOverlappingDeploymentZones(): void
|
||||
{
|
||||
$units = $this->unitSet();
|
||||
$battlefield = new Battlefield(8, 8);
|
||||
$scenario = new Scenario(
|
||||
id: 'demo',
|
||||
name: 'Demo',
|
||||
battlefield: $battlefield,
|
||||
units: $units,
|
||||
deploymentZones: [
|
||||
'alpha' => new DeploymentZone('alpha', [new Position(0, 0), new Position(1, 0), new Position(2, 0)]),
|
||||
'bravo' => new DeploymentZone('bravo', [new Position(1, 0), new Position(2, 0), new Position(7, 7), new Position(6, 7), new Position(5, 7)]),
|
||||
],
|
||||
objectives: [],
|
||||
victoryCondition: VictoryCondition::EliminateAll,
|
||||
holdRoundsRequired: 1,
|
||||
);
|
||||
|
||||
$errors = ScenarioValidator::validate($scenario);
|
||||
|
||||
self::assertTrue((bool) array_filter($errors, static fn (string $error): bool => str_contains($error, 'overlap')));
|
||||
}
|
||||
|
||||
public function testItRejectsDeploymentPositionsOnImpassableTerrain(): void
|
||||
{
|
||||
$units = $this->unitSet();
|
||||
$battlefield = new Battlefield(8, 8, ['0:0' => Terrain::Water]);
|
||||
$scenario = new Scenario(
|
||||
id: 'demo',
|
||||
name: 'Demo',
|
||||
battlefield: $battlefield,
|
||||
units: $units,
|
||||
deploymentZones: [
|
||||
'alpha' => new DeploymentZone('alpha', [new Position(0, 0), new Position(1, 0), new Position(2, 0)]),
|
||||
'bravo' => new DeploymentZone('bravo', [new Position(7, 7), new Position(6, 7), new Position(5, 7)]),
|
||||
],
|
||||
objectives: [],
|
||||
victoryCondition: VictoryCondition::EliminateAll,
|
||||
holdRoundsRequired: 1,
|
||||
);
|
||||
|
||||
$errors = ScenarioValidator::validate($scenario);
|
||||
|
||||
self::assertTrue((bool) array_filter($errors, static fn (string $error): bool => str_contains($error, 'impassable')));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, Terrain> $terrain
|
||||
*/
|
||||
private function validScenario(
|
||||
array $terrain = [],
|
||||
?Position $objectivePosition = null,
|
||||
): Scenario {
|
||||
$units = $this->unitSet();
|
||||
$battlefield = new Battlefield(8, 8, $terrain);
|
||||
$objectives = [];
|
||||
|
||||
if ($objectivePosition !== null) {
|
||||
$objectives = ['objective-1' => new ObjectiveMarker('objective-1', $objectivePosition)];
|
||||
}
|
||||
|
||||
return new Scenario(
|
||||
id: 'demo',
|
||||
name: 'Demo',
|
||||
battlefield: $battlefield,
|
||||
units: $units,
|
||||
deploymentZones: $this->zones(),
|
||||
objectives: $objectives,
|
||||
victoryCondition: $objectivePosition === null
|
||||
? VictoryCondition::EliminateAll
|
||||
: VictoryCondition::HoldObjective,
|
||||
holdRoundsRequired: 1,
|
||||
);
|
||||
}
|
||||
|
||||
/** @return array<string, DeploymentZone> */
|
||||
private function zones(): array
|
||||
{
|
||||
return [
|
||||
'alpha' => new DeploymentZone('alpha', [
|
||||
new Position(0, 0),
|
||||
new Position(1, 0),
|
||||
new Position(2, 0),
|
||||
]),
|
||||
'bravo' => new DeploymentZone('bravo', [
|
||||
new Position(7, 7),
|
||||
new Position(6, 7),
|
||||
new Position(5, 7),
|
||||
]),
|
||||
];
|
||||
}
|
||||
|
||||
/** @return list<UnitState> */
|
||||
private function unitSet(): array
|
||||
{
|
||||
return [
|
||||
new UnitState('alpha-1', 'alpha', new Position(0, 0), 10, 10, 4, 3, 2, 2, false, Archetype::Defender),
|
||||
new UnitState('alpha-2', 'alpha', new Position(1, 0), 8, 8, 5, 2, 3, 2, false, Archetype::Striker),
|
||||
new UnitState('alpha-3', 'alpha', new Position(2, 0), 7, 7, 2, 2, 3, 2, false, Archetype::Support),
|
||||
new UnitState('bravo-1', 'bravo', new Position(7, 7), 10, 10, 4, 3, 2, 2, false, Archetype::Defender),
|
||||
new UnitState('bravo-2', 'bravo', new Position(6, 7), 8, 8, 5, 2, 3, 2, false, Archetype::Striker),
|
||||
new UnitState('bravo-3', 'bravo', new Position(5, 7), 6, 6, 3, 1, 5, 2, false, Archetype::Scout),
|
||||
];
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user