Compare commits

..
20 Commits
Author SHA1 Message Date
Keith Solomon 0ac8c06c5e feat: catalog curated unit archetypes and templates 2026-07-05 18:41:53 -05:00
Keith Solomon 78fc802cdc fix: enforce LF for PHP files 2026-07-04 18:07:00 -05:00
Keith Solomon de0bc9c43f ci: pin workflow action revisions 2026-07-04 16:15:54 -05:00
Keith Solomon 07e58c84fe ci: enforce PHP quality checks 2026-07-04 16:11:14 -05:00
Keith Solomon fef22d7b56 fix: guard invalid turn transitions 2026-07-04 16:07:36 -05:00
Keith Solomon acfbbf4a91 feat: add alternating team turns 2026-07-04 16:02:49 -05:00
Keith Solomon 7588b304e1 refactor: reuse position distance for attacks 2026-07-04 15:58:29 -05:00
Keith Solomon 3c6359243a feat: resolve attacks and elimination victory 2026-07-04 15:53:57 -05:00
Keith Solomon 3eba823e52 fix: enforce executable movement state 2026-07-04 15:47:29 -05:00
Keith Solomon 1550e2920b feat: validate and resolve unit movement 2026-07-04 15:37:05 -05:00
Keith Solomon 5297b41208 fix: enforce immutable match state invariants 2026-07-04 15:33:05 -05:00
Keith Solomon 94560da5b4 test: verify original match remains unchanged 2026-07-04 15:25:06 -05:00
Keith Solomon 0c5e824942 feat: model immutable combat state 2026-07-04 15:23:00 -05:00
Keith Solomon 4421c5ae32 test: strengthen battlefield boundary coverage 2026-07-04 15:19:06 -05:00
Keith Solomon b789f1b61f fix: validate battlefield reachability inputs 2026-07-04 15:15:08 -05:00
Keith Solomon 189be5bc64 feat: model battlefield terrain and movement costs 2026-07-04 15:08:40 -05:00
Keith Solomon 7b82adbd71 chore: identify Composer package as project 2026-07-04 15:05:11 -05:00
Keith Solomon a97ecbc6d3 chore: bootstrap PHP quality toolchain 2026-07-04 15:02:06 -05:00
Keith Solomon e20017f1e5 docs: plan combat core implementation 2026-07-04 14:59:00 -05:00
Keith Solomon 5ddfff2679 chore: ignore local worktrees 2026-07-04 14:59:00 -05:00
25 changed files with 5468 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
*.php text eol=lf
+27
View File
@@ -0,0 +1,27 @@
name: CI
on:
push:
branches:
- main
- develop
pull_request:
permissions:
contents: read
jobs:
php:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
with:
persist-credentials: false
- uses: shivammathur/setup-php@b604ade2a87db23f8871b7182e69ec5e75effb45 # 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
+3
View File
@@ -0,0 +1,3 @@
.worktrees/
/vendor/
/var/
+41
View File
@@ -0,0 +1,41 @@
{
"name": "battleforge/battleforge",
"description": "A standalone tactical combat game.",
"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
}
}
Generated
+2040
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+12
View File
@@ -0,0 +1,12 @@
<?xml version="1.0"?>
<ruleset name="BattleForge">
<description>BattleForge coding standards.</description>
<file>src</file>
<file>tests</file>
<arg name="colors"/>
<arg value="sp"/>
<rule ref="PSR12"/>
</ruleset>
+6
View File
@@ -0,0 +1,6 @@
parameters:
level: 6
paths:
- src
- tests
tmpDir: var/phpstan
+18
View File
@@ -0,0 +1,18 @@
<?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 Test Suite">
<directory>tests</directory>
</testsuite>
</testsuites>
<source>
<include>
<directory>src</directory>
</include>
</source>
</phpunit>
+13
View File
@@ -0,0 +1,13 @@
<?php
declare(strict_types=1);
namespace BattleForge\Domain;
enum Archetype: string
{
case Defender = 'defender';
case Striker = 'striker';
case Support = 'support';
case Scout = 'scout';
}
+69
View File
@@ -0,0 +1,69 @@
<?php
declare(strict_types=1);
namespace BattleForge\Domain;
final class ArchetypeCatalog
{
/**
* @return array<string, ArchetypeTemplate>
*/
public static function templates(): array
{
static $cache = null;
if ($cache !== null) {
return $cache;
}
$cache = [
Archetype::Defender->value => new ArchetypeTemplate(
minHealth: 10,
maxHealth: 14,
minAttack: 2,
maxAttack: 4,
minDefense: 3,
maxDefense: 5,
minSpeed: 1,
maxSpeed: 3,
allowedAbilities: ['buff'],
),
Archetype::Striker->value => new ArchetypeTemplate(
minHealth: 6,
maxHealth: 10,
minAttack: 4,
maxAttack: 6,
minDefense: 1,
maxDefense: 2,
minSpeed: 2,
maxSpeed: 4,
allowedAbilities: ['area_damage'],
),
Archetype::Support->value => new ArchetypeTemplate(
minHealth: 5,
maxHealth: 9,
minAttack: 1,
maxAttack: 3,
minDefense: 1,
maxDefense: 3,
minSpeed: 2,
maxSpeed: 4,
allowedAbilities: ['heal', 'buff'],
),
Archetype::Scout->value => new ArchetypeTemplate(
minHealth: 4,
maxHealth: 7,
minAttack: 2,
maxAttack: 4,
minDefense: 0,
maxDefense: 2,
minSpeed: 4,
maxSpeed: 6,
allowedAbilities: [],
),
];
return $cache;
}
}
+50
View File
@@ -0,0 +1,50 @@
<?php
declare(strict_types=1);
namespace BattleForge\Domain;
use InvalidArgumentException;
final readonly class ArchetypeTemplate
{
/** @param list<string> $allowedAbilities */
public function __construct(
public int $minHealth,
public int $maxHealth,
public int $minAttack,
public int $maxAttack,
public int $minDefense,
public int $maxDefense,
public int $minSpeed,
public int $maxSpeed,
public array $allowedAbilities,
) {
if ($this->minHealth > $this->maxHealth) {
throw new InvalidArgumentException('Health range is inverted.');
}
if ($this->minAttack > $this->maxAttack) {
throw new InvalidArgumentException('Attack range is inverted.');
}
if ($this->minDefense > $this->maxDefense) {
throw new InvalidArgumentException('Defense range is inverted.');
}
if ($this->minSpeed > $this->maxSpeed || $this->minSpeed < 1) {
throw new InvalidArgumentException('Speed range must be at least 1 and non-inverted.');
}
if ($this->minHealth < 1) {
throw new InvalidArgumentException('Minimum health must be at least 1.');
}
}
/** @return array{maxHealth: int, attack: int, defense: int, speed: int} */
public function defaultStats(): array
{
return [
'maxHealth' => (int) ceil(($this->minHealth + $this->maxHealth) / 2),
'attack' => (int) ceil(($this->minAttack + $this->maxAttack) / 2),
'defense' => (int) ceil(($this->minDefense + $this->maxDefense) / 2),
'speed' => (int) ceil(($this->minSpeed + $this->maxSpeed) / 2),
];
}
}
+148
View File
@@ -0,0 +1,148 @@
<?php
declare(strict_types=1);
namespace BattleForge\Domain;
use InvalidArgumentException;
use SplQueue;
final readonly class Battlefield
{
/** @var array<string, Terrain> */
private array $terrain;
/**
* @param array<string, Terrain> $terrain
*/
public function __construct(
public int $width,
public int $height,
array $terrain = [],
) {
if ($width < 8 || $width > 16 || $height < 8 || $height > 16) {
throw new InvalidArgumentException('Battlefield dimensions must each be between 8 and 16.');
}
$validatedTerrain = [];
foreach ($terrain as $key => $terrainType) {
if (!self::isTerrain($terrainType)) {
throw new InvalidArgumentException("Invalid terrain value at coordinate {$key}.");
}
$position = self::positionFromTerrainKey($key);
if (!$this->contains($position)) {
throw new InvalidArgumentException("Terrain coordinate {$key} is outside the battlefield.");
}
$validatedTerrain[$key] = $terrainType;
}
$this->terrain = $validatedTerrain;
}
private static function isTerrain(mixed $terrain): bool
{
return $terrain instanceof Terrain;
}
private static function positionFromTerrainKey(mixed $key): Position
{
if (!is_string($key) || preg_match('/^(\d+):(\d+)$/', $key, $matches) !== 1) {
throw new InvalidArgumentException("Invalid terrain coordinate: {$key}.");
}
$position = new Position((int) $matches[1], (int) $matches[2]);
if ($key !== $position->key()) {
throw new InvalidArgumentException("Invalid terrain coordinate: {$key}.");
}
return $position;
}
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
{
if (!$this->contains($start)) {
throw new InvalidArgumentException('Reachability start position is outside the battlefield.');
}
if ($budget < 0) {
throw new InvalidArgumentException('Reachability budget cannot be negative.');
}
$occupiedKeys = [];
foreach ($occupied as $position) {
if (!$this->contains($position)) {
throw new InvalidArgumentException('Occupied position is outside the battlefield.');
}
$occupiedKeys[$position->key()] = true;
}
$costs = [$start->key() => 0];
/** @var SplQueue<Position> $queue */
$queue = new SplQueue();
$queue->enqueue($start);
while (!$queue->isEmpty()) {
$current = $queue->dequeue();
$currentCost = $costs[$current->key()];
foreach ($this->neighbors($current) as $neighbor) {
$key = $neighbor->key();
$movementCost = $this->terrainAt($neighbor)->movementCost();
if ($movementCost === null || isset($occupiedKeys[$key])) {
continue;
}
$cost = $currentCost + $movementCost;
if ($cost > $budget || (isset($costs[$key]) && $costs[$key] <= $cost)) {
continue;
}
$costs[$key] = $cost;
$queue->enqueue($neighbor);
}
}
return $costs;
}
/**
* @return list<Position>
*/
private function neighbors(Position $position): array
{
$neighbors = [
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($neighbors, $this->contains(...)));
}
}
+159
View File
@@ -0,0 +1,159 @@
<?php
declare(strict_types=1);
namespace BattleForge\Domain;
use InvalidArgumentException;
final class CombatEngine
{
public function move(MatchState $match, string $unitId, Position $destination): MatchState
{
$this->assertMatchActive($match);
$unit = $this->unitOrFail($match, $unitId);
$this->assertCanAct($match, $unit);
$occupied = [];
foreach ($match->units as $otherUnit) {
if ($otherUnit->id !== $unit->id && !$otherUnit->isDefeated()) {
$occupied[] = $otherUnit->position;
}
}
$reachable = $match->battlefield->reachable($unit->position, $unit->speed, $occupied);
if ($destination->key() === $unit->position->key() || !isset($reachable[$destination->key()])) {
throw new CombatException('Destination is not reachable.');
}
$movedUnit = $unit->moveTo($destination)->spendAction();
$next = $match->withUnit($movedUnit);
$actionLog = [...$match->actionLog, "{$unit->id} moved to {$destination->key()}"];
return $next->copy(actionLog: $actionLog);
}
public function attack(MatchState $match, string $attackerId, string $targetId): MatchState
{
$this->assertMatchActive($match);
$attacker = $this->unitOrFail($match, $attackerId);
$this->assertCanAct($match, $attacker);
if ($attacker->hasAttacked) {
throw new CombatException('Unit has already attacked this turn.');
}
$target = $this->unitOrFail($match, $targetId);
if ($target->teamId === $attacker->teamId || $target->isDefeated()) {
throw new CombatException('Target must be an active enemy unit.');
}
$distance = $attacker->position->distanceTo($target->position);
if ($distance !== 1) {
throw new CombatException('Target is outside attack range.');
}
$terrainDefense = $match->battlefield->terrainAt($target->position)->defenseBonus();
$damage = max(1, $attacker->attack - $target->defense - $terrainDefense);
$updatedAttacker = $attacker->markAttacked()->spendAction();
$updatedTarget = $target->takeDamage($damage);
$next = $match->withUnit($updatedAttacker)->withUnit($updatedTarget);
$actionLog = [...$match->actionLog, "{$attacker->id} attacked {$target->id} for {$damage} damage"];
$next = $next->copy(actionLog: $actionLog);
foreach ($next->units as $unit) {
if ($unit->teamId !== $attacker->teamId && !$unit->isDefeated()) {
return $next;
}
}
return $next->withWinner($attacker->teamId);
}
public function endTurn(MatchState $match): MatchState
{
$this->assertMatchActive($match);
$teamIds = [];
foreach ($match->units as $unit) {
$teamIds[$unit->teamId] = true;
}
$teamIds = array_keys($teamIds);
sort($teamIds);
if ($teamIds !== ['alpha', 'bravo']) {
throw new CombatException('Matches require alpha and bravo teams.');
}
$livingTeams = ['alpha' => false, 'bravo' => false];
foreach ($match->units as $unit) {
if (!$unit->isDefeated()) {
$livingTeams[$unit->teamId] = true;
}
}
if (!$livingTeams['alpha'] || !$livingTeams['bravo']) {
throw new CombatException('Cannot end turn while a team is eliminated.');
}
$endingTeamId = $match->activeTeamId;
$nextTeamId = $endingTeamId === 'alpha' ? 'bravo' : 'alpha';
if ($nextTeamId === 'alpha' && $match->round === PHP_INT_MAX) {
throw new CombatException('Match round limit reached.');
}
$units = [];
foreach ($match->units as $unit) {
$units[] = $unit->teamId === $nextTeamId ? $unit->startTurn() : $unit;
}
return $match->copy(
units: $units,
activeTeamId: $nextTeamId,
round: $match->round + ($nextTeamId === 'alpha' ? 1 : 0),
actionLog: [...$match->actionLog, "{$endingTeamId} ended turn"],
);
}
private function unitOrFail(MatchState $match, string $unitId): UnitState
{
try {
return $match->unit($unitId);
} catch (InvalidArgumentException $exception) {
throw new CombatException("Unknown unit: {$unitId}.", previous: $exception);
}
}
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.');
}
}
}
+11
View File
@@ -0,0 +1,11 @@
<?php
declare(strict_types=1);
namespace BattleForge\Domain;
use DomainException;
final class CombatException extends DomainException
{
}
+169
View File
@@ -0,0 +1,169 @@
<?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 = [],
) {
if (!self::isList($units)) {
throw new InvalidArgumentException('Match units must be a list.');
}
if ($units === []) {
throw new InvalidArgumentException('Match must contain at least one unit.');
}
$unitIds = [];
$teamIds = [];
$occupiedPositions = [];
foreach ($units as $unit) {
if (!self::isUnitState($unit)) {
throw new InvalidArgumentException('Match units must contain only UnitState instances.');
}
if (isset($unitIds[$unit->id])) {
throw new InvalidArgumentException("Duplicate unit id: {$unit->id}.");
}
if (!$battlefield->contains($unit->position)) {
throw new InvalidArgumentException("Unit {$unit->id} is outside the battlefield.");
}
if (!$unit->isDefeated() && $battlefield->terrainAt($unit->position)->movementCost() === null) {
throw new InvalidArgumentException("Living unit {$unit->id} must stand on passable terrain.");
}
$positionKey = $unit->position->key();
if (!$unit->isDefeated() && isset($occupiedPositions[$positionKey])) {
throw new InvalidArgumentException("Living units cannot share position {$positionKey}.");
}
if (!$unit->isDefeated()) {
$occupiedPositions[$positionKey] = true;
}
$unitIds[$unit->id] = true;
$teamIds[$unit->teamId] = true;
}
if ($activeTeamId === '') {
throw new InvalidArgumentException('Active team id cannot be empty.');
}
if (!isset($teamIds[$activeTeamId])) {
throw new InvalidArgumentException("Active team has no units: {$activeTeamId}.");
}
if ($round < 1) {
throw new InvalidArgumentException('Match round must be at least 1.');
}
if (!self::isList($actionLog)) {
throw new InvalidArgumentException('Match action log must be a list.');
}
foreach ($actionLog as $entry) {
if (!self::isString($entry)) {
throw new InvalidArgumentException('Match action log entries must be strings.');
}
}
if ($winnerTeamId !== null && $winnerTeamId === '') {
throw new InvalidArgumentException('Winner team id cannot be empty.');
}
if ($winnerTeamId !== null && !isset($teamIds[$winnerTeamId])) {
throw new InvalidArgumentException("Winner team has no units: {$winnerTeamId}.");
}
}
private static function isUnitState(mixed $unit): bool
{
return $unit instanceof UnitState;
}
private static function isString(mixed $value): bool
{
return is_string($value);
}
private static function isList(mixed $value): bool
{
return is_array($value) && array_is_list($value);
}
public function unit(string $id): UnitState
{
foreach ($this->units as $unit) {
if ($unit->id === $id) {
return $unit;
}
}
throw new InvalidArgumentException("Unknown unit id: {$id}.");
}
public function withUnit(UnitState $replacement): self
{
$units = $this->units;
foreach ($units as $index => $unit) {
if ($unit->id === $replacement->id) {
$units[$index] = $replacement;
return $this->copy(units: $units);
}
}
throw new InvalidArgumentException("Unknown unit id: {$replacement->id}.");
}
public function withWinner(?string $winnerTeamId): self
{
return new self(
$this->battlefield,
$this->units,
$this->activeTeamId,
$this->round,
$winnerTeamId,
$this->actionLog,
);
}
/**
* @param list<UnitState>|null $units
* @param list<string>|null $actionLog
*/
public function copy(
?array $units = null,
?string $activeTeamId = null,
?int $round = null,
?array $actionLog = null,
): self {
return new self(
$this->battlefield,
$units ?? $this->units,
$activeTeamId ?? $this->activeTeamId,
$round ?? $this->round,
$this->winnerTeamId,
$actionLog ?? $this->actionLog,
);
}
}
+24
View File
@@ -0,0 +1,24 @@
<?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 $position): int
{
return abs($this->x - $position->x) + abs($this->y - $position->y);
}
}
+28
View File
@@ -0,0 +1,28 @@
<?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;
}
}
+109
View File
@@ -0,0 +1,109 @@
<?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 === '') {
throw new InvalidArgumentException('Unit id cannot be empty.');
}
if ($teamId === '') {
throw new InvalidArgumentException('Unit team id cannot be empty.');
}
if ($maxHealth < 1) {
throw new InvalidArgumentException('Unit maximum health must be at least 1.');
}
if ($health < 0 || $health > $maxHealth) {
throw new InvalidArgumentException('Unit health must be between 0 and maximum health.');
}
if ($attack < 0 || $defense < 0) {
throw new InvalidArgumentException('Unit attack and defense cannot be negative.');
}
if ($speed < 1) {
throw new InvalidArgumentException('Unit speed must be at least 1.');
}
if ($actionsRemaining < 0 || $actionsRemaining > 2) {
throw new InvalidArgumentException('Unit actions remaining must be between 0 and 2.');
}
}
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
{
if ($this->isDefeated()) {
return $this;
}
return $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,
);
}
}
@@ -0,0 +1,56 @@
<?php
declare(strict_types=1);
namespace BattleForge\Tests\Unit\Domain;
use BattleForge\Domain\Archetype;
use BattleForge\Domain\ArchetypeCatalog;
use BattleForge\Domain\ArchetypeTemplate;
use PHPUnit\Framework\TestCase;
final class ArchetypeCatalogTest extends TestCase
{
public function testItExposesExactlyTheFourPlannedArchetypes(): void
{
$keys = array_keys(ArchetypeCatalog::templates());
sort($keys);
self::assertSame(['defender', 'scout', 'striker', 'support'], $keys);
}
public function testEveryArchetypeTemplateCarriesAllFourStatRanges(): void
{
foreach (ArchetypeCatalog::templates() as $key => $template) {
self::assertInstanceOf(ArchetypeTemplate::class, $template);
self::assertLessThanOrEqual($template->maxHealth, $template->minHealth);
self::assertLessThanOrEqual($template->maxAttack, $template->minAttack);
self::assertLessThanOrEqual($template->maxDefense, $template->minDefense);
self::assertLessThanOrEqual($template->maxSpeed, $template->minSpeed);
self::assertGreaterThanOrEqual(1, $template->minSpeed);
}
}
public function testEveryArchetypeTemplateDefaultsToTheCenterOfItsRanges(): void
{
foreach (ArchetypeCatalog::templates() as $template) {
$defaults = $template->defaultStats();
self::assertSame((int) ceil(($template->minHealth + $template->maxHealth) / 2), $defaults['maxHealth']);
self::assertSame((int) ceil(($template->minAttack + $template->maxAttack) / 2), $defaults['attack']);
self::assertSame((int) ceil(($template->minDefense + $template->maxDefense) / 2), $defaults['defense']);
self::assertSame((int) ceil(($template->minSpeed + $template->maxSpeed) / 2), $defaults['speed']);
}
}
public function testDefaultStatsReturnAllFourKeys(): void
{
$template = ArchetypeCatalog::templates()[Archetype::Defender->value];
self::assertSame(
['maxHealth', 'attack', 'defense', 'speed'],
array_keys($template->defaultStats()),
);
}
}
@@ -0,0 +1,62 @@
<?php
declare(strict_types=1);
namespace BattleForge\Tests\Unit\Domain;
use BattleForge\Domain\ArchetypeTemplate;
use PHPUnit\Framework\TestCase;
final class ArchetypeTemplateTest extends TestCase
{
public function testItRejectsInvertedStatRanges(): void
{
$this->expectException(\InvalidArgumentException::class);
new ArchetypeTemplate(
minHealth: 10,
maxHealth: 5,
minAttack: 1,
maxAttack: 3,
minDefense: 0,
maxDefense: 2,
minSpeed: 1,
maxSpeed: 4,
allowedAbilities: [],
);
}
public function testItRejectsZeroMinimumSpeed(): void
{
$this->expectException(\InvalidArgumentException::class);
new ArchetypeTemplate(
minHealth: 5,
maxHealth: 10,
minAttack: 1,
maxAttack: 3,
minDefense: 0,
maxDefense: 2,
minSpeed: 0,
maxSpeed: 4,
allowedAbilities: [],
);
}
public function testItExposesAllowedAbilitiesVerbatim(): void
{
$template = new ArchetypeTemplate(
minHealth: 5,
maxHealth: 10,
minAttack: 1,
maxAttack: 3,
minDefense: 0,
maxDefense: 2,
minSpeed: 1,
maxSpeed: 4,
allowedAbilities: ['heal', 'buff'],
);
self::assertSame(['heal', 'buff'], $template->allowedAbilities);
}
}
+121
View File
@@ -0,0 +1,121 @@
<?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 testItRejectsDimensionsOutsideTheSupportedRange(): void
{
$this->expectException(InvalidArgumentException::class);
new Battlefield(7, 16);
}
public function testItAcceptsMinimumAndMaximumDimensions(): void
{
$minimum = new Battlefield(8, 8);
$maximum = new Battlefield(16, 16);
self::assertSame([8, 8], [$minimum->width, $minimum->height]);
self::assertSame([16, 16], [$maximum->width, $maximum->height]);
}
public function testItRejectsDimensionsAboveTheSupportedRange(): void
{
$this->expectException(InvalidArgumentException::class);
new Battlefield(16, 17);
}
public function testItRejectsNonCanonicalTerrainCoordinates(): void
{
$this->expectException(InvalidArgumentException::class);
new Battlefield(8, 8, ['01:0' => Terrain::Forest]);
}
public function testItRejectsMalformedTerrainCoordinates(): void
{
$this->expectException(InvalidArgumentException::class);
new Battlefield(8, 8, ['not-a-coordinate' => Terrain::Forest]);
}
public function testItRejectsInvalidTerrainValues(): void
{
$this->expectException(InvalidArgumentException::class);
// @phpstan-ignore argument.type
new Battlefield(8, 8, ['0:0' => 'forest']);
}
public function testReachableRejectsAStartOutsideTheBattlefield(): void
{
$battlefield = new Battlefield(8, 8);
$this->expectException(InvalidArgumentException::class);
$battlefield->reachable(new Position(-1, 0), 2, []);
}
public function testReachableRejectsANegativeBudget(): void
{
$battlefield = new Battlefield(8, 8);
$this->expectException(InvalidArgumentException::class);
$battlefield->reachable(new Position(0, 0), -1, []);
}
public function testReachableRejectsOccupiedPositionsOutsideTheBattlefield(): void
{
$battlefield = new Battlefield(8, 8);
$this->expectException(InvalidArgumentException::class);
$battlefield->reachable(new Position(0, 0), 2, [new Position(8, 0)]);
}
public function testItFindsReachableTilesUsingTerrainCostsAndObstacles(): 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:0' => 0,
'1:0' => 2,
'0:1' => 2,
], $reachable);
}
public function testReachableReplacesAHighCostRouteWithALowerCostDetour(): void
{
$battlefield = new Battlefield(8, 8, [
'1:0' => Terrain::Forest,
'2:0' => Terrain::Forest,
'3:0' => Terrain::Forest,
]);
$reachable = $battlefield->reachable(new Position(0, 0), 7, []);
self::assertSame(6, $reachable['4:0']);
}
}
+763
View File
@@ -0,0 +1,763 @@
<?php
declare(strict_types=1);
namespace BattleForge\Tests\Unit\Domain;
use BattleForge\Domain\Battlefield;
use BattleForge\Domain\CombatEngine;
use BattleForge\Domain\CombatException;
use BattleForge\Domain\MatchState;
use BattleForge\Domain\Position;
use BattleForge\Domain\Terrain;
use BattleForge\Domain\UnitState;
use InvalidArgumentException;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\TestCase;
final class CombatEngineTest extends TestCase
{
public function testMatchAndUnitStateAreImmutable(): void
{
$unit = $this->unit('unit-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('unit-1')->position->key());
self::assertSame('1:0', $next->unit('unit-1')->position->key());
self::assertSame(1, $next->unit('unit-1')->actionsRemaining);
}
public function testMoveUpdatesUnitAndAppendsActionLogWithoutMutatingMatch(): 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',
actionLog: ['match started'],
);
$next = (new CombatEngine())->move($match, 'alpha-1', new Position(2, 0));
self::assertSame('0:0', $match->unit('alpha-1')->position->key());
self::assertSame(2, $match->unit('alpha-1')->actionsRemaining);
self::assertSame(['match started'], $match->actionLog);
self::assertSame('2:0', $next->unit('alpha-1')->position->key());
self::assertSame(1, $next->unit('alpha-1')->actionsRemaining);
self::assertSame(['match started', 'alpha-1 moved to 2:0'], $next->actionLog);
}
public function testMoveTranslatesUnknownUnitAtCommandBoundary(): void
{
$match = $this->match();
try {
(new CombatEngine())->move($match, 'missing', new Position(1, 0));
self::fail('Expected an unknown unit exception.');
} catch (CombatException $exception) {
self::assertSame('Unknown unit: missing.', $exception->getMessage());
self::assertInstanceOf(InvalidArgumentException::class, $exception->getPrevious());
self::assertSame('Unknown unit id: missing.', $exception->getPrevious()->getMessage());
}
}
public function testMoveRejectsCompletedMatchBeforeResolvingUnit(): void
{
$match = $this->match()->withWinner('alpha');
$this->expectException(CombatException::class);
$this->expectExceptionMessage('The match is already complete.');
(new CombatEngine())->move($match, 'missing', new Position(1, 0));
}
public function testMoveRejectsDefeatedActor(): void
{
$match = new MatchState(
new Battlefield(8, 8),
[
$this->unit('alpha-1', 'alpha', new Position(0, 0), health: 0),
$this->unit('bravo-1', 'bravo', new Position(7, 7)),
],
'alpha',
);
$this->expectException(CombatException::class);
$this->expectExceptionMessage('Defeated units cannot act.');
(new CombatEngine())->move($match, 'alpha-1', new Position(1, 0));
}
public function testMoveRejectsExhaustedActor(): void
{
$match = new MatchState(
new Battlefield(8, 8),
[
$this->unit('alpha-1', 'alpha', new Position(0, 0), actionsRemaining: 0),
$this->unit('bravo-1', 'bravo', new Position(7, 7)),
],
'alpha',
);
$this->expectException(CombatException::class);
$this->expectExceptionMessage('Unit has no actions remaining.');
(new CombatEngine())->move($match, 'alpha-1', new Position(1, 0));
}
public function testMoveRejectsCurrentPosition(): void
{
$match = $this->match();
$this->expectException(CombatException::class);
$this->expectExceptionMessage('Destination is not reachable.');
(new CombatEngine())->move($match, 'alpha-1', new Position(0, 0));
}
/**
* @param array<string, Terrain> $terrain
*/
#[DataProvider('unreachableDestinationProvider')]
public function testMoveRejectsUnreachableDestination(array $terrain, Position $destination): void
{
$match = new MatchState(
new Battlefield(8, 8, $terrain),
[
$this->unit('alpha-1', 'alpha', new Position(0, 0)),
$this->unit('bravo-1', 'bravo', new Position(7, 7)),
],
'alpha',
);
$this->expectException(CombatException::class);
$this->expectExceptionMessage('Destination is not reachable.');
(new CombatEngine())->move($match, 'alpha-1', $destination);
}
/**
* @return iterable<string, array{array<string, Terrain>, Position}>
*/
public static function unreachableDestinationProvider(): iterable
{
yield 'outside battlefield' => [[], new Position(8, 0)];
yield 'outside speed budget' => [[], new Position(5, 0)];
yield 'impassable terrain' => [['1:0' => Terrain::Blocking], new Position(1, 0)];
}
public function testDefeatedUnitDoesNotBlockItsPosition(): 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), health: 0),
$this->unit('bravo-1', 'bravo', new Position(7, 7)),
],
'alpha',
);
$next = (new CombatEngine())->move($match, 'alpha-1', new Position(1, 0));
self::assertSame('1:0', $next->unit('alpha-1')->position->key());
}
public function testMoveHonorsTerrainCostWithinSpeedBudget(): void
{
$match = new MatchState(
new Battlefield(8, 8, ['1:0' => Terrain::Forest]),
[
$this->unit('alpha-1', 'alpha', new Position(0, 0), speed: 3),
$this->unit('bravo-1', 'bravo', new Position(7, 7)),
],
'alpha',
);
$next = (new CombatEngine())->move($match, 'alpha-1', new Position(2, 0));
self::assertSame('2:0', $next->unit('alpha-1')->position->key());
}
public function testMoveRejectsUnitFromInactiveTeam(): 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->expectException(CombatException::class);
$this->expectExceptionMessage("It is not this unit's turn.");
(new CombatEngine())->move($match, 'bravo-1', new Position(6, 7));
}
public function testMoveRejectsOccupiedDestination(): 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->expectException(CombatException::class);
$this->expectExceptionMessage('Destination is not reachable.');
(new CombatEngine())->move($match, 'alpha-1', new Position(1, 0));
}
public function testAttackAppliesForestDefenseAndAppendsLogWithoutMutatingMatch(): void
{
$attacker = $this->unit('alpha-1', 'alpha', new Position(0, 0));
$target = $this->unit('bravo-1', 'bravo', new Position(1, 0));
$match = new MatchState(
new Battlefield(8, 8, ['1:0' => Terrain::Forest]),
[$attacker, $target],
'alpha',
actionLog: ['match started'],
);
$next = (new CombatEngine())->attack($match, 'alpha-1', 'bravo-1');
self::assertSame(10, $target->health);
self::assertSame(2, $attacker->actionsRemaining);
self::assertFalse($attacker->hasAttacked);
self::assertSame(10, $match->unit('bravo-1')->health);
self::assertSame(2, $match->unit('alpha-1')->actionsRemaining);
self::assertFalse($match->unit('alpha-1')->hasAttacked);
self::assertSame(['match started'], $match->actionLog);
self::assertSame(9, $next->unit('bravo-1')->health);
self::assertSame(1, $next->unit('alpha-1')->actionsRemaining);
self::assertTrue($next->unit('alpha-1')->hasAttacked);
self::assertSame(
['match started', 'alpha-1 attacked bravo-1 for 1 damage'],
$next->actionLog,
);
}
public function testAttackRejectsTargetOutsideRange(): 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->expectException(CombatException::class);
$this->expectExceptionMessage('Target is outside attack range.');
(new CombatEngine())->attack($match, 'alpha-1', 'bravo-1');
}
public function testAttackDefeatingLastEnemyAwardsVictory(): void
{
$match = new MatchState(
new Battlefield(8, 8),
[
$this->unit('alpha-1', 'alpha', new Position(0, 0), attack: 20),
$this->unit('bravo-1', 'bravo', new Position(1, 0)),
],
'alpha',
);
$next = (new CombatEngine())->attack($match, 'alpha-1', 'bravo-1');
self::assertSame(0, $next->unit('bravo-1')->health);
self::assertSame('alpha', $next->winnerTeamId);
self::assertNull($match->winnerTeamId);
}
public function testAttackRejectsUnitThatAlreadyAttacked(): void
{
$match = new MatchState(
new Battlefield(8, 8),
[
$this->unit('alpha-1', 'alpha', new Position(0, 0), hasAttacked: true),
$this->unit('bravo-1', 'bravo', new Position(1, 0)),
],
'alpha',
);
$this->expectException(CombatException::class);
$this->expectExceptionMessage('Unit has already attacked this turn.');
(new CombatEngine())->attack($match, 'alpha-1', 'bravo-1');
}
public function testAttackTranslatesUnknownAttackerAtCommandBoundary(): void
{
$match = $this->match();
try {
(new CombatEngine())->attack($match, 'missing', 'bravo-1');
self::fail('Expected an unknown unit exception.');
} catch (CombatException $exception) {
self::assertSame('Unknown unit: missing.', $exception->getMessage());
self::assertInstanceOf(InvalidArgumentException::class, $exception->getPrevious());
self::assertSame('Unknown unit id: missing.', $exception->getPrevious()->getMessage());
}
}
public function testAttackTranslatesUnknownTargetAtCommandBoundary(): void
{
$match = $this->match();
try {
(new CombatEngine())->attack($match, 'alpha-1', 'missing');
self::fail('Expected an unknown unit exception.');
} catch (CombatException $exception) {
self::assertSame('Unknown unit: missing.', $exception->getMessage());
self::assertInstanceOf(InvalidArgumentException::class, $exception->getPrevious());
self::assertSame('Unknown unit id: missing.', $exception->getPrevious()->getMessage());
}
}
public function testAttackRejectsCompletedMatchBeforeResolvingAttacker(): void
{
$match = $this->match()->withWinner('alpha');
$this->expectException(CombatException::class);
$this->expectExceptionMessage('The match is already complete.');
(new CombatEngine())->attack($match, 'missing', 'also-missing');
}
/**
* @param array{team?: string, health?: int, actionsRemaining?: int, hasAttacked?: bool} $overrides
*/
#[DataProvider('invalidAttackActorProvider')]
public function testAttackRejectsInvalidActor(array $overrides, string $message): void
{
$attacker = $this->unit(
'attacker',
$overrides['team'] ?? 'alpha',
new Position(0, 0),
health: $overrides['health'] ?? 10,
actionsRemaining: $overrides['actionsRemaining'] ?? 2,
hasAttacked: $overrides['hasAttacked'] ?? false,
);
$match = new MatchState(
new Battlefield(8, 8),
[
$attacker,
$this->unit('bravo-1', 'bravo', new Position(1, 0)),
$this->unit('alpha-2', 'alpha', new Position(7, 7)),
],
'alpha',
);
$this->expectException(CombatException::class);
$this->expectExceptionMessage($message);
(new CombatEngine())->attack($match, 'attacker', 'missing');
}
/**
* @return iterable<string, array{
* array{team?: string, health?: int, actionsRemaining?: int, hasAttacked?: bool},
* string
* }>
*/
public static function invalidAttackActorProvider(): iterable
{
yield 'inactive and already attacked' => [
['team' => 'bravo', 'hasAttacked' => true],
"It is not this unit's turn.",
];
yield 'defeated and already attacked' => [
['health' => 0, 'hasAttacked' => true],
'Defeated units cannot act.',
];
yield 'exhausted and already attacked' => [
['actionsRemaining' => 0, 'hasAttacked' => true],
'Unit has no actions remaining.',
];
}
#[DataProvider('invalidAttackTargetProvider')]
public function testAttackRejectsInvalidTarget(string $targetTeam, int $targetHealth): void
{
$match = new MatchState(
new Battlefield(8, 8),
[
$this->unit('alpha-1', 'alpha', new Position(0, 0)),
$this->unit('target', $targetTeam, new Position(1, 0), health: $targetHealth),
$this->unit('bravo-2', 'bravo', new Position(7, 7)),
],
'alpha',
);
$this->expectException(CombatException::class);
$this->expectExceptionMessage('Target must be an active enemy unit.');
(new CombatEngine())->attack($match, 'alpha-1', 'target');
}
/**
* @return iterable<string, array{string, int}>
*/
public static function invalidAttackTargetProvider(): iterable
{
yield 'friendly' => ['alpha', 10];
yield 'defeated enemy' => ['bravo', 0];
}
public function testAttackDealsCalculatedOpenTerrainDamage(): void
{
$target = $this->unit('bravo-1', 'bravo', new Position(1, 0), defense: 3);
$match = new MatchState(
new Battlefield(8, 8),
[
$this->unit('alpha-1', 'alpha', new Position(0, 0), attack: 8),
$target,
],
'alpha',
);
$next = (new CombatEngine())->attack($match, 'alpha-1', 'bravo-1');
self::assertSame(10, $target->health);
self::assertSame(5, $next->unit('bravo-1')->health);
self::assertSame(['alpha-1 attacked bravo-1 for 5 damage'], $next->actionLog);
}
public function testAttackAlwaysDealsAtLeastOneDamage(): void
{
$match = new MatchState(
new Battlefield(8, 8, ['1:0' => Terrain::Forest]),
[
$this->unit('alpha-1', 'alpha', new Position(0, 0), attack: 0),
$this->unit('bravo-1', 'bravo', new Position(1, 0), defense: 20),
],
'alpha',
);
$next = (new CombatEngine())->attack($match, 'alpha-1', 'bravo-1');
self::assertSame(9, $next->unit('bravo-1')->health);
self::assertSame(['alpha-1 attacked bravo-1 for 1 damage'], $next->actionLog);
}
public function testAttackDoesNotAwardVictoryWhileAnotherEnemyLives(): void
{
$match = new MatchState(
new Battlefield(8, 8),
[
$this->unit('alpha-1', 'alpha', new Position(0, 0), attack: 20),
$this->unit('bravo-1', 'bravo', new Position(1, 0)),
$this->unit('bravo-2', 'bravo', new Position(7, 7)),
],
'alpha',
);
$next = (new CombatEngine())->attack($match, 'alpha-1', 'bravo-1');
self::assertSame(0, $next->unit('bravo-1')->health);
self::assertSame(10, $next->unit('bravo-2')->health);
self::assertNull($next->winnerTeamId);
}
public function testEndTurnPassesControlToBravoAndRefreshesItsLivingUnits(): void
{
$match = new MatchState(
new Battlefield(8, 8),
[
$this->unit(
'alpha-1',
'alpha',
new Position(0, 0),
actionsRemaining: 1,
hasAttacked: true,
),
$this->unit(
'bravo-1',
'bravo',
new Position(7, 7),
actionsRemaining: 0,
hasAttacked: true,
),
],
'alpha',
actionLog: ['match started'],
);
$next = (new CombatEngine())->endTurn($match);
self::assertSame('alpha', $match->activeTeamId);
self::assertSame(1, $match->round);
self::assertSame(1, $match->unit('alpha-1')->actionsRemaining);
self::assertTrue($match->unit('alpha-1')->hasAttacked);
self::assertSame(0, $match->unit('bravo-1')->actionsRemaining);
self::assertTrue($match->unit('bravo-1')->hasAttacked);
self::assertSame(['match started'], $match->actionLog);
self::assertSame('bravo', $next->activeTeamId);
self::assertSame(1, $next->round);
self::assertSame(1, $next->unit('alpha-1')->actionsRemaining);
self::assertTrue($next->unit('alpha-1')->hasAttacked);
self::assertSame(2, $next->unit('bravo-1')->actionsRemaining);
self::assertFalse($next->unit('bravo-1')->hasAttacked);
self::assertSame(['match started', 'alpha ended turn'], $next->actionLog);
}
public function testEndTurnPassesControlToAlphaAndStartsANewRound(): void
{
$match = new MatchState(
new Battlefield(8, 8),
[
$this->unit(
'alpha-1',
'alpha',
new Position(0, 0),
actionsRemaining: 0,
hasAttacked: true,
),
$this->unit('bravo-1', 'bravo', new Position(7, 7), actionsRemaining: 1),
],
'bravo',
round: 1,
actionLog: ['alpha ended turn'],
);
$next = (new CombatEngine())->endTurn($match);
self::assertSame('alpha', $next->activeTeamId);
self::assertSame(2, $next->round);
self::assertSame(2, $next->unit('alpha-1')->actionsRemaining);
self::assertFalse($next->unit('alpha-1')->hasAttacked);
self::assertSame(1, $next->unit('bravo-1')->actionsRemaining);
self::assertSame(['alpha ended turn', 'bravo ended turn'], $next->actionLog);
self::assertSame('bravo', $match->activeTeamId);
self::assertSame(1, $match->round);
self::assertSame(0, $match->unit('alpha-1')->actionsRemaining);
self::assertTrue($match->unit('alpha-1')->hasAttacked);
self::assertSame(['alpha ended turn'], $match->actionLog);
}
public function testEndTurnRejectsCompletedMatchBeforeValidatingTeams(): void
{
$match = new MatchState(
new Battlefield(8, 8),
[$this->unit('alpha-1', 'alpha', new Position(0, 0))],
'alpha',
winnerTeamId: 'alpha',
);
$this->expectException(CombatException::class);
$this->expectExceptionMessage('The match is already complete.');
(new CombatEngine())->endTurn($match);
}
/**
* @param list<string> $teamIds
*/
#[DataProvider('invalidEndTurnTeamProvider')]
public function testEndTurnRejectsMatchesWithoutExactlyAlphaAndBravoTeams(array $teamIds): void
{
$units = [];
foreach ($teamIds as $index => $teamId) {
$units[] = $this->unit("{$teamId}-{$index}", $teamId, new Position($index, 0));
}
$match = new MatchState(
new Battlefield(8, 8),
$units,
'alpha',
);
$this->expectException(CombatException::class);
$this->expectExceptionMessage('Matches require alpha and bravo teams.');
(new CombatEngine())->endTurn($match);
}
/**
* @return iterable<string, array{list<string>}>
*/
public static function invalidEndTurnTeamProvider(): iterable
{
yield 'only alpha' => [['alpha']];
yield 'alpha and charlie' => [['alpha', 'charlie']];
yield 'alpha, bravo, and charlie' => [['alpha', 'bravo', 'charlie']];
}
#[DataProvider('eliminatedEndTurnTeamProvider')]
public function testEndTurnRejectsEliminatedTeam(string $activeTeamId, string $eliminatedTeamId): void
{
$match = new MatchState(
new Battlefield(8, 8),
[
$this->unit(
'alpha-1',
'alpha',
new Position(0, 0),
health: $eliminatedTeamId === 'alpha' ? 0 : 10,
),
$this->unit(
'bravo-1',
'bravo',
new Position(7, 7),
health: $eliminatedTeamId === 'bravo' ? 0 : 10,
),
],
$activeTeamId,
);
$this->expectException(CombatException::class);
$this->expectExceptionMessage('Cannot end turn while a team is eliminated.');
(new CombatEngine())->endTurn($match);
}
/**
* @return iterable<string, array{string, string}>
*/
public static function eliminatedEndTurnTeamProvider(): iterable
{
yield 'incoming team' => ['alpha', 'bravo'];
yield 'active team' => ['alpha', 'alpha'];
}
public function testEndTurnRejectsEliminatedTeamBeforeRoundLimit(): void
{
$match = new MatchState(
new Battlefield(8, 8),
[
$this->unit('alpha-1', 'alpha', new Position(0, 0), health: 0),
$this->unit('bravo-1', 'bravo', new Position(7, 7)),
],
'bravo',
round: PHP_INT_MAX,
);
$this->expectException(CombatException::class);
$this->expectExceptionMessage('Cannot end turn while a team is eliminated.');
(new CombatEngine())->endTurn($match);
}
public function testEndTurnRejectsRoundIncrementBeyondIntegerLimit(): 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',
round: PHP_INT_MAX,
);
$this->expectException(CombatException::class);
$this->expectExceptionMessage('Match round limit reached.');
(new CombatEngine())->endTurn($match);
}
public function testEndTurnAllowsAlphaToPassAtIntegerRoundLimit(): 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), actionsRemaining: 0),
],
'alpha',
round: PHP_INT_MAX,
);
$next = (new CombatEngine())->endTurn($match);
self::assertSame('bravo', $next->activeTeamId);
self::assertSame(PHP_INT_MAX, $next->round);
self::assertSame(2, $next->unit('bravo-1')->actionsRemaining);
self::assertSame(['alpha ended turn'], $next->actionLog);
}
public function testEndTurnDoesNotRefreshDefeatedUnitsOnIncomingTeam(): void
{
$match = new MatchState(
new Battlefield(8, 8),
[
$this->unit('alpha-1', 'alpha', new Position(0, 0)),
$this->unit(
'bravo-defeated',
'bravo',
new Position(6, 7),
health: 0,
actionsRemaining: 0,
hasAttacked: true,
),
$this->unit(
'bravo-living',
'bravo',
new Position(7, 7),
actionsRemaining: 0,
hasAttacked: true,
),
],
'alpha',
);
$next = (new CombatEngine())->endTurn($match);
self::assertSame(0, $next->unit('bravo-defeated')->actionsRemaining);
self::assertTrue($next->unit('bravo-defeated')->hasAttacked);
self::assertSame(2, $next->unit('bravo-living')->actionsRemaining);
self::assertFalse($next->unit('bravo-living')->hasAttacked);
}
private function match(): MatchState
{
return 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',
);
}
private function unit(
string $id,
string $team,
Position $position,
int $health = 10,
int $speed = 4,
int $actionsRemaining = 2,
int $attack = 4,
int $defense = 2,
bool $hasAttacked = false,
): UnitState {
return new UnitState(
$id,
$team,
$position,
10,
$health,
$attack,
$defense,
$speed,
$actionsRemaining,
$hasAttacked,
);
}
}
+252
View File
@@ -0,0 +1,252 @@
<?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\Terrain;
use BattleForge\Domain\UnitState;
use InvalidArgumentException;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\TestCase;
final class MatchStateTest extends TestCase
{
public function testItRejectsDuplicateUnitIds(): void
{
$this->expectException(InvalidArgumentException::class);
new MatchState(
new Battlefield(8, 8),
[$this->unit('unit-1', 'alpha'), $this->unit('unit-1', 'bravo')],
'alpha',
);
}
public function testItRejectsAUnitOutsideTheBattlefield(): void
{
$this->expectException(InvalidArgumentException::class);
$this->expectExceptionMessage('Unit alpha-1 is outside the battlefield.');
new MatchState(
new Battlefield(8, 8),
[$this->unit('alpha-1', 'alpha', new Position(8, 0))],
'alpha',
);
}
#[DataProvider('impassableTerrainProvider')]
public function testItRejectsALivingUnitOnImpassableTerrain(Terrain $terrain): void
{
$this->expectException(InvalidArgumentException::class);
$this->expectExceptionMessage('Living unit alpha-1 must stand on passable terrain.');
new MatchState(
new Battlefield(8, 8, ['0:0' => $terrain]),
[$this->unit('alpha-1', 'alpha')],
'alpha',
);
}
/**
* @return iterable<string, array{Terrain}>
*/
public static function impassableTerrainProvider(): iterable
{
yield 'water' => [Terrain::Water];
yield 'blocking' => [Terrain::Blocking];
}
public function testItRejectsLivingUnitsAtTheSamePosition(): void
{
$this->expectException(InvalidArgumentException::class);
$this->expectExceptionMessage('Living units cannot share position 0:0.');
new MatchState(
new Battlefield(8, 8),
[$this->unit('alpha-1', 'alpha'), $this->unit('bravo-1', 'bravo')],
'alpha',
);
}
public function testItAcceptsLivingAndDefeatedUnitsAtTheSamePosition(): void
{
$match = new MatchState(
new Battlefield(8, 8),
[
$this->unit('alpha-1', 'alpha'),
$this->unit('bravo-1', 'bravo', health: 0),
],
'alpha',
);
self::assertSame('0:0', $match->unit('alpha-1')->position->key());
self::assertSame('0:0', $match->unit('bravo-1')->position->key());
}
public function testItRejectsAnUnknownUnitLookup(): void
{
$match = $this->match();
$this->expectException(InvalidArgumentException::class);
$match->unit('unknown');
}
public function testItRejectsAnUnknownReplacement(): void
{
$match = $this->match();
$this->expectException(InvalidArgumentException::class);
$match->withUnit($this->unit('unknown', 'alpha'));
}
public function testReplacingAUnitDoesNotMutateTheMatch(): void
{
$match = $this->match();
$replacement = $match->unit('alpha-1')->moveTo(new Position(2, 0));
$replaced = $match->withUnit($replacement);
self::assertSame('0:0', $match->unit('alpha-1')->position->key());
self::assertSame('2:0', $replaced->unit('alpha-1')->position->key());
}
public function testCopyReplacesMutableMatchProgressIncludingAnEmptyLog(): void
{
$match = $this->match(actionLog: ['started']);
$copied = $match->copy(activeTeamId: 'bravo', round: 2, actionLog: []);
self::assertSame('alpha', $match->activeTeamId);
self::assertSame(1, $match->round);
self::assertSame(['started'], $match->actionLog);
self::assertSame('bravo', $copied->activeTeamId);
self::assertSame(2, $copied->round);
self::assertSame([], $copied->actionLog);
}
public function testWinnerCanBeSetAndClearedWithoutChangingOtherState(): void
{
$match = $this->match(actionLog: ['started']);
$won = $match->withWinner('alpha');
$cleared = $won->withWinner(null);
self::assertSame('alpha', $won->winnerTeamId);
self::assertNull($cleared->winnerTeamId);
self::assertSame($match->battlefield, $cleared->battlefield);
self::assertSame($match->units, $cleared->units);
self::assertSame($match->activeTeamId, $cleared->activeTeamId);
self::assertSame($match->round, $cleared->round);
self::assertSame($match->actionLog, $cleared->actionLog);
}
public function testItRejectsANonListUnitsArray(): void
{
$this->expectException(InvalidArgumentException::class);
// @phpstan-ignore argument.type
new MatchState(new Battlefield(8, 8), ['first' => $this->unit('alpha-1', 'alpha')], 'alpha');
}
public function testItRejectsAnEmptyUnitsArray(): void
{
$this->expectException(InvalidArgumentException::class);
new MatchState(new Battlefield(8, 8), [], 'alpha');
}
public function testItRejectsANonUnitMember(): void
{
$this->expectException(InvalidArgumentException::class);
// @phpstan-ignore argument.type
new MatchState(new Battlefield(8, 8), ['not-a-unit'], 'alpha');
}
public function testItRejectsAnEmptyActiveTeam(): void
{
$this->expectException(InvalidArgumentException::class);
new MatchState(new Battlefield(8, 8), [$this->unit('alpha-1', 'alpha')], '');
}
public function testItRejectsAnActiveTeamWithoutAUnit(): void
{
$this->expectException(InvalidArgumentException::class);
new MatchState(new Battlefield(8, 8), [$this->unit('alpha-1', 'alpha')], 'bravo');
}
public function testItRejectsARoundBelowOne(): void
{
$this->expectException(InvalidArgumentException::class);
new MatchState(new Battlefield(8, 8), [$this->unit('alpha-1', 'alpha')], 'alpha', 0);
}
public function testItRejectsANonListActionLog(): void
{
$this->expectException(InvalidArgumentException::class);
new MatchState(
new Battlefield(8, 8),
[$this->unit('alpha-1', 'alpha')],
'alpha',
// @phpstan-ignore argument.type
actionLog: ['first' => 'started'],
);
}
public function testItRejectsANonStringActionLogEntry(): void
{
$this->expectException(InvalidArgumentException::class);
// @phpstan-ignore argument.type
new MatchState(new Battlefield(8, 8), [$this->unit('alpha-1', 'alpha')], 'alpha', actionLog: [1]);
}
public function testItRejectsAnEmptyWinnerTeam(): void
{
$this->expectException(InvalidArgumentException::class);
new MatchState(new Battlefield(8, 8), [$this->unit('alpha-1', 'alpha')], 'alpha', winnerTeamId: '');
}
public function testItRejectsAWinnerWithoutAUnit(): void
{
$this->expectException(InvalidArgumentException::class);
new MatchState(
new Battlefield(8, 8),
[$this->unit('alpha-1', 'alpha')],
'alpha',
winnerTeamId: 'bravo',
);
}
/**
* @param list<string> $actionLog
*/
private function match(array $actionLog = []): MatchState
{
return new MatchState(
new Battlefield(8, 8),
[
$this->unit('alpha-1', 'alpha'),
$this->unit('bravo-1', 'bravo', new Position(7, 7)),
],
'alpha',
actionLog: $actionLog,
);
}
private function unit(
string $id,
string $teamId,
?Position $position = null,
int $health = 10,
): UnitState {
return new UnitState($id, $teamId, $position ?? new Position(0, 0), 10, $health, 4, 2, 4, 2);
}
}
+153
View File
@@ -0,0 +1,153 @@
<?php
declare(strict_types=1);
namespace BattleForge\Tests\Unit\Domain;
use BattleForge\Domain\Position;
use BattleForge\Domain\UnitState;
use InvalidArgumentException;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\TestCase;
final class UnitStateTest extends TestCase
{
/**
* @param callable(): UnitState $createUnit
*/
#[DataProvider('invalidUnitProvider')]
public function testItRejectsInvalidConstructorArguments(callable $createUnit): void
{
$this->expectException(InvalidArgumentException::class);
$createUnit();
}
/**
* @return iterable<string, array{callable(): UnitState}>
*/
public static function invalidUnitProvider(): iterable
{
yield 'empty unit id' => [
static fn (): UnitState => new UnitState('', 'alpha', new Position(0, 0), 10, 10, 4, 2, 4, 2),
];
yield 'empty team id' => [
static fn (): UnitState => new UnitState('unit-1', '', new Position(0, 0), 10, 10, 4, 2, 4, 2),
];
yield 'zero maximum health' => [
static fn (): UnitState => new UnitState('unit-1', 'alpha', new Position(0, 0), 0, 0, 4, 2, 4, 2),
];
yield 'negative health' => [
static fn (): UnitState => new UnitState('unit-1', 'alpha', new Position(0, 0), 10, -1, 4, 2, 4, 2),
];
yield 'health above maximum' => [
static fn (): UnitState => new UnitState('unit-1', 'alpha', new Position(0, 0), 10, 11, 4, 2, 4, 2),
];
yield 'negative attack' => [
static fn (): UnitState => new UnitState('unit-1', 'alpha', new Position(0, 0), 10, 10, -1, 2, 4, 2),
];
yield 'negative defense' => [
static fn (): UnitState => new UnitState('unit-1', 'alpha', new Position(0, 0), 10, 10, 4, -1, 4, 2),
];
yield 'zero speed' => [
static fn (): UnitState => new UnitState('unit-1', 'alpha', new Position(0, 0), 10, 10, 4, 2, 0, 2),
];
yield 'negative actions remaining' => [
static fn (): UnitState => new UnitState('unit-1', 'alpha', new Position(0, 0), 10, 10, 4, 2, 4, -1),
];
yield 'too many actions remaining' => [
static fn (): UnitState => new UnitState('unit-1', 'alpha', new Position(0, 0), 10, 10, 4, 2, 4, 3),
];
}
public function testItReportsWhetherItIsDefeated(): void
{
self::assertTrue($this->unit(health: 0)->isDefeated());
self::assertFalse($this->unit(health: 1)->isDefeated());
}
public function testMovingReturnsAChangedCopy(): void
{
$unit = $this->unit();
$moved = $unit->moveTo(new Position(2, 3));
self::assertNotSame($unit, $moved);
self::assertSame('0:0', $unit->position->key());
self::assertSame('2:3', $moved->position->key());
}
public function testSpendingAnActionReturnsAChangedCopyAndRejectsExhaustedUnits(): void
{
$unit = $this->unit(actionsRemaining: 1);
$spent = $unit->spendAction();
self::assertSame(1, $unit->actionsRemaining);
self::assertSame(0, $spent->actionsRemaining);
$this->expectException(InvalidArgumentException::class);
$spent->spendAction();
}
public function testDamageCannotReduceHealthBelowZero(): void
{
$unit = $this->unit(health: 3);
$damaged = $unit->takeDamage(5);
self::assertSame(3, $unit->health);
self::assertSame(0, $damaged->health);
}
public function testNegativeDamageIsANoOp(): void
{
$unit = $this->unit(health: 7);
$damaged = $unit->takeDamage(-3);
self::assertNotSame($unit, $damaged);
self::assertSame(7, $damaged->health);
}
public function testMarkingAttackedReturnsAChangedCopy(): void
{
$unit = $this->unit();
$attacked = $unit->markAttacked();
self::assertFalse($unit->hasAttacked);
self::assertTrue($attacked->hasAttacked);
}
public function testStartingTurnResetsALivingUnit(): void
{
$unit = $this->unit(actionsRemaining: 0, hasAttacked: true);
$started = $unit->startTurn();
self::assertNotSame($unit, $started);
self::assertSame(2, $started->actionsRemaining);
self::assertFalse($started->hasAttacked);
}
public function testStartingTurnReturnsTheSameDefeatedUnit(): void
{
$unit = $this->unit(health: 0, actionsRemaining: 0, hasAttacked: true);
self::assertSame($unit, $unit->startTurn());
}
private function unit(
int $health = 10,
int $actionsRemaining = 2,
bool $hasAttacked = false,
): UnitState {
return new UnitState(
'unit-1',
'alpha',
new Position(0, 0),
10,
$health,
4,
2,
4,
$actionsRemaining,
$hasAttacked,
);
}
}