Files
BattleForge/tests/Unit/Domain/CombatEngineTest.php
T
Keith Solomon 39b15c35da refactor: tighten useAbility and Scenario archetype lookup
Final-review-driven changes (Tasks 6/M1, I1 from the ledger):

- Drop the redundant abilities-list check in CombatEngine::useAbility.
  By construction (Scenario::__construct enforces the allowlist), any
  ability the unit 'knows' is also archetype-allowed; the second check
  alone is sufficient and removes a dead code path.
- Update testUseAbilityRejectsAnAbilityTheArchetypeForbids to assert the
  'Unit archetype forbids that ability.' message it actually exercises.
- Compute $template once in Scenario::__construct's per-unit loop and
  drop the redundant catalog lookup on the next line.
2026-07-05 20:33:03 -05:00

1040 lines
36 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\CombatException;
use BattleForge\Domain\MatchState;
use BattleForge\Domain\ObjectiveMarker;
use BattleForge\Domain\Position;
use BattleForge\Domain\Terrain;
use BattleForge\Domain\UnitState;
use BattleForge\Domain\VictoryCondition;
use InvalidArgumentException;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\TestCase;
final class CombatEngineTest extends TestCase
{
public function testMatchAndUnitStateAreImmutable(): void
{
$unit = $this->unit('unit-1', 'alpha', new Position(0, 0));
$match = new MatchState(new Battlefield(8, 8), [$unit], 'alpha');
$moved = $unit->moveTo(new Position(1, 0))->spendAction();
$next = $match->withUnit($moved);
self::assertSame('0:0', $match->unit('unit-1')->position->key());
self::assertSame('1:0', $next->unit('unit-1')->position->key());
self::assertSame(1, $next->unit('unit-1')->actionsRemaining);
}
public function testMoveUpdatesUnitAndAppendsActionLogWithoutMutatingMatch(): void
{
$match = new MatchState(
new Battlefield(8, 8),
[
$this->unit('alpha-1', 'alpha', new Position(0, 0)),
$this->unit('bravo-1', 'bravo', new Position(7, 7)),
],
'alpha',
actionLog: ['match started'],
);
$next = (new CombatEngine())->move($match, 'alpha-1', new Position(2, 0));
self::assertSame('0:0', $match->unit('alpha-1')->position->key());
self::assertSame(2, $match->unit('alpha-1')->actionsRemaining);
self::assertSame(['match started'], $match->actionLog);
self::assertSame('2:0', $next->unit('alpha-1')->position->key());
self::assertSame(1, $next->unit('alpha-1')->actionsRemaining);
self::assertSame(['match started', 'alpha-1 moved to 2:0'], $next->actionLog);
}
public function testMoveTranslatesUnknownUnitAtCommandBoundary(): void
{
$match = $this->match();
try {
(new CombatEngine())->move($match, 'missing', new Position(1, 0));
self::fail('Expected an unknown unit exception.');
} catch (CombatException $exception) {
self::assertSame('Unknown unit: missing.', $exception->getMessage());
self::assertInstanceOf(InvalidArgumentException::class, $exception->getPrevious());
self::assertSame('Unknown unit id: missing.', $exception->getPrevious()->getMessage());
}
}
public function testMoveRejectsCompletedMatchBeforeResolvingUnit(): void
{
$match = $this->match()->withWinner('alpha');
$this->expectException(CombatException::class);
$this->expectExceptionMessage('The match is already complete.');
(new CombatEngine())->move($match, 'missing', new Position(1, 0));
}
public function testMoveRejectsDefeatedActor(): void
{
$match = new MatchState(
new Battlefield(8, 8),
[
$this->unit('alpha-1', 'alpha', new Position(0, 0), health: 0),
$this->unit('bravo-1', 'bravo', new Position(7, 7)),
],
'alpha',
);
$this->expectException(CombatException::class);
$this->expectExceptionMessage('Defeated units cannot act.');
(new CombatEngine())->move($match, 'alpha-1', new Position(1, 0));
}
public function testMoveRejectsExhaustedActor(): void
{
$match = new MatchState(
new Battlefield(8, 8),
[
$this->unit('alpha-1', 'alpha', new Position(0, 0), actionsRemaining: 0),
$this->unit('bravo-1', 'bravo', new Position(7, 7)),
],
'alpha',
);
$this->expectException(CombatException::class);
$this->expectExceptionMessage('Unit has no actions remaining.');
(new CombatEngine())->move($match, 'alpha-1', new Position(1, 0));
}
public function testMoveRejectsCurrentPosition(): void
{
$match = $this->match();
$this->expectException(CombatException::class);
$this->expectExceptionMessage('Destination is not reachable.');
(new CombatEngine())->move($match, 'alpha-1', new Position(0, 0));
}
/**
* @param array<string, Terrain> $terrain
*/
#[DataProvider('unreachableDestinationProvider')]
public function testMoveRejectsUnreachableDestination(array $terrain, Position $destination): void
{
$match = new MatchState(
new Battlefield(8, 8, $terrain),
[
$this->unit('alpha-1', 'alpha', new Position(0, 0)),
$this->unit('bravo-1', 'bravo', new Position(7, 7)),
],
'alpha',
);
$this->expectException(CombatException::class);
$this->expectExceptionMessage('Destination is not reachable.');
(new CombatEngine())->move($match, 'alpha-1', $destination);
}
/**
* @return iterable<string, array{array<string, Terrain>, Position}>
*/
public static function unreachableDestinationProvider(): iterable
{
yield 'outside battlefield' => [[], new Position(8, 0)];
yield 'outside speed budget' => [[], new Position(5, 0)];
yield 'impassable terrain' => [['1:0' => Terrain::Blocking], new Position(1, 0)];
}
public function testDefeatedUnitDoesNotBlockItsPosition(): void
{
$match = new MatchState(
new Battlefield(8, 8),
[
$this->unit('alpha-1', 'alpha', new Position(0, 0)),
$this->unit('alpha-2', 'alpha', new Position(1, 0), health: 0),
$this->unit('bravo-1', 'bravo', new Position(7, 7)),
],
'alpha',
);
$next = (new CombatEngine())->move($match, 'alpha-1', new Position(1, 0));
self::assertSame('1:0', $next->unit('alpha-1')->position->key());
}
public function testMoveHonorsTerrainCostWithinSpeedBudget(): void
{
$match = new MatchState(
new Battlefield(8, 8, ['1:0' => Terrain::Forest]),
[
$this->unit('alpha-1', 'alpha', new Position(0, 0), speed: 3),
$this->unit('bravo-1', 'bravo', new Position(7, 7)),
],
'alpha',
);
$next = (new CombatEngine())->move($match, 'alpha-1', new Position(2, 0));
self::assertSame('2:0', $next->unit('alpha-1')->position->key());
}
public function testMoveRejectsUnitFromInactiveTeam(): void
{
$match = new MatchState(
new Battlefield(8, 8),
[
$this->unit('alpha-1', 'alpha', new Position(0, 0)),
$this->unit('bravo-1', 'bravo', new Position(7, 7)),
],
'alpha',
);
$this->expectException(CombatException::class);
$this->expectExceptionMessage("It is not this unit's turn.");
(new CombatEngine())->move($match, 'bravo-1', new Position(6, 7));
}
public function testMoveRejectsOccupiedDestination(): void
{
$match = new MatchState(
new Battlefield(8, 8),
[
$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)),
],
'alpha',
);
$this->expectException(CombatException::class);
$this->expectExceptionMessage('Destination is not reachable.');
(new CombatEngine())->move($match, 'alpha-1', new Position(1, 0));
}
public function testAttackAppliesForestDefenseAndAppendsLogWithoutMutatingMatch(): void
{
$attacker = $this->unit('alpha-1', 'alpha', new Position(0, 0));
$target = $this->unit('bravo-1', 'bravo', new Position(1, 0));
$match = new MatchState(
new Battlefield(8, 8, ['1:0' => Terrain::Forest]),
[$attacker, $target],
'alpha',
actionLog: ['match started'],
);
$next = (new CombatEngine())->attack($match, 'alpha-1', 'bravo-1');
self::assertSame(10, $target->health);
self::assertSame(2, $attacker->actionsRemaining);
self::assertFalse($attacker->hasAttacked);
self::assertSame(10, $match->unit('bravo-1')->health);
self::assertSame(2, $match->unit('alpha-1')->actionsRemaining);
self::assertFalse($match->unit('alpha-1')->hasAttacked);
self::assertSame(['match started'], $match->actionLog);
self::assertSame(9, $next->unit('bravo-1')->health);
self::assertSame(1, $next->unit('alpha-1')->actionsRemaining);
self::assertTrue($next->unit('alpha-1')->hasAttacked);
self::assertSame(
['match started', 'alpha-1 attacked bravo-1 for 1 damage'],
$next->actionLog,
);
}
public function testAttackRejectsTargetOutsideRange(): void
{
$match = new MatchState(
new Battlefield(8, 8),
[
$this->unit('alpha-1', 'alpha', new Position(0, 0)),
$this->unit('bravo-1', 'bravo', new Position(2, 0)),
],
'alpha',
);
$this->expectException(CombatException::class);
$this->expectExceptionMessage('Target is outside attack range.');
(new CombatEngine())->attack($match, 'alpha-1', 'bravo-1');
}
public function testAttackDefeatingLastEnemyAwardsVictory(): void
{
$match = new MatchState(
new Battlefield(8, 8),
[
$this->unit('alpha-1', 'alpha', new Position(0, 0), attack: 20),
$this->unit('bravo-1', 'bravo', new Position(1, 0)),
],
'alpha',
);
$next = (new CombatEngine())->attack($match, 'alpha-1', 'bravo-1');
self::assertSame(0, $next->unit('bravo-1')->health);
self::assertSame('alpha', $next->winnerTeamId);
self::assertNull($match->winnerTeamId);
}
public function testAttackRejectsUnitThatAlreadyAttacked(): void
{
$match = new MatchState(
new Battlefield(8, 8),
[
$this->unit('alpha-1', 'alpha', new Position(0, 0), hasAttacked: true),
$this->unit('bravo-1', 'bravo', new Position(1, 0)),
],
'alpha',
);
$this->expectException(CombatException::class);
$this->expectExceptionMessage('Unit has already attacked this turn.');
(new CombatEngine())->attack($match, 'alpha-1', 'bravo-1');
}
public function testAttackTranslatesUnknownAttackerAtCommandBoundary(): void
{
$match = $this->match();
try {
(new CombatEngine())->attack($match, 'missing', 'bravo-1');
self::fail('Expected an unknown unit exception.');
} catch (CombatException $exception) {
self::assertSame('Unknown unit: missing.', $exception->getMessage());
self::assertInstanceOf(InvalidArgumentException::class, $exception->getPrevious());
self::assertSame('Unknown unit id: missing.', $exception->getPrevious()->getMessage());
}
}
public function testAttackTranslatesUnknownTargetAtCommandBoundary(): void
{
$match = $this->match();
try {
(new CombatEngine())->attack($match, 'alpha-1', 'missing');
self::fail('Expected an unknown unit exception.');
} catch (CombatException $exception) {
self::assertSame('Unknown unit: missing.', $exception->getMessage());
self::assertInstanceOf(InvalidArgumentException::class, $exception->getPrevious());
self::assertSame('Unknown unit id: missing.', $exception->getPrevious()->getMessage());
}
}
public function testAttackRejectsCompletedMatchBeforeResolvingAttacker(): void
{
$match = $this->match()->withWinner('alpha');
$this->expectException(CombatException::class);
$this->expectExceptionMessage('The match is already complete.');
(new CombatEngine())->attack($match, 'missing', 'also-missing');
}
/**
* @param array{team?: string, health?: int, actionsRemaining?: int, hasAttacked?: bool} $overrides
*/
#[DataProvider('invalidAttackActorProvider')]
public function testAttackRejectsInvalidActor(array $overrides, string $message): void
{
$attacker = $this->unit(
'attacker',
$overrides['team'] ?? 'alpha',
new Position(0, 0),
health: $overrides['health'] ?? 10,
actionsRemaining: $overrides['actionsRemaining'] ?? 2,
hasAttacked: $overrides['hasAttacked'] ?? false,
);
$match = new MatchState(
new Battlefield(8, 8),
[
$attacker,
$this->unit('bravo-1', 'bravo', new Position(1, 0)),
$this->unit('alpha-2', 'alpha', new Position(7, 7)),
],
'alpha',
);
$this->expectException(CombatException::class);
$this->expectExceptionMessage($message);
(new CombatEngine())->attack($match, 'attacker', 'missing');
}
/**
* @return iterable<string, array{
* array{team?: string, health?: int, actionsRemaining?: int, hasAttacked?: bool},
* string
* }>
*/
public static function invalidAttackActorProvider(): iterable
{
yield 'inactive and already attacked' => [
['team' => 'bravo', 'hasAttacked' => true],
"It is not this unit's turn.",
];
yield 'defeated and already attacked' => [
['health' => 0, 'hasAttacked' => true],
'Defeated units cannot act.',
];
yield 'exhausted and already attacked' => [
['actionsRemaining' => 0, 'hasAttacked' => true],
'Unit has no actions remaining.',
];
}
#[DataProvider('invalidAttackTargetProvider')]
public function testAttackRejectsInvalidTarget(string $targetTeam, int $targetHealth): void
{
$match = new MatchState(
new Battlefield(8, 8),
[
$this->unit('alpha-1', 'alpha', new Position(0, 0)),
$this->unit('target', $targetTeam, new Position(1, 0), health: $targetHealth),
$this->unit('bravo-2', 'bravo', new Position(7, 7)),
],
'alpha',
);
$this->expectException(CombatException::class);
$this->expectExceptionMessage('Target must be an active enemy unit.');
(new CombatEngine())->attack($match, 'alpha-1', 'target');
}
/**
* @return iterable<string, array{string, int}>
*/
public static function invalidAttackTargetProvider(): iterable
{
yield 'friendly' => ['alpha', 10];
yield 'defeated enemy' => ['bravo', 0];
}
public function testAttackDealsCalculatedOpenTerrainDamage(): void
{
$target = $this->unit('bravo-1', 'bravo', new Position(1, 0), defense: 3);
$match = new MatchState(
new Battlefield(8, 8),
[
$this->unit('alpha-1', 'alpha', new Position(0, 0), attack: 8),
$target,
],
'alpha',
);
$next = (new CombatEngine())->attack($match, 'alpha-1', 'bravo-1');
self::assertSame(10, $target->health);
self::assertSame(5, $next->unit('bravo-1')->health);
self::assertSame(['alpha-1 attacked bravo-1 for 5 damage'], $next->actionLog);
}
public function testAttackAlwaysDealsAtLeastOneDamage(): void
{
$match = new MatchState(
new Battlefield(8, 8, ['1:0' => Terrain::Forest]),
[
$this->unit('alpha-1', 'alpha', new Position(0, 0), attack: 0),
$this->unit('bravo-1', 'bravo', new Position(1, 0), defense: 20),
],
'alpha',
);
$next = (new CombatEngine())->attack($match, 'alpha-1', 'bravo-1');
self::assertSame(9, $next->unit('bravo-1')->health);
self::assertSame(['alpha-1 attacked bravo-1 for 1 damage'], $next->actionLog);
}
public function testAttackDoesNotAwardVictoryWhileAnotherEnemyLives(): void
{
$match = new MatchState(
new Battlefield(8, 8),
[
$this->unit('alpha-1', 'alpha', new Position(0, 0), attack: 20),
$this->unit('bravo-1', 'bravo', new Position(1, 0)),
$this->unit('bravo-2', 'bravo', new Position(7, 7)),
],
'alpha',
);
$next = (new CombatEngine())->attack($match, 'alpha-1', 'bravo-1');
self::assertSame(0, $next->unit('bravo-1')->health);
self::assertSame(10, $next->unit('bravo-2')->health);
self::assertNull($next->winnerTeamId);
}
public function testEndTurnPassesControlToBravoAndRefreshesItsLivingUnits(): void
{
$match = new MatchState(
new Battlefield(8, 8),
[
$this->unit(
'alpha-1',
'alpha',
new Position(0, 0),
actionsRemaining: 1,
hasAttacked: true,
),
$this->unit(
'bravo-1',
'bravo',
new Position(7, 7),
actionsRemaining: 0,
hasAttacked: true,
),
],
'alpha',
actionLog: ['match started'],
);
$next = (new CombatEngine())->endTurn($match);
self::assertSame('alpha', $match->activeTeamId);
self::assertSame(1, $match->round);
self::assertSame(1, $match->unit('alpha-1')->actionsRemaining);
self::assertTrue($match->unit('alpha-1')->hasAttacked);
self::assertSame(0, $match->unit('bravo-1')->actionsRemaining);
self::assertTrue($match->unit('bravo-1')->hasAttacked);
self::assertSame(['match started'], $match->actionLog);
self::assertSame('bravo', $next->activeTeamId);
self::assertSame(1, $next->round);
self::assertSame(1, $next->unit('alpha-1')->actionsRemaining);
self::assertTrue($next->unit('alpha-1')->hasAttacked);
self::assertSame(2, $next->unit('bravo-1')->actionsRemaining);
self::assertFalse($next->unit('bravo-1')->hasAttacked);
self::assertSame(['match started', 'alpha ended turn'], $next->actionLog);
}
public function testEndTurnPassesControlToAlphaAndStartsANewRound(): void
{
$match = new MatchState(
new Battlefield(8, 8),
[
$this->unit(
'alpha-1',
'alpha',
new Position(0, 0),
actionsRemaining: 0,
hasAttacked: true,
),
$this->unit('bravo-1', 'bravo', new Position(7, 7), actionsRemaining: 1),
],
'bravo',
round: 1,
actionLog: ['alpha ended turn'],
);
$next = (new CombatEngine())->endTurn($match);
self::assertSame('alpha', $next->activeTeamId);
self::assertSame(2, $next->round);
self::assertSame(2, $next->unit('alpha-1')->actionsRemaining);
self::assertFalse($next->unit('alpha-1')->hasAttacked);
self::assertSame(1, $next->unit('bravo-1')->actionsRemaining);
self::assertSame(['alpha ended turn', 'bravo ended turn'], $next->actionLog);
self::assertSame('bravo', $match->activeTeamId);
self::assertSame(1, $match->round);
self::assertSame(0, $match->unit('alpha-1')->actionsRemaining);
self::assertTrue($match->unit('alpha-1')->hasAttacked);
self::assertSame(['alpha ended turn'], $match->actionLog);
}
public function testEndTurnRejectsCompletedMatchBeforeValidatingTeams(): void
{
$match = new MatchState(
new Battlefield(8, 8),
[$this->unit('alpha-1', 'alpha', new Position(0, 0))],
'alpha',
winnerTeamId: 'alpha',
);
$this->expectException(CombatException::class);
$this->expectExceptionMessage('The match is already complete.');
(new CombatEngine())->endTurn($match);
}
/**
* @param list<string> $teamIds
*/
#[DataProvider('invalidEndTurnTeamProvider')]
public function testEndTurnRejectsMatchesWithoutExactlyAlphaAndBravoTeams(array $teamIds): void
{
$units = [];
foreach ($teamIds as $index => $teamId) {
$units[] = $this->unit("{$teamId}-{$index}", $teamId, new Position($index, 0));
}
$match = new MatchState(
new Battlefield(8, 8),
$units,
'alpha',
);
$this->expectException(CombatException::class);
$this->expectExceptionMessage('Matches require exactly two teams.');
(new CombatEngine())->endTurn($match);
}
/**
* @return iterable<string, array{list<string>}>
*/
public static function invalidEndTurnTeamProvider(): iterable
{
yield 'only alpha' => [['alpha']];
yield 'alpha, bravo, and charlie' => [['alpha', 'bravo', 'charlie']];
}
#[DataProvider('eliminatedEndTurnTeamProvider')]
public function testEndTurnRejectsEliminatedTeam(string $activeTeamId, string $eliminatedTeamId): void
{
$match = new MatchState(
new Battlefield(8, 8),
[
$this->unit(
'alpha-1',
'alpha',
new Position(0, 0),
health: $eliminatedTeamId === 'alpha' ? 0 : 10,
),
$this->unit(
'bravo-1',
'bravo',
new Position(7, 7),
health: $eliminatedTeamId === 'bravo' ? 0 : 10,
),
],
$activeTeamId,
);
$this->expectException(CombatException::class);
$this->expectExceptionMessage('Cannot end turn while a team is eliminated.');
(new CombatEngine())->endTurn($match);
}
/**
* @return iterable<string, array{string, string}>
*/
public static function eliminatedEndTurnTeamProvider(): iterable
{
yield 'incoming team' => ['alpha', 'bravo'];
yield 'active team' => ['alpha', 'alpha'];
}
public function testEndTurnRejectsEliminatedTeamBeforeRoundLimit(): void
{
$match = new MatchState(
new Battlefield(8, 8),
[
$this->unit('alpha-1', 'alpha', new Position(0, 0), health: 0),
$this->unit('bravo-1', 'bravo', new Position(7, 7)),
],
'bravo',
round: PHP_INT_MAX,
);
$this->expectException(CombatException::class);
$this->expectExceptionMessage('Cannot end turn while a team is eliminated.');
(new CombatEngine())->endTurn($match);
}
public function testEndTurnRejectsRoundIncrementBeyondIntegerLimit(): void
{
$match = new MatchState(
new Battlefield(8, 8),
[
$this->unit('alpha-1', 'alpha', new Position(0, 0)),
$this->unit('bravo-1', 'bravo', new Position(7, 7)),
],
'bravo',
round: PHP_INT_MAX,
);
$this->expectException(CombatException::class);
$this->expectExceptionMessage('Match round limit reached.');
(new CombatEngine())->endTurn($match);
}
public function testEndTurnAllowsAlphaToPassAtIntegerRoundLimit(): void
{
$match = new MatchState(
new Battlefield(8, 8),
[
$this->unit('alpha-1', 'alpha', new Position(0, 0)),
$this->unit('bravo-1', 'bravo', new Position(7, 7), actionsRemaining: 0),
],
'alpha',
round: PHP_INT_MAX,
);
$next = (new CombatEngine())->endTurn($match);
self::assertSame('bravo', $next->activeTeamId);
self::assertSame(PHP_INT_MAX, $next->round);
self::assertSame(2, $next->unit('bravo-1')->actionsRemaining);
self::assertSame(['alpha ended turn'], $next->actionLog);
}
public function testEndTurnDoesNotRefreshDefeatedUnitsOnIncomingTeam(): void
{
$match = new MatchState(
new Battlefield(8, 8),
[
$this->unit('alpha-1', 'alpha', new Position(0, 0)),
$this->unit(
'bravo-defeated',
'bravo',
new Position(6, 7),
health: 0,
actionsRemaining: 0,
hasAttacked: true,
),
$this->unit(
'bravo-living',
'bravo',
new Position(7, 7),
actionsRemaining: 0,
hasAttacked: true,
),
],
'alpha',
);
$next = (new CombatEngine())->endTurn($match);
self::assertSame(0, $next->unit('bravo-defeated')->actionsRemaining);
self::assertTrue($next->unit('bravo-defeated')->hasAttacked);
self::assertSame(2, $next->unit('bravo-living')->actionsRemaining);
self::assertFalse($next->unit('bravo-living')->hasAttacked);
}
private function match(): MatchState
{
return new MatchState(
new Battlefield(8, 8),
[
$this->unit('alpha-1', 'alpha', new Position(0, 0)),
$this->unit('bravo-1', 'bravo', new Position(7, 7)),
],
'alpha',
);
}
private function unit(
string $id,
string $team,
Position $position,
int $health = 10,
int $speed = 4,
int $actionsRemaining = 2,
int $attack = 4,
int $defense = 2,
bool $hasAttacked = false,
int $attackBonus = 0,
): UnitState {
return new UnitState(
$id,
$team,
$position,
10,
$health,
$attack,
$defense,
$speed,
$actionsRemaining,
$hasAttacked,
attackBonus: $attackBonus,
);
}
public function testHealAbilityRestoresAlliedHealthAndConsumesOneAction(): void
{
$caster = new UnitState(
id: 'support-1',
teamId: 'alpha',
position: new Position(0, 0),
maxHealth: 8,
health: 8,
attack: 2,
defense: 2,
speed: 3,
actionsRemaining: 2,
hasAttacked: false,
archetype: Archetype::Support,
abilities: ['heal'],
attackBonus: 0,
hasUsedAbility: false,
);
$wounded = new UnitState(
id: 'alpha-1',
teamId: 'alpha',
position: new Position(0, 1),
maxHealth: 10,
health: 3,
attack: 4,
defense: 2,
speed: 4,
actionsRemaining: 2,
hasAttacked: false,
);
$bravo = $this->unit('bravo-1', 'bravo', new Position(7, 7));
$match = new MatchState(
new Battlefield(8, 8),
[$caster, $wounded, $bravo],
'alpha',
);
$next = (new CombatEngine())->useAbility($match, 'support-1', 'heal', new Position(0, 1));
self::assertSame(8, $next->unit('alpha-1')->health);
self::assertSame(1, $next->unit('support-1')->actionsRemaining);
self::assertTrue($next->unit('support-1')->hasUsedAbility);
self::assertSame(['support-1 used Heal on alpha-1 for 5'], $next->actionLog);
}
public function testAreaDamageAbilityHitsEveryEnemyInsideTheRadius(): void
{
$caster = new UnitState(
id: 'striker-1',
teamId: 'alpha',
position: new Position(0, 0),
maxHealth: 8,
health: 8,
attack: 5,
defense: 1,
speed: 3,
actionsRemaining: 2,
hasAttacked: false,
archetype: Archetype::Striker,
abilities: ['area_damage'],
attackBonus: 0,
hasUsedAbility: false,
);
$alpha = $this->unit('alpha-2', 'alpha', new Position(2, 0));
$bravoA = $this->unit('bravo-1', 'bravo', new Position(1, 1), defense: 1);
$bravoB = $this->unit('bravo-2', 'bravo', new Position(2, 1), defense: 1);
$bravoC = $this->unit('bravo-3', 'bravo', new Position(3, 1), defense: 1);
$bravoD = $this->unit('bravo-4', 'bravo', new Position(5, 5));
$match = new MatchState(
new Battlefield(8, 8),
[$caster, $alpha, $bravoA, $bravoB, $bravoC, $bravoD],
'alpha',
);
$next = (new CombatEngine())->useAbility($match, 'striker-1', 'area_damage', new Position(2, 1));
self::assertSame(8, $next->unit('bravo-1')->health);
self::assertSame(8, $next->unit('bravo-2')->health);
self::assertSame(8, $next->unit('bravo-3')->health);
self::assertSame(10, $next->unit('bravo-4')->health);
self::assertSame(10, $next->unit('alpha-2')->health);
self::assertSame(1, $next->unit('striker-1')->actionsRemaining);
}
public function testBuffAbilityReplacesTheAttackBonusOnAnAlly(): void
{
$caster = new UnitState(
id: 'support-1',
teamId: 'alpha',
position: new Position(0, 0),
maxHealth: 8,
health: 8,
attack: 2,
defense: 2,
speed: 3,
actionsRemaining: 2,
hasAttacked: false,
archetype: Archetype::Support,
abilities: ['buff'],
attackBonus: 0,
hasUsedAbility: false,
);
$alpha = $this->unit('alpha-2', 'alpha', new Position(0, 1), attackBonus: 1);
$bravo = $this->unit('bravo-1', 'bravo', new Position(7, 7));
$match = new MatchState(
new Battlefield(8, 8),
[$caster, $alpha, $bravo],
'alpha',
);
$next = (new CombatEngine())->useAbility($match, 'support-1', 'buff', new Position(0, 1));
self::assertSame(2, $next->unit('alpha-2')->attackBonus);
}
public function testUseAbilityRejectsAUnitThatHasAlreadyUsedOne(): void
{
$caster = new UnitState(
id: 'support-1',
teamId: 'alpha',
position: new Position(0, 0),
maxHealth: 8,
health: 8,
attack: 2,
defense: 2,
speed: 3,
actionsRemaining: 2,
hasAttacked: false,
archetype: Archetype::Support,
abilities: ['heal'],
attackBonus: 0,
hasUsedAbility: true,
);
$alpha = $this->unit('alpha-2', 'alpha', new Position(0, 1));
$bravo = $this->unit('bravo-1', 'bravo', new Position(7, 7));
$match = new MatchState(
new Battlefield(8, 8),
[$caster, $alpha, $bravo],
'alpha',
);
$this->expectException(CombatException::class);
$this->expectExceptionMessage('Unit has already used an ability this turn.');
(new CombatEngine())->useAbility($match, 'support-1', 'heal', new Position(0, 1));
}
public function testUseAbilityRejectsTargetsOutsideRange(): void
{
$caster = new UnitState(
id: 'support-1',
teamId: 'alpha',
position: new Position(0, 0),
maxHealth: 8,
health: 8,
attack: 2,
defense: 2,
speed: 3,
actionsRemaining: 2,
hasAttacked: false,
archetype: Archetype::Support,
abilities: ['heal'],
attackBonus: 0,
hasUsedAbility: false,
);
$alpha = $this->unit('alpha-2', 'alpha', new Position(5, 5));
$bravo = $this->unit('bravo-1', 'bravo', new Position(7, 7));
$match = new MatchState(
new Battlefield(8, 8),
[$caster, $alpha, $bravo],
'alpha',
);
$this->expectException(CombatException::class);
$this->expectExceptionMessage('Ability target is out of range.');
(new CombatEngine())->useAbility($match, 'support-1', 'heal', new Position(5, 5));
}
public function testUseAbilityRejectsAnAbilityTheArchetypeForbids(): void
{
$caster = new UnitState(
id: 'scout-1',
teamId: 'alpha',
position: new Position(0, 0),
maxHealth: 6,
health: 6,
attack: 3,
defense: 1,
speed: 5,
actionsRemaining: 2,
hasAttacked: false,
archetype: Archetype::Scout,
abilities: [],
attackBonus: 0,
hasUsedAbility: false,
);
$alpha = $this->unit('alpha-2', 'alpha', new Position(0, 1));
$bravo = $this->unit('bravo-1', 'bravo', new Position(7, 7));
$match = new MatchState(
new Battlefield(8, 8),
[$caster, $alpha, $bravo],
'alpha',
);
$this->expectException(CombatException::class);
$this->expectExceptionMessage('Unit archetype forbids that ability.');
(new CombatEngine())->useAbility($match, 'scout-1', 'heal', new Position(0, 1));
}
public function testEndTurnTalliesObjectiveControlAfterAFullRound(): void
{
$alpha = $this->unit('alpha-1', 'alpha', new Position(4, 4));
$bravo = $this->unit('bravo-1', 'bravo', new Position(7, 7));
$match = new MatchState(
new Battlefield(8, 8),
[$alpha, $bravo],
'alpha',
objectives: ['objective-1' => new ObjectiveMarker('objective-1', new Position(4, 4))],
victoryCondition: VictoryCondition::HoldObjective,
holdRoundsRequired: 2,
);
$afterAlphaEnd = (new CombatEngine())->endTurn($match);
$afterBravoEnd = (new CombatEngine())->endTurn($afterAlphaEnd);
self::assertSame(['alpha' => 1], $afterAlphaEnd->objectiveControl);
self::assertSame(['alpha' => 1], $afterBravoEnd->objectiveControl);
self::assertNull($afterBravoEnd->winnerTeamId);
}
public function testEndTurnDeclaresWinnerWhenHoldRoundsReached(): void
{
$alpha = $this->unit('alpha-1', 'alpha', new Position(4, 4));
$bravo = $this->unit('bravo-1', 'bravo', new Position(7, 7));
$match = new MatchState(
new Battlefield(8, 8),
[$alpha, $bravo],
'alpha',
objectives: ['objective-1' => new ObjectiveMarker('objective-1', new Position(4, 4))],
victoryCondition: VictoryCondition::HoldObjective,
holdRoundsRequired: 1,
objectiveControl: ['alpha' => 0],
);
$afterAlphaEnd = (new CombatEngine())->endTurn($match);
$afterBravoEnd = (new CombatEngine())->endTurn($afterAlphaEnd);
self::assertSame('alpha', $afterBravoEnd->winnerTeamId);
self::assertSame(['alpha' => 1], $afterBravoEnd->objectiveControl);
}
public function testEndTurnDoesNotAwardControlWhenNoTeamStandsOnTheObjective(): void
{
$alpha = $this->unit('alpha-1', 'alpha', new Position(0, 0));
$bravo = $this->unit('bravo-1', 'bravo', new Position(7, 7));
$match = new MatchState(
new Battlefield(8, 8),
[$alpha, $bravo],
'alpha',
objectives: ['objective-1' => new ObjectiveMarker('objective-1', new Position(4, 4))],
victoryCondition: VictoryCondition::HoldObjective,
holdRoundsRequired: 1,
);
$next = (new CombatEngine())->endTurn($match);
self::assertSame([], $next->objectiveControl);
self::assertNull($next->winnerTeamId);
}
}