32 KiB
Foundation and Combat Core Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Establish the standalone PHP project and deliver a tested, presentation-independent combat core supporting terrain-aware movement, adjacent attacks, alternating turns, and elimination victory.
Architecture: Domain code lives under src/Domain and has no dependency on HTTP, sessions, storage, or browser code. Immutable value objects describe battle state; CombatEngine validates commands and returns a new state, making later persistence, action logging, UI, and online play straightforward additions.
Tech Stack: PHP 8.3+, Composer PSR-4 autoloading, PHPUnit, PHPStan level 6, PHP_CodeSniffer with the repository ruleset.
Delivery Sequence
This is the first of four independently executable plans:
- Foundation and combat core — this plan.
- Curated content, abilities, objectives, and scenario validation.
- Anonymous-browser persistence, secure image handling, and scenario editors.
- Hot-seat battle UI, bundled scenarios, end-to-end smoke coverage, and release hardening.
Do not add persistence, HTTP routes, JavaScript, abilities, objectives, or user uploads in this plan.
Execution Preflight
Execute this plan in an isolated worktree on feature/combat-core, branched from
develop, as required by the repository workflow. If this new repository does not
yet have a local develop branch, create it once from the approved planning commit
on main; do not implement directly on main or develop.
The implementation branch will be submitted as a pull request into develop only
after every completion check is green.
File Structure
composer.json Composer scripts and dependencies
phpcs.xml Project coding standard
phpstan.neon Static-analysis configuration
phpunit.xml Test-suite configuration
src/Domain/Battlefield.php Grid bounds, terrain, and path costs
src/Domain/CombatEngine.php Move, attack, and turn command handling
src/Domain/CombatException.php Stable rejected-command reason
src/Domain/MatchState.php Immutable aggregate for one match
src/Domain/Position.php Grid coordinate value object
src/Domain/Terrain.php Curated terrain behavior
src/Domain/UnitState.php Immutable runtime state for one unit
tests/Unit/Domain/BattlefieldTest.php Terrain and reachability behavior
tests/Unit/Domain/CombatEngineTest.php Command, turn, and victory behavior
.github/workflows/ci.yml Required PHP quality checks
Task 1: Bootstrap the PHP quality toolchain
Files:
-
Create:
composer.json -
Create:
phpcs.xml -
Create:
phpstan.neon -
Create:
phpunit.xml -
Step 1: Create the Composer manifest
Create composer.json:
{
"name": "battleforge/battleforge",
"description": "A curated tactical sandbox web application.",
"type": "project",
"license": "proprietary",
"require": {
"php": "^8.3"
},
"require-dev": {
"dealerdirect/phpcodesniffer-composer-installer": "^1.0",
"phpstan/phpstan": "^2.0",
"phpunit/phpunit": "^11.5",
"squizlabs/php_codesniffer": "^3.10"
},
"autoload": {
"psr-4": {
"BattleForge\\": "src/"
}
},
"autoload-dev": {
"psr-4": {
"BattleForge\\Tests\\": "tests/"
}
},
"scripts": {
"lint": "phpcs",
"analyse": "phpstan analyse --no-progress",
"test": "phpunit",
"check": [
"@lint",
"@analyse",
"@test"
]
},
"config": {
"allow-plugins": {
"dealerdirect/phpcodesniffer-composer-installer": true
},
"sort-packages": true
}
}
- Step 2: Create the code-quality configuration
Create phpcs.xml:
<?xml version="1.0"?>
<ruleset name="BattleForge">
<description>BattleForge PHP coding standards.</description>
<file>src</file>
<file>tests</file>
<arg name="colors"/>
<arg value="sp"/>
<rule ref="PSR12"/>
</ruleset>
Create phpstan.neon:
parameters:
level: 6
paths:
- src
- tests
tmpDir: var/phpstan
Create phpunit.xml:
<?xml version="1.0" encoding="UTF-8"?>
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="https://schema.phpunit.de/11.5/phpunit.xsd"
bootstrap="vendor/autoload.php"
cacheDirectory="var/phpunit"
colors="true">
<testsuites>
<testsuite name="BattleForge">
<directory>tests</directory>
</testsuite>
</testsuites>
<source>
<include>
<directory>src</directory>
</include>
</source>
</phpunit>
- Step 3: Install dependencies and validate the manifest
Run: composer validate --strict && composer install
Expected: Composer reports that composer.json is valid and installs the four development tools without dependency-resolution errors.
- Step 4: Verify each installed tool is executable
Run: vendor/bin/phpcs --version && vendor/bin/phpstan --version && vendor/bin/phpunit --version
Expected: each tool prints its installed version and exits successfully. The complete
composer check suite begins in Task 2, after the source and test directories exist.
- Step 5: Commit the toolchain
git add composer.json composer.lock phpcs.xml phpstan.neon phpunit.xml
git commit -m "chore: bootstrap PHP quality toolchain"
Task 2: Model grid positions and curated terrain
Files:
-
Create:
src/Domain/Position.php -
Create:
src/Domain/Terrain.php -
Create:
src/Domain/Battlefield.php -
Test:
tests/Unit/Domain/BattlefieldTest.php -
Step 1: Write the failing battlefield tests
Create tests/Unit/Domain/BattlefieldTest.php:
<?php
declare(strict_types=1);
namespace BattleForge\Tests\Unit\Domain;
use BattleForge\Domain\Battlefield;
use BattleForge\Domain\Position;
use BattleForge\Domain\Terrain;
use InvalidArgumentException;
use PHPUnit\Framework\TestCase;
final class BattlefieldTest extends TestCase
{
public function testRejectsDimensionsOutsideMvpBounds(): void
{
$this->expectException(InvalidArgumentException::class);
new Battlefield(7, 16);
}
public function testFindsDestinationsWithinMovementBudget(): void
{
$battlefield = new Battlefield(
8,
8,
[
'1:0' => Terrain::Forest,
'0:1' => Terrain::Rough,
'2:0' => Terrain::Water,
'1:1' => Terrain::Blocking,
],
);
$reachable = $battlefield->reachable(
new Position(0, 0),
2,
[new Position(0, 2)],
);
self::assertSame(0, $reachable['0:0']);
self::assertSame(2, $reachable['1:0']);
self::assertSame(2, $reachable['0:1']);
self::assertArrayNotHasKey('2:0', $reachable);
self::assertArrayNotHasKey('1:1', $reachable);
self::assertArrayNotHasKey('0:2', $reachable);
}
}
- Step 2: Run the test to verify it fails
Run: vendor/bin/phpunit tests/Unit/Domain/BattlefieldTest.php
Expected: FAIL because BattleForge\Domain\Battlefield does not exist.
- Step 3: Implement positions, terrain, and path costs
Create src/Domain/Position.php:
<?php
declare(strict_types=1);
namespace BattleForge\Domain;
final readonly class Position
{
public function __construct(public int $x, public int $y)
{
}
public function key(): string
{
return $this->x . ':' . $this->y;
}
public function distanceTo(self $other): int
{
return abs($this->x - $other->x) + abs($this->y - $other->y);
}
}
Create src/Domain/Terrain.php:
<?php
declare(strict_types=1);
namespace BattleForge\Domain;
enum Terrain: string
{
case Open = 'open';
case Forest = 'forest';
case Rough = 'rough';
case Water = 'water';
case Blocking = 'blocking';
public function movementCost(): ?int
{
return match ($this) {
self::Open => 1,
self::Forest, self::Rough => 2,
self::Water, self::Blocking => null,
};
}
public function defenseBonus(): int
{
return $this === self::Forest ? 1 : 0;
}
}
Create src/Domain/Battlefield.php:
<?php
declare(strict_types=1);
namespace BattleForge\Domain;
use InvalidArgumentException;
use SplQueue;
final readonly class Battlefield
{
/** @param array<string, Terrain> $terrain */
public function __construct(
public int $width,
public int $height,
private array $terrain = [],
) {
if ($width < 8 || $width > 16 || $height < 8 || $height > 16) {
throw new InvalidArgumentException('Battlefields must be between 8x8 and 16x16 tiles.');
}
foreach ($terrain as $key => $_type) {
[$x, $y] = array_map('intval', explode(':', $key));
if (!$this->contains(new Position($x, $y))) {
throw new InvalidArgumentException('Terrain cannot be placed outside the battlefield.');
}
}
}
public function contains(Position $position): bool
{
return $position->x >= 0
&& $position->x < $this->width
&& $position->y >= 0
&& $position->y < $this->height;
}
public function terrainAt(Position $position): Terrain
{
return $this->terrain[$position->key()] ?? Terrain::Open;
}
/**
* @param list<Position> $occupied
* @return array<string, int>
*/
public function reachable(Position $start, int $budget, array $occupied): array
{
$costs = [$start->key() => 0];
$blocked = [];
foreach ($occupied as $position) {
$blocked[$position->key()] = true;
}
/** @var SplQueue<array{Position, int}> $queue */
$queue = new SplQueue();
$queue->enqueue([$start, 0]);
while (!$queue->isEmpty()) {
[$current, $spent] = $queue->dequeue();
foreach ($this->neighbors($current) as $neighbor) {
$step = $this->terrainAt($neighbor)->movementCost();
$total = $step === null ? $budget + 1 : $spent + $step;
$known = $costs[$neighbor->key()] ?? PHP_INT_MAX;
if ($total > $budget || isset($blocked[$neighbor->key()]) || $total >= $known) {
continue;
}
$costs[$neighbor->key()] = $total;
$queue->enqueue([$neighbor, $total]);
}
}
return $costs;
}
/** @return list<Position> */
private function neighbors(Position $position): array
{
$candidates = [
new Position($position->x + 1, $position->y),
new Position($position->x - 1, $position->y),
new Position($position->x, $position->y + 1),
new Position($position->x, $position->y - 1),
];
return array_values(array_filter($candidates, $this->contains(...)));
}
}
- Step 4: Run the battlefield tests
Run: vendor/bin/phpunit tests/Unit/Domain/BattlefieldTest.php
Expected: PASS with 2 tests.
- Step 5: Run quality checks and commit
Run: composer check
Expected: PHPCS, PHPStan, and PHPUnit all exit successfully.
git add src/Domain/Position.php src/Domain/Terrain.php src/Domain/Battlefield.php tests/Unit/Domain/BattlefieldTest.php
git commit -m "feat: model battlefield terrain and movement costs"
Task 3: Model immutable unit and match state
Files:
-
Create:
src/Domain/UnitState.php -
Create:
src/Domain/MatchState.php -
Test:
tests/Unit/Domain/CombatEngineTest.php -
Step 1: Write the failing state-transition test
Create tests/Unit/Domain/CombatEngineTest.php:
<?php
declare(strict_types=1);
namespace BattleForge\Tests\Unit\Domain;
use BattleForge\Domain\Battlefield;
use BattleForge\Domain\MatchState;
use BattleForge\Domain\Position;
use BattleForge\Domain\UnitState;
use PHPUnit\Framework\TestCase;
final class CombatEngineTest extends TestCase
{
public function testMatchReplacesAUnitWithoutMutatingOriginalState(): void
{
$unit = $this->unit('alpha-1', 'alpha', new Position(0, 0));
$match = new MatchState(new Battlefield(8, 8), [$unit], 'alpha');
$moved = $unit->moveTo(new Position(1, 0))->spendAction();
$next = $match->withUnit($moved);
self::assertSame('0:0', $match->unit('alpha-1')->position->key());
self::assertSame('1:0', $next->unit('alpha-1')->position->key());
self::assertSame(1, $next->unit('alpha-1')->actionsRemaining);
}
private function unit(string $id, string $team, Position $position): UnitState
{
return new UnitState($id, $team, $position, 10, 10, 4, 2, 4, 2);
}
}
- Step 2: Run the test to verify it fails
Run: vendor/bin/phpunit tests/Unit/Domain/CombatEngineTest.php
Expected: FAIL because BattleForge\Domain\UnitState does not exist.
- Step 3: Implement immutable unit state
Create src/Domain/UnitState.php:
<?php
declare(strict_types=1);
namespace BattleForge\Domain;
use InvalidArgumentException;
final readonly class UnitState
{
public function __construct(
public string $id,
public string $teamId,
public Position $position,
public int $maxHealth,
public int $health,
public int $attack,
public int $defense,
public int $speed,
public int $actionsRemaining,
public bool $hasAttacked = false,
) {
if ($id === '' || $teamId === '') {
throw new InvalidArgumentException('Unit and team identifiers are required.');
}
if ($maxHealth < 1 || $health < 0 || $health > $maxHealth) {
throw new InvalidArgumentException('Unit health is outside its valid range.');
}
if ($attack < 0 || $defense < 0 || $speed < 1) {
throw new InvalidArgumentException('Unit combat statistics are invalid.');
}
if ($actionsRemaining < 0 || $actionsRemaining > 2) {
throw new InvalidArgumentException('Units may have zero, one, or two actions.');
}
}
public function isDefeated(): bool
{
return $this->health === 0;
}
public function moveTo(Position $position): self
{
return $this->copy(position: $position);
}
public function spendAction(): self
{
if ($this->actionsRemaining === 0) {
throw new InvalidArgumentException('Unit has no actions remaining.');
}
return $this->copy(actionsRemaining: $this->actionsRemaining - 1);
}
public function takeDamage(int $damage): self
{
return $this->copy(health: max(0, $this->health - max(0, $damage)));
}
public function markAttacked(): self
{
return $this->copy(hasAttacked: true);
}
public function startTurn(): self
{
return $this->isDefeated()
? $this
: $this->copy(actionsRemaining: 2, hasAttacked: false);
}
private function copy(
?Position $position = null,
?int $health = null,
?int $actionsRemaining = null,
?bool $hasAttacked = null,
): self {
return new self(
$this->id,
$this->teamId,
$position ?? $this->position,
$this->maxHealth,
$health ?? $this->health,
$this->attack,
$this->defense,
$this->speed,
$actionsRemaining ?? $this->actionsRemaining,
$hasAttacked ?? $this->hasAttacked,
);
}
}
- Step 4: Implement immutable match state
Create src/Domain/MatchState.php:
<?php
declare(strict_types=1);
namespace BattleForge\Domain;
use InvalidArgumentException;
final readonly class MatchState
{
/**
* @param list<UnitState> $units
* @param list<string> $actionLog
*/
public function __construct(
public Battlefield $battlefield,
public array $units,
public string $activeTeamId,
public int $round = 1,
public ?string $winnerTeamId = null,
public array $actionLog = [],
) {
$ids = array_map(static fn (UnitState $unit): string => $unit->id, $units);
if (count($ids) !== count(array_unique($ids))) {
throw new InvalidArgumentException('Unit identifiers must be unique.');
}
}
public function unit(string $id): UnitState
{
foreach ($this->units as $unit) {
if ($unit->id === $id) {
return $unit;
}
}
throw new InvalidArgumentException('Unknown unit: ' . $id);
}
public function withUnit(UnitState $replacement): self
{
$found = false;
$units = array_map(
static function (UnitState $unit) use ($replacement, &$found): UnitState {
if ($unit->id !== $replacement->id) {
return $unit;
}
$found = true;
return $replacement;
},
$this->units,
);
if (!$found) {
throw new InvalidArgumentException('Cannot replace an unknown unit.');
}
return $this->copy(units: $units);
}
/** @param list<UnitState>|null $units */
public function copy(
?array $units = null,
?string $activeTeamId = null,
?int $round = null,
?string $winnerTeamId = null,
?array $actionLog = null,
): self {
return new self(
$this->battlefield,
$units ?? $this->units,
$activeTeamId ?? $this->activeTeamId,
$round ?? $this->round,
$winnerTeamId ?? $this->winnerTeamId,
$actionLog ?? $this->actionLog,
);
}
}
- Step 5: Run the state test
Run: vendor/bin/phpunit tests/Unit/Domain/CombatEngineTest.php
Expected: PASS with 1 test.
- Step 6: Run quality checks and commit
Run: composer check
Expected: all checks exit successfully.
git add src/Domain/UnitState.php src/Domain/MatchState.php tests/Unit/Domain/CombatEngineTest.php
git commit -m "feat: model immutable combat state"
Task 4: Validate and resolve movement
Files:
-
Create:
src/Domain/CombatException.php -
Create:
src/Domain/CombatEngine.php -
Modify:
tests/Unit/Domain/CombatEngineTest.php -
Step 1: Import the engine and add failing movement tests before the helper method
Add this import beside the existing domain imports:
use BattleForge\Domain\CombatEngine;
Add these methods to CombatEngineTest before unit():
public function testMovesActiveUnitWithoutMutatingPriorMatch(): void
{
$alpha = $this->unit('alpha-1', 'alpha', new Position(0, 0));
$bravo = $this->unit('bravo-1', 'bravo', new Position(7, 7));
$match = new MatchState(new Battlefield(8, 8), [$alpha, $bravo], 'alpha');
$next = (new CombatEngine())->move($match, 'alpha-1', new Position(2, 0));
self::assertSame('0:0', $match->unit('alpha-1')->position->key());
self::assertSame('2:0', $next->unit('alpha-1')->position->key());
self::assertSame(1, $next->unit('alpha-1')->actionsRemaining);
self::assertSame(['alpha-1 moved to 2:0'], $next->actionLog);
}
public function testRejectsMoveByInactiveTeam(): void
{
$match = new MatchState(
new Battlefield(8, 8),
[
$this->unit('alpha-1', 'alpha', new Position(0, 0)),
$this->unit('bravo-1', 'bravo', new Position(7, 7)),
],
'alpha',
);
$this->expectExceptionMessage('It is not this unit\'s turn.');
(new CombatEngine())->move($match, 'bravo-1', new Position(6, 7));
}
public function testRejectsUnreachableOrOccupiedDestination(): void
{
$match = new MatchState(
new Battlefield(8, 8),
[
$this->unit('alpha-1', 'alpha', new Position(0, 0)),
$this->unit('alpha-2', 'alpha', new Position(1, 0)),
$this->unit('bravo-1', 'bravo', new Position(7, 7)),
],
'alpha',
);
$this->expectExceptionMessage('Destination is not reachable.');
(new CombatEngine())->move($match, 'alpha-1', new Position(1, 0));
}
- Step 2: Run the movement tests to verify they fail
Run: vendor/bin/phpunit tests/Unit/Domain/CombatEngineTest.php
Expected: FAIL because BattleForge\Domain\CombatEngine does not exist.
- Step 3: Add the domain exception
Create src/Domain/CombatException.php:
<?php
declare(strict_types=1);
namespace BattleForge\Domain;
use DomainException;
final class CombatException extends DomainException
{
}
- Step 4: Implement movement in the combat engine
Create src/Domain/CombatEngine.php:
<?php
declare(strict_types=1);
namespace BattleForge\Domain;
final class CombatEngine
{
public function move(MatchState $match, string $unitId, Position $destination): MatchState
{
$this->assertMatchActive($match);
$unit = $match->unit($unitId);
$this->assertCanAct($match, $unit);
$occupied = array_values(array_map(
static fn (UnitState $other): Position => $other->position,
array_filter(
$match->units,
static fn (UnitState $other): bool => !$other->isDefeated() && $other->id !== $unit->id,
),
));
$reachable = $match->battlefield->reachable($unit->position, $unit->speed, $occupied);
if (!isset($reachable[$destination->key()]) || $destination->key() === $unit->position->key()) {
throw new CombatException('Destination is not reachable.');
}
$moved = $unit->moveTo($destination)->spendAction();
$next = $match->withUnit($moved);
return $next->copy(actionLog: [
...$next->actionLog,
$unit->id . ' moved to ' . $destination->key(),
]);
}
private function assertMatchActive(MatchState $match): void
{
if ($match->winnerTeamId !== null) {
throw new CombatException('The match is already complete.');
}
}
private function assertCanAct(MatchState $match, UnitState $unit): void
{
if ($unit->teamId !== $match->activeTeamId) {
throw new CombatException('It is not this unit\'s turn.');
}
if ($unit->isDefeated()) {
throw new CombatException('Defeated units cannot act.');
}
if ($unit->actionsRemaining === 0) {
throw new CombatException('Unit has no actions remaining.');
}
}
}
- Step 5: Run movement tests and the quality suite
Run: vendor/bin/phpunit tests/Unit/Domain/CombatEngineTest.php && composer check
Expected: 4 combat-engine tests pass and all quality checks exit successfully.
- Step 6: Commit movement
git add src/Domain/CombatException.php src/Domain/CombatEngine.php tests/Unit/Domain/CombatEngineTest.php
git commit -m "feat: validate and resolve unit movement"
Task 5: Resolve attacks and elimination victory
Files:
-
Modify:
src/Domain/CombatEngine.php -
Modify:
tests/Unit/Domain/CombatEngineTest.php -
Step 1: Add failing attack and victory tests before
unit()
public function testAttackUsesTargetTerrainDefenseAndSpendsOneAction(): void
{
$battlefield = new Battlefield(8, 8, ['1:0' => \BattleForge\Domain\Terrain::Forest]);
$match = new MatchState(
$battlefield,
[
$this->unit('alpha-1', 'alpha', new Position(0, 0)),
$this->unit('bravo-1', 'bravo', new Position(1, 0)),
],
'alpha',
);
$next = (new CombatEngine())->attack($match, 'alpha-1', 'bravo-1');
self::assertSame(9, $next->unit('bravo-1')->health);
self::assertSame(1, $next->unit('alpha-1')->actionsRemaining);
self::assertSame(['alpha-1 attacked bravo-1 for 1 damage'], $next->actionLog);
}
public function testRejectsFriendlyOrDistantTarget(): void
{
$match = new MatchState(
new Battlefield(8, 8),
[
$this->unit('alpha-1', 'alpha', new Position(0, 0)),
$this->unit('bravo-1', 'bravo', new Position(2, 0)),
],
'alpha',
);
$this->expectExceptionMessage('Target is outside attack range.');
(new CombatEngine())->attack($match, 'alpha-1', 'bravo-1');
}
public function testDeclaresWinnerWhenLastEnemyIsDefeated(): void
{
$attacker = new UnitState('alpha-1', 'alpha', new Position(0, 0), 10, 10, 20, 2, 4, 2);
$target = $this->unit('bravo-1', 'bravo', new Position(1, 0));
$match = new MatchState(new Battlefield(8, 8), [$attacker, $target], 'alpha');
$next = (new CombatEngine())->attack($match, 'alpha-1', 'bravo-1');
self::assertSame(0, $next->unit('bravo-1')->health);
self::assertSame('alpha', $next->winnerTeamId);
}
public function testUnitCannotAttackTwiceInOneTurn(): void
{
$match = new MatchState(
new Battlefield(8, 8),
[
new UnitState('alpha-1', 'alpha', new Position(0, 0), 10, 10, 4, 2, 4, 1, true),
$this->unit('bravo-1', 'bravo', new Position(1, 0)),
],
'alpha',
);
$this->expectExceptionMessage('Unit has already attacked this turn.');
(new CombatEngine())->attack($match, 'alpha-1', 'bravo-1');
}
- Step 2: Run the tests to verify they fail
Run: vendor/bin/phpunit tests/Unit/Domain/CombatEngineTest.php
Expected: FAIL because CombatEngine::attack() does not exist.
- Step 3: Add attack resolution to
CombatEngine
Add this public method after move():
public function attack(MatchState $match, string $attackerId, string $targetId): MatchState
{
$this->assertMatchActive($match);
$attacker = $match->unit($attackerId);
$target = $match->unit($targetId);
$this->assertCanAct($match, $attacker);
if ($attacker->hasAttacked) {
throw new CombatException('Unit has already attacked this turn.');
}
if ($attacker->teamId === $target->teamId || $target->isDefeated()) {
throw new CombatException('Target must be an active enemy unit.');
}
if ($attacker->position->distanceTo($target->position) !== 1) {
throw new CombatException('Target is outside attack range.');
}
$terrainDefense = $match->battlefield->terrainAt($target->position)->defenseBonus();
$damage = max(1, $attacker->attack - $target->defense - $terrainDefense);
$damagedTarget = $target->takeDamage($damage);
$spentAttacker = $attacker->markAttacked()->spendAction();
$next = $match->withUnit($damagedTarget)->withUnit($spentAttacker);
$livingEnemies = array_filter(
$next->units,
static fn (UnitState $unit): bool => $unit->teamId !== $attacker->teamId && !$unit->isDefeated(),
);
$winner = $livingEnemies === [] ? $attacker->teamId : null;
return $next->copy(
winnerTeamId: $winner,
actionLog: [
...$next->actionLog,
$attacker->id . ' attacked ' . $target->id . ' for ' . $damage . ' damage',
],
);
}
- Step 4: Run attack tests and quality checks
Run: vendor/bin/phpunit tests/Unit/Domain/CombatEngineTest.php && composer check
Expected: 8 combat-engine tests pass and all quality checks exit successfully.
- Step 5: Commit attack resolution
git add src/Domain/CombatEngine.php tests/Unit/Domain/CombatEngineTest.php
git commit -m "feat: resolve attacks and elimination victory"
Task 6: Alternate team turns and rounds
Files:
-
Modify:
src/Domain/CombatEngine.php -
Modify:
tests/Unit/Domain/CombatEngineTest.php -
Step 1: Add failing turn tests before
unit()
public function testEndTurnActivatesOtherTeamAndRefreshesItsUnits(): void
{
$alpha = $this->unit('alpha-1', 'alpha', new Position(0, 0))->spendAction();
$bravo = $this->unit('bravo-1', 'bravo', new Position(7, 7))->spendAction()->spendAction();
$match = new MatchState(new Battlefield(8, 8), [$alpha, $bravo], 'alpha');
$next = (new CombatEngine())->endTurn($match);
self::assertSame('bravo', $next->activeTeamId);
self::assertSame(2, $next->unit('bravo-1')->actionsRemaining);
self::assertSame(1, $next->round);
self::assertSame(['alpha ended turn'], $next->actionLog);
}
public function testRoundIncrementsWhenTurnReturnsToFirstTeam(): void
{
$match = new MatchState(
new Battlefield(8, 8),
[
$this->unit('alpha-1', 'alpha', new Position(0, 0)),
$this->unit('bravo-1', 'bravo', new Position(7, 7)),
],
'bravo',
);
$next = (new CombatEngine())->endTurn($match);
self::assertSame('alpha', $next->activeTeamId);
self::assertSame(2, $next->round);
}
- Step 2: Run the tests to verify they fail
Run: vendor/bin/phpunit tests/Unit/Domain/CombatEngineTest.php
Expected: FAIL because CombatEngine::endTurn() does not exist.
- Step 3: Implement deterministic two-team turn changes
Add this method after attack():
public function endTurn(MatchState $match): MatchState
{
$this->assertMatchActive($match);
$teamIds = array_values(array_unique(array_map(
static fn (UnitState $unit): string => $unit->teamId,
$match->units,
)));
sort($teamIds);
if ($teamIds !== ['alpha', 'bravo']) {
throw new CombatException('Matches require alpha and bravo teams.');
}
$endingTeam = $match->activeTeamId;
$nextTeam = $endingTeam === 'alpha' ? 'bravo' : 'alpha';
$units = array_map(
static fn (UnitState $unit): UnitState => $unit->teamId === $nextTeam
? $unit->startTurn()
: $unit,
$match->units,
);
return $match->copy(
units: $units,
activeTeamId: $nextTeam,
round: $match->round + ($nextTeam === 'alpha' ? 1 : 0),
actionLog: [...$match->actionLog, $endingTeam . ' ended turn'],
);
}
- Step 4: Run the complete local quality suite
Run: composer validate --strict && composer check
Expected: Composer validation succeeds, 2 battlefield tests and 10 combat-engine tests pass, and PHPCS and PHPStan exit successfully.
- Step 5: Commit turn handling
git add src/Domain/CombatEngine.php tests/Unit/Domain/CombatEngineTest.php
git commit -m "feat: add alternating team turns"
Task 7: Enforce the checks in CI
Files:
-
Create:
.github/workflows/ci.yml -
Step 1: Create the CI workflow
Create .github/workflows/ci.yml:
name: CI
on:
push:
branches: [main, develop]
pull_request:
permissions:
contents: read
jobs:
php:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: shivammathur/setup-php@v2
with:
php-version: '8.3'
coverage: none
tools: composer:v2
- run: composer validate --strict
- run: composer install --no-interaction --prefer-dist
- run: composer check
- Step 2: Re-run the same commands CI will execute
Run: composer validate --strict && composer install --no-interaction --prefer-dist && composer check
Expected: every command exits with status 0 and all 12 tests pass.
- Step 3: Commit CI
git add .github/workflows/ci.yml
git commit -m "ci: enforce PHP quality checks"
Completion Check
Run:
composer validate --strict
composer check
git status --short
Expected:
- Composer reports a valid manifest.
- PHPCS reports no coding-standard violations.
- PHPStan reports no errors at level 6.
- PHPUnit passes 12 tests.
- Git reports no unexpected changes from this plan.
- Movement, attack, turn, and victory behavior remain independent of HTTP and browser code.