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.