246 lines
8.6 KiB
PHP
246 lines
8.6 KiB
PHP
<?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\MatchState;
|
|
use BattleForge\Domain\ObjectiveMarker;
|
|
use BattleForge\Domain\Position;
|
|
use BattleForge\Domain\Scenario;
|
|
use BattleForge\Domain\UnitState;
|
|
use BattleForge\Domain\VictoryCondition;
|
|
use InvalidArgumentException;
|
|
use PHPUnit\Framework\TestCase;
|
|
|
|
final class ScenarioTest extends TestCase
|
|
{
|
|
public function testItRejectsNonListUnits(): void
|
|
{
|
|
$this->expectException(InvalidArgumentException::class);
|
|
|
|
new Scenario(
|
|
id: 'demo',
|
|
name: 'Demo',
|
|
battlefield: new Battlefield(8, 8),
|
|
units: ['alpha-1' => $this->unit('alpha-1', 'alpha', new Position(0, 0))], // @phpstan-ignore argument.type (Test fixture intentionally passes a non-list units array to verify constructor validation.)
|
|
deploymentZones: ['alpha' => new DeploymentZone('alpha', [new Position(0, 0)])],
|
|
objectives: [],
|
|
victoryCondition: VictoryCondition::EliminateAll,
|
|
holdRoundsRequired: 1,
|
|
);
|
|
}
|
|
|
|
public function testItRejectsTeamsOutsideTheThreeToSixSizeWindow(): void
|
|
{
|
|
$this->expectException(InvalidArgumentException::class);
|
|
$this->expectExceptionMessage('Team alpha must have between 3 and 6 units.');
|
|
|
|
$units = [
|
|
$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)),
|
|
$this->unit('bravo-2', 'bravo', new Position(6, 7)),
|
|
$this->unit('bravo-3', 'bravo', new Position(5, 7)),
|
|
];
|
|
|
|
new Scenario(
|
|
id: 'demo',
|
|
name: 'Demo',
|
|
battlefield: new Battlefield(8, 8),
|
|
units: $units,
|
|
deploymentZones: [
|
|
'alpha' => new DeploymentZone('alpha', [new Position(0, 0), new Position(1, 0)]),
|
|
'bravo' => new DeploymentZone('bravo', [new Position(7, 7), new Position(6, 7), new Position(5, 7)]),
|
|
],
|
|
objectives: [],
|
|
victoryCondition: VictoryCondition::EliminateAll,
|
|
holdRoundsRequired: 1,
|
|
);
|
|
}
|
|
|
|
public function testItRejectsAbilitiesOutsideTheArchetypeAllowlist(): void
|
|
{
|
|
$this->expectException(InvalidArgumentException::class);
|
|
$this->expectExceptionMessage('Unit alpha-1 has an ability that its archetype forbids.');
|
|
|
|
$bad = $this->unit('alpha-1', 'alpha', new Position(0, 0), archetype: Archetype::Scout, abilities: ['heal'], attack: 3, defense: 1, speed: 5, maxHealth: 6);
|
|
$alpha2 = $this->unit('alpha-2', 'alpha', new Position(1, 0));
|
|
$alpha3 = $this->unit('alpha-3', 'alpha', new Position(2, 0));
|
|
$bravo = $this->unit('bravo-1', 'bravo', new Position(7, 7));
|
|
$bravo2 = $this->unit('bravo-2', 'bravo', new Position(6, 7));
|
|
$bravo3 = $this->unit('bravo-3', 'bravo', new Position(5, 7));
|
|
|
|
new Scenario(
|
|
id: 'demo',
|
|
name: 'Demo',
|
|
battlefield: new Battlefield(8, 8),
|
|
units: [$bad, $alpha2, $alpha3, $bravo, $bravo2, $bravo3],
|
|
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,
|
|
);
|
|
}
|
|
|
|
public function testItRejectsUnitsOutsideTheirDeploymentZone(): void
|
|
{
|
|
$this->expectException(InvalidArgumentException::class);
|
|
$this->expectExceptionMessage('Unit alpha-1 must start inside team alpha deployment zone.');
|
|
|
|
$bad = $this->unit('alpha-1', 'alpha', new Position(3, 0));
|
|
$alpha2 = $this->unit('alpha-2', 'alpha', new Position(0, 0));
|
|
$alpha3 = $this->unit('alpha-3', 'alpha', new Position(1, 0));
|
|
$bravo = $this->unit('bravo-1', 'bravo', new Position(7, 7));
|
|
$bravo2 = $this->unit('bravo-2', 'bravo', new Position(6, 7));
|
|
$bravo3 = $this->unit('bravo-3', 'bravo', new Position(5, 7));
|
|
|
|
new Scenario(
|
|
id: 'demo',
|
|
name: 'Demo',
|
|
battlefield: new Battlefield(8, 8),
|
|
units: [$bad, $alpha2, $alpha3, $bravo, $bravo2, $bravo3],
|
|
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,
|
|
);
|
|
}
|
|
|
|
public function testHoldObjectiveRequiresExactlyOneObjective(): void
|
|
{
|
|
$this->expectException(InvalidArgumentException::class);
|
|
$this->expectExceptionMessage('Hold objective victory requires exactly one objective.');
|
|
|
|
$units = $this->validUnitSet();
|
|
$zones = $this->validZones();
|
|
|
|
new Scenario(
|
|
id: 'demo',
|
|
name: 'Demo',
|
|
battlefield: new Battlefield(8, 8),
|
|
units: $units,
|
|
deploymentZones: $zones,
|
|
objectives: [],
|
|
victoryCondition: VictoryCondition::HoldObjective,
|
|
holdRoundsRequired: 3,
|
|
);
|
|
}
|
|
|
|
public function testStartMatchReturnsAFreshMatchStateWithResetUnits(): void
|
|
{
|
|
$scenario = $this->validScenario();
|
|
|
|
$match = $scenario->startMatch('alpha');
|
|
|
|
self::assertInstanceOf(MatchState::class, $match);
|
|
self::assertSame('alpha', $match->activeTeamId);
|
|
self::assertSame(1, $match->round);
|
|
self::assertNull($match->winnerTeamId);
|
|
foreach ($match->units as $unit) {
|
|
self::assertSame(2, $unit->actionsRemaining);
|
|
self::assertFalse($unit->hasAttacked);
|
|
self::assertSame(0, $unit->attackBonus);
|
|
self::assertFalse($unit->hasUsedAbility);
|
|
}
|
|
}
|
|
|
|
public function testStartMatchDoesNotShareUnitInstancesWithTheScenario(): void
|
|
{
|
|
$scenario = $this->validScenario();
|
|
|
|
$match = $scenario->startMatch('alpha');
|
|
|
|
$original = $scenario->units[0];
|
|
$fromMatch = $match->unit($original->id);
|
|
|
|
self::assertNotSame($original, $fromMatch);
|
|
self::assertSame($original->id, $fromMatch->id);
|
|
}
|
|
|
|
/** @return list<UnitState> */
|
|
private function validUnitSet(): array
|
|
{
|
|
return [
|
|
$this->unit('alpha-1', 'alpha', new Position(0, 0)),
|
|
$this->unit('alpha-2', 'alpha', new Position(1, 0)),
|
|
$this->unit('alpha-3', 'alpha', new Position(2, 0)),
|
|
$this->unit('bravo-1', 'bravo', new Position(7, 7)),
|
|
$this->unit('bravo-2', 'bravo', new Position(6, 7)),
|
|
$this->unit('bravo-3', 'bravo', new Position(5, 7)),
|
|
];
|
|
}
|
|
|
|
/** @return array<string, DeploymentZone> */
|
|
private function validZones(): 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),
|
|
]),
|
|
];
|
|
}
|
|
|
|
private function validScenario(): Scenario
|
|
{
|
|
return new Scenario(
|
|
id: 'demo',
|
|
name: 'Demo',
|
|
battlefield: new Battlefield(8, 8),
|
|
units: $this->validUnitSet(),
|
|
deploymentZones: $this->validZones(),
|
|
objectives: [],
|
|
victoryCondition: VictoryCondition::EliminateAll,
|
|
holdRoundsRequired: 1,
|
|
);
|
|
}
|
|
|
|
/**
|
|
* @param list<string> $abilities
|
|
*/
|
|
private function unit(
|
|
string $id,
|
|
string $team,
|
|
Position $position,
|
|
Archetype $archetype = Archetype::Defender,
|
|
array $abilities = [],
|
|
int $attack = 4,
|
|
int $defense = 3,
|
|
int $speed = 3,
|
|
int $maxHealth = 10,
|
|
): UnitState {
|
|
return new UnitState(
|
|
$id,
|
|
$team,
|
|
$position,
|
|
$maxHealth,
|
|
$maxHealth,
|
|
$attack,
|
|
$defense,
|
|
$speed,
|
|
2,
|
|
false,
|
|
$archetype,
|
|
$abilities,
|
|
0,
|
|
false,
|
|
);
|
|
}
|
|
}
|