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