Files
BattleForge/docs/superpowers/plans/2026-07-04-foundation-and-combat-core.md
T

32 KiB

Foundation and Combat Core 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: Establish the standalone PHP project and deliver a tested, presentation-independent combat core supporting terrain-aware movement, adjacent attacks, alternating turns, and elimination victory.

Architecture: Domain code lives under src/Domain and has no dependency on HTTP, sessions, storage, or browser code. Immutable value objects describe battle state; CombatEngine validates commands and returns a new state, making later persistence, action logging, UI, and online play straightforward additions.

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


Delivery Sequence

This is the first of four independently executable plans:

  1. Foundation and combat core — this plan.
  2. Curated content, abilities, objectives, and scenario validation.
  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, abilities, objectives, or user uploads in this plan.

Execution Preflight

Execute this plan in an isolated worktree on feature/combat-core, branched from develop, as required by the repository workflow. If this new repository does not yet have a local develop branch, create it once from the approved planning commit on main; do not implement directly on main or develop.

The implementation branch will be submitted as a pull request into develop only after every completion check is green.

File Structure

composer.json                         Composer scripts and dependencies
phpcs.xml                             Project coding standard
phpstan.neon                          Static-analysis configuration
phpunit.xml                           Test-suite configuration
src/Domain/Battlefield.php            Grid bounds, terrain, and path costs
src/Domain/CombatEngine.php           Move, attack, and turn command handling
src/Domain/CombatException.php        Stable rejected-command reason
src/Domain/MatchState.php             Immutable aggregate for one match
src/Domain/Position.php               Grid coordinate value object
src/Domain/Terrain.php                Curated terrain behavior
src/Domain/UnitState.php              Immutable runtime state for one unit
tests/Unit/Domain/BattlefieldTest.php Terrain and reachability behavior
tests/Unit/Domain/CombatEngineTest.php Command, turn, and victory behavior
.github/workflows/ci.yml               Required PHP quality checks

Task 1: Bootstrap the PHP quality toolchain

Files:

  • Create: composer.json

  • Create: phpcs.xml

  • Create: phpstan.neon

  • Create: phpunit.xml

  • Step 1: Create the Composer manifest

Create composer.json:

{
    "name": "battleforge/battleforge",
    "description": "A curated tactical sandbox web application.",
    "type": "project",
    "license": "proprietary",
    "require": {
        "php": "^8.3"
    },
    "require-dev": {
        "dealerdirect/phpcodesniffer-composer-installer": "^1.0",
        "phpstan/phpstan": "^2.0",
        "phpunit/phpunit": "^11.5",
        "squizlabs/php_codesniffer": "^3.10"
    },
    "autoload": {
        "psr-4": {
            "BattleForge\\": "src/"
        }
    },
    "autoload-dev": {
        "psr-4": {
            "BattleForge\\Tests\\": "tests/"
        }
    },
    "scripts": {
        "lint": "phpcs",
        "analyse": "phpstan analyse --no-progress",
        "test": "phpunit",
        "check": [
            "@lint",
            "@analyse",
            "@test"
        ]
    },
    "config": {
        "allow-plugins": {
            "dealerdirect/phpcodesniffer-composer-installer": true
        },
        "sort-packages": true
    }
}
  • Step 2: Create the code-quality configuration

Create phpcs.xml:

<?xml version="1.0"?>
<ruleset name="BattleForge">
    <description>BattleForge PHP coding standards.</description>
    <file>src</file>
    <file>tests</file>
    <arg name="colors"/>
    <arg value="sp"/>
    <rule ref="PSR12"/>
</ruleset>

Create phpstan.neon:

parameters:
    level: 6
    paths:
        - src
        - tests
    tmpDir: var/phpstan

Create phpunit.xml:

<?xml version="1.0" encoding="UTF-8"?>
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:noNamespaceSchemaLocation="https://schema.phpunit.de/11.5/phpunit.xsd"
         bootstrap="vendor/autoload.php"
         cacheDirectory="var/phpunit"
         colors="true">
    <testsuites>
        <testsuite name="BattleForge">
            <directory>tests</directory>
        </testsuite>
    </testsuites>
    <source>
        <include>
            <directory>src</directory>
        </include>
    </source>
</phpunit>
  • Step 3: Install dependencies and validate the manifest

Run: composer validate --strict && composer install

Expected: Composer reports that composer.json is valid and installs the four development tools without dependency-resolution errors.

  • Step 4: Verify each installed tool is executable

Run: vendor/bin/phpcs --version && vendor/bin/phpstan --version && vendor/bin/phpunit --version

Expected: each tool prints its installed version and exits successfully. The complete composer check suite begins in Task 2, after the source and test directories exist.

  • Step 5: Commit the toolchain
git add composer.json composer.lock phpcs.xml phpstan.neon phpunit.xml
git commit -m "chore: bootstrap PHP quality toolchain"

Task 2: Model grid positions and curated terrain

Files:

  • Create: src/Domain/Position.php

  • Create: src/Domain/Terrain.php

  • Create: src/Domain/Battlefield.php

  • Test: tests/Unit/Domain/BattlefieldTest.php

  • Step 1: Write the failing battlefield tests

Create tests/Unit/Domain/BattlefieldTest.php:

<?php

declare(strict_types=1);

namespace BattleForge\Tests\Unit\Domain;

use BattleForge\Domain\Battlefield;
use BattleForge\Domain\Position;
use BattleForge\Domain\Terrain;
use InvalidArgumentException;
use PHPUnit\Framework\TestCase;

final class BattlefieldTest extends TestCase
{
    public function testRejectsDimensionsOutsideMvpBounds(): void
    {
        $this->expectException(InvalidArgumentException::class);

        new Battlefield(7, 16);
    }

    public function testFindsDestinationsWithinMovementBudget(): void
    {
        $battlefield = new Battlefield(
            8,
            8,
            [
                '1:0' => Terrain::Forest,
                '0:1' => Terrain::Rough,
                '2:0' => Terrain::Water,
                '1:1' => Terrain::Blocking,
            ],
        );

        $reachable = $battlefield->reachable(
            new Position(0, 0),
            2,
            [new Position(0, 2)],
        );

        self::assertSame(0, $reachable['0:0']);
        self::assertSame(2, $reachable['1:0']);
        self::assertSame(2, $reachable['0:1']);
        self::assertArrayNotHasKey('2:0', $reachable);
        self::assertArrayNotHasKey('1:1', $reachable);
        self::assertArrayNotHasKey('0:2', $reachable);
    }
}
  • Step 2: Run the test to verify it fails

Run: vendor/bin/phpunit tests/Unit/Domain/BattlefieldTest.php

Expected: FAIL because BattleForge\Domain\Battlefield does not exist.

  • Step 3: Implement positions, terrain, and path costs

Create src/Domain/Position.php:

<?php

declare(strict_types=1);

namespace BattleForge\Domain;

final readonly class Position
{
    public function __construct(public int $x, public int $y)
    {
    }

    public function key(): string
    {
        return $this->x . ':' . $this->y;
    }

    public function distanceTo(self $other): int
    {
        return abs($this->x - $other->x) + abs($this->y - $other->y);
    }
}

Create src/Domain/Terrain.php:

<?php

declare(strict_types=1);

namespace BattleForge\Domain;

enum Terrain: string
{
    case Open = 'open';
    case Forest = 'forest';
    case Rough = 'rough';
    case Water = 'water';
    case Blocking = 'blocking';

    public function movementCost(): ?int
    {
        return match ($this) {
            self::Open => 1,
            self::Forest, self::Rough => 2,
            self::Water, self::Blocking => null,
        };
    }

    public function defenseBonus(): int
    {
        return $this === self::Forest ? 1 : 0;
    }
}

Create src/Domain/Battlefield.php:

<?php

declare(strict_types=1);

namespace BattleForge\Domain;

use InvalidArgumentException;
use SplQueue;

final readonly class Battlefield
{
    /** @param array<string, Terrain> $terrain */
    public function __construct(
        public int $width,
        public int $height,
        private array $terrain = [],
    ) {
        if ($width < 8 || $width > 16 || $height < 8 || $height > 16) {
            throw new InvalidArgumentException('Battlefields must be between 8x8 and 16x16 tiles.');
        }

        foreach ($terrain as $key => $_type) {
            [$x, $y] = array_map('intval', explode(':', $key));
            if (!$this->contains(new Position($x, $y))) {
                throw new InvalidArgumentException('Terrain cannot be placed outside the battlefield.');
            }
        }
    }

    public function contains(Position $position): bool
    {
        return $position->x >= 0
            && $position->x < $this->width
            && $position->y >= 0
            && $position->y < $this->height;
    }

    public function terrainAt(Position $position): Terrain
    {
        return $this->terrain[$position->key()] ?? Terrain::Open;
    }

    /**
     * @param list<Position> $occupied
     * @return array<string, int>
     */
    public function reachable(Position $start, int $budget, array $occupied): array
    {
        $costs = [$start->key() => 0];
        $blocked = [];
        foreach ($occupied as $position) {
            $blocked[$position->key()] = true;
        }

        /** @var SplQueue<array{Position, int}> $queue */
        $queue = new SplQueue();
        $queue->enqueue([$start, 0]);

        while (!$queue->isEmpty()) {
            [$current, $spent] = $queue->dequeue();
            foreach ($this->neighbors($current) as $neighbor) {
                $step = $this->terrainAt($neighbor)->movementCost();
                $total = $step === null ? $budget + 1 : $spent + $step;
                $known = $costs[$neighbor->key()] ?? PHP_INT_MAX;
                if ($total > $budget || isset($blocked[$neighbor->key()]) || $total >= $known) {
                    continue;
                }

                $costs[$neighbor->key()] = $total;
                $queue->enqueue([$neighbor, $total]);
            }
        }

        return $costs;
    }

    /** @return list<Position> */
    private function neighbors(Position $position): array
    {
        $candidates = [
            new Position($position->x + 1, $position->y),
            new Position($position->x - 1, $position->y),
            new Position($position->x, $position->y + 1),
            new Position($position->x, $position->y - 1),
        ];

        return array_values(array_filter($candidates, $this->contains(...)));
    }
}
  • Step 4: Run the battlefield tests

Run: vendor/bin/phpunit tests/Unit/Domain/BattlefieldTest.php

Expected: PASS with 2 tests.

  • Step 5: Run quality checks and commit

Run: composer check

Expected: PHPCS, PHPStan, and PHPUnit all exit successfully.

git add src/Domain/Position.php src/Domain/Terrain.php src/Domain/Battlefield.php tests/Unit/Domain/BattlefieldTest.php
git commit -m "feat: model battlefield terrain and movement costs"

Task 3: Model immutable unit and match state

Files:

  • Create: src/Domain/UnitState.php

  • Create: src/Domain/MatchState.php

  • Test: tests/Unit/Domain/CombatEngineTest.php

  • Step 1: Write the failing state-transition test

Create tests/Unit/Domain/CombatEngineTest.php:

<?php

declare(strict_types=1);

namespace BattleForge\Tests\Unit\Domain;

use BattleForge\Domain\Battlefield;
use BattleForge\Domain\MatchState;
use BattleForge\Domain\Position;
use BattleForge\Domain\UnitState;
use PHPUnit\Framework\TestCase;

final class CombatEngineTest extends TestCase
{
    public function testMatchReplacesAUnitWithoutMutatingOriginalState(): void
    {
        $unit = $this->unit('alpha-1', 'alpha', new Position(0, 0));
        $match = new MatchState(new Battlefield(8, 8), [$unit], 'alpha');

        $moved = $unit->moveTo(new Position(1, 0))->spendAction();
        $next = $match->withUnit($moved);

        self::assertSame('0:0', $match->unit('alpha-1')->position->key());
        self::assertSame('1:0', $next->unit('alpha-1')->position->key());
        self::assertSame(1, $next->unit('alpha-1')->actionsRemaining);
    }

    private function unit(string $id, string $team, Position $position): UnitState
    {
        return new UnitState($id, $team, $position, 10, 10, 4, 2, 4, 2);
    }
}
  • Step 2: Run the test to verify it fails

Run: vendor/bin/phpunit tests/Unit/Domain/CombatEngineTest.php

Expected: FAIL because BattleForge\Domain\UnitState does not exist.

  • Step 3: Implement immutable unit state

Create src/Domain/UnitState.php:

<?php

declare(strict_types=1);

namespace BattleForge\Domain;

use InvalidArgumentException;

final readonly class UnitState
{
    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,
    ) {
        if ($id === '' || $teamId === '') {
            throw new InvalidArgumentException('Unit and team identifiers are required.');
        }
        if ($maxHealth < 1 || $health < 0 || $health > $maxHealth) {
            throw new InvalidArgumentException('Unit health is outside its valid range.');
        }
        if ($attack < 0 || $defense < 0 || $speed < 1) {
            throw new InvalidArgumentException('Unit combat statistics are invalid.');
        }
        if ($actionsRemaining < 0 || $actionsRemaining > 2) {
            throw new InvalidArgumentException('Units may have zero, one, or two actions.');
        }
    }

    public function isDefeated(): bool
    {
        return $this->health === 0;
    }

    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 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 startTurn(): self
    {
        return $this->isDefeated()
            ? $this
            : $this->copy(actionsRemaining: 2, hasAttacked: false);
    }

    private function copy(
        ?Position $position = null,
        ?int $health = null,
        ?int $actionsRemaining = null,
        ?bool $hasAttacked = 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,
        );
    }
}
  • Step 4: Implement immutable match state

Create src/Domain/MatchState.php:

<?php

declare(strict_types=1);

namespace BattleForge\Domain;

use InvalidArgumentException;

final readonly class MatchState
{
    /**
     * @param list<UnitState> $units
     * @param list<string> $actionLog
     */
    public function __construct(
        public Battlefield $battlefield,
        public array $units,
        public string $activeTeamId,
        public int $round = 1,
        public ?string $winnerTeamId = null,
        public array $actionLog = [],
    ) {
        $ids = array_map(static fn (UnitState $unit): string => $unit->id, $units);
        if (count($ids) !== count(array_unique($ids))) {
            throw new InvalidArgumentException('Unit identifiers must be unique.');
        }
    }

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

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

    public function withUnit(UnitState $replacement): self
    {
        $found = false;
        $units = array_map(
            static function (UnitState $unit) use ($replacement, &$found): UnitState {
                if ($unit->id !== $replacement->id) {
                    return $unit;
                }
                $found = true;
                return $replacement;
            },
            $this->units,
        );
        if (!$found) {
            throw new InvalidArgumentException('Cannot replace an unknown unit.');
        }

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

    /** @param list<UnitState>|null $units */
    public function copy(
        ?array $units = null,
        ?string $activeTeamId = null,
        ?int $round = null,
        ?string $winnerTeamId = null,
        ?array $actionLog = null,
    ): self {
        return new self(
            $this->battlefield,
            $units ?? $this->units,
            $activeTeamId ?? $this->activeTeamId,
            $round ?? $this->round,
            $winnerTeamId ?? $this->winnerTeamId,
            $actionLog ?? $this->actionLog,
        );
    }
}
  • Step 5: Run the state test

Run: vendor/bin/phpunit tests/Unit/Domain/CombatEngineTest.php

Expected: PASS with 1 test.

  • Step 6: Run quality checks and commit

Run: composer check

Expected: all checks exit successfully.

git add src/Domain/UnitState.php src/Domain/MatchState.php tests/Unit/Domain/CombatEngineTest.php
git commit -m "feat: model immutable combat state"

Task 4: Validate and resolve movement

Files:

  • Create: src/Domain/CombatException.php

  • Create: src/Domain/CombatEngine.php

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

  • Step 1: Import the engine and add failing movement tests before the helper method

Add this import beside the existing domain imports:

use BattleForge\Domain\CombatEngine;

Add these methods to CombatEngineTest before unit():

public function testMovesActiveUnitWithoutMutatingPriorMatch(): 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');

    $next = (new CombatEngine())->move($match, 'alpha-1', new Position(2, 0));

    self::assertSame('0:0', $match->unit('alpha-1')->position->key());
    self::assertSame('2:0', $next->unit('alpha-1')->position->key());
    self::assertSame(1, $next->unit('alpha-1')->actionsRemaining);
    self::assertSame(['alpha-1 moved to 2:0'], $next->actionLog);
}

public function testRejectsMoveByInactiveTeam(): void
{
    $match = new MatchState(
        new Battlefield(8, 8),
        [
            $this->unit('alpha-1', 'alpha', new Position(0, 0)),
            $this->unit('bravo-1', 'bravo', new Position(7, 7)),
        ],
        'alpha',
    );

    $this->expectExceptionMessage('It is not this unit\'s turn.');

    (new CombatEngine())->move($match, 'bravo-1', new Position(6, 7));
}

public function testRejectsUnreachableOrOccupiedDestination(): void
{
    $match = new MatchState(
        new Battlefield(8, 8),
        [
            $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)),
        ],
        'alpha',
    );

    $this->expectExceptionMessage('Destination is not reachable.');

    (new CombatEngine())->move($match, 'alpha-1', new Position(1, 0));
}
  • Step 2: Run the movement tests to verify they fail

Run: vendor/bin/phpunit tests/Unit/Domain/CombatEngineTest.php

Expected: FAIL because BattleForge\Domain\CombatEngine does not exist.

  • Step 3: Add the domain exception

Create src/Domain/CombatException.php:

<?php

declare(strict_types=1);

namespace BattleForge\Domain;

use DomainException;

final class CombatException extends DomainException
{
}
  • Step 4: Implement movement in the combat engine

Create src/Domain/CombatEngine.php:

<?php

declare(strict_types=1);

namespace BattleForge\Domain;

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

        $occupied = array_values(array_map(
            static fn (UnitState $other): Position => $other->position,
            array_filter(
                $match->units,
                static fn (UnitState $other): bool => !$other->isDefeated() && $other->id !== $unit->id,
            ),
        ));
        $reachable = $match->battlefield->reachable($unit->position, $unit->speed, $occupied);
        if (!isset($reachable[$destination->key()]) || $destination->key() === $unit->position->key()) {
            throw new CombatException('Destination is not reachable.');
        }

        $moved = $unit->moveTo($destination)->spendAction();
        $next = $match->withUnit($moved);

        return $next->copy(actionLog: [
            ...$next->actionLog,
            $unit->id . ' moved to ' . $destination->key(),
        ]);
    }

    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 5: Run movement tests and the quality suite

Run: vendor/bin/phpunit tests/Unit/Domain/CombatEngineTest.php && composer check

Expected: 4 combat-engine tests pass and all quality checks exit successfully.

  • Step 6: Commit movement
git add src/Domain/CombatException.php src/Domain/CombatEngine.php tests/Unit/Domain/CombatEngineTest.php
git commit -m "feat: validate and resolve unit movement"

Task 5: Resolve attacks and elimination victory

Files:

  • Modify: src/Domain/CombatEngine.php

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

  • Step 1: Add failing attack and victory tests before unit()

public function testAttackUsesTargetTerrainDefenseAndSpendsOneAction(): void
{
    $battlefield = new Battlefield(8, 8, ['1:0' => \BattleForge\Domain\Terrain::Forest]);
    $match = new MatchState(
        $battlefield,
        [
            $this->unit('alpha-1', 'alpha', new Position(0, 0)),
            $this->unit('bravo-1', 'bravo', new Position(1, 0)),
        ],
        'alpha',
    );

    $next = (new CombatEngine())->attack($match, 'alpha-1', 'bravo-1');

    self::assertSame(9, $next->unit('bravo-1')->health);
    self::assertSame(1, $next->unit('alpha-1')->actionsRemaining);
    self::assertSame(['alpha-1 attacked bravo-1 for 1 damage'], $next->actionLog);
}

public function testRejectsFriendlyOrDistantTarget(): void
{
    $match = new MatchState(
        new Battlefield(8, 8),
        [
            $this->unit('alpha-1', 'alpha', new Position(0, 0)),
            $this->unit('bravo-1', 'bravo', new Position(2, 0)),
        ],
        'alpha',
    );

    $this->expectExceptionMessage('Target is outside attack range.');

    (new CombatEngine())->attack($match, 'alpha-1', 'bravo-1');
}

public function testDeclaresWinnerWhenLastEnemyIsDefeated(): void
{
    $attacker = new UnitState('alpha-1', 'alpha', new Position(0, 0), 10, 10, 20, 2, 4, 2);
    $target = $this->unit('bravo-1', 'bravo', new Position(1, 0));
    $match = new MatchState(new Battlefield(8, 8), [$attacker, $target], 'alpha');

    $next = (new CombatEngine())->attack($match, 'alpha-1', 'bravo-1');

    self::assertSame(0, $next->unit('bravo-1')->health);
    self::assertSame('alpha', $next->winnerTeamId);
}

public function testUnitCannotAttackTwiceInOneTurn(): void
{
    $match = new MatchState(
        new Battlefield(8, 8),
        [
            new UnitState('alpha-1', 'alpha', new Position(0, 0), 10, 10, 4, 2, 4, 1, true),
            $this->unit('bravo-1', 'bravo', new Position(1, 0)),
        ],
        'alpha',
    );

    $this->expectExceptionMessage('Unit has already attacked this turn.');

    (new CombatEngine())->attack($match, 'alpha-1', 'bravo-1');
}
  • Step 2: Run the tests to verify they fail

Run: vendor/bin/phpunit tests/Unit/Domain/CombatEngineTest.php

Expected: FAIL because CombatEngine::attack() does not exist.

  • Step 3: Add attack resolution to CombatEngine

Add this public method after move():

public function attack(MatchState $match, string $attackerId, string $targetId): MatchState
{
    $this->assertMatchActive($match);
    $attacker = $match->unit($attackerId);
    $target = $match->unit($targetId);
    $this->assertCanAct($match, $attacker);

    if ($attacker->hasAttacked) {
        throw new CombatException('Unit has already attacked this turn.');
    }
    if ($attacker->teamId === $target->teamId || $target->isDefeated()) {
        throw new CombatException('Target must be an active enemy unit.');
    }
    if ($attacker->position->distanceTo($target->position) !== 1) {
        throw new CombatException('Target is outside attack range.');
    }

    $terrainDefense = $match->battlefield->terrainAt($target->position)->defenseBonus();
    $damage = max(1, $attacker->attack - $target->defense - $terrainDefense);
    $damagedTarget = $target->takeDamage($damage);
    $spentAttacker = $attacker->markAttacked()->spendAction();
    $next = $match->withUnit($damagedTarget)->withUnit($spentAttacker);

    $livingEnemies = array_filter(
        $next->units,
        static fn (UnitState $unit): bool => $unit->teamId !== $attacker->teamId && !$unit->isDefeated(),
    );
    $winner = $livingEnemies === [] ? $attacker->teamId : null;

    return $next->copy(
        winnerTeamId: $winner,
        actionLog: [
            ...$next->actionLog,
            $attacker->id . ' attacked ' . $target->id . ' for ' . $damage . ' damage',
        ],
    );
}
  • Step 4: Run attack tests and quality checks

Run: vendor/bin/phpunit tests/Unit/Domain/CombatEngineTest.php && composer check

Expected: 8 combat-engine tests pass and all quality checks exit successfully.

  • Step 5: Commit attack resolution
git add src/Domain/CombatEngine.php tests/Unit/Domain/CombatEngineTest.php
git commit -m "feat: resolve attacks and elimination victory"

Task 6: Alternate team turns and rounds

Files:

  • Modify: src/Domain/CombatEngine.php

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

  • Step 1: Add failing turn tests before unit()

public function testEndTurnActivatesOtherTeamAndRefreshesItsUnits(): void
{
    $alpha = $this->unit('alpha-1', 'alpha', new Position(0, 0))->spendAction();
    $bravo = $this->unit('bravo-1', 'bravo', new Position(7, 7))->spendAction()->spendAction();
    $match = new MatchState(new Battlefield(8, 8), [$alpha, $bravo], 'alpha');

    $next = (new CombatEngine())->endTurn($match);

    self::assertSame('bravo', $next->activeTeamId);
    self::assertSame(2, $next->unit('bravo-1')->actionsRemaining);
    self::assertSame(1, $next->round);
    self::assertSame(['alpha ended turn'], $next->actionLog);
}

public function testRoundIncrementsWhenTurnReturnsToFirstTeam(): void
{
    $match = new MatchState(
        new Battlefield(8, 8),
        [
            $this->unit('alpha-1', 'alpha', new Position(0, 0)),
            $this->unit('bravo-1', 'bravo', new Position(7, 7)),
        ],
        'bravo',
    );

    $next = (new CombatEngine())->endTurn($match);

    self::assertSame('alpha', $next->activeTeamId);
    self::assertSame(2, $next->round);
}
  • Step 2: Run the tests to verify they fail

Run: vendor/bin/phpunit tests/Unit/Domain/CombatEngineTest.php

Expected: FAIL because CombatEngine::endTurn() does not exist.

  • Step 3: Implement deterministic two-team turn changes

Add this method after attack():

public function endTurn(MatchState $match): MatchState
{
    $this->assertMatchActive($match);
    $teamIds = array_values(array_unique(array_map(
        static fn (UnitState $unit): string => $unit->teamId,
        $match->units,
    )));
    sort($teamIds);
    if ($teamIds !== ['alpha', 'bravo']) {
        throw new CombatException('Matches require alpha and bravo teams.');
    }

    $endingTeam = $match->activeTeamId;
    $nextTeam = $endingTeam === 'alpha' ? 'bravo' : 'alpha';
    $units = array_map(
        static fn (UnitState $unit): UnitState => $unit->teamId === $nextTeam
            ? $unit->startTurn()
            : $unit,
        $match->units,
    );

    return $match->copy(
        units: $units,
        activeTeamId: $nextTeam,
        round: $match->round + ($nextTeam === 'alpha' ? 1 : 0),
        actionLog: [...$match->actionLog, $endingTeam . ' ended turn'],
    );
}
  • Step 4: Run the complete local quality suite

Run: composer validate --strict && composer check

Expected: Composer validation succeeds, 2 battlefield tests and 10 combat-engine tests pass, and PHPCS and PHPStan exit successfully.

  • Step 5: Commit turn handling
git add src/Domain/CombatEngine.php tests/Unit/Domain/CombatEngineTest.php
git commit -m "feat: add alternating team turns"

Task 7: Enforce the checks in CI

Files:

  • Create: .github/workflows/ci.yml

  • Step 1: Create the CI workflow

Create .github/workflows/ci.yml:

name: CI

on:
  push:
    branches: [main, develop]
  pull_request:

permissions:
  contents: read

jobs:
  php:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: shivammathur/setup-php@v2
        with:
          php-version: '8.3'
          coverage: none
          tools: composer:v2
      - run: composer validate --strict
      - run: composer install --no-interaction --prefer-dist
      - run: composer check
  • Step 2: Re-run the same commands CI will execute

Run: composer validate --strict && composer install --no-interaction --prefer-dist && composer check

Expected: every command exits with status 0 and all 12 tests pass.

  • Step 3: Commit CI
git add .github/workflows/ci.yml
git commit -m "ci: enforce PHP quality checks"

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 12 tests.
  • Git reports no unexpected changes from this plan.
  • Movement, attack, turn, and victory behavior remain independent of HTTP and browser code.