281 lines
10 KiB
PHP
281 lines
10 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace BattleForge\Tests\Unit\Domain;
|
|
|
|
use BattleForge\Domain\Archetype;
|
|
use BattleForge\Domain\Battlefield;
|
|
use BattleForge\Domain\CombatEngine;
|
|
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);
|
|
}
|
|
|
|
public function testStartingAMatchWithAbilityAndObjectivePlaysThrough(): void
|
|
{
|
|
$units = [
|
|
new UnitState('alpha-1', 'alpha', new Position(0, 0), 10, 10, 4, 2, 4, 2, false, Archetype::Striker, ['area_damage']),
|
|
new UnitState('alpha-2', 'alpha', new Position(1, 0), 9, 9, 3, 2, 4, 2, false, Archetype::Support, ['heal', 'buff']),
|
|
new UnitState('alpha-3', 'alpha', new Position(2, 0), 10, 10, 4, 3, 3, 2, false, Archetype::Defender),
|
|
new UnitState('bravo-1', 'bravo', new Position(7, 7), 7, 7, 4, 2, 5, 2, false, Archetype::Scout),
|
|
new UnitState('bravo-2', 'bravo', new Position(6, 7), 10, 10, 4, 2, 4, 2, false, Archetype::Striker),
|
|
new UnitState('bravo-3', 'bravo', new Position(5, 7), 10, 10, 4, 3, 3, 2, false, Archetype::Defender),
|
|
];
|
|
$scenario = 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), new Position(2, 0)]),
|
|
'bravo' => new DeploymentZone('bravo', [new Position(7, 7), new Position(6, 7), new Position(5, 7)]),
|
|
],
|
|
objectives: ['objective-1' => new ObjectiveMarker('objective-1', new Position(0, 0))],
|
|
victoryCondition: VictoryCondition::HoldObjective,
|
|
holdRoundsRequired: 2,
|
|
);
|
|
|
|
$match = $scenario->startMatch('alpha');
|
|
$engine = new CombatEngine();
|
|
$end1 = $engine->endTurn($match);
|
|
$end2 = $engine->endTurn($end1);
|
|
|
|
self::assertSame('alpha', $end2->activeTeamId);
|
|
self::assertSame(['alpha' => 1], $end2->objectiveControl);
|
|
self::assertNull($end2->winnerTeamId);
|
|
}
|
|
|
|
/** @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,
|
|
);
|
|
}
|
|
}
|