feat: support curated abilities and objective victory

This commit is contained in:
Keith Solomon
2026-07-05 19:49:06 -05:00
committed by Keith Solomon
parent 088e85c9b4
commit 6befe7b3e2
3 changed files with 477 additions and 17 deletions
+252 -14
View File
@@ -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) {
+9
View File
@@ -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);
+215 -2
View File
@@ -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));
}
} }