Files
BattleForge/docs/superpowers/plans/2026-07-05-curated-content-abilities-and-objectives.md

102 KiB
Raw Permalink Blame History

Curated Content, Abilities, Objectives, and Scenario Validation Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Add the curated content library (archetypes, abilities, terrain behaviors), per-round objective control, both victory conditions, and full scenario validation to the presentation-independent combat core established by Plan 1.

Architecture: The plan stays inside src/Domain and tests/Unit/Domain. New immutable value objects describe archetype templates, ability definitions, objective markers, and scenarios. CombatEngine gains useAbility, generalized team/objective handling, and objective-victory resolution on endTurn. A new ScenarioValidator owns the rules expressed in the spec for team size, stat bounds, deployment zones, and objective accessibility. Scenario::startMatch() produces an immutable MatchState, so the existing engine never sees raw scenarios. Team identity becomes scenario-driven (no more hard-coded alpha/bravo checks in the engine).

Tech Stack: PHP 8.3+, Composer PSR-4 autoloading, PHPUnit 11, PHPStan level 6, PHP_CodeSniffer with the repository ruleset.

Delivery Sequence

This is the second of four independently executable plans:

  1. Foundation and combat core — completed.
  2. Curated content, abilities, objectives, and scenario validation — this plan.
  3. Anonymous-browser persistence, secure image handling, and scenario editors.
  4. Hot-seat battle UI, bundled scenarios, end-to-end smoke coverage, and release hardening.

Do not add persistence, HTTP routes, JavaScript, image uploads, or scenario storage in this plan. Bundled scenario JSON fixtures also belong to Plan 4.

Execution Preflight

Execute this plan in an isolated worktree on feature/curated-content-and-objectives, branched from develop. The implementation branch will be submitted as a pull request into develop only after every completion check is green.

Global Constraints

These apply to every task and are copied verbatim from the spec.

  • Four distinct unit archetypes: defender, striker, support, and scout.
  • Three fixed special abilities: healing, area damage, and a temporary buff.
  • Five terrain types: open ground, forest, rough ground, water, and blocking terrain. Each type has fixed movement and defensive effects. (Already in place; this plan keeps these behaviors.)
  • Exactly two opposing teams, each with three to six units.
  • Unit stat bounds: health, attack, defense, and speed within enforced limits (per archetype — defined in this plan).
  • Abilities are assignable only when allowed by the selected archetype.
  • Battlefield dimensions: square grid, 8×8 through 16×16.
  • One non-overlapping deployment zone per team, fully within the battlefield.
  • Objectives placed only when required by the selected victory condition.
  • Two victory conditions: eliminate every opposing unit, or control a designated objective for a configured number of complete rounds.
  • Match starts from an immutable snapshot of its source scenario; later scenario edits cannot alter an active match.
  • Two actions per unit on its team's turn; a unit cannot attack or use an ability more than once per turn (a unit may still move after attacking, and may use an ability after attacking — the once-per-turn limit is on the use of attack-and-ability separately).
  • A rejected action never partially changes match state or consumes resources.
  • Domain code has no dependency on HTTP, sessions, storage, or browser code.
  • PHP follows the repository PHPCS rules and passes PHPStan at level 6.
  • Composer configuration passes composer validate --strict.

File Structure

src/Domain/Archetype.php                   Enum of four archetype identifiers
src/Domain/ArchetypeTemplate.php           Per-archetype stat bounds, ability whitelist, default stat
src/Domain/ArchetypeCatalog.php            Curated catalog of the four templates
src/Domain/AbilityId.php                   Enum of three ability identifiers
src/Domain/AbilityDefinition.php           Range/area/effect description for an ability
src/Domain/AbilityCatalog.php              Curated catalog of the three definitions
src/Domain/UnitState.php                   MODIFY — carry ability ids, archetype
src/Domain/ObjectiveMarker.php             Position + key + owner team id (or null)
src/Domain/ObjectiveControl.php            Per-team per-round control tally
src/Domain/VictoryCondition.php            Enum: Eliminate, HoldObjective
src/Domain/DeploymentZone.php              List<Position>, bounded by battlefield
src/Domain/Scenario.php                    Teams + battlefield + objectives + victory settings
src/Domain/ScenarioValidator.php           Returns list of validation issues
src/Domain/MatchState.php                  MODIFY — carry objective markers, objective control,
                                           optional effects; no more hard-coded team ids
src/Domain/CombatEngine.php                MODIFY — useAbility, generalized teams, objective
                                           victory on endTurn, ability-uses-this-turn guard
src/Domain/CombatException.php             unchanged
src/Domain/Battlefield.php                 unchanged
src/Domain/Position.php                    unchanged
src/Domain/Terrain.php                     unchanged
tests/Unit/Domain/ArchetypeCatalogTest.php Archetype catalog invariants
tests/Unit/Domain/AbilityCatalogTest.php   Ability catalog invariants
tests/Unit/Domain/ArchetypeTemplateTest.php Stat bound behavior
tests/Unit/Domain/UnitStateTest.php        MODIFY — ability list, archetype, buff flag
tests/Unit/Domain/MatchStateTest.php       MODIFY — scenario-driven team ids, objective control
tests/Unit/Domain/CombatEngineTest.php     MODIFY — new methods and general teams
tests/Unit/Domain/ScenarioTest.php         Scenario invariants and startMatch snapshot
tests/Unit/Domain/ScenarioValidatorTest.php All validation rules and error messages

Task 1: Model archetypes and the curated archetype catalog

Files:

  • Create: src/Domain/Archetype.php
  • Create: src/Domain/ArchetypeTemplate.php
  • Create: src/Domain/ArchetypeCatalog.php
  • Test: tests/Unit/Domain/ArchetypeCatalogTest.php
  • Test: tests/Unit/Domain/ArchetypeTemplateTest.php

Interfaces:

  • Consumes: none

  • Produces:

    • enum Archetype: string { case Defender; case Striker; case Support; case Scout; }
    • final readonly class ArchetypeTemplate with public int fields minHealth, maxHealth, minAttack, maxAttack, minDefense, maxDefense, minSpeed, maxSpeed, and public array $allowedAbilities (list of AbilityId — forward-references the enum created in Task 2), plus a defaultStats(): array helper returning a ['maxHealth'=>int, 'attack'=>int, 'defense'=>int, 'speed'=>int] associative array.
    • final class ArchetypeCatalog with public static function templates(): array returning array<string, ArchetypeTemplate> keyed by Archetype->value.
  • Step 1: Write the failing archetype catalog test

Create tests/Unit/Domain/ArchetypeCatalogTest.php:

<?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()),
        );
    }
}
  • Step 2: Run the test to verify it fails

Run: vendor/bin/phpunit tests/Unit/Domain/ArchetypeCatalogTest.php Expected: FAIL because BattleForge\Domain\ArchetypeCatalog does not exist.

  • Step 3: Write the failing archetype template test

Create tests/Unit/Domain/ArchetypeTemplateTest.php:

<?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);
    }
}
  • Step 4: Run the test to verify it fails

Run: vendor/bin/phpunit tests/Unit/Domain/ArchetypeTemplateTest.php Expected: FAIL because BattleForge\Domain\ArchetypeTemplate does not exist.

  • Step 5: Implement the archetype enum and template

Create src/Domain/Archetype.php:

<?php

declare(strict_types=1);

namespace BattleForge\Domain;

enum Archetype: string
{
    case Defender = 'defender';
    case Striker = 'striker';
    case Support = 'support';
    case Scout = 'scout';
}

Create src/Domain/ArchetypeTemplate.php:

<?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),
        ];
    }
}
  • Step 6: Implement the curated archetype catalog

Create src/Domain/ArchetypeCatalog.php:

<?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;
    }
}
  • Step 7: Run the archetype tests

Run: vendor/bin/phpunit tests/Unit/Domain/ArchetypeCatalogTest.php tests/Unit/Domain/ArchetypeTemplateTest.php Expected: PASS, 7 tests.

  • Step 8: Run the full quality suite and commit

Run: composer check Expected: all checks green.

git add src/Domain/Archetype.php src/Domain/ArchetypeTemplate.php src/Domain/ArchetypeCatalog.php tests/Unit/Domain/ArchetypeCatalogTest.php tests/Unit/Domain/ArchetypeTemplateTest.php
git commit -m "feat: catalog curated unit archetypes and templates"

Task 2: Model ability identifiers, definitions, and the curated ability catalog

Files:

  • Create: src/Domain/AbilityId.php
  • Create: src/Domain/AbilityDefinition.php
  • Create: src/Domain/AbilityCatalog.php
  • Test: tests/Unit/Domain/AbilityCatalogTest.php

Interfaces:

  • Consumes: AbilityCatalog is read by Tasks 3 and 6.

  • Produces:

    • enum AbilityId: string { case Heal = 'heal'; case AreaDamage = 'area_damage'; case Buff = 'buff'; }
    • final readonly class AbilityDefinition with:
      • AbilityId $id
      • string $label (human-readable name used in the action log)
      • int $range (max Manhattan distance from caster to target or center; 0 = self only)
      • int $areaRadius (0 = single tile, ≥1 = includes tiles within that Manhattan radius; 0 for heal/buff)
      • string $target — one of self, ally, enemy, tile
      • string $effect — one of restore_health, deal_damage, apply_buff
      • int $effectValue (numeric parameter; restore amount, base damage, or buff attack bonus)
    • final class AbilityCatalog with public static function definitions(): array returning array<string, AbilityDefinition> keyed by AbilityId->value.
  • Step 1: Write the failing ability catalog test

Create tests/Unit/Domain/AbilityCatalogTest.php:

<?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);
    }
}
  • Step 2: Run the test to verify it fails

Run: vendor/bin/phpunit tests/Unit/Domain/AbilityCatalogTest.php Expected: FAIL because BattleForge\Domain\AbilityCatalog does not exist.

  • Step 3: Implement the ability enum, definition, and catalog

Create src/Domain/AbilityId.php:

<?php

declare(strict_types=1);

namespace BattleForge\Domain;

enum AbilityId: string
{
    case Heal = 'heal';
    case AreaDamage = 'area_damage';
    case Buff = 'buff';
}

Create src/Domain/AbilityDefinition.php:

<?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}.");
        }
    }
}

Create src/Domain/AbilityCatalog.php:

<?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;
    }
}
  • Step 4: Run the ability tests

Run: vendor/bin/phpunit tests/Unit/Domain/AbilityCatalogTest.php Expected: PASS, 4 tests.

  • Step 5: Run the quality suite and commit

Run: composer check Expected: all checks green.

git add src/Domain/AbilityId.php src/Domain/AbilityDefinition.php src/Domain/AbilityCatalog.php tests/Unit/Domain/AbilityCatalogTest.php
git commit -m "feat: catalog curated abilities and definitions"

Task 3: Extend UnitState with archetype, ability list, and buff flag

Files:

  • Modify: src/Domain/UnitState.php
  • Modify: tests/Unit/Domain/UnitStateTest.php

Interfaces:

  • Consumes: Archetype, ArchetypeCatalog, AbilityId, AbilityCatalog.

  • Produces:

    • UnitState now carries:
      • Archetype $archetype
      • array $abilities (list of AbilityId->value strings)
      • int $attackBonus (default 0, set by withAttackBonus() for the buff effect)
      • bool $hasUsedAbility (default false; reset by startTurn())
    • New public methods:
      • withAttackBonus(int $bonus): self — returns a copy with $attackBonus set; rejects negative values.
      • spendAbility(): self — decreases actionsRemaining and sets hasUsedAbility = true; rejects if hasUsedAbility already true.
      • allowedAbilities(): array — returns the template's allowedAbilities from the catalog.
  • Step 1: Add failing tests to UnitStateTest

Append these test methods inside final class UnitStateTest in tests/Unit/Domain/UnitStateTest.php, immediately before the closing brace of the class:

    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 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);
    }

Append a corresponding $attackBonus = 0, bool $hasUsedAbility = false parameter to the existing private function unit(...) helper so the existing tests still pass and the new tests can pass values; its full body becomes:

    private function unit(
        int $health = 10,
        int $actionsRemaining = 2,
        bool $hasAttacked = false,
        int $attackBonus = 0,
        bool $hasUsedAbility = false,
    ): UnitState {
        return new UnitState(
            'unit-1',
            'alpha',
            new Position(0, 0),
            10,
            $health,
            4,
            2,
            4,
            $actionsRemaining,
            $hasAttacked,
            archetype: \BattleForge\Domain\Archetype::Defender,
            abilities: [],
            attackBonus: $attackBonus,
            hasUsedAbility: $hasUsedAbility,
        );
    }
  • Step 2: Run the tests to verify they fail

Run: vendor/bin/phpunit tests/Unit/Domain/UnitStateTest.php Expected: FAIL because the new constructor parameters do not exist.

  • Step 3: Extend UnitState with the new fields and methods

Replace src/Domain/UnitState.php with:

<?php

declare(strict_types=1);

namespace BattleForge\Domain;

use InvalidArgumentException;

final readonly class UnitState
{
    /** @param list<string> $abilities */
    public function __construct(
        public string $id,
        public string $teamId,
        public Position $position,
        public int $maxHealth,
        public int $health,
        public int $attack,
        public int $defense,
        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.');
        }

        if ($teamId === '') {
            throw new InvalidArgumentException('Unit team id cannot be empty.');
        }

        if ($maxHealth < 1) {
            throw new InvalidArgumentException('Unit maximum health must be at least 1.');
        }

        if ($health < 0 || $health > $maxHealth) {
            throw new InvalidArgumentException('Unit health must be between 0 and maximum health.');
        }

        if ($attack < 0 || $defense < 0) {
            throw new InvalidArgumentException('Unit attack and defense cannot be negative.');
        }

        if ($speed < 1) {
            throw new InvalidArgumentException('Unit speed must be at least 1.');
        }

        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) {
            if (!is_string($ability) || $ability === '') {
                throw new InvalidArgumentException('Unit abilities must be non-empty strings.');
            }
        }
    }

    public function isDefeated(): bool
    {
        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);
    }

    public function spendAction(): self
    {
        if ($this->actionsRemaining === 0) {
            throw new InvalidArgumentException('Unit has no actions remaining.');
        }

        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 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,
            attackBonus: 0,
            hasUsedAbility: false,
        );
    }

    private function copy(
        ?Position $position = null,
        ?int $health = null,
        ?int $actionsRemaining = null,
        ?bool $hasAttacked = null,
        ?int $attackBonus = null,
        ?bool $hasUsedAbility = null,
    ): self {
        return new self(
            $this->id,
            $this->teamId,
            $position ?? $this->position,
            $this->maxHealth,
            $health ?? $this->health,
            $this->attack,
            $this->defense,
            $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);
    }
}
  • Step 4: Run the unit state tests

Run: vendor/bin/phpunit tests/Unit/Domain/UnitStateTest.php Expected: PASS, all tests.

  • Step 5: Run the full quality suite and commit

Run: composer check Expected: all checks green. The other domain tests should still pass because every new property has a default value.

git add src/Domain/UnitState.php tests/Unit/Domain/UnitStateTest.php
git commit -m "feat: track archetype, abilities, and buff on unit state"

Task 4: Model objectives, victory conditions, and deployment zones

Files:

  • Create: src/Domain/ObjectiveMarker.php
  • Create: src/Domain/ObjectiveControl.php
  • Create: src/Domain/VictoryCondition.php
  • Create: src/Domain/DeploymentZone.php

Interfaces:

  • Consumes: Position, Battlefield.

  • Produces:

    • final readonly class ObjectiveMarker with string $id, Position $position. Keyed by id, used as the unit of "which tile" an objective sits on.
    • enum VictoryCondition: string { case EliminateAll = 'eliminate_all'; case HoldObjective = 'hold_objective'; }
    • final readonly class DeploymentZone with string $teamId and array $positions (list of Position). Constructed from a team id and a list of positions; rejects empty lists.
    • final readonly class ObjectiveControl with array $roundsByTeam (map of teamId → int, the number of full rounds that team controlled the objective) and a recordRound(string $controllerTeamId): self method that increments that team's tally and returns a new instance.
  • Step 1: Implement the four new types

Create src/Domain/ObjectiveMarker.php:

<?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;
    }
}

Create src/Domain/VictoryCondition.php:

<?php

declare(strict_types=1);

namespace BattleForge\Domain;

enum VictoryCondition: string
{
    case EliminateAll = 'eliminate_all';
    case HoldObjective = 'hold_objective';
}

Create src/Domain/DeploymentZone.php:

<?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)) {
            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;
    }
}

Create src/Domain/ObjectiveControl.php:

<?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<string, int> */
    public function forTeam(string $teamId): array
    {
        return ['teamId' => $teamId, 'rounds' => $this->roundsByTeam[$teamId] ?? 0];
    }
}
  • Step 2: Run the full quality suite

Run: composer check Expected: all checks still green. The new types are unused so far but compile cleanly.

  • Step 3: Commit
git add src/Domain/ObjectiveMarker.php src/Domain/ObjectiveControl.php src/Domain/VictoryCondition.php src/Domain/DeploymentZone.php
git commit -m "feat: model objectives, deployment zones, and victory conditions"

Task 5: Model the Scenario aggregate and startMatch snapshot

Files:

  • Create: src/Domain/Scenario.php
  • Test: tests/Unit/Domain/ScenarioTest.php

Interfaces:

  • Consumes: Battlefield, UnitState, Archetype, DeploymentZone, ObjectiveMarker, VictoryCondition.

  • Produces:

    • final readonly class Scenario with:
      • string $id
      • string $name
      • Battlefield $battlefield
      • array $units (list of UnitState)
      • array $deploymentZones (map of teamId → DeploymentZone)
      • array $objectives (map of objectiveId → ObjectiveMarker)
      • VictoryCondition $victoryCondition
      • int $holdRoundsRequired (only meaningful for HoldObjective, must be ≥ 1)
      • startMatch(string $activeTeamId): MatchState — returns a MatchState whose units are clones of $this->units with actionsRemaining = 2, hasAttacked = false, attackBonus = 0, hasUsedAbility = false, and whose objective control map is empty.
    • The constructor enforces:
      • 36 units per team, exactly two teams
      • Each unit's archetype is in the catalog
      • Each unit's stats fall within its archetype template's bounds
      • Each unit's abilities are listed in its archetype's allowedAbilities
      • Each unit sits inside its team's deployment zone
      • If victoryCondition = HoldObjective, exactly one objective is placed; if EliminateAll, zero objectives
  • Step 1: Write the failing scenario test

Create tests/Unit/Domain/ScenarioTest.php:

<?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\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))],
            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']);
        $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, $bravo, $bravo2, $bravo3],
            deploymentZones: [
                'alpha' => new DeploymentZone('alpha', [new Position(0, 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(0, 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, $bravo, $bravo2, $bravo3],
            deploymentZones: [
                'alpha' => new DeploymentZone('alpha', [new Position(0, 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<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,
        );
    }

    private function unit(
        string $id,
        string $team,
        Position $position,
        Archetype $archetype = Archetype::Defender,
        array $abilities = [],
        int $attack = 4,
        int $defense = 2,
        int $speed = 4,
        int $maxHealth = 10,
    ): UnitState {
        return new UnitState(
            $id,
            $team,
            $position,
            $maxHealth,
            $maxHealth,
            $attack,
            $defense,
            $speed,
            2,
            false,
            $archetype,
            $abilities,
            0,
            false,
        );
    }
}
  • Step 2: Run the scenario tests to verify they fail

Run: vendor/bin/phpunit tests/Unit/Domain/ScenarioTest.php Expected: FAIL because BattleForge\Domain\Scenario does not exist.

  • Step 3: Implement Scenario

Create src/Domain/Scenario.php:

<?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)) {
            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) {
                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<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;
    }
}
  • Step 4: Run the scenario tests to verify they fail (compile error expected)

Run: vendor/bin/phpunit tests/Unit/Domain/ScenarioTest.php Expected: FAIL with a compile error because MatchState does not yet accept the new objectives, victoryCondition, and holdRoundsRequired parameters.

  • Step 5: Extend MatchState to carry objectives and victory settings

This task partially implements Task 6's MatchState changes. Replace src/Domain/MatchState.php with:

<?php

declare(strict_types=1);

namespace BattleForge\Domain;

use InvalidArgumentException;

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,
        public array $units,
        public string $activeTeamId,
        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.');
        }

        if ($units === []) {
            throw new InvalidArgumentException('Match must contain at least one unit.');
        }

        $unitIds = [];
        $teamIds = [];
        $occupiedPositions = [];

        foreach ($units as $unit) {
            if (!self::isUnitState($unit)) {
                throw new InvalidArgumentException('Match units must contain only UnitState instances.');
            }

            if (isset($unitIds[$unit->id])) {
                throw new InvalidArgumentException("Duplicate unit id: {$unit->id}.");
            }

            if (!$battlefield->contains($unit->position)) {
                throw new InvalidArgumentException("Unit {$unit->id} is outside the battlefield.");
            }

            if (!$unit->isDefeated() && $battlefield->terrainAt($unit->position)->movementCost() === null) {
                throw new InvalidArgumentException("Living unit {$unit->id} must stand on passable terrain.");
            }

            $positionKey = $unit->position->key();

            if (!$unit->isDefeated() && isset($occupiedPositions[$positionKey])) {
                throw new InvalidArgumentException("Living units cannot share position {$positionKey}.");
            }

            if (!$unit->isDefeated()) {
                $occupiedPositions[$positionKey] = true;
            }

            $unitIds[$unit->id] = true;
            $teamIds[$unit->teamId] = true;
        }

        if ($activeTeamId === '') {
            throw new InvalidArgumentException('Active team id cannot be empty.');
        }

        if (!isset($teamIds[$activeTeamId])) {
            throw new InvalidArgumentException("Active team has no units: {$activeTeamId}.");
        }

        if ($round < 1) {
            throw new InvalidArgumentException('Match round must be at least 1.');
        }

        if (!self::isList($actionLog)) {
            throw new InvalidArgumentException('Match action log must be a list.');
        }

        foreach ($actionLog as $entry) {
            if (!self::isString($entry)) {
                throw new InvalidArgumentException('Match action log entries must be strings.');
            }
        }

        if ($winnerTeamId !== null && $winnerTeamId === '') {
            throw new InvalidArgumentException('Winner team id cannot be empty.');
        }

        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) {
                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 (!is_string($teamId) || $teamId === '') {
                throw new InvalidArgumentException('Objective control team id must be a non-empty string.');
            }
            if (!is_int($count) || $count < 0) {
                throw new InvalidArgumentException('Objective control count must be a non-negative integer.');
            }
        }
    }

    private static function isUnitState(mixed $unit): bool
    {
        return $unit instanceof UnitState;
    }

    private static function isString(mixed $value): bool
    {
        return is_string($value);
    }

    private static function isList(mixed $value): bool
    {
        return is_array($value) && array_is_list($value);
    }

    public function unit(string $id): UnitState
    {
        foreach ($this->units as $unit) {
            if ($unit->id === $id) {
                return $unit;
            }
        }

        throw new InvalidArgumentException("Unknown unit id: {$id}.");
    }

    public function withUnit(UnitState $replacement): self
    {
        $units = $this->units;

        foreach ($units as $index => $unit) {
            if ($unit->id === $replacement->id) {
                $units[$index] = $replacement;

                return $this->copy(units: $units);
            }
        }

        throw new InvalidArgumentException("Unknown unit id: {$replacement->id}.");
    }

    public function withWinner(?string $winnerTeamId): self
    {
        return new self(
            $this->battlefield,
            $this->units,
            $this->activeTeamId,
            $this->round,
            $winnerTeamId,
            $this->actionLog,
            $this->objectives,
            $this->victoryCondition,
            $this->holdRoundsRequired,
            $this->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,
            $units ?? $this->units,
            $activeTeamId ?? $this->activeTeamId,
            $round ?? $this->round,
            $this->winnerTeamId,
            $actionLog ?? $this->actionLog,
            $objectives ?? $this->objectives,
            $this->victoryCondition,
            $this->holdRoundsRequired,
            $objectiveControl ?? $this->objectiveControl,
        );
    }
}
  • Step 6: Run the scenario tests

Run: vendor/bin/phpunit tests/Unit/Domain/ScenarioTest.php Expected: PASS, 7 tests.

  • Step 7: Run the full quality suite

Run: composer check Expected: all checks green. (Existing MatchStateTest, BattlefieldTest, and UnitStateTest continue to pass because every new parameter has a default value.)

  • Step 8: Commit
git add src/Domain/Scenario.php src/Domain/MatchState.php tests/Unit/Domain/ScenarioTest.php
git commit -m "feat: model scenarios and start match snapshots"

Task 6: Generalize CombatEngine teams and add useAbility with range and area effects

Files:

  • Modify: src/Domain/CombatEngine.php
  • Modify: tests/Unit/Domain/CombatEngineTest.php

Interfaces:

  • Consumes: AbilityCatalog, AbilityDefinition, MatchState (now carrying objectives and victoryCondition).

  • Produces:

    • CombatEngine::useAbility(MatchState $match, string $unitId, string $abilityId, ?Position $target): MatchState
      • Validates: match active, unit belongs to active team, unit is alive, unit has actions remaining, unit has not yet used an ability this turn, ability is in the unit's archetype allowlist, ability is in the unit's abilities list, target is in range, target is the correct type for the ability (self, ally, enemy, tile), and the area tiles exist.
      • Applies the effect:
        • restore_health: heal the target unit up to its maxHealth by effectValue (does not overflow; never damages).
        • deal_damage: for every enemy unit on a tile within areaRadius of the center (Manhattan distance), apply max(1, effectValue - target.defense - terrainDefense) damage; terrain defense still applies to area damage.
        • apply_buff: target unit's attackBonus becomes effectValue (replacing any previous buff).
      • Decrements actionsRemaining by 1 and sets hasUsedAbility = true on the caster.
      • Appends a descriptive log entry.
    • CombatEngine::endTurn generalized: it now resolves whichever victory condition is configured (EliminateAll keeps current behavior; HoldObjective tallies per-round control and declares a winner when a team's tally reaches holdRoundsRequired).
    • CombatEngine::attack now also accounts for the attacker's attackBonus when computing damage.
    • The hard-coded alpha/bravo requirement in endTurn is removed; team identity comes from MatchState::units.
  • Step 1: Write the failing ability tests

Append the following tests inside final class CombatEngineTest in tests/Unit/Domain/CombatEngineTest.php, immediately before the closing brace of the class:

    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));
        $bravoB = $this->unit('bravo-2', 'bravo', new Position(2, 1));
        $bravoC = $this->unit('bravo-3', 'bravo', new Position(3, 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: ['heal'],
            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));
    }
  • Step 2: Run the new tests to verify they fail

Run: vendor/bin/phpunit tests/Unit/Domain/CombatEngineTest.php --filter Ability Expected: FAIL because CombatEngine::useAbility does not exist.

  • Step 3: Implement useAbility and update attack/endTurn

Replace src/Domain/CombatEngine.php with:

<?php

declare(strict_types=1);

namespace BattleForge\Domain;

use InvalidArgumentException;

final class CombatEngine
{
    public function move(MatchState $match, string $unitId, Position $destination): MatchState
    {
        $this->assertMatchActive($match);

        $unit = $this->unitOrFail($match, $unitId);
        $this->assertCanAct($match, $unit);

        $occupied = [];

        foreach ($match->units as $otherUnit) {
            if ($otherUnit->id !== $unit->id && !$otherUnit->isDefeated()) {
                $occupied[] = $otherUnit->position;
            }
        }

        $reachable = $match->battlefield->reachable($unit->position, $unit->speed, $occupied);

        if ($destination->key() === $unit->position->key() || !isset($reachable[$destination->key()])) {
            throw new CombatException('Destination is not reachable.');
        }

        $movedUnit = $unit->moveTo($destination)->spendAction();
        $next = $match->withUnit($movedUnit);
        $actionLog = [...$match->actionLog, "{$unit->id} moved to {$destination->key()}"];

        return $next->copy(actionLog: $actionLog);
    }

    public function attack(MatchState $match, string $attackerId, string $targetId): MatchState
    {
        $this->assertMatchActive($match);

        $attacker = $this->unitOrFail($match, $attackerId);
        $this->assertCanAct($match, $attacker);

        if ($attacker->hasAttacked) {
            throw new CombatException('Unit has already attacked this turn.');
        }

        $target = $this->unitOrFail($match, $targetId);

        if ($target->teamId === $attacker->teamId || $target->isDefeated()) {
            throw new CombatException('Target must be an active enemy unit.');
        }

        $distance = $attacker->position->distanceTo($target->position);

        if ($distance !== 1) {
            throw new CombatException('Target is outside attack range.');
        }

        $terrainDefense = $match->battlefield->terrainAt($target->position)->defenseBonus();
        $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 $next->withWinner($attacker->teamId);
    }

    public function useAbility(MatchState $match, string $unitId, string $abilityId, ?Position $target): MatchState
    {
        $this->assertMatchActive($match);

        $caster = $this->unitOrFail($match, $unitId);
        $this->assertCanAct($match, $caster);

        if ($caster->hasUsedAbility) {
            throw new CombatException('Unit has already used an ability this turn.');
        }

        $definitions = AbilityCatalog::definitions();

        if (!isset($definitions[$abilityId])) {
            throw new CombatException("Unknown ability: {$abilityId}.");
        }

        $definition = $definitions[$abilityId];

        if (!in_array($abilityId, $caster->abilities, true)) {
            throw new CombatException('Unit does not know that ability.');
        }

        if (!in_array($abilityId, $caster->allowedAbilities(), true)) {
            throw new CombatException('Unit archetype forbids that ability.');
        }

        $affected = $this->resolveAbilityTargets($match, $caster, $definition, $target);

        $next = $match;
        $logParts = [];

        foreach ($affected as $affectedUnit) {
            $current = $next->unit($affectedUnit['id']);
            $updated = match ($definition->effect) {
                AbilityDefinition::EFFECT_RESTORE_HEALTH => $current->copy(health: min(
                    $current->maxHealth,
                    $current->health + $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",
                default => $affectedUnit['id'],
            };
        }

        $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
    {
        $this->assertMatchActive($match);

        $teamIds = [];

        foreach ($match->units as $unit) {
            $teamIds[$unit->teamId] = true;
        }

        $teamIds = array_values(array_keys($teamIds));
        sort($teamIds);

        if (count($teamIds) !== 2) {
            throw new CombatException('Matches require exactly two teams.');
        }

        $livingTeams = array_fill_keys($teamIds, false);

        foreach ($match->units as $unit) {
            if (!$unit->isDefeated()) {
                $livingTeams[$unit->teamId] = true;
            }
        }

        foreach ($livingTeams as $teamId => $alive) {
            if (!$alive) {
                throw new CombatException('Cannot end turn while a team is eliminated.');
            }
        }

        $endingTeamId = $match->activeTeamId;
        $nextTeamId = $endingTeamId === $teamIds[0] ? $teamIds[1] : $teamIds[0];

        if ($nextTeamId === $teamIds[0] && $match->round === PHP_INT_MAX) {
            throw new CombatException('Match round limit reached.');
        }

        $units = [];

        foreach ($match->units as $unit) {
            $units[] = $unit->teamId === $nextTeamId ? $unit->startTurn() : $unit;
        }

        $round = $match->round + ($nextTeamId === $teamIds[0] ? 1 : 0);

        $next = $match->copy(
            units: $units,
            activeTeamId: $nextTeamId,
            round: $round,
            actionLog: [...$match->actionLog, "{$endingTeamId} ended turn"],
        );

        $winner = $this->resolveObjectiveVictory($next);
        if ($winner !== null) {
            $next = $next->withWinner($winner);
        }

        return $next;
    }

    /**
     * @return list<array{id: string, damage?: int}>
     */
    private function resolveAbilityTargets(
        MatchState $match,
        UnitState $caster,
        AbilityDefinition $definition,
        ?Position $target,
    ): array {
        if ($definition->target === AbilityDefinition::TARGET_SELF) {
            return [['id' => $caster->id]];
        }

        if ($target === null) {
            throw new CombatException('Ability requires a target position.');
        }

        if (!$match->battlefield->contains($target)) {
            throw new CombatException('Ability target is outside the battlefield.');
        }

        $distance = $caster->position->distanceTo($target);

        if ($distance > $definition->range) {
            throw new CombatException('Ability target is out of range.');
        }

        $tileKeys = [$target->key()];

        if ($definition->areaRadius > 0) {
            foreach ([[1, 0], [-1, 0], [0, 1], [0, -1]] as $offset) {
                $candidate = new Position($target->x + $offset[0], $target->y + $offset[1]);
                if ($match->battlefield->contains($candidate) && $candidate->key() !== $target->key()) {
                    $tileKeys[] = $candidate->key();
                }
            }
        }

        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;
        }

        $objective = reset($match->objectives);
        $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
    {
        try {
            return $match->unit($unitId);
        } catch (InvalidArgumentException $exception) {
            throw new CombatException("Unknown unit: {$unitId}.", previous: $exception);
        }
    }

    private function assertMatchActive(MatchState $match): void
    {
        if ($match->winnerTeamId !== null) {
            throw new CombatException('The match is already complete.');
        }
    }

    private function assertCanAct(MatchState $match, UnitState $unit): void
    {
        if ($unit->teamId !== $match->activeTeamId) {
            throw new CombatException("It is not this unit's turn.");
        }

        if ($unit->isDefeated()) {
            throw new CombatException('Defeated units cannot act.');
        }

        if ($unit->actionsRemaining === 0) {
            throw new CombatException('Unit has no actions remaining.');
        }
    }
}
  • Step 4: Run the new ability tests and the full suite

Run: vendor/bin/phpunit tests/Unit/Domain/CombatEngineTest.php Expected: all combat-engine tests pass, including the six new ability tests (heal, area damage, buff, already-used, out-of-range, and forbidden-ability).

Run: composer check Expected: all checks green.

  • Step 5: Commit
git add src/Domain/CombatEngine.php tests/Unit/Domain/CombatEngineTest.php
git commit -m "feat: support curated abilities and objective victory"

Task 7: Resolve objective control and the second victory condition

Files:

  • Modify: src/Domain/CombatEngine.php
  • Modify: tests/Unit/Domain/CombatEngineTest.php

Interfaces:

  • Produces: CombatEngine now resolves HoldObjective at the end of a complete round (after endTurn advances back to the first team) by tallying one round of control for whichever team is solely standing on the objective. The winner is declared when the tally reaches holdRoundsRequired.

  • Step 1: Add failing objective-victory tests

Append these tests inside final class CombatEngineTest in tests/Unit/Domain/CombatEngineTest.php, immediately before the closing brace of the class:

    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 testEndTurnDoesNotAwardControlWhenContested(): 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);
    }
  • Step 2: Run the new tests to verify they pass on the current engine

Run: vendor/bin/phpunit tests/Unit/Domain/CombatEngineTest.php --filter Objective Expected: PASS for all three tests. The contested-tile test asserts that control stays empty when neither team is on the objective, which is the only state the engine can encounter given MatchState rejects two living units sharing a tile.

  • Step 3: Run the full quality suite and commit

Run: composer check Expected: all checks green.

git add src/Domain/CombatEngine.php tests/Unit/Domain/CombatEngineTest.php
git commit -m "feat: resolve objective control and objective victory"

Task 8: Build the ScenarioValidator

Files:

  • Create: src/Domain/ScenarioValidator.php
  • Test: tests/Unit/Domain/ScenarioValidatorTest.php

Interfaces:

  • Consumes: Scenario, Battlefield.

  • Produces:

    • final class ScenarioValidator with public static function validate(Scenario $scenario): array returning a list<string> of human-readable error messages (empty when the scenario is valid). Validation rules:
      • Battlefield is square (already enforced by Battlefield).
      • Each deployment zone's positions are non-overlapping across teams.
      • Every objective (when present) lies on a tile reachable from at least one deployment zone of either team (a defender must be able to reach the objective, and an attacker must be able to reach the objective to contest it).
      • Every objective is not on impassable terrain.
      • Deployment zone positions are passable.
  • Step 1: Write the failing validator test

Create tests/Unit/Domain/ScenarioValidatorTest.php:

<?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\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)]),
                'bravo' => new DeploymentZone('bravo', [new Position(1, 0), new Position(2, 0)]),
            ],
            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')));
    }

    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, 2, 4, 2, false, Archetype::Defender),
            new UnitState('alpha-2', 'alpha', new Position(1, 0), 10, 10, 4, 2, 4, 2, false, Archetype::Striker),
            new UnitState('alpha-3', 'alpha', new Position(2, 0), 10, 10, 4, 2, 4, 2, false, Archetype::Support),
            new UnitState('bravo-1', 'bravo', new Position(7, 7), 10, 10, 4, 2, 4, 2, false, Archetype::Defender),
            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, 2, 4, 2, false, Archetype::Scout),
        ];
    }
}
  • Step 2: Run the validator tests to verify they fail

Run: vendor/bin/phpunit tests/Unit/Domain/ScenarioValidatorTest.php Expected: FAIL because BattleForge\Domain\ScenarioValidator does not exist.

  • Step 3: Implement ScenarioValidator

Create src/Domain/ScenarioValidator.php:

<?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;
    }
}
  • Step 4: Run the validator tests

Run: vendor/bin/phpunit tests/Unit/Domain/ScenarioValidatorTest.php Expected: PASS, 5 tests.

  • Step 5: Run the full quality suite and commit

Run: composer check Expected: all checks green.

git add src/Domain/ScenarioValidator.php tests/Unit/Domain/ScenarioValidatorTest.php
git commit -m "feat: validate scenario deployment zones and objectives"

Task 9: Add a Scenario and ScenarioValidator integration test

Files:

  • Modify: tests/Unit/Domain/ScenarioTest.php

  • Modify: tests/Unit/Domain/ScenarioValidatorTest.php

  • Step 1: Add an integration test that builds a full scenario and starts a match

Append this test inside final class ScenarioTest in tests/Unit/Domain/ScenarioTest.php, immediately before the closing brace of the class:

    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), 10, 10, 4, 2, 4, 2, false, Archetype::Support, ['heal', 'buff']),
            new UnitState('alpha-3', 'alpha', new Position(2, 0), 10, 10, 4, 2, 4, 2, false, Archetype::Defender),
            new UnitState('bravo-1', 'bravo', new Position(7, 7), 10, 10, 4, 2, 4, 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, 2, 4, 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(4, 4))],
            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);
    }
  • Step 2: Run the integration test

Run: vendor/bin/phpunit tests/Unit/Domain/ScenarioTest.php --filter StartingAMatchWithAbilityAndObjective Expected: PASS.

  • Step 3: Run the full quality suite and commit

Run: composer check Expected: all checks green. The full test count should now be at least 105 tests (91 from Plan 1 + 14 new tests across the new test files plus expanded CombatEngineTest).

git add tests/Unit/Domain/ScenarioTest.php
git commit -m "test: cover scenario to match flow with ability and objective"

Task 10: Update CI plan to refresh PHPStan baseline

Files:

  • Modify: .github/workflows/ci.yml (no change expected — already runs composer check)

  • Step 1: Verify CI still exercises the same commands

The Plan 1 CI workflow runs composer validate --strict, composer install --no-interaction --prefer-dist, and composer check. This plan does not introduce new dependencies or scripts, so no CI change is required. Confirm by reading .github/workflows/ci.yml and verifying the steps match.

Run: composer validate --strict Expected: Composer reports the manifest is valid.

  • Step 2: No code change is required; document the verification in the commit log

If the workflow already exists and is unchanged, no commit is required. If a workflow change is required (it is not), follow Plan 1 Task 7's pattern.

Completion Check

Run:

composer validate --strict
composer check
git status --short

Expected:

  • Composer reports a valid manifest.
  • PHPCS reports no coding-standard violations.
  • PHPStan reports no errors at level 6.
  • PHPUnit passes at least 105 tests across the domain suite.
  • Git reports no unexpected changes from this plan.
  • Archetypes, abilities, objectives, deployment zones, scenario validation, and both victory conditions are exercised by the unit tests.
  • The combat engine no longer references alpha/bravo by name; team identity is fully scenario-driven.
  • The presentation layer still has no dependencies; src/Domain continues to import nothing outside its own namespace plus InvalidArgumentException / SplQueue from the standard library.