# 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.