Files
BattleForge/tests/Unit/Domain/ScenarioTest.php
T

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,
);
}
}