feat: support curated abilities and objective victory
This commit is contained in:
committed by
Keith Solomon
parent
088e85c9b4
commit
6befe7b3e2
+252
-14
@@ -60,20 +60,71 @@ final class CombatEngine
|
|||||||
}
|
}
|
||||||
|
|
||||||
$terrainDefense = $match->battlefield->terrainAt($target->position)->defenseBonus();
|
$terrainDefense = $match->battlefield->terrainAt($target->position)->defenseBonus();
|
||||||
$damage = max(1, $attacker->attack - $target->defense - $terrainDefense);
|
$damage = max(1, $attacker->attack + $attacker->attackBonus - $target->defense - $terrainDefense);
|
||||||
$updatedAttacker = $attacker->markAttacked()->spendAction();
|
$updatedAttacker = $attacker->markAttacked()->spendAction();
|
||||||
$updatedTarget = $target->takeDamage($damage);
|
$updatedTarget = $target->takeDamage($damage);
|
||||||
$next = $match->withUnit($updatedAttacker)->withUnit($updatedTarget);
|
$next = $match->withUnit($updatedAttacker)->withUnit($updatedTarget);
|
||||||
$actionLog = [...$match->actionLog, "{$attacker->id} attacked {$target->id} for {$damage} damage"];
|
$actionLog = [...$match->actionLog, "{$attacker->id} attacked {$target->id} for {$damage} damage"];
|
||||||
$next = $next->copy(actionLog: $actionLog);
|
$next = $next->copy(actionLog: $actionLog);
|
||||||
|
|
||||||
foreach ($next->units as $unit) {
|
return $this->checkVictoryAfterAction($next, $attacker->teamId);
|
||||||
if ($unit->teamId !== $attacker->teamId && !$unit->isDefeated()) {
|
|
||||||
return $next;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return $next->withWinner($attacker->teamId);
|
public function useAbility(MatchState $match, string $unitId, string $abilityId, ?Position $target): MatchState
|
||||||
|
{
|
||||||
|
$this->assertMatchActive($match);
|
||||||
|
|
||||||
|
$caster = $this->unitOrFail($match, $unitId);
|
||||||
|
$this->assertCanAct($match, $caster);
|
||||||
|
|
||||||
|
if ($caster->hasUsedAbility) {
|
||||||
|
throw new CombatException('Unit has already used an ability this turn.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$definitions = AbilityCatalog::definitions();
|
||||||
|
|
||||||
|
if (!isset($definitions[$abilityId])) {
|
||||||
|
throw new CombatException("Unknown ability: {$abilityId}.");
|
||||||
|
}
|
||||||
|
|
||||||
|
$definition = $definitions[$abilityId];
|
||||||
|
|
||||||
|
if (!in_array($abilityId, $caster->abilities, true)) {
|
||||||
|
throw new CombatException('Unit does not know that ability.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!in_array($abilityId, $caster->allowedAbilities(), true)) {
|
||||||
|
throw new CombatException('Unit archetype forbids that ability.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$affected = $this->resolveAbilityTargets($match, $caster, $definition, $target);
|
||||||
|
|
||||||
|
$next = $match;
|
||||||
|
$logParts = [];
|
||||||
|
|
||||||
|
foreach ($affected as $affectedUnit) {
|
||||||
|
$current = $next->unit($affectedUnit['id']);
|
||||||
|
$updated = match ($definition->effect) {
|
||||||
|
AbilityDefinition::EFFECT_RESTORE_HEALTH => $current->restoreHealth($definition->effectValue),
|
||||||
|
AbilityDefinition::EFFECT_DEAL_DAMAGE => $current->takeDamage($affectedUnit['damage'] ?? 0),
|
||||||
|
AbilityDefinition::EFFECT_APPLY_BUFF => $current->withAttackBonus($definition->effectValue),
|
||||||
|
default => throw new CombatException("Unknown ability effect: {$definition->effect}."),
|
||||||
|
};
|
||||||
|
$next = $next->withUnit($updated);
|
||||||
|
|
||||||
|
$logParts[] = match ($definition->effect) {
|
||||||
|
AbilityDefinition::EFFECT_RESTORE_HEALTH => "{$affectedUnit['id']} for {$definition->effectValue}",
|
||||||
|
AbilityDefinition::EFFECT_DEAL_DAMAGE => "{$affectedUnit['id']} for {$affectedUnit['damage']} damage",
|
||||||
|
AbilityDefinition::EFFECT_APPLY_BUFF => "{$affectedUnit['id']} with +{$definition->effectValue} attack",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
$next = $next->withUnit($next->unit($caster->id)->spendAbility());
|
||||||
|
|
||||||
|
$summary = "{$caster->id} used {$definition->label} on " . implode(', ', $logParts);
|
||||||
|
$next = $next->copy(actionLog: [...$next->actionLog, $summary]);
|
||||||
|
|
||||||
|
return $this->checkVictoryAfterAction($next, $caster->teamId);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function endTurn(MatchState $match): MatchState
|
public function endTurn(MatchState $match): MatchState
|
||||||
@@ -89,11 +140,11 @@ final class CombatEngine
|
|||||||
$teamIds = array_keys($teamIds);
|
$teamIds = array_keys($teamIds);
|
||||||
sort($teamIds);
|
sort($teamIds);
|
||||||
|
|
||||||
if ($teamIds !== ['alpha', 'bravo']) {
|
if (count($teamIds) !== 2) {
|
||||||
throw new CombatException('Matches require alpha and bravo teams.');
|
throw new CombatException('Matches require exactly two teams.');
|
||||||
}
|
}
|
||||||
|
|
||||||
$livingTeams = ['alpha' => false, 'bravo' => false];
|
$livingTeams = array_fill_keys($teamIds, false);
|
||||||
|
|
||||||
foreach ($match->units as $unit) {
|
foreach ($match->units as $unit) {
|
||||||
if (!$unit->isDefeated()) {
|
if (!$unit->isDefeated()) {
|
||||||
@@ -101,14 +152,16 @@ final class CombatEngine
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!$livingTeams['alpha'] || !$livingTeams['bravo']) {
|
foreach ($livingTeams as $teamId => $alive) {
|
||||||
|
if (!$alive) {
|
||||||
throw new CombatException('Cannot end turn while a team is eliminated.');
|
throw new CombatException('Cannot end turn while a team is eliminated.');
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
$endingTeamId = $match->activeTeamId;
|
$endingTeamId = $match->activeTeamId;
|
||||||
$nextTeamId = $endingTeamId === 'alpha' ? 'bravo' : 'alpha';
|
$nextTeamId = $endingTeamId === $teamIds[0] ? $teamIds[1] : $teamIds[0];
|
||||||
|
|
||||||
if ($nextTeamId === 'alpha' && $match->round === PHP_INT_MAX) {
|
if ($nextTeamId === $teamIds[0] && $match->round === PHP_INT_MAX) {
|
||||||
throw new CombatException('Match round limit reached.');
|
throw new CombatException('Match round limit reached.');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -118,12 +171,167 @@ final class CombatEngine
|
|||||||
$units[] = $unit->teamId === $nextTeamId ? $unit->startTurn() : $unit;
|
$units[] = $unit->teamId === $nextTeamId ? $unit->startTurn() : $unit;
|
||||||
}
|
}
|
||||||
|
|
||||||
return $match->copy(
|
$round = $match->round + ($nextTeamId === $teamIds[0] ? 1 : 0);
|
||||||
|
|
||||||
|
$next = $match->copy(
|
||||||
units: $units,
|
units: $units,
|
||||||
activeTeamId: $nextTeamId,
|
activeTeamId: $nextTeamId,
|
||||||
round: $match->round + ($nextTeamId === 'alpha' ? 1 : 0),
|
round: $round,
|
||||||
actionLog: [...$match->actionLog, "{$endingTeamId} ended turn"],
|
actionLog: [...$match->actionLog, "{$endingTeamId} ended turn"],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
$winner = $this->resolveObjectiveVictory($next);
|
||||||
|
if ($winner !== null) {
|
||||||
|
$next = $next->withWinner($winner);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $next;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return list<array{id: string, damage?: int}>
|
||||||
|
*/
|
||||||
|
private function resolveAbilityTargets(
|
||||||
|
MatchState $match,
|
||||||
|
UnitState $caster,
|
||||||
|
AbilityDefinition $definition,
|
||||||
|
?Position $target,
|
||||||
|
): array {
|
||||||
|
if ($definition->target === AbilityDefinition::TARGET_SELF) {
|
||||||
|
return [['id' => $caster->id]];
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($target === null) {
|
||||||
|
throw new CombatException('Ability requires a target position.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!$match->battlefield->contains($target)) {
|
||||||
|
throw new CombatException('Ability target is outside the battlefield.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$distance = $caster->position->distanceTo($target);
|
||||||
|
|
||||||
|
if ($distance > $definition->range) {
|
||||||
|
throw new CombatException('Ability target is out of range.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$tileKeys = [$target->key()];
|
||||||
|
|
||||||
|
if ($definition->areaRadius > 0) {
|
||||||
|
$tileKeys = self::tilesWithinRadius(
|
||||||
|
$match->battlefield,
|
||||||
|
$target,
|
||||||
|
$definition->areaRadius,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($definition->effect === AbilityDefinition::EFFECT_DEAL_DAMAGE) {
|
||||||
|
$affected = [];
|
||||||
|
|
||||||
|
foreach ($match->units as $candidate) {
|
||||||
|
if ($candidate->isDefeated()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if ($candidate->teamId === $caster->teamId) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$found = false;
|
||||||
|
foreach ($tileKeys as $key) {
|
||||||
|
if ($candidate->position->key() === $key) {
|
||||||
|
$found = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!$found) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$terrainDefense = $match->battlefield->terrainAt($candidate->position)->defenseBonus();
|
||||||
|
$damage = max(1, $definition->effectValue - $candidate->defense - $terrainDefense);
|
||||||
|
$affected[] = ['id' => $candidate->id, 'damage' => $damage];
|
||||||
|
}
|
||||||
|
|
||||||
|
return $affected;
|
||||||
|
}
|
||||||
|
|
||||||
|
$targetUnit = null;
|
||||||
|
|
||||||
|
foreach ($match->units as $candidate) {
|
||||||
|
if ($candidate->position->key() === $target->key()) {
|
||||||
|
$targetUnit = $candidate;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($targetUnit === null) {
|
||||||
|
throw new CombatException('Ability target is empty.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$expectedTeam = $definition->target === AbilityDefinition::TARGET_ALLY ? $caster->teamId : null;
|
||||||
|
|
||||||
|
if ($expectedTeam !== null && $targetUnit->teamId !== $expectedTeam) {
|
||||||
|
throw new CombatException('Ability target must be an ally.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($definition->target === AbilityDefinition::TARGET_ENEMY && $targetUnit->teamId === $caster->teamId) {
|
||||||
|
throw new CombatException('Ability target must be an enemy.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return [['id' => $targetUnit->id]];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function checkVictoryAfterAction(MatchState $match, string $actingTeamId): MatchState
|
||||||
|
{
|
||||||
|
foreach ($match->units as $unit) {
|
||||||
|
if ($unit->teamId !== $actingTeamId && !$unit->isDefeated()) {
|
||||||
|
return $match;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $match->withWinner($actingTeamId);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function resolveObjectiveVictory(MatchState $match): ?string
|
||||||
|
{
|
||||||
|
if ($match->victoryCondition !== VictoryCondition::HoldObjective || $match->objectives === []) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @var list<ObjectiveMarker> $objectiveList */
|
||||||
|
$objectiveList = array_values($match->objectives);
|
||||||
|
$objective = $objectiveList[0];
|
||||||
|
$controller = $this->objectiveController($match, $objective->position);
|
||||||
|
|
||||||
|
if ($controller === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$control = (new ObjectiveControl($match->objectiveControl))->recordRound($controller);
|
||||||
|
$next = $match->withObjectiveControl($control->roundsByTeam);
|
||||||
|
|
||||||
|
if (($control->roundsByTeam[$controller] ?? 0) >= $next->holdRoundsRequired) {
|
||||||
|
return $controller;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function objectiveController(MatchState $match, Position $tile): ?string
|
||||||
|
{
|
||||||
|
$teams = [];
|
||||||
|
|
||||||
|
foreach ($match->units as $unit) {
|
||||||
|
if ($unit->isDefeated() || $unit->position->key() !== $tile->key()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$teams[$unit->teamId] = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (count($teams) !== 1) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return array_key_first($teams);
|
||||||
}
|
}
|
||||||
|
|
||||||
private function unitOrFail(MatchState $match, string $unitId): UnitState
|
private function unitOrFail(MatchState $match, string $unitId): UnitState
|
||||||
@@ -142,6 +350,36 @@ final class CombatEngine
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** @return list<string> */
|
||||||
|
private static function tilesWithinRadius(Battlefield $battlefield, Position $center, int $radius): array
|
||||||
|
{
|
||||||
|
$seen = [$center->key() => true];
|
||||||
|
$frontier = [$center];
|
||||||
|
$result = [$center->key()];
|
||||||
|
|
||||||
|
for ($step = 0; $step < $radius; $step++) {
|
||||||
|
$next = [];
|
||||||
|
foreach ($frontier as $position) {
|
||||||
|
foreach ([[1, 0], [-1, 0], [0, 1], [0, -1]] as $offset) {
|
||||||
|
$candidate = new Position($position->x + $offset[0], $position->y + $offset[1]);
|
||||||
|
if (!$battlefield->contains($candidate)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$key = $candidate->key();
|
||||||
|
if (isset($seen[$key])) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$seen[$key] = true;
|
||||||
|
$result[] = $key;
|
||||||
|
$next[] = $candidate;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$frontier = $next;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $result;
|
||||||
|
}
|
||||||
|
|
||||||
private function assertCanAct(MatchState $match, UnitState $unit): void
|
private function assertCanAct(MatchState $match, UnitState $unit): void
|
||||||
{
|
{
|
||||||
if ($unit->teamId !== $match->activeTeamId) {
|
if ($unit->teamId !== $match->activeTeamId) {
|
||||||
|
|||||||
@@ -112,6 +112,15 @@ final readonly class UnitState
|
|||||||
return $this->copy(health: max(0, $this->health - max(0, $damage)));
|
return $this->copy(health: max(0, $this->health - max(0, $damage)));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function restoreHealth(int $amount): self
|
||||||
|
{
|
||||||
|
if ($amount < 0) {
|
||||||
|
throw new InvalidArgumentException('Restore health amount cannot be negative.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->copy(health: min($this->maxHealth, $this->health + $amount));
|
||||||
|
}
|
||||||
|
|
||||||
public function markAttacked(): self
|
public function markAttacked(): self
|
||||||
{
|
{
|
||||||
return $this->copy(hasAttacked: true);
|
return $this->copy(hasAttacked: true);
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace BattleForge\Tests\Unit\Domain;
|
namespace BattleForge\Tests\Unit\Domain;
|
||||||
|
|
||||||
|
use BattleForge\Domain\Archetype;
|
||||||
use BattleForge\Domain\Battlefield;
|
use BattleForge\Domain\Battlefield;
|
||||||
use BattleForge\Domain\CombatEngine;
|
use BattleForge\Domain\CombatEngine;
|
||||||
use BattleForge\Domain\CombatException;
|
use BattleForge\Domain\CombatException;
|
||||||
@@ -583,7 +584,7 @@ final class CombatEngineTest extends TestCase
|
|||||||
);
|
);
|
||||||
|
|
||||||
$this->expectException(CombatException::class);
|
$this->expectException(CombatException::class);
|
||||||
$this->expectExceptionMessage('Matches require alpha and bravo teams.');
|
$this->expectExceptionMessage('Matches require exactly two teams.');
|
||||||
|
|
||||||
(new CombatEngine())->endTurn($match);
|
(new CombatEngine())->endTurn($match);
|
||||||
}
|
}
|
||||||
@@ -594,7 +595,6 @@ final class CombatEngineTest extends TestCase
|
|||||||
public static function invalidEndTurnTeamProvider(): iterable
|
public static function invalidEndTurnTeamProvider(): iterable
|
||||||
{
|
{
|
||||||
yield 'only alpha' => [['alpha']];
|
yield 'only alpha' => [['alpha']];
|
||||||
yield 'alpha and charlie' => [['alpha', 'charlie']];
|
|
||||||
yield 'alpha, bravo, and charlie' => [['alpha', 'bravo', 'charlie']];
|
yield 'alpha, bravo, and charlie' => [['alpha', 'bravo', 'charlie']];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -746,6 +746,7 @@ final class CombatEngineTest extends TestCase
|
|||||||
int $attack = 4,
|
int $attack = 4,
|
||||||
int $defense = 2,
|
int $defense = 2,
|
||||||
bool $hasAttacked = false,
|
bool $hasAttacked = false,
|
||||||
|
int $attackBonus = 0,
|
||||||
): UnitState {
|
): UnitState {
|
||||||
return new UnitState(
|
return new UnitState(
|
||||||
$id,
|
$id,
|
||||||
@@ -758,6 +759,218 @@ final class CombatEngineTest extends TestCase
|
|||||||
$speed,
|
$speed,
|
||||||
$actionsRemaining,
|
$actionsRemaining,
|
||||||
$hasAttacked,
|
$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 does not know that ability.');
|
||||||
|
|
||||||
|
(new CombatEngine())->useAbility($match, 'scout-1', 'heal', new Position(0, 1));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user