Compare commits
30
Commits
main
..
86d82c353d
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
86d82c353d | ||
|
|
39b15c35da | ||
|
|
12f5c0bd3a | ||
|
|
6fffaa2306 | ||
|
|
84fb175c0c | ||
|
|
6befe7b3e2 | ||
|
|
088e85c9b4 | ||
|
|
06632ff9b3 | ||
|
|
e70b60696c | ||
|
|
88149ab1cf | ||
|
|
0ac8c06c5e | ||
|
|
78fc802cdc | ||
|
|
de0bc9c43f | ||
|
|
07e58c84fe | ||
|
|
fef22d7b56 | ||
|
|
acfbbf4a91 | ||
|
|
7588b304e1 | ||
|
|
3c6359243a | ||
|
|
3eba823e52 | ||
|
|
1550e2920b | ||
|
|
5297b41208 | ||
|
|
94560da5b4 | ||
|
|
0c5e824942 | ||
|
|
4421c5ae32 | ||
|
|
b789f1b61f | ||
|
|
189be5bc64 | ||
|
|
7b82adbd71 | ||
|
|
a97ecbc6d3 | ||
|
|
e20017f1e5 | ||
|
|
5ddfff2679 |
@@ -0,0 +1 @@
|
|||||||
|
*.php text eol=lf
|
||||||
@@ -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
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
.worktrees/
|
||||||
|
/vendor/
|
||||||
|
/var/
|
||||||
@@ -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
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -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>
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
parameters:
|
||||||
|
level: 6
|
||||||
|
paths:
|
||||||
|
- src
|
||||||
|
- tests
|
||||||
|
tmpDir: var/phpstan
|
||||||
+18
@@ -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>
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace BattleForge\Domain;
|
||||||
|
|
||||||
|
final class AbilityCatalog
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @return array<string, AbilityDefinition>
|
||||||
|
*/
|
||||||
|
public static function definitions(): array
|
||||||
|
{
|
||||||
|
static $cache = null;
|
||||||
|
|
||||||
|
if ($cache !== null) {
|
||||||
|
return $cache;
|
||||||
|
}
|
||||||
|
|
||||||
|
$cache = [
|
||||||
|
AbilityId::Heal->value => new AbilityDefinition(
|
||||||
|
id: AbilityId::Heal,
|
||||||
|
label: 'Heal',
|
||||||
|
range: 2,
|
||||||
|
areaRadius: 0,
|
||||||
|
target: AbilityDefinition::TARGET_ALLY,
|
||||||
|
effect: AbilityDefinition::EFFECT_RESTORE_HEALTH,
|
||||||
|
effectValue: 5,
|
||||||
|
),
|
||||||
|
AbilityId::AreaDamage->value => new AbilityDefinition(
|
||||||
|
id: AbilityId::AreaDamage,
|
||||||
|
label: 'Area Damage',
|
||||||
|
range: 3,
|
||||||
|
areaRadius: 1,
|
||||||
|
target: AbilityDefinition::TARGET_TILE,
|
||||||
|
effect: AbilityDefinition::EFFECT_DEAL_DAMAGE,
|
||||||
|
effectValue: 3,
|
||||||
|
),
|
||||||
|
AbilityId::Buff->value => new AbilityDefinition(
|
||||||
|
id: AbilityId::Buff,
|
||||||
|
label: 'Rally',
|
||||||
|
range: 1,
|
||||||
|
areaRadius: 0,
|
||||||
|
target: AbilityDefinition::TARGET_ALLY,
|
||||||
|
effect: AbilityDefinition::EFFECT_APPLY_BUFF,
|
||||||
|
effectValue: 2,
|
||||||
|
),
|
||||||
|
];
|
||||||
|
|
||||||
|
return $cache;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace BattleForge\Domain;
|
||||||
|
|
||||||
|
use InvalidArgumentException;
|
||||||
|
|
||||||
|
final readonly class AbilityDefinition
|
||||||
|
{
|
||||||
|
public const TARGET_SELF = 'self';
|
||||||
|
public const TARGET_ALLY = 'ally';
|
||||||
|
public const TARGET_ENEMY = 'enemy';
|
||||||
|
public const TARGET_TILE = 'tile';
|
||||||
|
|
||||||
|
public const EFFECT_RESTORE_HEALTH = 'restore_health';
|
||||||
|
public const EFFECT_DEAL_DAMAGE = 'deal_damage';
|
||||||
|
public const EFFECT_APPLY_BUFF = 'apply_buff';
|
||||||
|
|
||||||
|
/** @var list<string> */
|
||||||
|
private const TARGETS = [self::TARGET_SELF, self::TARGET_ALLY, self::TARGET_ENEMY, self::TARGET_TILE];
|
||||||
|
|
||||||
|
/** @var list<string> */
|
||||||
|
private const EFFECTS = [self::EFFECT_RESTORE_HEALTH, self::EFFECT_DEAL_DAMAGE, self::EFFECT_APPLY_BUFF];
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
public AbilityId $id,
|
||||||
|
public string $label,
|
||||||
|
public int $range,
|
||||||
|
public int $areaRadius,
|
||||||
|
public string $target,
|
||||||
|
public string $effect,
|
||||||
|
public int $effectValue,
|
||||||
|
) {
|
||||||
|
if ($this->range < 0) {
|
||||||
|
throw new InvalidArgumentException('Ability range cannot be negative.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($this->areaRadius < 0) {
|
||||||
|
throw new InvalidArgumentException('Ability area radius cannot be negative.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!in_array($this->target, self::TARGETS, true)) {
|
||||||
|
throw new InvalidArgumentException("Unknown ability target: {$this->target}.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!in_array($this->effect, self::EFFECTS, true)) {
|
||||||
|
throw new InvalidArgumentException("Unknown ability effect: {$this->effect}.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace BattleForge\Domain;
|
||||||
|
|
||||||
|
enum AbilityId: string
|
||||||
|
{
|
||||||
|
case Heal = 'heal';
|
||||||
|
case AreaDamage = 'area_damage';
|
||||||
|
case Buff = 'buff';
|
||||||
|
}
|
||||||
@@ -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';
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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(...)));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,398 @@
|
|||||||
|
<?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 + $attacker->attackBonus - $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);
|
||||||
|
|
||||||
|
return $this->checkVictoryAfterAction($next, $attacker->teamId);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function useAbility(MatchState $match, string $unitId, string $abilityId, ?Position $target): MatchState
|
||||||
|
{
|
||||||
|
$this->assertMatchActive($match);
|
||||||
|
|
||||||
|
$caster = $this->unitOrFail($match, $unitId);
|
||||||
|
$this->assertCanAct($match, $caster);
|
||||||
|
|
||||||
|
if ($caster->hasUsedAbility) {
|
||||||
|
throw new CombatException('Unit has already used an ability this turn.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$definitions = AbilityCatalog::definitions();
|
||||||
|
|
||||||
|
if (!isset($definitions[$abilityId])) {
|
||||||
|
throw new CombatException("Unknown ability: {$abilityId}.");
|
||||||
|
}
|
||||||
|
|
||||||
|
$definition = $definitions[$abilityId];
|
||||||
|
|
||||||
|
if (!in_array($abilityId, $caster->allowedAbilities(), true)) {
|
||||||
|
throw new CombatException('Unit archetype forbids that ability.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$affected = $this->resolveAbilityTargets($match, $caster, $definition, $target);
|
||||||
|
|
||||||
|
$next = $match;
|
||||||
|
$logParts = [];
|
||||||
|
|
||||||
|
foreach ($affected as $affectedUnit) {
|
||||||
|
$current = $next->unit($affectedUnit['id']);
|
||||||
|
$updated = match ($definition->effect) {
|
||||||
|
AbilityDefinition::EFFECT_RESTORE_HEALTH => $current->restoreHealth($definition->effectValue),
|
||||||
|
AbilityDefinition::EFFECT_DEAL_DAMAGE => $current->takeDamage($affectedUnit['damage'] ?? 0),
|
||||||
|
AbilityDefinition::EFFECT_APPLY_BUFF => $current->withAttackBonus($definition->effectValue),
|
||||||
|
default => throw new CombatException("Unknown ability effect: {$definition->effect}."),
|
||||||
|
};
|
||||||
|
$next = $next->withUnit($updated);
|
||||||
|
|
||||||
|
$logParts[] = match ($definition->effect) {
|
||||||
|
AbilityDefinition::EFFECT_RESTORE_HEALTH => "{$affectedUnit['id']} for {$definition->effectValue}",
|
||||||
|
AbilityDefinition::EFFECT_DEAL_DAMAGE => "{$affectedUnit['id']} for {$affectedUnit['damage']} damage",
|
||||||
|
AbilityDefinition::EFFECT_APPLY_BUFF => "{$affectedUnit['id']} with +{$definition->effectValue} attack",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
$next = $next->withUnit($next->unit($caster->id)->spendAbility());
|
||||||
|
|
||||||
|
$summary = "{$caster->id} used {$definition->label} on " . implode(', ', $logParts);
|
||||||
|
$next = $next->copy(actionLog: [...$next->actionLog, $summary]);
|
||||||
|
|
||||||
|
return $this->checkVictoryAfterAction($next, $caster->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 (count($teamIds) !== 2) {
|
||||||
|
throw new CombatException('Matches require exactly two teams.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$livingTeams = array_fill_keys($teamIds, false);
|
||||||
|
|
||||||
|
foreach ($match->units as $unit) {
|
||||||
|
if (!$unit->isDefeated()) {
|
||||||
|
$livingTeams[$unit->teamId] = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($livingTeams as $teamId => $alive) {
|
||||||
|
if (!$alive) {
|
||||||
|
throw new CombatException('Cannot end turn while a team is eliminated.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$endingTeamId = $match->activeTeamId;
|
||||||
|
$nextTeamId = $endingTeamId === $teamIds[0] ? $teamIds[1] : $teamIds[0];
|
||||||
|
|
||||||
|
if ($nextTeamId === $teamIds[0] && $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;
|
||||||
|
}
|
||||||
|
|
||||||
|
$round = $match->round + ($nextTeamId === $teamIds[0] ? 1 : 0);
|
||||||
|
|
||||||
|
$next = $match->copy(
|
||||||
|
units: $units,
|
||||||
|
activeTeamId: $nextTeamId,
|
||||||
|
round: $round,
|
||||||
|
actionLog: [...$match->actionLog, "{$endingTeamId} ended turn"],
|
||||||
|
);
|
||||||
|
|
||||||
|
return $this->resolveObjectiveVictory($next, $endingTeamId === $teamIds[0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return list<array{id: string, damage?: int}>
|
||||||
|
*/
|
||||||
|
private function resolveAbilityTargets(
|
||||||
|
MatchState $match,
|
||||||
|
UnitState $caster,
|
||||||
|
AbilityDefinition $definition,
|
||||||
|
?Position $target,
|
||||||
|
): array {
|
||||||
|
if ($definition->target === AbilityDefinition::TARGET_SELF) {
|
||||||
|
return [['id' => $caster->id]];
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($target === null) {
|
||||||
|
throw new CombatException('Ability requires a target position.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!$match->battlefield->contains($target)) {
|
||||||
|
throw new CombatException('Ability target is outside the battlefield.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$distance = $caster->position->distanceTo($target);
|
||||||
|
|
||||||
|
if ($distance > $definition->range) {
|
||||||
|
throw new CombatException('Ability target is out of range.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$tileKeys = [$target->key()];
|
||||||
|
|
||||||
|
if ($definition->areaRadius > 0) {
|
||||||
|
$tileKeys = self::tilesWithinRadius(
|
||||||
|
$match->battlefield,
|
||||||
|
$target,
|
||||||
|
$definition->areaRadius,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($definition->effect === AbilityDefinition::EFFECT_DEAL_DAMAGE) {
|
||||||
|
$affected = [];
|
||||||
|
|
||||||
|
foreach ($match->units as $candidate) {
|
||||||
|
if ($candidate->isDefeated()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if ($candidate->teamId === $caster->teamId) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$found = false;
|
||||||
|
foreach ($tileKeys as $key) {
|
||||||
|
if ($candidate->position->key() === $key) {
|
||||||
|
$found = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!$found) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$terrainDefense = $match->battlefield->terrainAt($candidate->position)->defenseBonus();
|
||||||
|
$damage = max(1, $definition->effectValue - $candidate->defense - $terrainDefense);
|
||||||
|
$affected[] = ['id' => $candidate->id, 'damage' => $damage];
|
||||||
|
}
|
||||||
|
|
||||||
|
return $affected;
|
||||||
|
}
|
||||||
|
|
||||||
|
$targetUnit = null;
|
||||||
|
|
||||||
|
foreach ($match->units as $candidate) {
|
||||||
|
if ($candidate->position->key() === $target->key()) {
|
||||||
|
$targetUnit = $candidate;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($targetUnit === null) {
|
||||||
|
throw new CombatException('Ability target is empty.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$expectedTeam = $definition->target === AbilityDefinition::TARGET_ALLY ? $caster->teamId : null;
|
||||||
|
|
||||||
|
if ($expectedTeam !== null && $targetUnit->teamId !== $expectedTeam) {
|
||||||
|
throw new CombatException('Ability target must be an ally.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($definition->target === AbilityDefinition::TARGET_ENEMY && $targetUnit->teamId === $caster->teamId) {
|
||||||
|
throw new CombatException('Ability target must be an enemy.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return [['id' => $targetUnit->id]];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function checkVictoryAfterAction(MatchState $match, string $actingTeamId): MatchState
|
||||||
|
{
|
||||||
|
foreach ($match->units as $unit) {
|
||||||
|
if ($unit->teamId !== $actingTeamId && !$unit->isDefeated()) {
|
||||||
|
return $match;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $match->withWinner($actingTeamId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param bool $endingTeamIsFirstInOrder true when the team that just ended is the first team in
|
||||||
|
* sorted team order; this identifies the first half of a round.
|
||||||
|
*/
|
||||||
|
private function resolveObjectiveVictory(MatchState $match, bool $endingTeamIsFirstInOrder): MatchState
|
||||||
|
{
|
||||||
|
if ($match->victoryCondition !== VictoryCondition::HoldObjective || $match->objectives === []) {
|
||||||
|
return $match;
|
||||||
|
}
|
||||||
|
|
||||||
|
$next = $match;
|
||||||
|
|
||||||
|
if ($endingTeamIsFirstInOrder) {
|
||||||
|
/** @var list<ObjectiveMarker> $objectiveList */
|
||||||
|
$objectiveList = array_values($match->objectives);
|
||||||
|
$objective = $objectiveList[0];
|
||||||
|
$controller = $this->objectiveController($match, $objective->position);
|
||||||
|
|
||||||
|
if ($controller !== null) {
|
||||||
|
$control = (new ObjectiveControl($match->objectiveControl))->recordRound($controller);
|
||||||
|
$next = $match->withObjectiveControl($control->roundsByTeam);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $next;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($match->objectiveControl as $teamId => $count) {
|
||||||
|
if ($count >= $match->holdRoundsRequired) {
|
||||||
|
return $next->withWinner($teamId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $next;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function objectiveController(MatchState $match, Position $tile): ?string
|
||||||
|
{
|
||||||
|
$teams = [];
|
||||||
|
|
||||||
|
foreach ($match->units as $unit) {
|
||||||
|
if ($unit->isDefeated() || $unit->position->key() !== $tile->key()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$teams[$unit->teamId] = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (count($teams) !== 1) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return array_key_first($teams);
|
||||||
|
}
|
||||||
|
|
||||||
|
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.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return list<string> */
|
||||||
|
private static function tilesWithinRadius(Battlefield $battlefield, Position $center, int $radius): array
|
||||||
|
{
|
||||||
|
$seen = [$center->key() => true];
|
||||||
|
$frontier = [$center];
|
||||||
|
$result = [$center->key()];
|
||||||
|
|
||||||
|
for ($step = 0; $step < $radius; $step++) {
|
||||||
|
$next = [];
|
||||||
|
foreach ($frontier as $position) {
|
||||||
|
foreach ([[1, 0], [-1, 0], [0, 1], [0, -1]] as $offset) {
|
||||||
|
$candidate = new Position($position->x + $offset[0], $position->y + $offset[1]);
|
||||||
|
if (!$battlefield->contains($candidate)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$key = $candidate->key();
|
||||||
|
if (isset($seen[$key])) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$seen[$key] = true;
|
||||||
|
$result[] = $key;
|
||||||
|
$next[] = $candidate;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$frontier = $next;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $result;
|
||||||
|
}
|
||||||
|
|
||||||
|
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.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace BattleForge\Domain;
|
||||||
|
|
||||||
|
use DomainException;
|
||||||
|
|
||||||
|
final class CombatException extends DomainException
|
||||||
|
{
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace BattleForge\Domain;
|
||||||
|
|
||||||
|
use InvalidArgumentException;
|
||||||
|
|
||||||
|
final readonly class DeploymentZone
|
||||||
|
{
|
||||||
|
/** @param list<Position> $positions */
|
||||||
|
public function __construct(
|
||||||
|
public string $teamId,
|
||||||
|
public array $positions,
|
||||||
|
) {
|
||||||
|
if ($teamId === '') {
|
||||||
|
throw new InvalidArgumentException('Deployment zone team id cannot be empty.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($positions === []) {
|
||||||
|
throw new InvalidArgumentException('Deployment zone must contain at least one position.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!array_is_list($positions)) { // @phpstan-ignore function.alreadyNarrowedType (Runtime guard: PHPDoc is not a runtime contract for JSON-sourced arrays in Plan 4.)
|
||||||
|
throw new InvalidArgumentException('Deployment zone positions must be a list.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function contains(Position $position): bool
|
||||||
|
{
|
||||||
|
foreach ($this->positions as $candidate) {
|
||||||
|
if ($candidate->key() === $position->key()) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,233 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace BattleForge\Domain;
|
||||||
|
|
||||||
|
use InvalidArgumentException;
|
||||||
|
|
||||||
|
final readonly class MatchState
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @param list<UnitState> $units
|
||||||
|
* @param list<string> $actionLog
|
||||||
|
* @param array<string, ObjectiveMarker> $objectives
|
||||||
|
* @param array<string, int> $objectiveControl
|
||||||
|
*/
|
||||||
|
public function __construct(
|
||||||
|
public Battlefield $battlefield,
|
||||||
|
public array $units,
|
||||||
|
public string $activeTeamId,
|
||||||
|
public int $round = 1,
|
||||||
|
public ?string $winnerTeamId = null,
|
||||||
|
public array $actionLog = [],
|
||||||
|
public array $objectives = [],
|
||||||
|
public VictoryCondition $victoryCondition = VictoryCondition::EliminateAll,
|
||||||
|
public int $holdRoundsRequired = 1,
|
||||||
|
public array $objectiveControl = [],
|
||||||
|
) {
|
||||||
|
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}.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($holdRoundsRequired < 1) {
|
||||||
|
throw new InvalidArgumentException('Hold rounds required must be at least 1.');
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($objectives as $objective) {
|
||||||
|
if (!$objective instanceof ObjectiveMarker) { // @phpstan-ignore instanceof.alwaysTrue (Runtime guard: PHPDoc is not a runtime contract for JSON-sourced arrays in Plan 4.)
|
||||||
|
throw new InvalidArgumentException('Objectives must be ObjectiveMarker instances.');
|
||||||
|
}
|
||||||
|
if (!$battlefield->contains($objective->position)) {
|
||||||
|
throw new InvalidArgumentException("Objective {$objective->id} is outside the battlefield.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($objectiveControl as $teamId => $count) {
|
||||||
|
if (!self::isString($teamId) || $teamId === '') {
|
||||||
|
throw new InvalidArgumentException('Objective control team id must be a non-empty string.');
|
||||||
|
}
|
||||||
|
if (!self::isInt($count) || $count < 0) {
|
||||||
|
throw new InvalidArgumentException('Objective control count must be a non-negative integer.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 isInt(mixed $value): bool
|
||||||
|
{
|
||||||
|
return is_int($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,
|
||||||
|
$this->objectives,
|
||||||
|
$this->victoryCondition,
|
||||||
|
$this->holdRoundsRequired,
|
||||||
|
$this->objectiveControl,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, int> $objectiveControl
|
||||||
|
*/
|
||||||
|
public function withObjectiveControl(array $objectiveControl): self
|
||||||
|
{
|
||||||
|
return new self(
|
||||||
|
$this->battlefield,
|
||||||
|
$this->units,
|
||||||
|
$this->activeTeamId,
|
||||||
|
$this->round,
|
||||||
|
$this->winnerTeamId,
|
||||||
|
$this->actionLog,
|
||||||
|
$this->objectives,
|
||||||
|
$this->victoryCondition,
|
||||||
|
$this->holdRoundsRequired,
|
||||||
|
$objectiveControl,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param list<UnitState>|null $units
|
||||||
|
* @param list<string>|null $actionLog
|
||||||
|
* @param array<string, ObjectiveMarker>|null $objectives
|
||||||
|
* @param array<string, int>|null $objectiveControl
|
||||||
|
*/
|
||||||
|
public function copy(
|
||||||
|
?array $units = null,
|
||||||
|
?string $activeTeamId = null,
|
||||||
|
?int $round = null,
|
||||||
|
?array $actionLog = null,
|
||||||
|
?array $objectives = null,
|
||||||
|
?array $objectiveControl = null,
|
||||||
|
): self {
|
||||||
|
return new self(
|
||||||
|
$this->battlefield,
|
||||||
|
$units ?? $this->units,
|
||||||
|
$activeTeamId ?? $this->activeTeamId,
|
||||||
|
$round ?? $this->round,
|
||||||
|
$this->winnerTeamId,
|
||||||
|
$actionLog ?? $this->actionLog,
|
||||||
|
$objectives ?? $this->objectives,
|
||||||
|
$this->victoryCondition,
|
||||||
|
$this->holdRoundsRequired,
|
||||||
|
$objectiveControl ?? $this->objectiveControl,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace BattleForge\Domain;
|
||||||
|
|
||||||
|
use InvalidArgumentException;
|
||||||
|
|
||||||
|
final readonly class ObjectiveControl
|
||||||
|
{
|
||||||
|
/** @param array<string, int> $roundsByTeam */
|
||||||
|
public function __construct(public array $roundsByTeam)
|
||||||
|
{
|
||||||
|
foreach ($roundsByTeam as $teamId => $count) {
|
||||||
|
if ($teamId === '') {
|
||||||
|
throw new InvalidArgumentException('Objective control team id cannot be empty.');
|
||||||
|
}
|
||||||
|
if ($count < 0) {
|
||||||
|
throw new InvalidArgumentException('Objective control count cannot be negative.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function empty(): self
|
||||||
|
{
|
||||||
|
return new self([]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function recordRound(string $controllerTeamId): self
|
||||||
|
{
|
||||||
|
if ($controllerTeamId === '') {
|
||||||
|
throw new InvalidArgumentException('Controller team id cannot be empty.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$next = $this->roundsByTeam;
|
||||||
|
$next[$controllerTeamId] = ($next[$controllerTeamId] ?? 0) + 1;
|
||||||
|
|
||||||
|
return new self($next);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return array{teamId: string, rounds: int} */
|
||||||
|
public function forTeam(string $teamId): array
|
||||||
|
{
|
||||||
|
return ['teamId' => $teamId, 'rounds' => $this->roundsByTeam[$teamId] ?? 0];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace BattleForge\Domain;
|
||||||
|
|
||||||
|
use InvalidArgumentException;
|
||||||
|
|
||||||
|
final readonly class ObjectiveMarker
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
public string $id,
|
||||||
|
public Position $position,
|
||||||
|
) {
|
||||||
|
if ($id === '') {
|
||||||
|
throw new InvalidArgumentException('Objective id cannot be empty.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function key(): string
|
||||||
|
{
|
||||||
|
return $this->id;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,160 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace BattleForge\Domain;
|
||||||
|
|
||||||
|
use InvalidArgumentException;
|
||||||
|
|
||||||
|
final readonly class Scenario
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @param list<UnitState> $units
|
||||||
|
* @param array<string, DeploymentZone> $deploymentZones
|
||||||
|
* @param array<string, ObjectiveMarker> $objectives
|
||||||
|
*/
|
||||||
|
public function __construct(
|
||||||
|
public string $id,
|
||||||
|
public string $name,
|
||||||
|
public Battlefield $battlefield,
|
||||||
|
public array $units,
|
||||||
|
public array $deploymentZones,
|
||||||
|
public array $objectives,
|
||||||
|
public VictoryCondition $victoryCondition,
|
||||||
|
public int $holdRoundsRequired,
|
||||||
|
) {
|
||||||
|
if ($id === '') {
|
||||||
|
throw new InvalidArgumentException('Scenario id cannot be empty.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($name === '') {
|
||||||
|
throw new InvalidArgumentException('Scenario name cannot be empty.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!array_is_list($units)) { // @phpstan-ignore function.alreadyNarrowedType (Runtime guard: PHPDoc is not a runtime contract for JSON-sourced arrays in Plan 4.)
|
||||||
|
throw new InvalidArgumentException('Scenario units must be a list.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($deploymentZones === []) {
|
||||||
|
throw new InvalidArgumentException('Scenario must declare at least one deployment zone.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (count($deploymentZones) !== 2) {
|
||||||
|
throw new InvalidArgumentException('Scenarios must declare exactly two deployment zones.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($holdRoundsRequired < 1) {
|
||||||
|
throw new InvalidArgumentException('Hold rounds required must be at least 1.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($victoryCondition === VictoryCondition::HoldObjective && count($this->objectives) !== 1) {
|
||||||
|
throw new InvalidArgumentException('Hold objective victory requires exactly one objective.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($victoryCondition === VictoryCondition::EliminateAll && $this->objectives !== []) {
|
||||||
|
throw new InvalidArgumentException('Eliminate all victory does not allow objectives.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$unitsByTeam = $this->groupUnitsByTeam($units);
|
||||||
|
|
||||||
|
foreach ($unitsByTeam as $teamId => $teamUnits) {
|
||||||
|
if (count($teamUnits) < 3 || count($teamUnits) > 6) {
|
||||||
|
throw new InvalidArgumentException("Team {$teamId} must have between 3 and 6 units.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isset($deploymentZones[$teamId])) {
|
||||||
|
throw new InvalidArgumentException("Team {$teamId} is missing a deployment zone.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($units as $unit) {
|
||||||
|
if (!$this->battlefield->contains($unit->position)) {
|
||||||
|
throw new InvalidArgumentException("Unit {$unit->id} is outside the battlefield.");
|
||||||
|
}
|
||||||
|
|
||||||
|
$template = ArchetypeCatalog::templates()[$unit->archetype->value] ?? null;
|
||||||
|
if ($template === null) {
|
||||||
|
throw new InvalidArgumentException("Unit {$unit->id} uses an unknown archetype.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($unit->maxHealth < $template->minHealth || $unit->maxHealth > $template->maxHealth) {
|
||||||
|
throw new InvalidArgumentException("Unit {$unit->id} health is outside archetype bounds.");
|
||||||
|
}
|
||||||
|
if ($unit->attack < $template->minAttack || $unit->attack > $template->maxAttack) {
|
||||||
|
throw new InvalidArgumentException("Unit {$unit->id} attack is outside archetype bounds.");
|
||||||
|
}
|
||||||
|
if ($unit->defense < $template->minDefense || $unit->defense > $template->maxDefense) {
|
||||||
|
throw new InvalidArgumentException("Unit {$unit->id} defense is outside archetype bounds.");
|
||||||
|
}
|
||||||
|
if ($unit->speed < $template->minSpeed || $unit->speed > $template->maxSpeed) {
|
||||||
|
throw new InvalidArgumentException("Unit {$unit->id} speed is outside archetype bounds.");
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($unit->abilities as $ability) {
|
||||||
|
if (!in_array($ability, $template->allowedAbilities, true)) {
|
||||||
|
throw new InvalidArgumentException("Unit {$unit->id} has an ability that its archetype forbids.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$zone = $deploymentZones[$unit->teamId] ?? null;
|
||||||
|
if ($zone === null || !$zone->contains($unit->position)) {
|
||||||
|
throw new InvalidArgumentException("Unit {$unit->id} must start inside team {$unit->teamId} deployment zone.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($objectives as $objective) {
|
||||||
|
if (!$this->battlefield->contains($objective->position)) {
|
||||||
|
throw new InvalidArgumentException("Objective {$objective->id} is outside the battlefield.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function startMatch(string $activeTeamId): MatchState
|
||||||
|
{
|
||||||
|
$resetUnits = [];
|
||||||
|
|
||||||
|
foreach ($this->units as $unit) {
|
||||||
|
$resetUnits[] = new UnitState(
|
||||||
|
$unit->id,
|
||||||
|
$unit->teamId,
|
||||||
|
$unit->position,
|
||||||
|
$unit->maxHealth,
|
||||||
|
$unit->maxHealth,
|
||||||
|
$unit->attack,
|
||||||
|
$unit->defense,
|
||||||
|
$unit->speed,
|
||||||
|
2,
|
||||||
|
false,
|
||||||
|
$unit->archetype,
|
||||||
|
$unit->abilities,
|
||||||
|
0,
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new MatchState(
|
||||||
|
battlefield: $this->battlefield,
|
||||||
|
units: $resetUnits,
|
||||||
|
activeTeamId: $activeTeamId,
|
||||||
|
objectives: $this->objectives,
|
||||||
|
victoryCondition: $this->victoryCondition,
|
||||||
|
holdRoundsRequired: $this->holdRoundsRequired,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param list<UnitState> $units
|
||||||
|
* @return array<string, list<UnitState>>
|
||||||
|
*/
|
||||||
|
private function groupUnitsByTeam(array $units): array
|
||||||
|
{
|
||||||
|
$grouped = [];
|
||||||
|
|
||||||
|
foreach ($units as $unit) {
|
||||||
|
$grouped[$unit->teamId] ??= [];
|
||||||
|
$grouped[$unit->teamId][] = $unit;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $grouped;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace BattleForge\Domain;
|
||||||
|
|
||||||
|
final class ScenarioValidator
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @return list<string>
|
||||||
|
*/
|
||||||
|
public static function validate(Scenario $scenario): array
|
||||||
|
{
|
||||||
|
$errors = [];
|
||||||
|
|
||||||
|
$occupied = [];
|
||||||
|
|
||||||
|
foreach ($scenario->deploymentZones as $teamId => $zone) {
|
||||||
|
foreach ($zone->positions as $position) {
|
||||||
|
if (!$scenario->battlefield->contains($position)) {
|
||||||
|
$errors[] = "Deployment zone for {$teamId} includes a position outside the battlefield.";
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if ($scenario->battlefield->terrainAt($position)->movementCost() === null) {
|
||||||
|
$errors[] = "Deployment zone for {$teamId} sits on impassable terrain at {$position->key()}.";
|
||||||
|
}
|
||||||
|
if (isset($occupied[$position->key()])) {
|
||||||
|
$errors[] = "Deployment zones overlap at {$position->key()}.";
|
||||||
|
}
|
||||||
|
$occupied[$position->key()] = $teamId;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$reachableFromDeployment = self::reachableFromDeployment($scenario, $occupied);
|
||||||
|
|
||||||
|
foreach ($scenario->objectives as $objective) {
|
||||||
|
if (!$scenario->battlefield->contains($objective->position)) {
|
||||||
|
$errors[] = "Objective {$objective->id} is outside the battlefield.";
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if ($scenario->battlefield->terrainAt($objective->position)->movementCost() === null) {
|
||||||
|
$errors[] = "Objective {$objective->id} sits on impassable terrain.";
|
||||||
|
}
|
||||||
|
if (!isset($reachableFromDeployment[$objective->position->key()])) {
|
||||||
|
$errors[] = "Objective {$objective->id} is unreachable from any deployment zone.";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $errors;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, string> $occupied
|
||||||
|
* @return array<string, true>
|
||||||
|
*/
|
||||||
|
private static function reachableFromDeployment(Scenario $scenario, array $occupied): array
|
||||||
|
{
|
||||||
|
$merged = $scenario->units;
|
||||||
|
$allReachable = [];
|
||||||
|
|
||||||
|
foreach ($merged as $unit) {
|
||||||
|
$others = [];
|
||||||
|
|
||||||
|
foreach ($merged as $other) {
|
||||||
|
if ($other->id === $unit->id || $other->isDefeated()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$others[] = $other->position;
|
||||||
|
}
|
||||||
|
|
||||||
|
$reachable = $scenario->battlefield->reachable($unit->position, $unit->speed, $others);
|
||||||
|
|
||||||
|
foreach ($reachable as $key => $_cost) {
|
||||||
|
if (!isset($occupied[$key])) {
|
||||||
|
$allReachable[$key] = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $allReachable;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,182 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace BattleForge\Domain;
|
||||||
|
|
||||||
|
use InvalidArgumentException;
|
||||||
|
|
||||||
|
final readonly class UnitState
|
||||||
|
{
|
||||||
|
/** @param list<string> $abilities */
|
||||||
|
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,
|
||||||
|
public Archetype $archetype = Archetype::Defender,
|
||||||
|
public array $abilities = [],
|
||||||
|
public int $attackBonus = 0,
|
||||||
|
public bool $hasUsedAbility = 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.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($attackBonus < 0) {
|
||||||
|
throw new InvalidArgumentException('Unit attack bonus cannot be negative.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!self::isList($abilities)) {
|
||||||
|
throw new InvalidArgumentException('Unit abilities must be a list.');
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($abilities as $ability) {
|
||||||
|
// @phpstan-ignore function.alreadyNarrowedType (Runtime guard: PHPDoc is not a runtime contract for JSON-sourced arrays in Plan 4.)
|
||||||
|
if (!is_string($ability) || $ability === '') {
|
||||||
|
throw new InvalidArgumentException('Unit abilities must be non-empty strings.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function isDefeated(): bool
|
||||||
|
{
|
||||||
|
return $this->health === 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return list<string> */
|
||||||
|
public function allowedAbilities(): array
|
||||||
|
{
|
||||||
|
return ArchetypeCatalog::templates()[$this->archetype->value]->allowedAbilities;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 spendAbility(): self
|
||||||
|
{
|
||||||
|
if ($this->hasUsedAbility) {
|
||||||
|
throw new InvalidArgumentException('Unit has already used an ability this turn.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($this->actionsRemaining === 0) {
|
||||||
|
throw new InvalidArgumentException('Unit has no actions remaining.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->copy(actionsRemaining: $this->actionsRemaining - 1, hasUsedAbility: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function takeDamage(int $damage): self
|
||||||
|
{
|
||||||
|
return $this->copy(health: max(0, $this->health - max(0, $damage)));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function restoreHealth(int $amount): self
|
||||||
|
{
|
||||||
|
if ($amount < 0) {
|
||||||
|
throw new InvalidArgumentException('Restore health amount cannot be negative.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->copy(health: min($this->maxHealth, $this->health + $amount));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function markAttacked(): self
|
||||||
|
{
|
||||||
|
return $this->copy(hasAttacked: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function withAttackBonus(int $bonus): self
|
||||||
|
{
|
||||||
|
if ($bonus < 0) {
|
||||||
|
throw new InvalidArgumentException('Attack bonus cannot be negative.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->copy(attackBonus: $bonus);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function startTurn(): self
|
||||||
|
{
|
||||||
|
if ($this->isDefeated()) {
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->copy(
|
||||||
|
actionsRemaining: 2,
|
||||||
|
hasAttacked: false,
|
||||||
|
attackBonus: 0,
|
||||||
|
hasUsedAbility: false,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function copy(
|
||||||
|
?Position $position = null,
|
||||||
|
?int $health = null,
|
||||||
|
?int $actionsRemaining = null,
|
||||||
|
?bool $hasAttacked = null,
|
||||||
|
?int $attackBonus = null,
|
||||||
|
?bool $hasUsedAbility = 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,
|
||||||
|
$this->archetype,
|
||||||
|
$this->abilities,
|
||||||
|
$attackBonus ?? $this->attackBonus,
|
||||||
|
$hasUsedAbility ?? $this->hasUsedAbility,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function isList(mixed $value): bool
|
||||||
|
{
|
||||||
|
return is_array($value) && array_is_list($value);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace BattleForge\Domain;
|
||||||
|
|
||||||
|
enum VictoryCondition: string
|
||||||
|
{
|
||||||
|
case EliminateAll = 'eliminate_all';
|
||||||
|
case HoldObjective = 'hold_objective';
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace BattleForge\Tests\Unit\Domain;
|
||||||
|
|
||||||
|
use BattleForge\Domain\AbilityCatalog;
|
||||||
|
use BattleForge\Domain\AbilityDefinition;
|
||||||
|
use BattleForge\Domain\AbilityId;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
|
||||||
|
final class AbilityCatalogTest extends TestCase
|
||||||
|
{
|
||||||
|
public function testItExposesExactlyTheThreePlannedAbilities(): void
|
||||||
|
{
|
||||||
|
$keys = array_keys(AbilityCatalog::definitions());
|
||||||
|
|
||||||
|
sort($keys);
|
||||||
|
|
||||||
|
self::assertSame(['area_damage', 'buff', 'heal'], $keys);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testHealTargetsAnAllyWithinRangeAndRestoresHealth(): void
|
||||||
|
{
|
||||||
|
$definition = AbilityCatalog::definitions()[AbilityId::Heal->value];
|
||||||
|
|
||||||
|
self::assertInstanceOf(AbilityDefinition::class, $definition);
|
||||||
|
self::assertSame('Heal', $definition->label);
|
||||||
|
self::assertSame(2, $definition->range);
|
||||||
|
self::assertSame(0, $definition->areaRadius);
|
||||||
|
self::assertSame('ally', $definition->target);
|
||||||
|
self::assertSame('restore_health', $definition->effect);
|
||||||
|
self::assertSame(5, $definition->effectValue);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testAreaDamageTargetsAnEnemyTileAndAffectsANeighborhood(): void
|
||||||
|
{
|
||||||
|
$definition = AbilityCatalog::definitions()[AbilityId::AreaDamage->value];
|
||||||
|
|
||||||
|
self::assertSame('Area Damage', $definition->label);
|
||||||
|
self::assertSame(3, $definition->range);
|
||||||
|
self::assertSame(1, $definition->areaRadius);
|
||||||
|
self::assertSame('tile', $definition->target);
|
||||||
|
self::assertSame('deal_damage', $definition->effect);
|
||||||
|
self::assertSame(3, $definition->effectValue);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testBuffTargetsAnAllyAndAppliesAnAttackBonus(): void
|
||||||
|
{
|
||||||
|
$definition = AbilityCatalog::definitions()[AbilityId::Buff->value];
|
||||||
|
|
||||||
|
self::assertSame('Rally', $definition->label);
|
||||||
|
self::assertSame(1, $definition->range);
|
||||||
|
self::assertSame(0, $definition->areaRadius);
|
||||||
|
self::assertSame('ally', $definition->target);
|
||||||
|
self::assertSame('apply_buff', $definition->effect);
|
||||||
|
self::assertSame(2, $definition->effectValue);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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']);
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,280 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace BattleForge\Tests\Unit\Domain;
|
||||||
|
|
||||||
|
use BattleForge\Domain\Archetype;
|
||||||
|
use BattleForge\Domain\Battlefield;
|
||||||
|
use BattleForge\Domain\CombatEngine;
|
||||||
|
use BattleForge\Domain\DeploymentZone;
|
||||||
|
use BattleForge\Domain\MatchState;
|
||||||
|
use BattleForge\Domain\ObjectiveMarker;
|
||||||
|
use BattleForge\Domain\Position;
|
||||||
|
use BattleForge\Domain\Scenario;
|
||||||
|
use BattleForge\Domain\UnitState;
|
||||||
|
use BattleForge\Domain\VictoryCondition;
|
||||||
|
use InvalidArgumentException;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
|
||||||
|
final class ScenarioTest extends TestCase
|
||||||
|
{
|
||||||
|
public function testItRejectsNonListUnits(): void
|
||||||
|
{
|
||||||
|
$this->expectException(InvalidArgumentException::class);
|
||||||
|
|
||||||
|
new Scenario(
|
||||||
|
id: 'demo',
|
||||||
|
name: 'Demo',
|
||||||
|
battlefield: new Battlefield(8, 8),
|
||||||
|
units: ['alpha-1' => $this->unit('alpha-1', 'alpha', new Position(0, 0))], // @phpstan-ignore argument.type (Test fixture intentionally passes a non-list units array to verify constructor validation.)
|
||||||
|
deploymentZones: ['alpha' => new DeploymentZone('alpha', [new Position(0, 0)])],
|
||||||
|
objectives: [],
|
||||||
|
victoryCondition: VictoryCondition::EliminateAll,
|
||||||
|
holdRoundsRequired: 1,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testItRejectsTeamsOutsideTheThreeToSixSizeWindow(): void
|
||||||
|
{
|
||||||
|
$this->expectException(InvalidArgumentException::class);
|
||||||
|
$this->expectExceptionMessage('Team alpha must have between 3 and 6 units.');
|
||||||
|
|
||||||
|
$units = [
|
||||||
|
$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)),
|
||||||
|
$this->unit('bravo-2', 'bravo', new Position(6, 7)),
|
||||||
|
$this->unit('bravo-3', 'bravo', new Position(5, 7)),
|
||||||
|
];
|
||||||
|
|
||||||
|
new Scenario(
|
||||||
|
id: 'demo',
|
||||||
|
name: 'Demo',
|
||||||
|
battlefield: new Battlefield(8, 8),
|
||||||
|
units: $units,
|
||||||
|
deploymentZones: [
|
||||||
|
'alpha' => new DeploymentZone('alpha', [new Position(0, 0), new Position(1, 0)]),
|
||||||
|
'bravo' => new DeploymentZone('bravo', [new Position(7, 7), new Position(6, 7), new Position(5, 7)]),
|
||||||
|
],
|
||||||
|
objectives: [],
|
||||||
|
victoryCondition: VictoryCondition::EliminateAll,
|
||||||
|
holdRoundsRequired: 1,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testItRejectsAbilitiesOutsideTheArchetypeAllowlist(): void
|
||||||
|
{
|
||||||
|
$this->expectException(InvalidArgumentException::class);
|
||||||
|
$this->expectExceptionMessage('Unit alpha-1 has an ability that its archetype forbids.');
|
||||||
|
|
||||||
|
$bad = $this->unit('alpha-1', 'alpha', new Position(0, 0), archetype: Archetype::Scout, abilities: ['heal'], attack: 3, defense: 1, speed: 5, maxHealth: 6);
|
||||||
|
$alpha2 = $this->unit('alpha-2', 'alpha', new Position(1, 0));
|
||||||
|
$alpha3 = $this->unit('alpha-3', 'alpha', new Position(2, 0));
|
||||||
|
$bravo = $this->unit('bravo-1', 'bravo', new Position(7, 7));
|
||||||
|
$bravo2 = $this->unit('bravo-2', 'bravo', new Position(6, 7));
|
||||||
|
$bravo3 = $this->unit('bravo-3', 'bravo', new Position(5, 7));
|
||||||
|
|
||||||
|
new Scenario(
|
||||||
|
id: 'demo',
|
||||||
|
name: 'Demo',
|
||||||
|
battlefield: new Battlefield(8, 8),
|
||||||
|
units: [$bad, $alpha2, $alpha3, $bravo, $bravo2, $bravo3],
|
||||||
|
deploymentZones: [
|
||||||
|
'alpha' => new DeploymentZone('alpha', [new Position(0, 0), new Position(1, 0), new Position(2, 0)]),
|
||||||
|
'bravo' => new DeploymentZone('bravo', [new Position(7, 7), new Position(6, 7), new Position(5, 7)]),
|
||||||
|
],
|
||||||
|
objectives: [],
|
||||||
|
victoryCondition: VictoryCondition::EliminateAll,
|
||||||
|
holdRoundsRequired: 1,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testItRejectsUnitsOutsideTheirDeploymentZone(): void
|
||||||
|
{
|
||||||
|
$this->expectException(InvalidArgumentException::class);
|
||||||
|
$this->expectExceptionMessage('Unit alpha-1 must start inside team alpha deployment zone.');
|
||||||
|
|
||||||
|
$bad = $this->unit('alpha-1', 'alpha', new Position(3, 0));
|
||||||
|
$alpha2 = $this->unit('alpha-2', 'alpha', new Position(0, 0));
|
||||||
|
$alpha3 = $this->unit('alpha-3', 'alpha', new Position(1, 0));
|
||||||
|
$bravo = $this->unit('bravo-1', 'bravo', new Position(7, 7));
|
||||||
|
$bravo2 = $this->unit('bravo-2', 'bravo', new Position(6, 7));
|
||||||
|
$bravo3 = $this->unit('bravo-3', 'bravo', new Position(5, 7));
|
||||||
|
|
||||||
|
new Scenario(
|
||||||
|
id: 'demo',
|
||||||
|
name: 'Demo',
|
||||||
|
battlefield: new Battlefield(8, 8),
|
||||||
|
units: [$bad, $alpha2, $alpha3, $bravo, $bravo2, $bravo3],
|
||||||
|
deploymentZones: [
|
||||||
|
'alpha' => new DeploymentZone('alpha', [new Position(0, 0), new Position(1, 0), new Position(2, 0)]),
|
||||||
|
'bravo' => new DeploymentZone('bravo', [new Position(7, 7), new Position(6, 7), new Position(5, 7)]),
|
||||||
|
],
|
||||||
|
objectives: [],
|
||||||
|
victoryCondition: VictoryCondition::EliminateAll,
|
||||||
|
holdRoundsRequired: 1,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testHoldObjectiveRequiresExactlyOneObjective(): void
|
||||||
|
{
|
||||||
|
$this->expectException(InvalidArgumentException::class);
|
||||||
|
$this->expectExceptionMessage('Hold objective victory requires exactly one objective.');
|
||||||
|
|
||||||
|
$units = $this->validUnitSet();
|
||||||
|
$zones = $this->validZones();
|
||||||
|
|
||||||
|
new Scenario(
|
||||||
|
id: 'demo',
|
||||||
|
name: 'Demo',
|
||||||
|
battlefield: new Battlefield(8, 8),
|
||||||
|
units: $units,
|
||||||
|
deploymentZones: $zones,
|
||||||
|
objectives: [],
|
||||||
|
victoryCondition: VictoryCondition::HoldObjective,
|
||||||
|
holdRoundsRequired: 3,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testStartMatchReturnsAFreshMatchStateWithResetUnits(): void
|
||||||
|
{
|
||||||
|
$scenario = $this->validScenario();
|
||||||
|
|
||||||
|
$match = $scenario->startMatch('alpha');
|
||||||
|
|
||||||
|
self::assertInstanceOf(MatchState::class, $match);
|
||||||
|
self::assertSame('alpha', $match->activeTeamId);
|
||||||
|
self::assertSame(1, $match->round);
|
||||||
|
self::assertNull($match->winnerTeamId);
|
||||||
|
foreach ($match->units as $unit) {
|
||||||
|
self::assertSame(2, $unit->actionsRemaining);
|
||||||
|
self::assertFalse($unit->hasAttacked);
|
||||||
|
self::assertSame(0, $unit->attackBonus);
|
||||||
|
self::assertFalse($unit->hasUsedAbility);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testStartMatchDoesNotShareUnitInstancesWithTheScenario(): void
|
||||||
|
{
|
||||||
|
$scenario = $this->validScenario();
|
||||||
|
|
||||||
|
$match = $scenario->startMatch('alpha');
|
||||||
|
|
||||||
|
$original = $scenario->units[0];
|
||||||
|
$fromMatch = $match->unit($original->id);
|
||||||
|
|
||||||
|
self::assertNotSame($original, $fromMatch);
|
||||||
|
self::assertSame($original->id, $fromMatch->id);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testStartingAMatchWithAbilityAndObjectivePlaysThrough(): void
|
||||||
|
{
|
||||||
|
$units = [
|
||||||
|
new UnitState('alpha-1', 'alpha', new Position(0, 0), 10, 10, 4, 2, 4, 2, false, Archetype::Striker, ['area_damage']),
|
||||||
|
new UnitState('alpha-2', 'alpha', new Position(1, 0), 9, 9, 3, 2, 4, 2, false, Archetype::Support, ['heal', 'buff']),
|
||||||
|
new UnitState('alpha-3', 'alpha', new Position(2, 0), 10, 10, 4, 3, 3, 2, false, Archetype::Defender),
|
||||||
|
new UnitState('bravo-1', 'bravo', new Position(7, 7), 7, 7, 4, 2, 5, 2, false, Archetype::Scout),
|
||||||
|
new UnitState('bravo-2', 'bravo', new Position(6, 7), 10, 10, 4, 2, 4, 2, false, Archetype::Striker),
|
||||||
|
new UnitState('bravo-3', 'bravo', new Position(5, 7), 10, 10, 4, 3, 3, 2, false, Archetype::Defender),
|
||||||
|
];
|
||||||
|
$scenario = new Scenario(
|
||||||
|
id: 'demo',
|
||||||
|
name: 'Demo',
|
||||||
|
battlefield: new Battlefield(8, 8),
|
||||||
|
units: $units,
|
||||||
|
deploymentZones: [
|
||||||
|
'alpha' => new DeploymentZone('alpha', [new Position(0, 0), new Position(1, 0), new Position(2, 0)]),
|
||||||
|
'bravo' => new DeploymentZone('bravo', [new Position(7, 7), new Position(6, 7), new Position(5, 7)]),
|
||||||
|
],
|
||||||
|
objectives: ['objective-1' => new ObjectiveMarker('objective-1', new Position(0, 0))],
|
||||||
|
victoryCondition: VictoryCondition::HoldObjective,
|
||||||
|
holdRoundsRequired: 2,
|
||||||
|
);
|
||||||
|
|
||||||
|
$match = $scenario->startMatch('alpha');
|
||||||
|
$engine = new CombatEngine();
|
||||||
|
$end1 = $engine->endTurn($match);
|
||||||
|
$end2 = $engine->endTurn($end1);
|
||||||
|
|
||||||
|
self::assertSame('alpha', $end2->activeTeamId);
|
||||||
|
self::assertSame(['alpha' => 1], $end2->objectiveControl);
|
||||||
|
self::assertNull($end2->winnerTeamId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return list<UnitState> */
|
||||||
|
private function validUnitSet(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
$this->unit('alpha-1', 'alpha', new Position(0, 0)),
|
||||||
|
$this->unit('alpha-2', 'alpha', new Position(1, 0)),
|
||||||
|
$this->unit('alpha-3', 'alpha', new Position(2, 0)),
|
||||||
|
$this->unit('bravo-1', 'bravo', new Position(7, 7)),
|
||||||
|
$this->unit('bravo-2', 'bravo', new Position(6, 7)),
|
||||||
|
$this->unit('bravo-3', 'bravo', new Position(5, 7)),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return array<string, DeploymentZone> */
|
||||||
|
private function validZones(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'alpha' => new DeploymentZone('alpha', [
|
||||||
|
new Position(0, 0),
|
||||||
|
new Position(1, 0),
|
||||||
|
new Position(2, 0),
|
||||||
|
]),
|
||||||
|
'bravo' => new DeploymentZone('bravo', [
|
||||||
|
new Position(7, 7),
|
||||||
|
new Position(6, 7),
|
||||||
|
new Position(5, 7),
|
||||||
|
]),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function validScenario(): Scenario
|
||||||
|
{
|
||||||
|
return new Scenario(
|
||||||
|
id: 'demo',
|
||||||
|
name: 'Demo',
|
||||||
|
battlefield: new Battlefield(8, 8),
|
||||||
|
units: $this->validUnitSet(),
|
||||||
|
deploymentZones: $this->validZones(),
|
||||||
|
objectives: [],
|
||||||
|
victoryCondition: VictoryCondition::EliminateAll,
|
||||||
|
holdRoundsRequired: 1,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param list<string> $abilities
|
||||||
|
*/
|
||||||
|
private function unit(
|
||||||
|
string $id,
|
||||||
|
string $team,
|
||||||
|
Position $position,
|
||||||
|
Archetype $archetype = Archetype::Defender,
|
||||||
|
array $abilities = [],
|
||||||
|
int $attack = 4,
|
||||||
|
int $defense = 3,
|
||||||
|
int $speed = 3,
|
||||||
|
int $maxHealth = 10,
|
||||||
|
): UnitState {
|
||||||
|
return new UnitState(
|
||||||
|
$id,
|
||||||
|
$team,
|
||||||
|
$position,
|
||||||
|
$maxHealth,
|
||||||
|
$maxHealth,
|
||||||
|
$attack,
|
||||||
|
$defense,
|
||||||
|
$speed,
|
||||||
|
2,
|
||||||
|
false,
|
||||||
|
$archetype,
|
||||||
|
$abilities,
|
||||||
|
0,
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,162 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace BattleForge\Tests\Unit\Domain;
|
||||||
|
|
||||||
|
use BattleForge\Domain\Archetype;
|
||||||
|
use BattleForge\Domain\Battlefield;
|
||||||
|
use BattleForge\Domain\DeploymentZone;
|
||||||
|
use BattleForge\Domain\ObjectiveMarker;
|
||||||
|
use BattleForge\Domain\Position;
|
||||||
|
use BattleForge\Domain\Scenario;
|
||||||
|
use BattleForge\Domain\ScenarioValidator;
|
||||||
|
use BattleForge\Domain\Terrain;
|
||||||
|
use BattleForge\Domain\UnitState;
|
||||||
|
use BattleForge\Domain\VictoryCondition;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
|
||||||
|
final class ScenarioValidatorTest extends TestCase
|
||||||
|
{
|
||||||
|
public function testItReturnsAnEmptyListForAValidScenario(): void
|
||||||
|
{
|
||||||
|
$scenario = $this->validScenario();
|
||||||
|
|
||||||
|
self::assertSame([], ScenarioValidator::validate($scenario));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testItRejectsAnObjectiveOnImpassableTerrain(): void
|
||||||
|
{
|
||||||
|
$scenario = $this->validScenario(
|
||||||
|
terrain: ['4:4' => Terrain::Water],
|
||||||
|
objectivePosition: new Position(4, 4),
|
||||||
|
);
|
||||||
|
|
||||||
|
$errors = ScenarioValidator::validate($scenario);
|
||||||
|
|
||||||
|
self::assertNotEmpty($errors);
|
||||||
|
self::assertTrue((bool) array_filter($errors, static fn (string $error): bool => str_contains($error, 'impassable')));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testItRejectsAnObjectiveUnreachableFromAnyDeploymentZone(): void
|
||||||
|
{
|
||||||
|
$scenario = $this->validScenario(
|
||||||
|
terrain: [
|
||||||
|
'3:4' => Terrain::Blocking,
|
||||||
|
'4:3' => Terrain::Blocking,
|
||||||
|
'5:4' => Terrain::Blocking,
|
||||||
|
'4:5' => Terrain::Blocking,
|
||||||
|
],
|
||||||
|
objectivePosition: new Position(4, 4),
|
||||||
|
);
|
||||||
|
|
||||||
|
$errors = ScenarioValidator::validate($scenario);
|
||||||
|
|
||||||
|
self::assertTrue((bool) array_filter($errors, static fn (string $error): bool => str_contains($error, 'unreachable')));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testItRejectsOverlappingDeploymentZones(): void
|
||||||
|
{
|
||||||
|
$units = $this->unitSet();
|
||||||
|
$battlefield = new Battlefield(8, 8);
|
||||||
|
$scenario = new Scenario(
|
||||||
|
id: 'demo',
|
||||||
|
name: 'Demo',
|
||||||
|
battlefield: $battlefield,
|
||||||
|
units: $units,
|
||||||
|
deploymentZones: [
|
||||||
|
'alpha' => new DeploymentZone('alpha', [new Position(0, 0), new Position(1, 0), new Position(2, 0)]),
|
||||||
|
'bravo' => new DeploymentZone('bravo', [new Position(1, 0), new Position(2, 0), new Position(7, 7), new Position(6, 7), new Position(5, 7)]),
|
||||||
|
],
|
||||||
|
objectives: [],
|
||||||
|
victoryCondition: VictoryCondition::EliminateAll,
|
||||||
|
holdRoundsRequired: 1,
|
||||||
|
);
|
||||||
|
|
||||||
|
$errors = ScenarioValidator::validate($scenario);
|
||||||
|
|
||||||
|
self::assertTrue((bool) array_filter($errors, static fn (string $error): bool => str_contains($error, 'overlap')));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testItRejectsDeploymentPositionsOnImpassableTerrain(): void
|
||||||
|
{
|
||||||
|
$units = $this->unitSet();
|
||||||
|
$battlefield = new Battlefield(8, 8, ['0:0' => Terrain::Water]);
|
||||||
|
$scenario = new Scenario(
|
||||||
|
id: 'demo',
|
||||||
|
name: 'Demo',
|
||||||
|
battlefield: $battlefield,
|
||||||
|
units: $units,
|
||||||
|
deploymentZones: [
|
||||||
|
'alpha' => new DeploymentZone('alpha', [new Position(0, 0), new Position(1, 0), new Position(2, 0)]),
|
||||||
|
'bravo' => new DeploymentZone('bravo', [new Position(7, 7), new Position(6, 7), new Position(5, 7)]),
|
||||||
|
],
|
||||||
|
objectives: [],
|
||||||
|
victoryCondition: VictoryCondition::EliminateAll,
|
||||||
|
holdRoundsRequired: 1,
|
||||||
|
);
|
||||||
|
|
||||||
|
$errors = ScenarioValidator::validate($scenario);
|
||||||
|
|
||||||
|
self::assertTrue((bool) array_filter($errors, static fn (string $error): bool => str_contains($error, 'impassable')));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, Terrain> $terrain
|
||||||
|
*/
|
||||||
|
private function validScenario(
|
||||||
|
array $terrain = [],
|
||||||
|
?Position $objectivePosition = null,
|
||||||
|
): Scenario {
|
||||||
|
$units = $this->unitSet();
|
||||||
|
$battlefield = new Battlefield(8, 8, $terrain);
|
||||||
|
$objectives = [];
|
||||||
|
|
||||||
|
if ($objectivePosition !== null) {
|
||||||
|
$objectives = ['objective-1' => new ObjectiveMarker('objective-1', $objectivePosition)];
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Scenario(
|
||||||
|
id: 'demo',
|
||||||
|
name: 'Demo',
|
||||||
|
battlefield: $battlefield,
|
||||||
|
units: $units,
|
||||||
|
deploymentZones: $this->zones(),
|
||||||
|
objectives: $objectives,
|
||||||
|
victoryCondition: $objectivePosition === null
|
||||||
|
? VictoryCondition::EliminateAll
|
||||||
|
: VictoryCondition::HoldObjective,
|
||||||
|
holdRoundsRequired: 1,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return array<string, DeploymentZone> */
|
||||||
|
private function zones(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'alpha' => new DeploymentZone('alpha', [
|
||||||
|
new Position(0, 0),
|
||||||
|
new Position(1, 0),
|
||||||
|
new Position(2, 0),
|
||||||
|
]),
|
||||||
|
'bravo' => new DeploymentZone('bravo', [
|
||||||
|
new Position(7, 7),
|
||||||
|
new Position(6, 7),
|
||||||
|
new Position(5, 7),
|
||||||
|
]),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return list<UnitState> */
|
||||||
|
private function unitSet(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
new UnitState('alpha-1', 'alpha', new Position(0, 0), 10, 10, 4, 3, 2, 2, false, Archetype::Defender),
|
||||||
|
new UnitState('alpha-2', 'alpha', new Position(1, 0), 8, 8, 5, 2, 3, 2, false, Archetype::Striker),
|
||||||
|
new UnitState('alpha-3', 'alpha', new Position(2, 0), 7, 7, 2, 2, 3, 2, false, Archetype::Support),
|
||||||
|
new UnitState('bravo-1', 'bravo', new Position(7, 7), 10, 10, 4, 3, 2, 2, false, Archetype::Defender),
|
||||||
|
new UnitState('bravo-2', 'bravo', new Position(6, 7), 8, 8, 5, 2, 3, 2, false, Archetype::Striker),
|
||||||
|
new UnitState('bravo-3', 'bravo', new Position(5, 7), 6, 6, 3, 1, 5, 2, false, Archetype::Scout),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,256 @@
|
|||||||
|
<?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());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testItCarriesArchetypeAbilitiesAndBonus(): void
|
||||||
|
{
|
||||||
|
$unit = new UnitState(
|
||||||
|
id: 'unit-1',
|
||||||
|
teamId: 'alpha',
|
||||||
|
position: new Position(0, 0),
|
||||||
|
maxHealth: 10,
|
||||||
|
health: 10,
|
||||||
|
attack: 4,
|
||||||
|
defense: 2,
|
||||||
|
speed: 4,
|
||||||
|
actionsRemaining: 2,
|
||||||
|
hasAttacked: false,
|
||||||
|
archetype: \BattleForge\Domain\Archetype::Support,
|
||||||
|
abilities: ['heal', 'buff'],
|
||||||
|
attackBonus: 2,
|
||||||
|
hasUsedAbility: false,
|
||||||
|
);
|
||||||
|
|
||||||
|
self::assertSame(\BattleForge\Domain\Archetype::Support, $unit->archetype);
|
||||||
|
self::assertSame(['heal', 'buff'], $unit->abilities);
|
||||||
|
self::assertSame(2, $unit->attackBonus);
|
||||||
|
self::assertFalse($unit->hasUsedAbility);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testItRejectsEmptyStringAbilities(): void
|
||||||
|
{
|
||||||
|
$this->expectException(InvalidArgumentException::class);
|
||||||
|
$this->expectExceptionMessage('Unit abilities must be non-empty strings.');
|
||||||
|
|
||||||
|
new UnitState(
|
||||||
|
id: 'unit-1',
|
||||||
|
teamId: 'alpha',
|
||||||
|
position: new Position(0, 0),
|
||||||
|
maxHealth: 10,
|
||||||
|
health: 10,
|
||||||
|
attack: 4,
|
||||||
|
defense: 2,
|
||||||
|
speed: 4,
|
||||||
|
actionsRemaining: 2,
|
||||||
|
hasAttacked: false,
|
||||||
|
archetype: \BattleForge\Domain\Archetype::Defender,
|
||||||
|
abilities: [''],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testItExposesAllowedAbilitiesFromTheArchetypeCatalog(): void
|
||||||
|
{
|
||||||
|
$unit = $this->unit();
|
||||||
|
|
||||||
|
self::assertSame(
|
||||||
|
\BattleForge\Domain\ArchetypeCatalog::templates()[$unit->archetype->value]->allowedAbilities,
|
||||||
|
$unit->allowedAbilities(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testWithAttackBonusReturnsAChangedCopyAndRejectsNegativeBonus(): void
|
||||||
|
{
|
||||||
|
$unit = $this->unit();
|
||||||
|
$buffed = $unit->withAttackBonus(3);
|
||||||
|
|
||||||
|
self::assertNotSame($unit, $buffed);
|
||||||
|
self::assertSame(0, $unit->attackBonus);
|
||||||
|
self::assertSame(3, $buffed->attackBonus);
|
||||||
|
|
||||||
|
$this->expectException(\InvalidArgumentException::class);
|
||||||
|
$unit->withAttackBonus(-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testSpendAbilityMarksAndConsumesOneActionButNotTwo(): void
|
||||||
|
{
|
||||||
|
$unit = $this->unit();
|
||||||
|
$spent = $unit->spendAbility();
|
||||||
|
|
||||||
|
self::assertSame(1, $spent->actionsRemaining);
|
||||||
|
self::assertTrue($spent->hasUsedAbility);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testSpendAbilityRejectsAUnitThatAlreadyUsedAnAbility(): void
|
||||||
|
{
|
||||||
|
$unit = $this->unit(hasUsedAbility: true);
|
||||||
|
|
||||||
|
$this->expectException(\InvalidArgumentException::class);
|
||||||
|
$unit->spendAbility();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testStartTurnClearsAttackBonusAndAbilityUse(): void
|
||||||
|
{
|
||||||
|
$unit = $this->unit(actionsRemaining: 0, hasAttacked: true, attackBonus: 3, hasUsedAbility: true);
|
||||||
|
$started = $unit->startTurn();
|
||||||
|
|
||||||
|
self::assertSame(2, $started->actionsRemaining);
|
||||||
|
self::assertFalse($started->hasAttacked);
|
||||||
|
self::assertSame(0, $started->attackBonus);
|
||||||
|
self::assertFalse($started->hasUsedAbility);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function unit(
|
||||||
|
int $health = 10,
|
||||||
|
int $actionsRemaining = 2,
|
||||||
|
bool $hasAttacked = false,
|
||||||
|
int $attackBonus = 0,
|
||||||
|
bool $hasUsedAbility = false,
|
||||||
|
): UnitState {
|
||||||
|
return new UnitState(
|
||||||
|
'unit-1',
|
||||||
|
'alpha',
|
||||||
|
new Position(0, 0),
|
||||||
|
10,
|
||||||
|
$health,
|
||||||
|
4,
|
||||||
|
2,
|
||||||
|
4,
|
||||||
|
$actionsRemaining,
|
||||||
|
$hasAttacked,
|
||||||
|
archetype: \BattleForge\Domain\Archetype::Defender,
|
||||||
|
abilities: [],
|
||||||
|
attackBonus: $attackBonus,
|
||||||
|
hasUsedAbility: $hasUsedAbility,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user