feat: resolve attacks and elimination victory
This commit is contained in:
@@ -12,11 +12,7 @@ final class CombatEngine
|
|||||||
{
|
{
|
||||||
$this->assertMatchActive($match);
|
$this->assertMatchActive($match);
|
||||||
|
|
||||||
try {
|
$unit = $this->unitOrFail($match, $unitId);
|
||||||
$unit = $match->unit($unitId);
|
|
||||||
} catch (InvalidArgumentException $exception) {
|
|
||||||
throw new CombatException("Unknown unit: {$unitId}.", previous: $exception);
|
|
||||||
}
|
|
||||||
$this->assertCanAct($match, $unit);
|
$this->assertCanAct($match, $unit);
|
||||||
|
|
||||||
$occupied = [];
|
$occupied = [];
|
||||||
@@ -40,6 +36,56 @@ final class CombatEngine
|
|||||||
return $next->copy(actionLog: $actionLog);
|
return $next->copy(actionLog: $actionLog);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function attack(MatchState $match, string $attackerId, string $targetId): MatchState
|
||||||
|
{
|
||||||
|
$this->assertMatchActive($match);
|
||||||
|
|
||||||
|
$attacker = $this->unitOrFail($match, $attackerId);
|
||||||
|
$this->assertCanAct($match, $attacker);
|
||||||
|
|
||||||
|
if ($attacker->hasAttacked) {
|
||||||
|
throw new CombatException('Unit has already attacked this turn.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$target = $this->unitOrFail($match, $targetId);
|
||||||
|
|
||||||
|
if ($target->teamId === $attacker->teamId || $target->isDefeated()) {
|
||||||
|
throw new CombatException('Target must be an active enemy unit.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$distance = abs($attacker->position->x - $target->position->x)
|
||||||
|
+ abs($attacker->position->y - $target->position->y);
|
||||||
|
|
||||||
|
if ($distance !== 1) {
|
||||||
|
throw new CombatException('Target is outside attack range.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$terrainDefense = $match->battlefield->terrainAt($target->position)->defenseBonus();
|
||||||
|
$damage = max(1, $attacker->attack - $target->defense - $terrainDefense);
|
||||||
|
$updatedAttacker = $attacker->markAttacked()->spendAction();
|
||||||
|
$updatedTarget = $target->takeDamage($damage);
|
||||||
|
$next = $match->withUnit($updatedAttacker)->withUnit($updatedTarget);
|
||||||
|
$actionLog = [...$match->actionLog, "{$attacker->id} attacked {$target->id} for {$damage} damage"];
|
||||||
|
$next = $next->copy(actionLog: $actionLog);
|
||||||
|
|
||||||
|
foreach ($next->units as $unit) {
|
||||||
|
if ($unit->teamId !== $attacker->teamId && !$unit->isDefeated()) {
|
||||||
|
return $next;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $next->withWinner($attacker->teamId);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function unitOrFail(MatchState $match, string $unitId): UnitState
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
return $match->unit($unitId);
|
||||||
|
} catch (InvalidArgumentException $exception) {
|
||||||
|
throw new CombatException("Unknown unit: {$unitId}.", previous: $exception);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private function assertMatchActive(MatchState $match): void
|
private function assertMatchActive(MatchState $match): void
|
||||||
{
|
{
|
||||||
if ($match->winnerTeamId !== null) {
|
if ($match->winnerTeamId !== null) {
|
||||||
|
|||||||
@@ -219,6 +219,241 @@ final class CombatEngineTest extends TestCase
|
|||||||
(new CombatEngine())->move($match, 'alpha-1', new Position(1, 0));
|
(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
|
private function match(): MatchState
|
||||||
{
|
{
|
||||||
return new MatchState(
|
return new MatchState(
|
||||||
@@ -238,7 +473,21 @@ final class CombatEngineTest extends TestCase
|
|||||||
int $health = 10,
|
int $health = 10,
|
||||||
int $speed = 4,
|
int $speed = 4,
|
||||||
int $actionsRemaining = 2,
|
int $actionsRemaining = 2,
|
||||||
|
int $attack = 4,
|
||||||
|
int $defense = 2,
|
||||||
|
bool $hasAttacked = false,
|
||||||
): UnitState {
|
): 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,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user