diff --git a/docs/superpowers/plans/2026-07-04-foundation-and-combat-core.md b/docs/superpowers/plans/2026-07-04-foundation-and-combat-core.md
new file mode 100644
index 0000000..c676c5c
--- /dev/null
+++ b/docs/superpowers/plans/2026-07-04-foundation-and-combat-core.md
@@ -0,0 +1,1133 @@
+# 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
+
+```text
+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`:
+
+```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
+
+
+ BattleForge PHP coding standards.
+ src
+ tests
+
+
+
+
+```
+
+Create `phpstan.neon`:
+
+```neon
+parameters:
+ level: 6
+ paths:
+ - src
+ - tests
+ tmpDir: var/phpstan
+```
+
+Create `phpunit.xml`:
+
+```xml
+
+
+
+
+ tests
+
+
+
+
+ src
+
+
+
+```
+
+- [ ] **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**
+
+```powershell
+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
+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
+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
+ 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
+ $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 $occupied
+ * @return array
+ */
+ 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 $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 */
+ 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.
+
+```powershell
+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
+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
+ $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
+ $units
+ * @param list $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|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.
+
+```powershell
+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:
+
+```php
+use BattleForge\Domain\CombatEngine;
+```
+
+Add these methods to `CombatEngineTest` before `unit()`:
+
+```php
+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
+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**
+
+```powershell
+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()`**
+
+```php
+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()`:
+
+```php
+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**
+
+```powershell
+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()`**
+
+```php
+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()`:
+
+```php
+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**
+
+```powershell
+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`:
+
+```yaml
+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**
+
+```powershell
+git add .github/workflows/ci.yml
+git commit -m "ci: enforce PHP quality checks"
+```
+
+## Completion Check
+
+Run:
+
+```powershell
+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.