From 0ac8c06c5ebef87ba44ee7417dc64e0b68fe14d9 Mon Sep 17 00:00:00 2001 From: Keith Solomon Date: Sun, 5 Jul 2026 18:41:53 -0500 Subject: [PATCH 01/10] feat: catalog curated unit archetypes and templates --- src/Domain/Archetype.php | 13 ++++ src/Domain/ArchetypeCatalog.php | 69 +++++++++++++++++++++ src/Domain/ArchetypeTemplate.php | 50 +++++++++++++++ tests/Unit/Domain/ArchetypeCatalogTest.php | 56 +++++++++++++++++ tests/Unit/Domain/ArchetypeTemplateTest.php | 62 ++++++++++++++++++ 5 files changed, 250 insertions(+) create mode 100644 src/Domain/Archetype.php create mode 100644 src/Domain/ArchetypeCatalog.php create mode 100644 src/Domain/ArchetypeTemplate.php create mode 100644 tests/Unit/Domain/ArchetypeCatalogTest.php create mode 100644 tests/Unit/Domain/ArchetypeTemplateTest.php diff --git a/src/Domain/Archetype.php b/src/Domain/Archetype.php new file mode 100644 index 0000000..f904493 --- /dev/null +++ b/src/Domain/Archetype.php @@ -0,0 +1,13 @@ + + */ + 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; + } +} diff --git a/src/Domain/ArchetypeTemplate.php b/src/Domain/ArchetypeTemplate.php new file mode 100644 index 0000000..a447912 --- /dev/null +++ b/src/Domain/ArchetypeTemplate.php @@ -0,0 +1,50 @@ + $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), + ]; + } +} diff --git a/tests/Unit/Domain/ArchetypeCatalogTest.php b/tests/Unit/Domain/ArchetypeCatalogTest.php new file mode 100644 index 0000000..7331be8 --- /dev/null +++ b/tests/Unit/Domain/ArchetypeCatalogTest.php @@ -0,0 +1,56 @@ + $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()), + ); + } +} diff --git a/tests/Unit/Domain/ArchetypeTemplateTest.php b/tests/Unit/Domain/ArchetypeTemplateTest.php new file mode 100644 index 0000000..2324c5a --- /dev/null +++ b/tests/Unit/Domain/ArchetypeTemplateTest.php @@ -0,0 +1,62 @@ +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); + } +} From 88149ab1cf6348a32f64e213ba0ce9bb760c095d Mon Sep 17 00:00:00 2001 From: Keith Solomon Date: Sun, 5 Jul 2026 18:47:20 -0500 Subject: [PATCH 02/10] feat: catalog curated abilities and definitions --- src/Domain/AbilityCatalog.php | 52 +++++++++++++++++++++ src/Domain/AbilityDefinition.php | 51 ++++++++++++++++++++ src/Domain/AbilityId.php | 12 +++++ tests/Unit/Domain/AbilityCatalogTest.php | 59 ++++++++++++++++++++++++ 4 files changed, 174 insertions(+) create mode 100644 src/Domain/AbilityCatalog.php create mode 100644 src/Domain/AbilityDefinition.php create mode 100644 src/Domain/AbilityId.php create mode 100644 tests/Unit/Domain/AbilityCatalogTest.php diff --git a/src/Domain/AbilityCatalog.php b/src/Domain/AbilityCatalog.php new file mode 100644 index 0000000..6114d62 --- /dev/null +++ b/src/Domain/AbilityCatalog.php @@ -0,0 +1,52 @@ + + */ + 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; + } +} diff --git a/src/Domain/AbilityDefinition.php b/src/Domain/AbilityDefinition.php new file mode 100644 index 0000000..506d0a2 --- /dev/null +++ b/src/Domain/AbilityDefinition.php @@ -0,0 +1,51 @@ + */ + private const TARGETS = [self::TARGET_SELF, self::TARGET_ALLY, self::TARGET_ENEMY, self::TARGET_TILE]; + + /** @var list */ + 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}."); + } + } +} diff --git a/src/Domain/AbilityId.php b/src/Domain/AbilityId.php new file mode 100644 index 0000000..b4f5d0f --- /dev/null +++ b/src/Domain/AbilityId.php @@ -0,0 +1,12 @@ +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); + } +} From e70b60696c881ff25c9e0668f5371a0a59acea91 Mon Sep 17 00:00:00 2001 From: Keith Solomon Date: Sun, 5 Jul 2026 18:59:55 -0500 Subject: [PATCH 03/10] feat: track archetype, abilities, and buff on unit state --- src/Domain/UnitState.php | 66 +++++++++++++++++- tests/Unit/Domain/UnitStateTest.php | 103 ++++++++++++++++++++++++++++ 2 files changed, 168 insertions(+), 1 deletion(-) diff --git a/src/Domain/UnitState.php b/src/Domain/UnitState.php index 76f8a1a..5ae486c 100644 --- a/src/Domain/UnitState.php +++ b/src/Domain/UnitState.php @@ -8,6 +8,7 @@ use InvalidArgumentException; final readonly class UnitState { + /** @param list $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 */ + public function allowedAbilities(): array + { + return ArchetypeCatalog::templates()[$this->archetype->value]->allowedAbilities; + } + public function moveTo(Position $position): self { return $this->copy(position: $position); @@ -68,6 +94,19 @@ 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))); @@ -78,13 +117,27 @@ final readonly class UnitState 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 +145,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 +159,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); + } } diff --git a/tests/Unit/Domain/UnitStateTest.php b/tests/Unit/Domain/UnitStateTest.php index 0bf48f9..c28072c 100644 --- a/tests/Unit/Domain/UnitStateTest.php +++ b/tests/Unit/Domain/UnitStateTest.php @@ -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, ); } } From 06632ff9b3b0cadb5d3eef949378d2129cffdfe4 Mon Sep 17 00:00:00 2001 From: Keith Solomon Date: Sun, 5 Jul 2026 19:11:24 -0500 Subject: [PATCH 04/10] feat: model objectives, deployment zones, and victory conditions --- src/Domain/DeploymentZone.php | 39 ++++++++++++++++++++++++++++ src/Domain/ObjectiveControl.php | 46 +++++++++++++++++++++++++++++++++ src/Domain/ObjectiveMarker.php | 24 +++++++++++++++++ src/Domain/VictoryCondition.php | 11 ++++++++ 4 files changed, 120 insertions(+) create mode 100644 src/Domain/DeploymentZone.php create mode 100644 src/Domain/ObjectiveControl.php create mode 100644 src/Domain/ObjectiveMarker.php create mode 100644 src/Domain/VictoryCondition.php diff --git a/src/Domain/DeploymentZone.php b/src/Domain/DeploymentZone.php new file mode 100644 index 0000000..9e2f993 --- /dev/null +++ b/src/Domain/DeploymentZone.php @@ -0,0 +1,39 @@ + $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; + } +} diff --git a/src/Domain/ObjectiveControl.php b/src/Domain/ObjectiveControl.php new file mode 100644 index 0000000..99914ff --- /dev/null +++ b/src/Domain/ObjectiveControl.php @@ -0,0 +1,46 @@ + $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]; + } +} diff --git a/src/Domain/ObjectiveMarker.php b/src/Domain/ObjectiveMarker.php new file mode 100644 index 0000000..a3d9b23 --- /dev/null +++ b/src/Domain/ObjectiveMarker.php @@ -0,0 +1,24 @@ +id; + } +} diff --git a/src/Domain/VictoryCondition.php b/src/Domain/VictoryCondition.php new file mode 100644 index 0000000..8790875 --- /dev/null +++ b/src/Domain/VictoryCondition.php @@ -0,0 +1,11 @@ + Date: Sun, 5 Jul 2026 19:27:22 -0500 Subject: [PATCH 05/10] feat: model scenarios and start match snapshots --- src/Domain/MatchState.php | 64 ++++++++ src/Domain/Scenario.php | 161 +++++++++++++++++++ tests/Unit/Domain/ScenarioTest.php | 245 +++++++++++++++++++++++++++++ 3 files changed, 470 insertions(+) create mode 100644 src/Domain/Scenario.php create mode 100644 tests/Unit/Domain/ScenarioTest.php diff --git a/src/Domain/MatchState.php b/src/Domain/MatchState.php index 5e2e6f8..06bf1dc 100644 --- a/src/Domain/MatchState.php +++ b/src/Domain/MatchState.php @@ -11,6 +11,8 @@ final readonly class MatchState /** * @param list $units * @param list $actionLog + * @param array $objectives + * @param array $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 $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|null $units * @param list|null $actionLog + * @param array|null $objectives + * @param array|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, ); } } diff --git a/src/Domain/Scenario.php b/src/Domain/Scenario.php new file mode 100644 index 0000000..4a3c4ac --- /dev/null +++ b/src/Domain/Scenario.php @@ -0,0 +1,161 @@ + $units + * @param array $deploymentZones + * @param array $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."); + } + + if (!ArchetypeCatalog::templates()[$unit->archetype->value] ?? false) { // @phpstan-ignore nullCoalesce.expr, if.alwaysFalse, booleanNot.alwaysFalse (Runtime guard: defense in depth against an unknown Archetype enum value.) + throw new InvalidArgumentException("Unit {$unit->id} uses an unknown archetype."); + } + + $template = ArchetypeCatalog::templates()[$unit->archetype->value]; + + 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 $units + * @return array> + */ + private function groupUnitsByTeam(array $units): array + { + $grouped = []; + + foreach ($units as $unit) { + $grouped[$unit->teamId] ??= []; + $grouped[$unit->teamId][] = $unit; + } + + return $grouped; + } +} diff --git a/tests/Unit/Domain/ScenarioTest.php b/tests/Unit/Domain/ScenarioTest.php new file mode 100644 index 0000000..18dad5b --- /dev/null +++ b/tests/Unit/Domain/ScenarioTest.php @@ -0,0 +1,245 @@ +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); + } + + /** @return list */ + 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 */ + 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 $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, + ); + } +} From 6befe7b3e22af1991566b0dc472e0fedc9461e31 Mon Sep 17 00:00:00 2001 From: Keith Solomon Date: Sun, 5 Jul 2026 19:43:17 -0500 Subject: [PATCH 06/10] feat: support curated abilities and objective victory --- src/Domain/CombatEngine.php | 268 +++++++++++++++++++++++-- src/Domain/UnitState.php | 9 + tests/Unit/Domain/CombatEngineTest.php | 217 +++++++++++++++++++- 3 files changed, 477 insertions(+), 17 deletions(-) diff --git a/src/Domain/CombatEngine.php b/src/Domain/CombatEngine.php index cfcaeee..77e4375 100644 --- a/src/Domain/CombatEngine.php +++ b/src/Domain/CombatEngine.php @@ -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 + */ + 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 $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 */ + 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) { diff --git a/src/Domain/UnitState.php b/src/Domain/UnitState.php index 5ae486c..04f1dc0 100644 --- a/src/Domain/UnitState.php +++ b/src/Domain/UnitState.php @@ -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); diff --git a/tests/Unit/Domain/CombatEngineTest.php b/tests/Unit/Domain/CombatEngineTest.php index e2bcd63..b64ddb4 100644 --- a/tests/Unit/Domain/CombatEngineTest.php +++ b/tests/Unit/Domain/CombatEngineTest.php @@ -4,6 +4,7 @@ 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; @@ -583,7 +584,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 +595,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 +746,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 +759,218 @@ 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 does not know that ability.'); + + (new CombatEngine())->useAbility($match, 'scout-1', 'heal', new Position(0, 1)); + } } From 84fb175c0cbb622d167010af042fab3b76d9ac0c Mon Sep 17 00:00:00 2001 From: Keith Solomon Date: Sun, 5 Jul 2026 20:02:13 -0500 Subject: [PATCH 07/10] feat: resolve objective control and objective victory --- src/Domain/CombatEngine.php | 45 ++++++++++-------- tests/Unit/Domain/CombatEngineTest.php | 63 ++++++++++++++++++++++++++ 2 files changed, 88 insertions(+), 20 deletions(-) diff --git a/src/Domain/CombatEngine.php b/src/Domain/CombatEngine.php index 77e4375..2880a8c 100644 --- a/src/Domain/CombatEngine.php +++ b/src/Domain/CombatEngine.php @@ -180,12 +180,7 @@ final class CombatEngine actionLog: [...$match->actionLog, "{$endingTeamId} ended turn"], ); - $winner = $this->resolveObjectiveVictory($next); - if ($winner !== null) { - $next = $next->withWinner($winner); - } - - return $next; + return $this->resolveObjectiveVictory($next, $endingTeamId === $teamIds[0]); } /** @@ -291,29 +286,39 @@ final class CombatEngine return $match->withWinner($actingTeamId); } - private function resolveObjectiveVictory(MatchState $match): ?string + /** + * @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 null; + return $match; } - /** @var list $objectiveList */ - $objectiveList = array_values($match->objectives); - $objective = $objectiveList[0]; - $controller = $this->objectiveController($match, $objective->position); + $next = $match; - if ($controller === null) { - return null; + if ($endingTeamIsFirstInOrder) { + /** @var list $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; } - $control = (new ObjectiveControl($match->objectiveControl))->recordRound($controller); - $next = $match->withObjectiveControl($control->roundsByTeam); - - if (($control->roundsByTeam[$controller] ?? 0) >= $next->holdRoundsRequired) { - return $controller; + foreach ($match->objectiveControl as $teamId => $count) { + if ($count >= $match->holdRoundsRequired) { + return $next->withWinner($teamId); + } } - return null; + return $next; } private function objectiveController(MatchState $match, Position $tile): ?string diff --git a/tests/Unit/Domain/CombatEngineTest.php b/tests/Unit/Domain/CombatEngineTest.php index b64ddb4..59f0026 100644 --- a/tests/Unit/Domain/CombatEngineTest.php +++ b/tests/Unit/Domain/CombatEngineTest.php @@ -9,9 +9,11 @@ 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; @@ -973,4 +975,65 @@ final class CombatEngineTest extends TestCase (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); + } } From 6fffaa230697121e9a550481d4f796f93d66fac9 Mon Sep 17 00:00:00 2001 From: Keith Solomon Date: Sun, 5 Jul 2026 20:11:35 -0500 Subject: [PATCH 08/10] feat: validate scenario deployment zones and objectives --- src/Domain/ScenarioValidator.php | 82 ++++++++++ tests/Unit/Domain/ScenarioValidatorTest.php | 162 ++++++++++++++++++++ 2 files changed, 244 insertions(+) create mode 100644 src/Domain/ScenarioValidator.php create mode 100644 tests/Unit/Domain/ScenarioValidatorTest.php diff --git a/src/Domain/ScenarioValidator.php b/src/Domain/ScenarioValidator.php new file mode 100644 index 0000000..e90bbe5 --- /dev/null +++ b/src/Domain/ScenarioValidator.php @@ -0,0 +1,82 @@ + + */ + 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 $occupied + * @return array + */ + 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; + } +} diff --git a/tests/Unit/Domain/ScenarioValidatorTest.php b/tests/Unit/Domain/ScenarioValidatorTest.php new file mode 100644 index 0000000..27f4342 --- /dev/null +++ b/tests/Unit/Domain/ScenarioValidatorTest.php @@ -0,0 +1,162 @@ +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 $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 */ + 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 */ + 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), + ]; + } +} From 12f5c0bd3a2b0689df1f3a9df7dcff4b80757aa2 Mon Sep 17 00:00:00 2001 From: Keith Solomon Date: Sun, 5 Jul 2026 20:23:28 -0500 Subject: [PATCH 09/10] test: cover scenario to match flow with ability and objective --- tests/Unit/Domain/ScenarioTest.php | 35 ++++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/tests/Unit/Domain/ScenarioTest.php b/tests/Unit/Domain/ScenarioTest.php index 18dad5b..0c160bc 100644 --- a/tests/Unit/Domain/ScenarioTest.php +++ b/tests/Unit/Domain/ScenarioTest.php @@ -6,6 +6,7 @@ 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; @@ -167,6 +168,40 @@ final class ScenarioTest extends TestCase 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 */ private function validUnitSet(): array { From 39b15c35da82968c2028d7a0c46a735f7e78ed30 Mon Sep 17 00:00:00 2001 From: Keith Solomon Date: Sun, 5 Jul 2026 20:33:03 -0500 Subject: [PATCH 10/10] refactor: tighten useAbility and Scenario archetype lookup Final-review-driven changes (Tasks 6/M1, I1 from the ledger): - Drop the redundant abilities-list check in CombatEngine::useAbility. By construction (Scenario::__construct enforces the allowlist), any ability the unit 'knows' is also archetype-allowed; the second check alone is sufficient and removes a dead code path. - Update testUseAbilityRejectsAnAbilityTheArchetypeForbids to assert the 'Unit archetype forbids that ability.' message it actually exercises. - Compute $template once in Scenario::__construct's per-unit loop and drop the redundant catalog lookup on the next line. --- src/Domain/CombatEngine.php | 4 ---- src/Domain/Scenario.php | 5 ++--- tests/Unit/Domain/CombatEngineTest.php | 2 +- 3 files changed, 3 insertions(+), 8 deletions(-) diff --git a/src/Domain/CombatEngine.php b/src/Domain/CombatEngine.php index 2880a8c..c96ca19 100644 --- a/src/Domain/CombatEngine.php +++ b/src/Domain/CombatEngine.php @@ -89,10 +89,6 @@ final class CombatEngine $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.'); } diff --git a/src/Domain/Scenario.php b/src/Domain/Scenario.php index 4a3c4ac..9d9efb6 100644 --- a/src/Domain/Scenario.php +++ b/src/Domain/Scenario.php @@ -72,12 +72,11 @@ final readonly class Scenario throw new InvalidArgumentException("Unit {$unit->id} is outside the battlefield."); } - if (!ArchetypeCatalog::templates()[$unit->archetype->value] ?? false) { // @phpstan-ignore nullCoalesce.expr, if.alwaysFalse, booleanNot.alwaysFalse (Runtime guard: defense in depth against an unknown Archetype enum value.) + $template = ArchetypeCatalog::templates()[$unit->archetype->value] ?? null; + if ($template === null) { throw new InvalidArgumentException("Unit {$unit->id} uses an unknown archetype."); } - $template = ArchetypeCatalog::templates()[$unit->archetype->value]; - if ($unit->maxHealth < $template->minHealth || $unit->maxHealth > $template->maxHealth) { throw new InvalidArgumentException("Unit {$unit->id} health is outside archetype bounds."); } diff --git a/tests/Unit/Domain/CombatEngineTest.php b/tests/Unit/Domain/CombatEngineTest.php index 59f0026..57b33bd 100644 --- a/tests/Unit/Domain/CombatEngineTest.php +++ b/tests/Unit/Domain/CombatEngineTest.php @@ -971,7 +971,7 @@ final class CombatEngineTest extends TestCase ); $this->expectException(CombatException::class); - $this->expectExceptionMessage('Unit does not know that ability.'); + $this->expectExceptionMessage('Unit archetype forbids that ability.'); (new CombatEngine())->useAbility($match, 'scout-1', 'heal', new Position(0, 1)); }