feat: support curated abilities and objective victory
This commit is contained in:
committed by
Keith Solomon
parent
088e85c9b4
commit
6befe7b3e2
+253
-15
@@ -60,20 +60,71 @@ final class CombatEngine
|
||||
}
|
||||
|
||||
$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();
|
||||
$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 $this->checkVictoryAfterAction($next, $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.');
|
||||
}
|
||||
|
||||
return $next->withWinner($attacker->teamId);
|
||||
$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
|
||||
@@ -89,11 +140,11 @@ final class CombatEngine
|
||||
$teamIds = array_keys($teamIds);
|
||||
sort($teamIds);
|
||||
|
||||
if ($teamIds !== ['alpha', 'bravo']) {
|
||||
throw new CombatException('Matches require alpha and bravo teams.');
|
||||
if (count($teamIds) !== 2) {
|
||||
throw new CombatException('Matches require exactly two teams.');
|
||||
}
|
||||
|
||||
$livingTeams = ['alpha' => false, 'bravo' => false];
|
||||
$livingTeams = array_fill_keys($teamIds, false);
|
||||
|
||||
foreach ($match->units as $unit) {
|
||||
if (!$unit->isDefeated()) {
|
||||
@@ -101,14 +152,16 @@ final class CombatEngine
|
||||
}
|
||||
}
|
||||
|
||||
if (!$livingTeams['alpha'] || !$livingTeams['bravo']) {
|
||||
throw new CombatException('Cannot end turn while a team is eliminated.');
|
||||
foreach ($livingTeams as $teamId => $alive) {
|
||||
if (!$alive) {
|
||||
throw new CombatException('Cannot end turn while a team is eliminated.');
|
||||
}
|
||||
}
|
||||
|
||||
$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.');
|
||||
}
|
||||
|
||||
@@ -118,12 +171,167 @@ final class CombatEngine
|
||||
$units[] = $unit->teamId === $nextTeamId ? $unit->startTurn() : $unit;
|
||||
}
|
||||
|
||||
return $match->copy(
|
||||
$round = $match->round + ($nextTeamId === $teamIds[0] ? 1 : 0);
|
||||
|
||||
$next = $match->copy(
|
||||
units: $units,
|
||||
activeTeamId: $nextTeamId,
|
||||
round: $match->round + ($nextTeamId === 'alpha' ? 1 : 0),
|
||||
round: $round,
|
||||
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
|
||||
@@ -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
|
||||
{
|
||||
if ($unit->teamId !== $match->activeTeamId) {
|
||||
|
||||
@@ -112,6 +112,15 @@ final readonly class UnitState
|
||||
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
|
||||
{
|
||||
return $this->copy(hasAttacked: true);
|
||||
|
||||
Reference in New Issue
Block a user