Merge feature/curated-content-and-objectives into develop
CI / php (push) Failing after 1m37s

Plan 2: Curated Content, Abilities, Objectives, and Scenario Validation.

Adds the curated content library (archetypes, abilities, terrain behaviors),
per-round objective control, both victory conditions, and full scenario
validation. 9 production files, 7 test files, 130/130 tests, PHPStan level 6
clean, PHPCS clean. See docs/superpowers/plans/2026-07-05-curated-content-
abilities-and-objectives.md for the implementation plan.
This commit is contained in:
Keith Solomon
2026-07-05 20:38:44 -05:00
22 changed files with 2001 additions and 18 deletions
+52
View File
@@ -0,0 +1,52 @@
<?php
declare(strict_types=1);
namespace BattleForge\Domain;
final class AbilityCatalog
{
/**
* @return array<string, AbilityDefinition>
*/
public static function definitions(): array
{
static $cache = null;
if ($cache !== null) {
return $cache;
}
$cache = [
AbilityId::Heal->value => new AbilityDefinition(
id: AbilityId::Heal,
label: 'Heal',
range: 2,
areaRadius: 0,
target: AbilityDefinition::TARGET_ALLY,
effect: AbilityDefinition::EFFECT_RESTORE_HEALTH,
effectValue: 5,
),
AbilityId::AreaDamage->value => new AbilityDefinition(
id: AbilityId::AreaDamage,
label: 'Area Damage',
range: 3,
areaRadius: 1,
target: AbilityDefinition::TARGET_TILE,
effect: AbilityDefinition::EFFECT_DEAL_DAMAGE,
effectValue: 3,
),
AbilityId::Buff->value => new AbilityDefinition(
id: AbilityId::Buff,
label: 'Rally',
range: 1,
areaRadius: 0,
target: AbilityDefinition::TARGET_ALLY,
effect: AbilityDefinition::EFFECT_APPLY_BUFF,
effectValue: 2,
),
];
return $cache;
}
}
+51
View File
@@ -0,0 +1,51 @@
<?php
declare(strict_types=1);
namespace BattleForge\Domain;
use InvalidArgumentException;
final readonly class AbilityDefinition
{
public const TARGET_SELF = 'self';
public const TARGET_ALLY = 'ally';
public const TARGET_ENEMY = 'enemy';
public const TARGET_TILE = 'tile';
public const EFFECT_RESTORE_HEALTH = 'restore_health';
public const EFFECT_DEAL_DAMAGE = 'deal_damage';
public const EFFECT_APPLY_BUFF = 'apply_buff';
/** @var list<string> */
private const TARGETS = [self::TARGET_SELF, self::TARGET_ALLY, self::TARGET_ENEMY, self::TARGET_TILE];
/** @var list<string> */
private const EFFECTS = [self::EFFECT_RESTORE_HEALTH, self::EFFECT_DEAL_DAMAGE, self::EFFECT_APPLY_BUFF];
public function __construct(
public AbilityId $id,
public string $label,
public int $range,
public int $areaRadius,
public string $target,
public string $effect,
public int $effectValue,
) {
if ($this->range < 0) {
throw new InvalidArgumentException('Ability range cannot be negative.');
}
if ($this->areaRadius < 0) {
throw new InvalidArgumentException('Ability area radius cannot be negative.');
}
if (!in_array($this->target, self::TARGETS, true)) {
throw new InvalidArgumentException("Unknown ability target: {$this->target}.");
}
if (!in_array($this->effect, self::EFFECTS, true)) {
throw new InvalidArgumentException("Unknown ability effect: {$this->effect}.");
}
}
}
+12
View File
@@ -0,0 +1,12 @@
<?php
declare(strict_types=1);
namespace BattleForge\Domain;
enum AbilityId: string
{
case Heal = 'heal';
case AreaDamage = 'area_damage';
case Buff = 'buff';
}
+13
View File
@@ -0,0 +1,13 @@
<?php
declare(strict_types=1);
namespace BattleForge\Domain;
enum Archetype: string
{
case Defender = 'defender';
case Striker = 'striker';
case Support = 'support';
case Scout = 'scout';
}
+69
View File
@@ -0,0 +1,69 @@
<?php
declare(strict_types=1);
namespace BattleForge\Domain;
final class ArchetypeCatalog
{
/**
* @return array<string, ArchetypeTemplate>
*/
public static function templates(): array
{
static $cache = null;
if ($cache !== null) {
return $cache;
}
$cache = [
Archetype::Defender->value => new ArchetypeTemplate(
minHealth: 10,
maxHealth: 14,
minAttack: 2,
maxAttack: 4,
minDefense: 3,
maxDefense: 5,
minSpeed: 1,
maxSpeed: 3,
allowedAbilities: ['buff'],
),
Archetype::Striker->value => new ArchetypeTemplate(
minHealth: 6,
maxHealth: 10,
minAttack: 4,
maxAttack: 6,
minDefense: 1,
maxDefense: 2,
minSpeed: 2,
maxSpeed: 4,
allowedAbilities: ['area_damage'],
),
Archetype::Support->value => new ArchetypeTemplate(
minHealth: 5,
maxHealth: 9,
minAttack: 1,
maxAttack: 3,
minDefense: 1,
maxDefense: 3,
minSpeed: 2,
maxSpeed: 4,
allowedAbilities: ['heal', 'buff'],
),
Archetype::Scout->value => new ArchetypeTemplate(
minHealth: 4,
maxHealth: 7,
minAttack: 2,
maxAttack: 4,
minDefense: 0,
maxDefense: 2,
minSpeed: 4,
maxSpeed: 6,
allowedAbilities: [],
),
];
return $cache;
}
}
+50
View File
@@ -0,0 +1,50 @@
<?php
declare(strict_types=1);
namespace BattleForge\Domain;
use InvalidArgumentException;
final readonly class ArchetypeTemplate
{
/** @param list<string> $allowedAbilities */
public function __construct(
public int $minHealth,
public int $maxHealth,
public int $minAttack,
public int $maxAttack,
public int $minDefense,
public int $maxDefense,
public int $minSpeed,
public int $maxSpeed,
public array $allowedAbilities,
) {
if ($this->minHealth > $this->maxHealth) {
throw new InvalidArgumentException('Health range is inverted.');
}
if ($this->minAttack > $this->maxAttack) {
throw new InvalidArgumentException('Attack range is inverted.');
}
if ($this->minDefense > $this->maxDefense) {
throw new InvalidArgumentException('Defense range is inverted.');
}
if ($this->minSpeed > $this->maxSpeed || $this->minSpeed < 1) {
throw new InvalidArgumentException('Speed range must be at least 1 and non-inverted.');
}
if ($this->minHealth < 1) {
throw new InvalidArgumentException('Minimum health must be at least 1.');
}
}
/** @return array{maxHealth: int, attack: int, defense: int, speed: int} */
public function defaultStats(): array
{
return [
'maxHealth' => (int) ceil(($this->minHealth + $this->maxHealth) / 2),
'attack' => (int) ceil(($this->minAttack + $this->maxAttack) / 2),
'defense' => (int) ceil(($this->minDefense + $this->maxDefense) / 2),
'speed' => (int) ceil(($this->minSpeed + $this->maxSpeed) / 2),
];
}
}
+254 -15
View File
@@ -60,20 +60,67 @@ 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->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 +136,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 +148,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 +167,172 @@ 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"],
);
return $this->resolveObjectiveVictory($next, $endingTeamId === $teamIds[0]);
}
/**
* @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);
}
/**
* @param bool $endingTeamIsFirstInOrder true when the team that just ended is the first team in
* sorted team order; this identifies the first half of a round.
*/
private function resolveObjectiveVictory(MatchState $match, bool $endingTeamIsFirstInOrder): MatchState
{
if ($match->victoryCondition !== VictoryCondition::HoldObjective || $match->objectives === []) {
return $match;
}
$next = $match;
if ($endingTeamIsFirstInOrder) {
/** @var list<ObjectiveMarker> $objectiveList */
$objectiveList = array_values($match->objectives);
$objective = $objectiveList[0];
$controller = $this->objectiveController($match, $objective->position);
if ($controller !== null) {
$control = (new ObjectiveControl($match->objectiveControl))->recordRound($controller);
$next = $match->withObjectiveControl($control->roundsByTeam);
}
return $next;
}
foreach ($match->objectiveControl as $teamId => $count) {
if ($count >= $match->holdRoundsRequired) {
return $next->withWinner($teamId);
}
}
return $next;
}
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 +351,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) {
+39
View File
@@ -0,0 +1,39 @@
<?php
declare(strict_types=1);
namespace BattleForge\Domain;
use InvalidArgumentException;
final readonly class DeploymentZone
{
/** @param list<Position> $positions */
public function __construct(
public string $teamId,
public array $positions,
) {
if ($teamId === '') {
throw new InvalidArgumentException('Deployment zone team id cannot be empty.');
}
if ($positions === []) {
throw new InvalidArgumentException('Deployment zone must contain at least one position.');
}
if (!array_is_list($positions)) { // @phpstan-ignore function.alreadyNarrowedType (Runtime guard: PHPDoc is not a runtime contract for JSON-sourced arrays in Plan 4.)
throw new InvalidArgumentException('Deployment zone positions must be a list.');
}
}
public function contains(Position $position): bool
{
foreach ($this->positions as $candidate) {
if ($candidate->key() === $position->key()) {
return true;
}
}
return false;
}
}
+64
View File
@@ -11,6 +11,8 @@ final readonly class MatchState
/**
* @param list<UnitState> $units
* @param list<string> $actionLog
* @param array<string, ObjectiveMarker> $objectives
* @param array<string, int> $objectiveControl
*/
public function __construct(
public Battlefield $battlefield,
@@ -19,6 +21,10 @@ final readonly class MatchState
public int $round = 1,
public ?string $winnerTeamId = null,
public array $actionLog = [],
public array $objectives = [],
public VictoryCondition $victoryCondition = VictoryCondition::EliminateAll,
public int $holdRoundsRequired = 1,
public array $objectiveControl = [],
) {
if (!self::isList($units)) {
throw new InvalidArgumentException('Match units must be a list.');
@@ -92,6 +98,28 @@ final readonly class MatchState
if ($winnerTeamId !== null && !isset($teamIds[$winnerTeamId])) {
throw new InvalidArgumentException("Winner team has no units: {$winnerTeamId}.");
}
if ($holdRoundsRequired < 1) {
throw new InvalidArgumentException('Hold rounds required must be at least 1.');
}
foreach ($objectives as $objective) {
if (!$objective instanceof ObjectiveMarker) { // @phpstan-ignore instanceof.alwaysTrue (Runtime guard: PHPDoc is not a runtime contract for JSON-sourced arrays in Plan 4.)
throw new InvalidArgumentException('Objectives must be ObjectiveMarker instances.');
}
if (!$battlefield->contains($objective->position)) {
throw new InvalidArgumentException("Objective {$objective->id} is outside the battlefield.");
}
}
foreach ($objectiveControl as $teamId => $count) {
if (!self::isString($teamId) || $teamId === '') {
throw new InvalidArgumentException('Objective control team id must be a non-empty string.');
}
if (!self::isInt($count) || $count < 0) {
throw new InvalidArgumentException('Objective control count must be a non-negative integer.');
}
}
}
private static function isUnitState(mixed $unit): bool
@@ -104,6 +132,11 @@ final readonly class MatchState
return is_string($value);
}
private static function isInt(mixed $value): bool
{
return is_int($value);
}
private static function isList(mixed $value): bool
{
return is_array($value) && array_is_list($value);
@@ -144,18 +177,45 @@ final readonly class MatchState
$this->round,
$winnerTeamId,
$this->actionLog,
$this->objectives,
$this->victoryCondition,
$this->holdRoundsRequired,
$this->objectiveControl,
);
}
/**
* @param array<string, int> $objectiveControl
*/
public function withObjectiveControl(array $objectiveControl): self
{
return new self(
$this->battlefield,
$this->units,
$this->activeTeamId,
$this->round,
$this->winnerTeamId,
$this->actionLog,
$this->objectives,
$this->victoryCondition,
$this->holdRoundsRequired,
$objectiveControl,
);
}
/**
* @param list<UnitState>|null $units
* @param list<string>|null $actionLog
* @param array<string, ObjectiveMarker>|null $objectives
* @param array<string, int>|null $objectiveControl
*/
public function copy(
?array $units = null,
?string $activeTeamId = null,
?int $round = null,
?array $actionLog = null,
?array $objectives = null,
?array $objectiveControl = null,
): self {
return new self(
$this->battlefield,
@@ -164,6 +224,10 @@ final readonly class MatchState
$round ?? $this->round,
$this->winnerTeamId,
$actionLog ?? $this->actionLog,
$objectives ?? $this->objectives,
$this->victoryCondition,
$this->holdRoundsRequired,
$objectiveControl ?? $this->objectiveControl,
);
}
}
+46
View File
@@ -0,0 +1,46 @@
<?php
declare(strict_types=1);
namespace BattleForge\Domain;
use InvalidArgumentException;
final readonly class ObjectiveControl
{
/** @param array<string, int> $roundsByTeam */
public function __construct(public array $roundsByTeam)
{
foreach ($roundsByTeam as $teamId => $count) {
if ($teamId === '') {
throw new InvalidArgumentException('Objective control team id cannot be empty.');
}
if ($count < 0) {
throw new InvalidArgumentException('Objective control count cannot be negative.');
}
}
}
public static function empty(): self
{
return new self([]);
}
public function recordRound(string $controllerTeamId): self
{
if ($controllerTeamId === '') {
throw new InvalidArgumentException('Controller team id cannot be empty.');
}
$next = $this->roundsByTeam;
$next[$controllerTeamId] = ($next[$controllerTeamId] ?? 0) + 1;
return new self($next);
}
/** @return array{teamId: string, rounds: int} */
public function forTeam(string $teamId): array
{
return ['teamId' => $teamId, 'rounds' => $this->roundsByTeam[$teamId] ?? 0];
}
}
+24
View File
@@ -0,0 +1,24 @@
<?php
declare(strict_types=1);
namespace BattleForge\Domain;
use InvalidArgumentException;
final readonly class ObjectiveMarker
{
public function __construct(
public string $id,
public Position $position,
) {
if ($id === '') {
throw new InvalidArgumentException('Objective id cannot be empty.');
}
}
public function key(): string
{
return $this->id;
}
}
+160
View File
@@ -0,0 +1,160 @@
<?php
declare(strict_types=1);
namespace BattleForge\Domain;
use InvalidArgumentException;
final readonly class Scenario
{
/**
* @param list<UnitState> $units
* @param array<string, DeploymentZone> $deploymentZones
* @param array<string, ObjectiveMarker> $objectives
*/
public function __construct(
public string $id,
public string $name,
public Battlefield $battlefield,
public array $units,
public array $deploymentZones,
public array $objectives,
public VictoryCondition $victoryCondition,
public int $holdRoundsRequired,
) {
if ($id === '') {
throw new InvalidArgumentException('Scenario id cannot be empty.');
}
if ($name === '') {
throw new InvalidArgumentException('Scenario name cannot be empty.');
}
if (!array_is_list($units)) { // @phpstan-ignore function.alreadyNarrowedType (Runtime guard: PHPDoc is not a runtime contract for JSON-sourced arrays in Plan 4.)
throw new InvalidArgumentException('Scenario units must be a list.');
}
if ($deploymentZones === []) {
throw new InvalidArgumentException('Scenario must declare at least one deployment zone.');
}
if (count($deploymentZones) !== 2) {
throw new InvalidArgumentException('Scenarios must declare exactly two deployment zones.');
}
if ($holdRoundsRequired < 1) {
throw new InvalidArgumentException('Hold rounds required must be at least 1.');
}
if ($victoryCondition === VictoryCondition::HoldObjective && count($this->objectives) !== 1) {
throw new InvalidArgumentException('Hold objective victory requires exactly one objective.');
}
if ($victoryCondition === VictoryCondition::EliminateAll && $this->objectives !== []) {
throw new InvalidArgumentException('Eliminate all victory does not allow objectives.');
}
$unitsByTeam = $this->groupUnitsByTeam($units);
foreach ($unitsByTeam as $teamId => $teamUnits) {
if (count($teamUnits) < 3 || count($teamUnits) > 6) {
throw new InvalidArgumentException("Team {$teamId} must have between 3 and 6 units.");
}
if (!isset($deploymentZones[$teamId])) {
throw new InvalidArgumentException("Team {$teamId} is missing a deployment zone.");
}
}
foreach ($units as $unit) {
if (!$this->battlefield->contains($unit->position)) {
throw new InvalidArgumentException("Unit {$unit->id} is outside the battlefield.");
}
$template = ArchetypeCatalog::templates()[$unit->archetype->value] ?? null;
if ($template === null) {
throw new InvalidArgumentException("Unit {$unit->id} uses an unknown archetype.");
}
if ($unit->maxHealth < $template->minHealth || $unit->maxHealth > $template->maxHealth) {
throw new InvalidArgumentException("Unit {$unit->id} health is outside archetype bounds.");
}
if ($unit->attack < $template->minAttack || $unit->attack > $template->maxAttack) {
throw new InvalidArgumentException("Unit {$unit->id} attack is outside archetype bounds.");
}
if ($unit->defense < $template->minDefense || $unit->defense > $template->maxDefense) {
throw new InvalidArgumentException("Unit {$unit->id} defense is outside archetype bounds.");
}
if ($unit->speed < $template->minSpeed || $unit->speed > $template->maxSpeed) {
throw new InvalidArgumentException("Unit {$unit->id} speed is outside archetype bounds.");
}
foreach ($unit->abilities as $ability) {
if (!in_array($ability, $template->allowedAbilities, true)) {
throw new InvalidArgumentException("Unit {$unit->id} has an ability that its archetype forbids.");
}
}
$zone = $deploymentZones[$unit->teamId] ?? null;
if ($zone === null || !$zone->contains($unit->position)) {
throw new InvalidArgumentException("Unit {$unit->id} must start inside team {$unit->teamId} deployment zone.");
}
}
foreach ($objectives as $objective) {
if (!$this->battlefield->contains($objective->position)) {
throw new InvalidArgumentException("Objective {$objective->id} is outside the battlefield.");
}
}
}
public function startMatch(string $activeTeamId): MatchState
{
$resetUnits = [];
foreach ($this->units as $unit) {
$resetUnits[] = new UnitState(
$unit->id,
$unit->teamId,
$unit->position,
$unit->maxHealth,
$unit->maxHealth,
$unit->attack,
$unit->defense,
$unit->speed,
2,
false,
$unit->archetype,
$unit->abilities,
0,
false,
);
}
return new MatchState(
battlefield: $this->battlefield,
units: $resetUnits,
activeTeamId: $activeTeamId,
objectives: $this->objectives,
victoryCondition: $this->victoryCondition,
holdRoundsRequired: $this->holdRoundsRequired,
);
}
/**
* @param list<UnitState> $units
* @return array<string, list<UnitState>>
*/
private function groupUnitsByTeam(array $units): array
{
$grouped = [];
foreach ($units as $unit) {
$grouped[$unit->teamId] ??= [];
$grouped[$unit->teamId][] = $unit;
}
return $grouped;
}
}
+82
View File
@@ -0,0 +1,82 @@
<?php
declare(strict_types=1);
namespace BattleForge\Domain;
final class ScenarioValidator
{
/**
* @return list<string>
*/
public static function validate(Scenario $scenario): array
{
$errors = [];
$occupied = [];
foreach ($scenario->deploymentZones as $teamId => $zone) {
foreach ($zone->positions as $position) {
if (!$scenario->battlefield->contains($position)) {
$errors[] = "Deployment zone for {$teamId} includes a position outside the battlefield.";
continue;
}
if ($scenario->battlefield->terrainAt($position)->movementCost() === null) {
$errors[] = "Deployment zone for {$teamId} sits on impassable terrain at {$position->key()}.";
}
if (isset($occupied[$position->key()])) {
$errors[] = "Deployment zones overlap at {$position->key()}.";
}
$occupied[$position->key()] = $teamId;
}
}
$reachableFromDeployment = self::reachableFromDeployment($scenario, $occupied);
foreach ($scenario->objectives as $objective) {
if (!$scenario->battlefield->contains($objective->position)) {
$errors[] = "Objective {$objective->id} is outside the battlefield.";
continue;
}
if ($scenario->battlefield->terrainAt($objective->position)->movementCost() === null) {
$errors[] = "Objective {$objective->id} sits on impassable terrain.";
}
if (!isset($reachableFromDeployment[$objective->position->key()])) {
$errors[] = "Objective {$objective->id} is unreachable from any deployment zone.";
}
}
return $errors;
}
/**
* @param array<string, string> $occupied
* @return array<string, true>
*/
private static function reachableFromDeployment(Scenario $scenario, array $occupied): array
{
$merged = $scenario->units;
$allReachable = [];
foreach ($merged as $unit) {
$others = [];
foreach ($merged as $other) {
if ($other->id === $unit->id || $other->isDefeated()) {
continue;
}
$others[] = $other->position;
}
$reachable = $scenario->battlefield->reachable($unit->position, $unit->speed, $others);
foreach ($reachable as $key => $_cost) {
if (!isset($occupied[$key])) {
$allReachable[$key] = true;
}
}
}
return $allReachable;
}
}
+74 -1
View File
@@ -8,6 +8,7 @@ use InvalidArgumentException;
final readonly class UnitState
{
/** @param list<string> $abilities */
public function __construct(
public string $id,
public string $teamId,
@@ -19,6 +20,10 @@ final readonly class UnitState
public int $speed,
public int $actionsRemaining,
public bool $hasAttacked = false,
public Archetype $archetype = Archetype::Defender,
public array $abilities = [],
public int $attackBonus = 0,
public bool $hasUsedAbility = false,
) {
if ($id === '') {
throw new InvalidArgumentException('Unit id cannot be empty.');
@@ -47,6 +52,21 @@ final readonly class UnitState
if ($actionsRemaining < 0 || $actionsRemaining > 2) {
throw new InvalidArgumentException('Unit actions remaining must be between 0 and 2.');
}
if ($attackBonus < 0) {
throw new InvalidArgumentException('Unit attack bonus cannot be negative.');
}
if (!self::isList($abilities)) {
throw new InvalidArgumentException('Unit abilities must be a list.');
}
foreach ($abilities as $ability) {
// @phpstan-ignore function.alreadyNarrowedType (Runtime guard: PHPDoc is not a runtime contract for JSON-sourced arrays in Plan 4.)
if (!is_string($ability) || $ability === '') {
throw new InvalidArgumentException('Unit abilities must be non-empty strings.');
}
}
}
public function isDefeated(): bool
@@ -54,6 +74,12 @@ final readonly class UnitState
return $this->health === 0;
}
/** @return list<string> */
public function allowedAbilities(): array
{
return ArchetypeCatalog::templates()[$this->archetype->value]->allowedAbilities;
}
public function moveTo(Position $position): self
{
return $this->copy(position: $position);
@@ -68,23 +94,59 @@ final readonly class UnitState
return $this->copy(actionsRemaining: $this->actionsRemaining - 1);
}
public function spendAbility(): self
{
if ($this->hasUsedAbility) {
throw new InvalidArgumentException('Unit has already used an ability this turn.');
}
if ($this->actionsRemaining === 0) {
throw new InvalidArgumentException('Unit has no actions remaining.');
}
return $this->copy(actionsRemaining: $this->actionsRemaining - 1, hasUsedAbility: true);
}
public function takeDamage(int $damage): self
{
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);
}
public function withAttackBonus(int $bonus): self
{
if ($bonus < 0) {
throw new InvalidArgumentException('Attack bonus cannot be negative.');
}
return $this->copy(attackBonus: $bonus);
}
public function startTurn(): self
{
if ($this->isDefeated()) {
return $this;
}
return $this->copy(actionsRemaining: 2, hasAttacked: false);
return $this->copy(
actionsRemaining: 2,
hasAttacked: false,
attackBonus: 0,
hasUsedAbility: false,
);
}
private function copy(
@@ -92,6 +154,8 @@ final readonly class UnitState
?int $health = null,
?int $actionsRemaining = null,
?bool $hasAttacked = null,
?int $attackBonus = null,
?bool $hasUsedAbility = null,
): self {
return new self(
$this->id,
@@ -104,6 +168,15 @@ final readonly class UnitState
$this->speed,
$actionsRemaining ?? $this->actionsRemaining,
$hasAttacked ?? $this->hasAttacked,
$this->archetype,
$this->abilities,
$attackBonus ?? $this->attackBonus,
$hasUsedAbility ?? $this->hasUsedAbility,
);
}
private static function isList(mixed $value): bool
{
return is_array($value) && array_is_list($value);
}
}
+11
View File
@@ -0,0 +1,11 @@
<?php
declare(strict_types=1);
namespace BattleForge\Domain;
enum VictoryCondition: string
{
case EliminateAll = 'eliminate_all';
case HoldObjective = 'hold_objective';
}
+59
View File
@@ -0,0 +1,59 @@
<?php
declare(strict_types=1);
namespace BattleForge\Tests\Unit\Domain;
use BattleForge\Domain\AbilityCatalog;
use BattleForge\Domain\AbilityDefinition;
use BattleForge\Domain\AbilityId;
use PHPUnit\Framework\TestCase;
final class AbilityCatalogTest extends TestCase
{
public function testItExposesExactlyTheThreePlannedAbilities(): void
{
$keys = array_keys(AbilityCatalog::definitions());
sort($keys);
self::assertSame(['area_damage', 'buff', 'heal'], $keys);
}
public function testHealTargetsAnAllyWithinRangeAndRestoresHealth(): void
{
$definition = AbilityCatalog::definitions()[AbilityId::Heal->value];
self::assertInstanceOf(AbilityDefinition::class, $definition);
self::assertSame('Heal', $definition->label);
self::assertSame(2, $definition->range);
self::assertSame(0, $definition->areaRadius);
self::assertSame('ally', $definition->target);
self::assertSame('restore_health', $definition->effect);
self::assertSame(5, $definition->effectValue);
}
public function testAreaDamageTargetsAnEnemyTileAndAffectsANeighborhood(): void
{
$definition = AbilityCatalog::definitions()[AbilityId::AreaDamage->value];
self::assertSame('Area Damage', $definition->label);
self::assertSame(3, $definition->range);
self::assertSame(1, $definition->areaRadius);
self::assertSame('tile', $definition->target);
self::assertSame('deal_damage', $definition->effect);
self::assertSame(3, $definition->effectValue);
}
public function testBuffTargetsAnAllyAndAppliesAnAttackBonus(): void
{
$definition = AbilityCatalog::definitions()[AbilityId::Buff->value];
self::assertSame('Rally', $definition->label);
self::assertSame(1, $definition->range);
self::assertSame(0, $definition->areaRadius);
self::assertSame('ally', $definition->target);
self::assertSame('apply_buff', $definition->effect);
self::assertSame(2, $definition->effectValue);
}
}
@@ -0,0 +1,56 @@
<?php
declare(strict_types=1);
namespace BattleForge\Tests\Unit\Domain;
use BattleForge\Domain\Archetype;
use BattleForge\Domain\ArchetypeCatalog;
use BattleForge\Domain\ArchetypeTemplate;
use PHPUnit\Framework\TestCase;
final class ArchetypeCatalogTest extends TestCase
{
public function testItExposesExactlyTheFourPlannedArchetypes(): void
{
$keys = array_keys(ArchetypeCatalog::templates());
sort($keys);
self::assertSame(['defender', 'scout', 'striker', 'support'], $keys);
}
public function testEveryArchetypeTemplateCarriesAllFourStatRanges(): void
{
foreach (ArchetypeCatalog::templates() as $key => $template) {
self::assertInstanceOf(ArchetypeTemplate::class, $template);
self::assertLessThanOrEqual($template->maxHealth, $template->minHealth);
self::assertLessThanOrEqual($template->maxAttack, $template->minAttack);
self::assertLessThanOrEqual($template->maxDefense, $template->minDefense);
self::assertLessThanOrEqual($template->maxSpeed, $template->minSpeed);
self::assertGreaterThanOrEqual(1, $template->minSpeed);
}
}
public function testEveryArchetypeTemplateDefaultsToTheCenterOfItsRanges(): void
{
foreach (ArchetypeCatalog::templates() as $template) {
$defaults = $template->defaultStats();
self::assertSame((int) ceil(($template->minHealth + $template->maxHealth) / 2), $defaults['maxHealth']);
self::assertSame((int) ceil(($template->minAttack + $template->maxAttack) / 2), $defaults['attack']);
self::assertSame((int) ceil(($template->minDefense + $template->maxDefense) / 2), $defaults['defense']);
self::assertSame((int) ceil(($template->minSpeed + $template->maxSpeed) / 2), $defaults['speed']);
}
}
public function testDefaultStatsReturnAllFourKeys(): void
{
$template = ArchetypeCatalog::templates()[Archetype::Defender->value];
self::assertSame(
['maxHealth', 'attack', 'defense', 'speed'],
array_keys($template->defaultStats()),
);
}
}
@@ -0,0 +1,62 @@
<?php
declare(strict_types=1);
namespace BattleForge\Tests\Unit\Domain;
use BattleForge\Domain\ArchetypeTemplate;
use PHPUnit\Framework\TestCase;
final class ArchetypeTemplateTest extends TestCase
{
public function testItRejectsInvertedStatRanges(): void
{
$this->expectException(\InvalidArgumentException::class);
new ArchetypeTemplate(
minHealth: 10,
maxHealth: 5,
minAttack: 1,
maxAttack: 3,
minDefense: 0,
maxDefense: 2,
minSpeed: 1,
maxSpeed: 4,
allowedAbilities: [],
);
}
public function testItRejectsZeroMinimumSpeed(): void
{
$this->expectException(\InvalidArgumentException::class);
new ArchetypeTemplate(
minHealth: 5,
maxHealth: 10,
minAttack: 1,
maxAttack: 3,
minDefense: 0,
maxDefense: 2,
minSpeed: 0,
maxSpeed: 4,
allowedAbilities: [],
);
}
public function testItExposesAllowedAbilitiesVerbatim(): void
{
$template = new ArchetypeTemplate(
minHealth: 5,
maxHealth: 10,
minAttack: 1,
maxAttack: 3,
minDefense: 0,
maxDefense: 2,
minSpeed: 1,
maxSpeed: 4,
allowedAbilities: ['heal', 'buff'],
);
self::assertSame(['heal', 'buff'], $template->allowedAbilities);
}
}
+278 -2
View File
@@ -4,13 +4,16 @@ declare(strict_types=1);
namespace BattleForge\Tests\Unit\Domain;
use BattleForge\Domain\Archetype;
use BattleForge\Domain\Battlefield;
use BattleForge\Domain\CombatEngine;
use BattleForge\Domain\CombatException;
use BattleForge\Domain\MatchState;
use BattleForge\Domain\ObjectiveMarker;
use BattleForge\Domain\Position;
use BattleForge\Domain\Terrain;
use BattleForge\Domain\UnitState;
use BattleForge\Domain\VictoryCondition;
use InvalidArgumentException;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\TestCase;
@@ -583,7 +586,7 @@ final class CombatEngineTest extends TestCase
);
$this->expectException(CombatException::class);
$this->expectExceptionMessage('Matches require alpha and bravo teams.');
$this->expectExceptionMessage('Matches require exactly two teams.');
(new CombatEngine())->endTurn($match);
}
@@ -594,7 +597,6 @@ final class CombatEngineTest extends TestCase
public static function invalidEndTurnTeamProvider(): iterable
{
yield 'only alpha' => [['alpha']];
yield 'alpha and charlie' => [['alpha', 'charlie']];
yield 'alpha, bravo, and charlie' => [['alpha', 'bravo', 'charlie']];
}
@@ -746,6 +748,7 @@ final class CombatEngineTest extends TestCase
int $attack = 4,
int $defense = 2,
bool $hasAttacked = false,
int $attackBonus = 0,
): UnitState {
return new UnitState(
$id,
@@ -758,6 +761,279 @@ final class CombatEngineTest extends TestCase
$speed,
$actionsRemaining,
$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 archetype forbids that ability.');
(new CombatEngine())->useAbility($match, 'scout-1', 'heal', new Position(0, 1));
}
public function testEndTurnTalliesObjectiveControlAfterAFullRound(): void
{
$alpha = $this->unit('alpha-1', 'alpha', new Position(4, 4));
$bravo = $this->unit('bravo-1', 'bravo', new Position(7, 7));
$match = new MatchState(
new Battlefield(8, 8),
[$alpha, $bravo],
'alpha',
objectives: ['objective-1' => new ObjectiveMarker('objective-1', new Position(4, 4))],
victoryCondition: VictoryCondition::HoldObjective,
holdRoundsRequired: 2,
);
$afterAlphaEnd = (new CombatEngine())->endTurn($match);
$afterBravoEnd = (new CombatEngine())->endTurn($afterAlphaEnd);
self::assertSame(['alpha' => 1], $afterAlphaEnd->objectiveControl);
self::assertSame(['alpha' => 1], $afterBravoEnd->objectiveControl);
self::assertNull($afterBravoEnd->winnerTeamId);
}
public function testEndTurnDeclaresWinnerWhenHoldRoundsReached(): void
{
$alpha = $this->unit('alpha-1', 'alpha', new Position(4, 4));
$bravo = $this->unit('bravo-1', 'bravo', new Position(7, 7));
$match = new MatchState(
new Battlefield(8, 8),
[$alpha, $bravo],
'alpha',
objectives: ['objective-1' => new ObjectiveMarker('objective-1', new Position(4, 4))],
victoryCondition: VictoryCondition::HoldObjective,
holdRoundsRequired: 1,
objectiveControl: ['alpha' => 0],
);
$afterAlphaEnd = (new CombatEngine())->endTurn($match);
$afterBravoEnd = (new CombatEngine())->endTurn($afterAlphaEnd);
self::assertSame('alpha', $afterBravoEnd->winnerTeamId);
self::assertSame(['alpha' => 1], $afterBravoEnd->objectiveControl);
}
public function testEndTurnDoesNotAwardControlWhenNoTeamStandsOnTheObjective(): void
{
$alpha = $this->unit('alpha-1', 'alpha', new Position(0, 0));
$bravo = $this->unit('bravo-1', 'bravo', new Position(7, 7));
$match = new MatchState(
new Battlefield(8, 8),
[$alpha, $bravo],
'alpha',
objectives: ['objective-1' => new ObjectiveMarker('objective-1', new Position(4, 4))],
victoryCondition: VictoryCondition::HoldObjective,
holdRoundsRequired: 1,
);
$next = (new CombatEngine())->endTurn($match);
self::assertSame([], $next->objectiveControl);
self::assertNull($next->winnerTeamId);
}
}
+280
View File
@@ -0,0 +1,280 @@
<?php
declare(strict_types=1);
namespace BattleForge\Tests\Unit\Domain;
use BattleForge\Domain\Archetype;
use BattleForge\Domain\Battlefield;
use BattleForge\Domain\CombatEngine;
use BattleForge\Domain\DeploymentZone;
use BattleForge\Domain\MatchState;
use BattleForge\Domain\ObjectiveMarker;
use BattleForge\Domain\Position;
use BattleForge\Domain\Scenario;
use BattleForge\Domain\UnitState;
use BattleForge\Domain\VictoryCondition;
use InvalidArgumentException;
use PHPUnit\Framework\TestCase;
final class ScenarioTest extends TestCase
{
public function testItRejectsNonListUnits(): void
{
$this->expectException(InvalidArgumentException::class);
new Scenario(
id: 'demo',
name: 'Demo',
battlefield: new Battlefield(8, 8),
units: ['alpha-1' => $this->unit('alpha-1', 'alpha', new Position(0, 0))], // @phpstan-ignore argument.type (Test fixture intentionally passes a non-list units array to verify constructor validation.)
deploymentZones: ['alpha' => new DeploymentZone('alpha', [new Position(0, 0)])],
objectives: [],
victoryCondition: VictoryCondition::EliminateAll,
holdRoundsRequired: 1,
);
}
public function testItRejectsTeamsOutsideTheThreeToSixSizeWindow(): void
{
$this->expectException(InvalidArgumentException::class);
$this->expectExceptionMessage('Team alpha must have between 3 and 6 units.');
$units = [
$this->unit('alpha-1', 'alpha', new Position(0, 0)),
$this->unit('alpha-2', 'alpha', new Position(1, 0)),
$this->unit('bravo-1', 'bravo', new Position(7, 7)),
$this->unit('bravo-2', 'bravo', new Position(6, 7)),
$this->unit('bravo-3', 'bravo', new Position(5, 7)),
];
new Scenario(
id: 'demo',
name: 'Demo',
battlefield: new Battlefield(8, 8),
units: $units,
deploymentZones: [
'alpha' => new DeploymentZone('alpha', [new Position(0, 0), new Position(1, 0)]),
'bravo' => new DeploymentZone('bravo', [new Position(7, 7), new Position(6, 7), new Position(5, 7)]),
],
objectives: [],
victoryCondition: VictoryCondition::EliminateAll,
holdRoundsRequired: 1,
);
}
public function testItRejectsAbilitiesOutsideTheArchetypeAllowlist(): void
{
$this->expectException(InvalidArgumentException::class);
$this->expectExceptionMessage('Unit alpha-1 has an ability that its archetype forbids.');
$bad = $this->unit('alpha-1', 'alpha', new Position(0, 0), archetype: Archetype::Scout, abilities: ['heal'], attack: 3, defense: 1, speed: 5, maxHealth: 6);
$alpha2 = $this->unit('alpha-2', 'alpha', new Position(1, 0));
$alpha3 = $this->unit('alpha-3', 'alpha', new Position(2, 0));
$bravo = $this->unit('bravo-1', 'bravo', new Position(7, 7));
$bravo2 = $this->unit('bravo-2', 'bravo', new Position(6, 7));
$bravo3 = $this->unit('bravo-3', 'bravo', new Position(5, 7));
new Scenario(
id: 'demo',
name: 'Demo',
battlefield: new Battlefield(8, 8),
units: [$bad, $alpha2, $alpha3, $bravo, $bravo2, $bravo3],
deploymentZones: [
'alpha' => new DeploymentZone('alpha', [new Position(0, 0), new Position(1, 0), new Position(2, 0)]),
'bravo' => new DeploymentZone('bravo', [new Position(7, 7), new Position(6, 7), new Position(5, 7)]),
],
objectives: [],
victoryCondition: VictoryCondition::EliminateAll,
holdRoundsRequired: 1,
);
}
public function testItRejectsUnitsOutsideTheirDeploymentZone(): void
{
$this->expectException(InvalidArgumentException::class);
$this->expectExceptionMessage('Unit alpha-1 must start inside team alpha deployment zone.');
$bad = $this->unit('alpha-1', 'alpha', new Position(3, 0));
$alpha2 = $this->unit('alpha-2', 'alpha', new Position(0, 0));
$alpha3 = $this->unit('alpha-3', 'alpha', new Position(1, 0));
$bravo = $this->unit('bravo-1', 'bravo', new Position(7, 7));
$bravo2 = $this->unit('bravo-2', 'bravo', new Position(6, 7));
$bravo3 = $this->unit('bravo-3', 'bravo', new Position(5, 7));
new Scenario(
id: 'demo',
name: 'Demo',
battlefield: new Battlefield(8, 8),
units: [$bad, $alpha2, $alpha3, $bravo, $bravo2, $bravo3],
deploymentZones: [
'alpha' => new DeploymentZone('alpha', [new Position(0, 0), new Position(1, 0), new Position(2, 0)]),
'bravo' => new DeploymentZone('bravo', [new Position(7, 7), new Position(6, 7), new Position(5, 7)]),
],
objectives: [],
victoryCondition: VictoryCondition::EliminateAll,
holdRoundsRequired: 1,
);
}
public function testHoldObjectiveRequiresExactlyOneObjective(): void
{
$this->expectException(InvalidArgumentException::class);
$this->expectExceptionMessage('Hold objective victory requires exactly one objective.');
$units = $this->validUnitSet();
$zones = $this->validZones();
new Scenario(
id: 'demo',
name: 'Demo',
battlefield: new Battlefield(8, 8),
units: $units,
deploymentZones: $zones,
objectives: [],
victoryCondition: VictoryCondition::HoldObjective,
holdRoundsRequired: 3,
);
}
public function testStartMatchReturnsAFreshMatchStateWithResetUnits(): void
{
$scenario = $this->validScenario();
$match = $scenario->startMatch('alpha');
self::assertInstanceOf(MatchState::class, $match);
self::assertSame('alpha', $match->activeTeamId);
self::assertSame(1, $match->round);
self::assertNull($match->winnerTeamId);
foreach ($match->units as $unit) {
self::assertSame(2, $unit->actionsRemaining);
self::assertFalse($unit->hasAttacked);
self::assertSame(0, $unit->attackBonus);
self::assertFalse($unit->hasUsedAbility);
}
}
public function testStartMatchDoesNotShareUnitInstancesWithTheScenario(): void
{
$scenario = $this->validScenario();
$match = $scenario->startMatch('alpha');
$original = $scenario->units[0];
$fromMatch = $match->unit($original->id);
self::assertNotSame($original, $fromMatch);
self::assertSame($original->id, $fromMatch->id);
}
public function testStartingAMatchWithAbilityAndObjectivePlaysThrough(): void
{
$units = [
new UnitState('alpha-1', 'alpha', new Position(0, 0), 10, 10, 4, 2, 4, 2, false, Archetype::Striker, ['area_damage']),
new UnitState('alpha-2', 'alpha', new Position(1, 0), 9, 9, 3, 2, 4, 2, false, Archetype::Support, ['heal', 'buff']),
new UnitState('alpha-3', 'alpha', new Position(2, 0), 10, 10, 4, 3, 3, 2, false, Archetype::Defender),
new UnitState('bravo-1', 'bravo', new Position(7, 7), 7, 7, 4, 2, 5, 2, false, Archetype::Scout),
new UnitState('bravo-2', 'bravo', new Position(6, 7), 10, 10, 4, 2, 4, 2, false, Archetype::Striker),
new UnitState('bravo-3', 'bravo', new Position(5, 7), 10, 10, 4, 3, 3, 2, false, Archetype::Defender),
];
$scenario = new Scenario(
id: 'demo',
name: 'Demo',
battlefield: new Battlefield(8, 8),
units: $units,
deploymentZones: [
'alpha' => new DeploymentZone('alpha', [new Position(0, 0), new Position(1, 0), new Position(2, 0)]),
'bravo' => new DeploymentZone('bravo', [new Position(7, 7), new Position(6, 7), new Position(5, 7)]),
],
objectives: ['objective-1' => new ObjectiveMarker('objective-1', new Position(0, 0))],
victoryCondition: VictoryCondition::HoldObjective,
holdRoundsRequired: 2,
);
$match = $scenario->startMatch('alpha');
$engine = new CombatEngine();
$end1 = $engine->endTurn($match);
$end2 = $engine->endTurn($end1);
self::assertSame('alpha', $end2->activeTeamId);
self::assertSame(['alpha' => 1], $end2->objectiveControl);
self::assertNull($end2->winnerTeamId);
}
/** @return list<UnitState> */
private function validUnitSet(): array
{
return [
$this->unit('alpha-1', 'alpha', new Position(0, 0)),
$this->unit('alpha-2', 'alpha', new Position(1, 0)),
$this->unit('alpha-3', 'alpha', new Position(2, 0)),
$this->unit('bravo-1', 'bravo', new Position(7, 7)),
$this->unit('bravo-2', 'bravo', new Position(6, 7)),
$this->unit('bravo-3', 'bravo', new Position(5, 7)),
];
}
/** @return array<string, DeploymentZone> */
private function validZones(): array
{
return [
'alpha' => new DeploymentZone('alpha', [
new Position(0, 0),
new Position(1, 0),
new Position(2, 0),
]),
'bravo' => new DeploymentZone('bravo', [
new Position(7, 7),
new Position(6, 7),
new Position(5, 7),
]),
];
}
private function validScenario(): Scenario
{
return new Scenario(
id: 'demo',
name: 'Demo',
battlefield: new Battlefield(8, 8),
units: $this->validUnitSet(),
deploymentZones: $this->validZones(),
objectives: [],
victoryCondition: VictoryCondition::EliminateAll,
holdRoundsRequired: 1,
);
}
/**
* @param list<string> $abilities
*/
private function unit(
string $id,
string $team,
Position $position,
Archetype $archetype = Archetype::Defender,
array $abilities = [],
int $attack = 4,
int $defense = 3,
int $speed = 3,
int $maxHealth = 10,
): UnitState {
return new UnitState(
$id,
$team,
$position,
$maxHealth,
$maxHealth,
$attack,
$defense,
$speed,
2,
false,
$archetype,
$abilities,
0,
false,
);
}
}
+162
View File
@@ -0,0 +1,162 @@
<?php
declare(strict_types=1);
namespace BattleForge\Tests\Unit\Domain;
use BattleForge\Domain\Archetype;
use BattleForge\Domain\Battlefield;
use BattleForge\Domain\DeploymentZone;
use BattleForge\Domain\ObjectiveMarker;
use BattleForge\Domain\Position;
use BattleForge\Domain\Scenario;
use BattleForge\Domain\ScenarioValidator;
use BattleForge\Domain\Terrain;
use BattleForge\Domain\UnitState;
use BattleForge\Domain\VictoryCondition;
use PHPUnit\Framework\TestCase;
final class ScenarioValidatorTest extends TestCase
{
public function testItReturnsAnEmptyListForAValidScenario(): void
{
$scenario = $this->validScenario();
self::assertSame([], ScenarioValidator::validate($scenario));
}
public function testItRejectsAnObjectiveOnImpassableTerrain(): void
{
$scenario = $this->validScenario(
terrain: ['4:4' => Terrain::Water],
objectivePosition: new Position(4, 4),
);
$errors = ScenarioValidator::validate($scenario);
self::assertNotEmpty($errors);
self::assertTrue((bool) array_filter($errors, static fn (string $error): bool => str_contains($error, 'impassable')));
}
public function testItRejectsAnObjectiveUnreachableFromAnyDeploymentZone(): void
{
$scenario = $this->validScenario(
terrain: [
'3:4' => Terrain::Blocking,
'4:3' => Terrain::Blocking,
'5:4' => Terrain::Blocking,
'4:5' => Terrain::Blocking,
],
objectivePosition: new Position(4, 4),
);
$errors = ScenarioValidator::validate($scenario);
self::assertTrue((bool) array_filter($errors, static fn (string $error): bool => str_contains($error, 'unreachable')));
}
public function testItRejectsOverlappingDeploymentZones(): void
{
$units = $this->unitSet();
$battlefield = new Battlefield(8, 8);
$scenario = new Scenario(
id: 'demo',
name: 'Demo',
battlefield: $battlefield,
units: $units,
deploymentZones: [
'alpha' => new DeploymentZone('alpha', [new Position(0, 0), new Position(1, 0), new Position(2, 0)]),
'bravo' => new DeploymentZone('bravo', [new Position(1, 0), new Position(2, 0), new Position(7, 7), new Position(6, 7), new Position(5, 7)]),
],
objectives: [],
victoryCondition: VictoryCondition::EliminateAll,
holdRoundsRequired: 1,
);
$errors = ScenarioValidator::validate($scenario);
self::assertTrue((bool) array_filter($errors, static fn (string $error): bool => str_contains($error, 'overlap')));
}
public function testItRejectsDeploymentPositionsOnImpassableTerrain(): void
{
$units = $this->unitSet();
$battlefield = new Battlefield(8, 8, ['0:0' => Terrain::Water]);
$scenario = new Scenario(
id: 'demo',
name: 'Demo',
battlefield: $battlefield,
units: $units,
deploymentZones: [
'alpha' => new DeploymentZone('alpha', [new Position(0, 0), new Position(1, 0), new Position(2, 0)]),
'bravo' => new DeploymentZone('bravo', [new Position(7, 7), new Position(6, 7), new Position(5, 7)]),
],
objectives: [],
victoryCondition: VictoryCondition::EliminateAll,
holdRoundsRequired: 1,
);
$errors = ScenarioValidator::validate($scenario);
self::assertTrue((bool) array_filter($errors, static fn (string $error): bool => str_contains($error, 'impassable')));
}
/**
* @param array<string, Terrain> $terrain
*/
private function validScenario(
array $terrain = [],
?Position $objectivePosition = null,
): Scenario {
$units = $this->unitSet();
$battlefield = new Battlefield(8, 8, $terrain);
$objectives = [];
if ($objectivePosition !== null) {
$objectives = ['objective-1' => new ObjectiveMarker('objective-1', $objectivePosition)];
}
return new Scenario(
id: 'demo',
name: 'Demo',
battlefield: $battlefield,
units: $units,
deploymentZones: $this->zones(),
objectives: $objectives,
victoryCondition: $objectivePosition === null
? VictoryCondition::EliminateAll
: VictoryCondition::HoldObjective,
holdRoundsRequired: 1,
);
}
/** @return array<string, DeploymentZone> */
private function zones(): array
{
return [
'alpha' => new DeploymentZone('alpha', [
new Position(0, 0),
new Position(1, 0),
new Position(2, 0),
]),
'bravo' => new DeploymentZone('bravo', [
new Position(7, 7),
new Position(6, 7),
new Position(5, 7),
]),
];
}
/** @return list<UnitState> */
private function unitSet(): array
{
return [
new UnitState('alpha-1', 'alpha', new Position(0, 0), 10, 10, 4, 3, 2, 2, false, Archetype::Defender),
new UnitState('alpha-2', 'alpha', new Position(1, 0), 8, 8, 5, 2, 3, 2, false, Archetype::Striker),
new UnitState('alpha-3', 'alpha', new Position(2, 0), 7, 7, 2, 2, 3, 2, false, Archetype::Support),
new UnitState('bravo-1', 'bravo', new Position(7, 7), 10, 10, 4, 3, 2, 2, false, Archetype::Defender),
new UnitState('bravo-2', 'bravo', new Position(6, 7), 8, 8, 5, 2, 3, 2, false, Archetype::Striker),
new UnitState('bravo-3', 'bravo', new Position(5, 7), 6, 6, 3, 1, 5, 2, false, Archetype::Scout),
];
}
}
+103
View File
@@ -132,10 +132,109 @@ final class UnitStateTest extends TestCase
self::assertSame($unit, $unit->startTurn());
}
public function testItCarriesArchetypeAbilitiesAndBonus(): void
{
$unit = new UnitState(
id: 'unit-1',
teamId: 'alpha',
position: new Position(0, 0),
maxHealth: 10,
health: 10,
attack: 4,
defense: 2,
speed: 4,
actionsRemaining: 2,
hasAttacked: false,
archetype: \BattleForge\Domain\Archetype::Support,
abilities: ['heal', 'buff'],
attackBonus: 2,
hasUsedAbility: false,
);
self::assertSame(\BattleForge\Domain\Archetype::Support, $unit->archetype);
self::assertSame(['heal', 'buff'], $unit->abilities);
self::assertSame(2, $unit->attackBonus);
self::assertFalse($unit->hasUsedAbility);
}
public function testItRejectsEmptyStringAbilities(): void
{
$this->expectException(InvalidArgumentException::class);
$this->expectExceptionMessage('Unit abilities must be non-empty strings.');
new UnitState(
id: 'unit-1',
teamId: 'alpha',
position: new Position(0, 0),
maxHealth: 10,
health: 10,
attack: 4,
defense: 2,
speed: 4,
actionsRemaining: 2,
hasAttacked: false,
archetype: \BattleForge\Domain\Archetype::Defender,
abilities: [''],
);
}
public function testItExposesAllowedAbilitiesFromTheArchetypeCatalog(): void
{
$unit = $this->unit();
self::assertSame(
\BattleForge\Domain\ArchetypeCatalog::templates()[$unit->archetype->value]->allowedAbilities,
$unit->allowedAbilities(),
);
}
public function testWithAttackBonusReturnsAChangedCopyAndRejectsNegativeBonus(): void
{
$unit = $this->unit();
$buffed = $unit->withAttackBonus(3);
self::assertNotSame($unit, $buffed);
self::assertSame(0, $unit->attackBonus);
self::assertSame(3, $buffed->attackBonus);
$this->expectException(\InvalidArgumentException::class);
$unit->withAttackBonus(-1);
}
public function testSpendAbilityMarksAndConsumesOneActionButNotTwo(): void
{
$unit = $this->unit();
$spent = $unit->spendAbility();
self::assertSame(1, $spent->actionsRemaining);
self::assertTrue($spent->hasUsedAbility);
}
public function testSpendAbilityRejectsAUnitThatAlreadyUsedAnAbility(): void
{
$unit = $this->unit(hasUsedAbility: true);
$this->expectException(\InvalidArgumentException::class);
$unit->spendAbility();
}
public function testStartTurnClearsAttackBonusAndAbilityUse(): void
{
$unit = $this->unit(actionsRemaining: 0, hasAttacked: true, attackBonus: 3, hasUsedAbility: true);
$started = $unit->startTurn();
self::assertSame(2, $started->actionsRemaining);
self::assertFalse($started->hasAttacked);
self::assertSame(0, $started->attackBonus);
self::assertFalse($started->hasUsedAbility);
}
private function unit(
int $health = 10,
int $actionsRemaining = 2,
bool $hasAttacked = false,
int $attackBonus = 0,
bool $hasUsedAbility = false,
): UnitState {
return new UnitState(
'unit-1',
@@ -148,6 +247,10 @@ final class UnitStateTest extends TestCase
4,
$actionsRemaining,
$hasAttacked,
archetype: \BattleForge\Domain\Archetype::Defender,
abilities: [],
attackBonus: $attackBonus,
hasUsedAbility: $hasUsedAbility,
);
}
}