Compare commits

..
32 Commits
Author SHA1 Message Date
Keith Solomon 1d69e89faf chore: add editor settings and initial project notes
CI / php (push) Failing after 1m43s
2026-07-06 14:34:43 -05:00
Keith Solomon 02addec8df docs: include Plan 2 implementation plan in repo 2026-07-06 14:33:41 -05:00
Keith Solomon 86d82c353d Merge feature/curated-content-and-objectives into develop
CI / php (push) Failing after 1m37s
Plan 2: Curated Content, Abilities, Objectives, and Scenario Validation.

Adds the curated content library (archetypes, abilities, terrain behaviors),
per-round objective control, both victory conditions, and full scenario
validation. 9 production files, 7 test files, 130/130 tests, PHPStan level 6
clean, PHPCS clean. See docs/superpowers/plans/2026-07-05-curated-content-
abilities-and-objectives.md for the implementation plan.
2026-07-05 20:38:44 -05:00
Keith Solomon 39b15c35da refactor: tighten useAbility and Scenario archetype lookup
Final-review-driven changes (Tasks 6/M1, I1 from the ledger):

- Drop the redundant abilities-list check in CombatEngine::useAbility.
  By construction (Scenario::__construct enforces the allowlist), any
  ability the unit 'knows' is also archetype-allowed; the second check
  alone is sufficient and removes a dead code path.
- Update testUseAbilityRejectsAnAbilityTheArchetypeForbids to assert the
  'Unit archetype forbids that ability.' message it actually exercises.
- Compute $template once in Scenario::__construct's per-unit loop and
  drop the redundant catalog lookup on the next line.
2026-07-05 20:33:03 -05:00
Keith Solomon 12f5c0bd3a test: cover scenario to match flow with ability and objective 2026-07-05 20:23:28 -05:00
Keith Solomon 6fffaa2306 feat: validate scenario deployment zones and objectives 2026-07-05 20:11:35 -05:00
Keith Solomon 84fb175c0c feat: resolve objective control and objective victory 2026-07-05 20:06:27 -05:00
Keith SolomonandKeith Solomon 6befe7b3e2 feat: support curated abilities and objective victory 2026-07-05 19:49:06 -05:00
Keith Solomon 088e85c9b4 feat: model scenarios and start match snapshots 2026-07-05 19:27:22 -05:00
Keith Solomon 06632ff9b3 feat: model objectives, deployment zones, and victory conditions 2026-07-05 19:11:24 -05:00
Keith Solomon e70b60696c feat: track archetype, abilities, and buff on unit state 2026-07-05 19:05:03 -05:00
Keith Solomon 88149ab1cf feat: catalog curated abilities and definitions 2026-07-05 18:47:20 -05:00
Keith Solomon 0ac8c06c5e feat: catalog curated unit archetypes and templates 2026-07-05 18:41:53 -05:00
Keith Solomon 78fc802cdc fix: enforce LF for PHP files 2026-07-04 18:07:00 -05:00
Keith Solomon de0bc9c43f ci: pin workflow action revisions 2026-07-04 16:15:54 -05:00
Keith Solomon 07e58c84fe ci: enforce PHP quality checks 2026-07-04 16:11:14 -05:00
Keith Solomon fef22d7b56 fix: guard invalid turn transitions 2026-07-04 16:07:36 -05:00
Keith Solomon acfbbf4a91 feat: add alternating team turns 2026-07-04 16:02:49 -05:00
Keith Solomon 7588b304e1 refactor: reuse position distance for attacks 2026-07-04 15:58:29 -05:00
Keith Solomon 3c6359243a feat: resolve attacks and elimination victory 2026-07-04 15:53:57 -05:00
Keith Solomon 3eba823e52 fix: enforce executable movement state 2026-07-04 15:47:29 -05:00
Keith Solomon 1550e2920b feat: validate and resolve unit movement 2026-07-04 15:37:05 -05:00
Keith Solomon 5297b41208 fix: enforce immutable match state invariants 2026-07-04 15:33:05 -05:00
Keith Solomon 94560da5b4 test: verify original match remains unchanged 2026-07-04 15:25:06 -05:00
Keith Solomon 0c5e824942 feat: model immutable combat state 2026-07-04 15:23:00 -05:00
Keith Solomon 4421c5ae32 test: strengthen battlefield boundary coverage 2026-07-04 15:19:06 -05:00
Keith Solomon b789f1b61f fix: validate battlefield reachability inputs 2026-07-04 15:15:08 -05:00
Keith Solomon 189be5bc64 feat: model battlefield terrain and movement costs 2026-07-04 15:08:40 -05:00
Keith Solomon 7b82adbd71 chore: identify Composer package as project 2026-07-04 15:05:11 -05:00
Keith Solomon a97ecbc6d3 chore: bootstrap PHP quality toolchain 2026-07-04 15:02:06 -05:00
Keith Solomon e20017f1e5 docs: plan combat core implementation 2026-07-04 14:59:00 -05:00
Keith Solomon 5ddfff2679 chore: ignore local worktrees 2026-07-04 14:59:00 -05:00
40 changed files with 10196 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
*.php text eol=lf
+27
View File
@@ -0,0 +1,27 @@
name: CI
on:
push:
branches:
- main
- develop
pull_request:
permissions:
contents: read
jobs:
php:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
with:
persist-credentials: false
- uses: shivammathur/setup-php@b604ade2a87db23f8871b7182e69ec5e75effb45 # v2
with:
php-version: '8.3'
coverage: none
tools: composer:v2
- run: composer validate --strict
- run: composer install --no-interaction --prefer-dist
- run: composer check
+3
View File
@@ -0,0 +1,3 @@
.worktrees/
/vendor/
/var/
+16
View File
@@ -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"
}
}
+18
View File
@@ -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.
+41
View File
@@ -0,0 +1,41 @@
{
"name": "battleforge/battleforge",
"description": "A standalone tactical combat game.",
"type": "project",
"license": "proprietary",
"require": {
"php": "^8.3"
},
"require-dev": {
"dealerdirect/phpcodesniffer-composer-installer": "^1.0",
"phpstan/phpstan": "^2.0",
"phpunit/phpunit": "^11.5",
"squizlabs/php_codesniffer": "^3.10"
},
"autoload": {
"psr-4": {
"BattleForge\\": "src/"
}
},
"autoload-dev": {
"psr-4": {
"BattleForge\\Tests\\": "tests/"
}
},
"scripts": {
"lint": "phpcs",
"analyse": "phpstan analyse --no-progress",
"test": "phpunit",
"check": [
"@lint",
"@analyse",
"@test"
]
},
"config": {
"allow-plugins": {
"dealerdirect/phpcodesniffer-composer-installer": true
},
"sort-packages": true
}
}
Generated
+2040
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+12
View File
@@ -0,0 +1,12 @@
<?xml version="1.0"?>
<ruleset name="BattleForge">
<description>BattleForge coding standards.</description>
<file>src</file>
<file>tests</file>
<arg name="colors"/>
<arg value="sp"/>
<rule ref="PSR12"/>
</ruleset>
+6
View File
@@ -0,0 +1,6 @@
parameters:
level: 6
paths:
- src
- tests
tmpDir: var/phpstan
+18
View File
@@ -0,0 +1,18 @@
<?xml version="1.0" encoding="UTF-8"?>
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="https://schema.phpunit.de/11.5/phpunit.xsd"
bootstrap="vendor/autoload.php"
cacheDirectory="var/phpunit"
colors="true">
<testsuites>
<testsuite name="BattleForge Test Suite">
<directory>tests</directory>
</testsuite>
</testsuites>
<source>
<include>
<directory>src</directory>
</include>
</source>
</phpunit>
+52
View File
@@ -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;
}
}
+51
View File
@@ -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}.");
}
}
}
+12
View File
@@ -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';
}
+13
View File
@@ -0,0 +1,13 @@
<?php
declare(strict_types=1);
namespace BattleForge\Domain;
enum Archetype: string
{
case Defender = 'defender';
case Striker = 'striker';
case Support = 'support';
case Scout = 'scout';
}
+69
View File
@@ -0,0 +1,69 @@
<?php
declare(strict_types=1);
namespace BattleForge\Domain;
final class ArchetypeCatalog
{
/**
* @return array<string, ArchetypeTemplate>
*/
public static function templates(): array
{
static $cache = null;
if ($cache !== null) {
return $cache;
}
$cache = [
Archetype::Defender->value => new ArchetypeTemplate(
minHealth: 10,
maxHealth: 14,
minAttack: 2,
maxAttack: 4,
minDefense: 3,
maxDefense: 5,
minSpeed: 1,
maxSpeed: 3,
allowedAbilities: ['buff'],
),
Archetype::Striker->value => new ArchetypeTemplate(
minHealth: 6,
maxHealth: 10,
minAttack: 4,
maxAttack: 6,
minDefense: 1,
maxDefense: 2,
minSpeed: 2,
maxSpeed: 4,
allowedAbilities: ['area_damage'],
),
Archetype::Support->value => new ArchetypeTemplate(
minHealth: 5,
maxHealth: 9,
minAttack: 1,
maxAttack: 3,
minDefense: 1,
maxDefense: 3,
minSpeed: 2,
maxSpeed: 4,
allowedAbilities: ['heal', 'buff'],
),
Archetype::Scout->value => new ArchetypeTemplate(
minHealth: 4,
maxHealth: 7,
minAttack: 2,
maxAttack: 4,
minDefense: 0,
maxDefense: 2,
minSpeed: 4,
maxSpeed: 6,
allowedAbilities: [],
),
];
return $cache;
}
}
+50
View File
@@ -0,0 +1,50 @@
<?php
declare(strict_types=1);
namespace BattleForge\Domain;
use InvalidArgumentException;
final readonly class ArchetypeTemplate
{
/** @param list<string> $allowedAbilities */
public function __construct(
public int $minHealth,
public int $maxHealth,
public int $minAttack,
public int $maxAttack,
public int $minDefense,
public int $maxDefense,
public int $minSpeed,
public int $maxSpeed,
public array $allowedAbilities,
) {
if ($this->minHealth > $this->maxHealth) {
throw new InvalidArgumentException('Health range is inverted.');
}
if ($this->minAttack > $this->maxAttack) {
throw new InvalidArgumentException('Attack range is inverted.');
}
if ($this->minDefense > $this->maxDefense) {
throw new InvalidArgumentException('Defense range is inverted.');
}
if ($this->minSpeed > $this->maxSpeed || $this->minSpeed < 1) {
throw new InvalidArgumentException('Speed range must be at least 1 and non-inverted.');
}
if ($this->minHealth < 1) {
throw new InvalidArgumentException('Minimum health must be at least 1.');
}
}
/** @return array{maxHealth: int, attack: int, defense: int, speed: int} */
public function defaultStats(): array
{
return [
'maxHealth' => (int) ceil(($this->minHealth + $this->maxHealth) / 2),
'attack' => (int) ceil(($this->minAttack + $this->maxAttack) / 2),
'defense' => (int) ceil(($this->minDefense + $this->maxDefense) / 2),
'speed' => (int) ceil(($this->minSpeed + $this->maxSpeed) / 2),
];
}
}
+148
View File
@@ -0,0 +1,148 @@
<?php
declare(strict_types=1);
namespace BattleForge\Domain;
use InvalidArgumentException;
use SplQueue;
final readonly class Battlefield
{
/** @var array<string, Terrain> */
private array $terrain;
/**
* @param array<string, Terrain> $terrain
*/
public function __construct(
public int $width,
public int $height,
array $terrain = [],
) {
if ($width < 8 || $width > 16 || $height < 8 || $height > 16) {
throw new InvalidArgumentException('Battlefield dimensions must each be between 8 and 16.');
}
$validatedTerrain = [];
foreach ($terrain as $key => $terrainType) {
if (!self::isTerrain($terrainType)) {
throw new InvalidArgumentException("Invalid terrain value at coordinate {$key}.");
}
$position = self::positionFromTerrainKey($key);
if (!$this->contains($position)) {
throw new InvalidArgumentException("Terrain coordinate {$key} is outside the battlefield.");
}
$validatedTerrain[$key] = $terrainType;
}
$this->terrain = $validatedTerrain;
}
private static function isTerrain(mixed $terrain): bool
{
return $terrain instanceof Terrain;
}
private static function positionFromTerrainKey(mixed $key): Position
{
if (!is_string($key) || preg_match('/^(\d+):(\d+)$/', $key, $matches) !== 1) {
throw new InvalidArgumentException("Invalid terrain coordinate: {$key}.");
}
$position = new Position((int) $matches[1], (int) $matches[2]);
if ($key !== $position->key()) {
throw new InvalidArgumentException("Invalid terrain coordinate: {$key}.");
}
return $position;
}
public function contains(Position $position): bool
{
return $position->x >= 0
&& $position->x < $this->width
&& $position->y >= 0
&& $position->y < $this->height;
}
public function terrainAt(Position $position): Terrain
{
return $this->terrain[$position->key()] ?? Terrain::Open;
}
/**
* @param list<Position> $occupied
* @return array<string, int>
*/
public function reachable(Position $start, int $budget, array $occupied): array
{
if (!$this->contains($start)) {
throw new InvalidArgumentException('Reachability start position is outside the battlefield.');
}
if ($budget < 0) {
throw new InvalidArgumentException('Reachability budget cannot be negative.');
}
$occupiedKeys = [];
foreach ($occupied as $position) {
if (!$this->contains($position)) {
throw new InvalidArgumentException('Occupied position is outside the battlefield.');
}
$occupiedKeys[$position->key()] = true;
}
$costs = [$start->key() => 0];
/** @var SplQueue<Position> $queue */
$queue = new SplQueue();
$queue->enqueue($start);
while (!$queue->isEmpty()) {
$current = $queue->dequeue();
$currentCost = $costs[$current->key()];
foreach ($this->neighbors($current) as $neighbor) {
$key = $neighbor->key();
$movementCost = $this->terrainAt($neighbor)->movementCost();
if ($movementCost === null || isset($occupiedKeys[$key])) {
continue;
}
$cost = $currentCost + $movementCost;
if ($cost > $budget || (isset($costs[$key]) && $costs[$key] <= $cost)) {
continue;
}
$costs[$key] = $cost;
$queue->enqueue($neighbor);
}
}
return $costs;
}
/**
* @return list<Position>
*/
private function neighbors(Position $position): array
{
$neighbors = [
new Position($position->x + 1, $position->y),
new Position($position->x - 1, $position->y),
new Position($position->x, $position->y + 1),
new Position($position->x, $position->y - 1),
];
return array_values(array_filter($neighbors, $this->contains(...)));
}
}
+398
View File
@@ -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.');
}
}
}
+11
View File
@@ -0,0 +1,11 @@
<?php
declare(strict_types=1);
namespace BattleForge\Domain;
use DomainException;
final class CombatException extends DomainException
{
}
+39
View File
@@ -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;
}
}
+233
View File
@@ -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,
);
}
}
+46
View File
@@ -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];
}
}
+24
View File
@@ -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;
}
}
+24
View File
@@ -0,0 +1,24 @@
<?php
declare(strict_types=1);
namespace BattleForge\Domain;
final readonly class Position
{
public function __construct(
public int $x,
public int $y,
) {
}
public function key(): string
{
return $this->x . ':' . $this->y;
}
public function distanceTo(self $position): int
{
return abs($this->x - $position->x) + abs($this->y - $position->y);
}
}
+160
View File
@@ -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;
}
}
+82
View File
@@ -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;
}
}
+28
View File
@@ -0,0 +1,28 @@
<?php
declare(strict_types=1);
namespace BattleForge\Domain;
enum Terrain: string
{
case Open = 'open';
case Forest = 'forest';
case Rough = 'rough';
case Water = 'water';
case Blocking = 'blocking';
public function movementCost(): ?int
{
return match ($this) {
self::Open => 1,
self::Forest, self::Rough => 2,
self::Water, self::Blocking => null,
};
}
public function defenseBonus(): int
{
return $this === self::Forest ? 1 : 0;
}
}
+182
View File
@@ -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);
}
}
+11
View File
@@ -0,0 +1,11 @@
<?php
declare(strict_types=1);
namespace BattleForge\Domain;
enum VictoryCondition: string
{
case EliminateAll = 'eliminate_all';
case HoldObjective = 'hold_objective';
}
+59
View File
@@ -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);
}
}
+121
View File
@@ -0,0 +1,121 @@
<?php
declare(strict_types=1);
namespace BattleForge\Tests\Unit\Domain;
use BattleForge\Domain\Battlefield;
use BattleForge\Domain\Position;
use BattleForge\Domain\Terrain;
use InvalidArgumentException;
use PHPUnit\Framework\TestCase;
final class BattlefieldTest extends TestCase
{
public function testItRejectsDimensionsOutsideTheSupportedRange(): void
{
$this->expectException(InvalidArgumentException::class);
new Battlefield(7, 16);
}
public function testItAcceptsMinimumAndMaximumDimensions(): void
{
$minimum = new Battlefield(8, 8);
$maximum = new Battlefield(16, 16);
self::assertSame([8, 8], [$minimum->width, $minimum->height]);
self::assertSame([16, 16], [$maximum->width, $maximum->height]);
}
public function testItRejectsDimensionsAboveTheSupportedRange(): void
{
$this->expectException(InvalidArgumentException::class);
new Battlefield(16, 17);
}
public function testItRejectsNonCanonicalTerrainCoordinates(): void
{
$this->expectException(InvalidArgumentException::class);
new Battlefield(8, 8, ['01:0' => Terrain::Forest]);
}
public function testItRejectsMalformedTerrainCoordinates(): void
{
$this->expectException(InvalidArgumentException::class);
new Battlefield(8, 8, ['not-a-coordinate' => Terrain::Forest]);
}
public function testItRejectsInvalidTerrainValues(): void
{
$this->expectException(InvalidArgumentException::class);
// @phpstan-ignore argument.type
new Battlefield(8, 8, ['0:0' => 'forest']);
}
public function testReachableRejectsAStartOutsideTheBattlefield(): void
{
$battlefield = new Battlefield(8, 8);
$this->expectException(InvalidArgumentException::class);
$battlefield->reachable(new Position(-1, 0), 2, []);
}
public function testReachableRejectsANegativeBudget(): void
{
$battlefield = new Battlefield(8, 8);
$this->expectException(InvalidArgumentException::class);
$battlefield->reachable(new Position(0, 0), -1, []);
}
public function testReachableRejectsOccupiedPositionsOutsideTheBattlefield(): void
{
$battlefield = new Battlefield(8, 8);
$this->expectException(InvalidArgumentException::class);
$battlefield->reachable(new Position(0, 0), 2, [new Position(8, 0)]);
}
public function testItFindsReachableTilesUsingTerrainCostsAndObstacles(): void
{
$battlefield = new Battlefield(8, 8, [
'1:0' => Terrain::Forest,
'0:1' => Terrain::Rough,
'2:0' => Terrain::Water,
'1:1' => Terrain::Blocking,
]);
$reachable = $battlefield->reachable(
new Position(0, 0),
2,
[new Position(0, 2)],
);
self::assertSame([
'0:0' => 0,
'1:0' => 2,
'0:1' => 2,
], $reachable);
}
public function testReachableReplacesAHighCostRouteWithALowerCostDetour(): void
{
$battlefield = new Battlefield(8, 8, [
'1:0' => Terrain::Forest,
'2:0' => Terrain::Forest,
'3:0' => Terrain::Forest,
]);
$reachable = $battlefield->reachable(new Position(0, 0), 7, []);
self::assertSame(6, $reachable['4:0']);
}
}
File diff suppressed because it is too large Load Diff
+252
View File
@@ -0,0 +1,252 @@
<?php
declare(strict_types=1);
namespace BattleForge\Tests\Unit\Domain;
use BattleForge\Domain\Battlefield;
use BattleForge\Domain\MatchState;
use BattleForge\Domain\Position;
use BattleForge\Domain\Terrain;
use BattleForge\Domain\UnitState;
use InvalidArgumentException;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\TestCase;
final class MatchStateTest extends TestCase
{
public function testItRejectsDuplicateUnitIds(): void
{
$this->expectException(InvalidArgumentException::class);
new MatchState(
new Battlefield(8, 8),
[$this->unit('unit-1', 'alpha'), $this->unit('unit-1', 'bravo')],
'alpha',
);
}
public function testItRejectsAUnitOutsideTheBattlefield(): void
{
$this->expectException(InvalidArgumentException::class);
$this->expectExceptionMessage('Unit alpha-1 is outside the battlefield.');
new MatchState(
new Battlefield(8, 8),
[$this->unit('alpha-1', 'alpha', new Position(8, 0))],
'alpha',
);
}
#[DataProvider('impassableTerrainProvider')]
public function testItRejectsALivingUnitOnImpassableTerrain(Terrain $terrain): void
{
$this->expectException(InvalidArgumentException::class);
$this->expectExceptionMessage('Living unit alpha-1 must stand on passable terrain.');
new MatchState(
new Battlefield(8, 8, ['0:0' => $terrain]),
[$this->unit('alpha-1', 'alpha')],
'alpha',
);
}
/**
* @return iterable<string, array{Terrain}>
*/
public static function impassableTerrainProvider(): iterable
{
yield 'water' => [Terrain::Water];
yield 'blocking' => [Terrain::Blocking];
}
public function testItRejectsLivingUnitsAtTheSamePosition(): void
{
$this->expectException(InvalidArgumentException::class);
$this->expectExceptionMessage('Living units cannot share position 0:0.');
new MatchState(
new Battlefield(8, 8),
[$this->unit('alpha-1', 'alpha'), $this->unit('bravo-1', 'bravo')],
'alpha',
);
}
public function testItAcceptsLivingAndDefeatedUnitsAtTheSamePosition(): void
{
$match = new MatchState(
new Battlefield(8, 8),
[
$this->unit('alpha-1', 'alpha'),
$this->unit('bravo-1', 'bravo', health: 0),
],
'alpha',
);
self::assertSame('0:0', $match->unit('alpha-1')->position->key());
self::assertSame('0:0', $match->unit('bravo-1')->position->key());
}
public function testItRejectsAnUnknownUnitLookup(): void
{
$match = $this->match();
$this->expectException(InvalidArgumentException::class);
$match->unit('unknown');
}
public function testItRejectsAnUnknownReplacement(): void
{
$match = $this->match();
$this->expectException(InvalidArgumentException::class);
$match->withUnit($this->unit('unknown', 'alpha'));
}
public function testReplacingAUnitDoesNotMutateTheMatch(): void
{
$match = $this->match();
$replacement = $match->unit('alpha-1')->moveTo(new Position(2, 0));
$replaced = $match->withUnit($replacement);
self::assertSame('0:0', $match->unit('alpha-1')->position->key());
self::assertSame('2:0', $replaced->unit('alpha-1')->position->key());
}
public function testCopyReplacesMutableMatchProgressIncludingAnEmptyLog(): void
{
$match = $this->match(actionLog: ['started']);
$copied = $match->copy(activeTeamId: 'bravo', round: 2, actionLog: []);
self::assertSame('alpha', $match->activeTeamId);
self::assertSame(1, $match->round);
self::assertSame(['started'], $match->actionLog);
self::assertSame('bravo', $copied->activeTeamId);
self::assertSame(2, $copied->round);
self::assertSame([], $copied->actionLog);
}
public function testWinnerCanBeSetAndClearedWithoutChangingOtherState(): void
{
$match = $this->match(actionLog: ['started']);
$won = $match->withWinner('alpha');
$cleared = $won->withWinner(null);
self::assertSame('alpha', $won->winnerTeamId);
self::assertNull($cleared->winnerTeamId);
self::assertSame($match->battlefield, $cleared->battlefield);
self::assertSame($match->units, $cleared->units);
self::assertSame($match->activeTeamId, $cleared->activeTeamId);
self::assertSame($match->round, $cleared->round);
self::assertSame($match->actionLog, $cleared->actionLog);
}
public function testItRejectsANonListUnitsArray(): void
{
$this->expectException(InvalidArgumentException::class);
// @phpstan-ignore argument.type
new MatchState(new Battlefield(8, 8), ['first' => $this->unit('alpha-1', 'alpha')], 'alpha');
}
public function testItRejectsAnEmptyUnitsArray(): void
{
$this->expectException(InvalidArgumentException::class);
new MatchState(new Battlefield(8, 8), [], 'alpha');
}
public function testItRejectsANonUnitMember(): void
{
$this->expectException(InvalidArgumentException::class);
// @phpstan-ignore argument.type
new MatchState(new Battlefield(8, 8), ['not-a-unit'], 'alpha');
}
public function testItRejectsAnEmptyActiveTeam(): void
{
$this->expectException(InvalidArgumentException::class);
new MatchState(new Battlefield(8, 8), [$this->unit('alpha-1', 'alpha')], '');
}
public function testItRejectsAnActiveTeamWithoutAUnit(): void
{
$this->expectException(InvalidArgumentException::class);
new MatchState(new Battlefield(8, 8), [$this->unit('alpha-1', 'alpha')], 'bravo');
}
public function testItRejectsARoundBelowOne(): void
{
$this->expectException(InvalidArgumentException::class);
new MatchState(new Battlefield(8, 8), [$this->unit('alpha-1', 'alpha')], 'alpha', 0);
}
public function testItRejectsANonListActionLog(): void
{
$this->expectException(InvalidArgumentException::class);
new MatchState(
new Battlefield(8, 8),
[$this->unit('alpha-1', 'alpha')],
'alpha',
// @phpstan-ignore argument.type
actionLog: ['first' => 'started'],
);
}
public function testItRejectsANonStringActionLogEntry(): void
{
$this->expectException(InvalidArgumentException::class);
// @phpstan-ignore argument.type
new MatchState(new Battlefield(8, 8), [$this->unit('alpha-1', 'alpha')], 'alpha', actionLog: [1]);
}
public function testItRejectsAnEmptyWinnerTeam(): void
{
$this->expectException(InvalidArgumentException::class);
new MatchState(new Battlefield(8, 8), [$this->unit('alpha-1', 'alpha')], 'alpha', winnerTeamId: '');
}
public function testItRejectsAWinnerWithoutAUnit(): void
{
$this->expectException(InvalidArgumentException::class);
new MatchState(
new Battlefield(8, 8),
[$this->unit('alpha-1', 'alpha')],
'alpha',
winnerTeamId: 'bravo',
);
}
/**
* @param list<string> $actionLog
*/
private function match(array $actionLog = []): MatchState
{
return new MatchState(
new Battlefield(8, 8),
[
$this->unit('alpha-1', 'alpha'),
$this->unit('bravo-1', 'bravo', new Position(7, 7)),
],
'alpha',
actionLog: $actionLog,
);
}
private function unit(
string $id,
string $teamId,
?Position $position = null,
int $health = 10,
): UnitState {
return new UnitState($id, $teamId, $position ?? new Position(0, 0), 10, $health, 4, 2, 4, 2);
}
}
+280
View File
@@ -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,
);
}
}
+162
View File
@@ -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),
];
}
}
+256
View File
@@ -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,
);
}
}