feat: resolve attacks and elimination victory

This commit is contained in:
Keith Solomon
2026-07-04 15:53:57 -05:00
parent 3eba823e52
commit 3c6359243a
2 changed files with 301 additions and 6 deletions
+250 -1
View File
@@ -219,6 +219,241 @@ final class CombatEngineTest extends TestCase
(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 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);
}
private function match(): MatchState
{
return new MatchState(
@@ -238,7 +473,21 @@ final class CombatEngineTest extends TestCase
int $health = 10,
int $speed = 4,
int $actionsRemaining = 2,
int $attack = 4,
int $defense = 2,
bool $hasAttacked = false,
): UnitState {
return new UnitState($id, $team, $position, 10, $health, 4, 2, $speed, $actionsRemaining);
return new UnitState(
$id,
$team,
$position,
10,
$health,
$attack,
$defense,
$speed,
$actionsRemaining,
$hasAttacked,
);
}
}