Compare commits
32
Commits
main
...
1d69e89faf
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1d69e89faf | ||
|
|
02addec8df | ||
|
|
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/
|
||||
Vendored
+16
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"workbench.colorCustomizations": {
|
||||
"tree.indentGuidesStroke": "#3d92ec",
|
||||
"activityBar.background": "#053241",
|
||||
"titleBar.activeBackground": "#07465B",
|
||||
"titleBar.activeForeground": "#F3FBFE",
|
||||
"titleBar.inactiveBackground": "#053241",
|
||||
"titleBar.inactiveForeground": "#F3FBFE",
|
||||
"statusBar.background": "#053241",
|
||||
"statusBar.foreground": "#F3FBFE",
|
||||
"statusBar.debuggingBackground": "#053241",
|
||||
"statusBar.debuggingForeground": "#F3FBFE",
|
||||
"statusBar.noFolderBackground": "#053241",
|
||||
"statusBar.noFolderForeground": "#F3FBFE"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
# BattleForge
|
||||
|
||||
## Initial Ideas
|
||||
|
||||
- **Concept**: BattleForge is a flexible "tactical game engine" web app that can be used to create skirmish-style games in a variety of genres.
|
||||
- **Core Gameplay**: Players control a team of units on a grid-based battlefield, taking turns to move and attack. The game emphasizes strategic positioning, unit abilities, and resource management.
|
||||
- **Customization**: Players can create and customize their own units, abilities, and battlefields, allowing for endless variety and replayability.
|
||||
- **Multiplayer**: Support asynchronous multiplayer, allowing players to compete against friends or strangers, with turn notifications (via email, in-app, or browser notifications) and matchmaking.
|
||||
- **Single Player**: Sandbox mode for free play with simple AI opponents.
|
||||
- **Art Style**: Images can be uploaded by users, but the app will also provide a library of free assets (units, structures, and terrain) to get started. The art style can be pixel art, isometric, or top-down, depending on user preference.
|
||||
|
||||
## Basic Features
|
||||
|
||||
- **Unit Creation**: Users can create custom units with various stats (health, attack, defense, speed) and abilities (e.g., heal, area damage, buffs).
|
||||
- **Battlefield Editor**: A drag-and-drop interface for designing custom battlefields with different terrain types (e.g., forests, mountains, water) that affect gameplay.
|
||||
- **Turn-Based Combat**: Players take turns moving their units and using abilities, with a simple UI for selecting actions and targeting.
|
||||
- **Resource Management**: Players can earn resources during battles to upgrade their units or purchase new ones for future battles.
|
||||
- **Matchmaking and Leaderboards**: A system for finding opponents and tracking player rankings based on wins, losses, and other performance metrics.
|
||||
@@ -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
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