Files
BattleForge/docs/superpowers/plans/2026-07-05-curated-content-abilities-and-objectives.md
T

2962 lines
102 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Curated Content, Abilities, Objectives, and Scenario Validation Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Add the curated content library (archetypes, abilities, terrain behaviors), per-round objective control, both victory conditions, and full scenario validation to the presentation-independent combat core established by Plan 1.
**Architecture:** The plan stays inside `src/Domain` and `tests/Unit/Domain`. New immutable value objects describe archetype templates, ability definitions, objective markers, and scenarios. `CombatEngine` gains `useAbility`, generalized team/objective handling, and objective-victory resolution on `endTurn`. A new `ScenarioValidator` owns the rules expressed in the spec for team size, stat bounds, deployment zones, and objective accessibility. `Scenario::startMatch()` produces an immutable `MatchState`, so the existing engine never sees raw scenarios. Team identity becomes scenario-driven (no more hard-coded `alpha`/`bravo` checks in the engine).
**Tech Stack:** PHP 8.3+, Composer PSR-4 autoloading, PHPUnit 11, PHPStan level 6, PHP_CodeSniffer with the repository ruleset.
## Delivery Sequence
This is the second of four independently executable plans:
1. Foundation and combat core — completed.
2. **Curated content, abilities, objectives, and scenario validation — this plan.**
3. Anonymous-browser persistence, secure image handling, and scenario editors.
4. Hot-seat battle UI, bundled scenarios, end-to-end smoke coverage, and release hardening.
Do not add persistence, HTTP routes, JavaScript, image uploads, or scenario storage in this plan. Bundled scenario JSON fixtures also belong to Plan 4.
## Execution Preflight
Execute this plan in an isolated worktree on `feature/curated-content-and-objectives`, branched from `develop`. The implementation branch will be submitted as a pull request into `develop` only after every completion check is green.
## Global Constraints
These apply to every task and are copied verbatim from the spec.
- Four distinct unit archetypes: defender, striker, support, and scout.
- Three fixed special abilities: healing, area damage, and a temporary buff.
- Five terrain types: open ground, forest, rough ground, water, and blocking terrain. Each type has fixed movement and defensive effects. (Already in place; this plan keeps these behaviors.)
- Exactly two opposing teams, each with three to six units.
- Unit stat bounds: health, attack, defense, and speed within enforced limits (per archetype — defined in this plan).
- Abilities are assignable only when allowed by the selected archetype.
- Battlefield dimensions: square grid, 8×8 through 16×16.
- One non-overlapping deployment zone per team, fully within the battlefield.
- Objectives placed only when required by the selected victory condition.
- Two victory conditions: eliminate every opposing unit, or control a designated objective for a configured number of complete rounds.
- Match starts from an immutable snapshot of its source scenario; later scenario edits cannot alter an active match.
- Two actions per unit on its team's turn; a unit cannot attack or use an ability more than once per turn (a unit may still move after attacking, and may use an ability after attacking — the once-per-turn limit is on the use of attack-and-ability separately).
- A rejected action never partially changes match state or consumes resources.
- Domain code has no dependency on HTTP, sessions, storage, or browser code.
- PHP follows the repository PHPCS rules and passes PHPStan at level 6.
- Composer configuration passes `composer validate --strict`.
## File Structure
```text
src/Domain/Archetype.php Enum of four archetype identifiers
src/Domain/ArchetypeTemplate.php Per-archetype stat bounds, ability whitelist, default stat
src/Domain/ArchetypeCatalog.php Curated catalog of the four templates
src/Domain/AbilityId.php Enum of three ability identifiers
src/Domain/AbilityDefinition.php Range/area/effect description for an ability
src/Domain/AbilityCatalog.php Curated catalog of the three definitions
src/Domain/UnitState.php MODIFY — carry ability ids, archetype
src/Domain/ObjectiveMarker.php Position + key + owner team id (or null)
src/Domain/ObjectiveControl.php Per-team per-round control tally
src/Domain/VictoryCondition.php Enum: Eliminate, HoldObjective
src/Domain/DeploymentZone.php List<Position>, bounded by battlefield
src/Domain/Scenario.php Teams + battlefield + objectives + victory settings
src/Domain/ScenarioValidator.php Returns list of validation issues
src/Domain/MatchState.php MODIFY — carry objective markers, objective control,
optional effects; no more hard-coded team ids
src/Domain/CombatEngine.php MODIFY — useAbility, generalized teams, objective
victory on endTurn, ability-uses-this-turn guard
src/Domain/CombatException.php unchanged
src/Domain/Battlefield.php unchanged
src/Domain/Position.php unchanged
src/Domain/Terrain.php unchanged
tests/Unit/Domain/ArchetypeCatalogTest.php Archetype catalog invariants
tests/Unit/Domain/AbilityCatalogTest.php Ability catalog invariants
tests/Unit/Domain/ArchetypeTemplateTest.php Stat bound behavior
tests/Unit/Domain/UnitStateTest.php MODIFY — ability list, archetype, buff flag
tests/Unit/Domain/MatchStateTest.php MODIFY — scenario-driven team ids, objective control
tests/Unit/Domain/CombatEngineTest.php MODIFY — new methods and general teams
tests/Unit/Domain/ScenarioTest.php Scenario invariants and startMatch snapshot
tests/Unit/Domain/ScenarioValidatorTest.php All validation rules and error messages
```
### Task 1: Model archetypes and the curated archetype catalog
**Files:**
- Create: `src/Domain/Archetype.php`
- Create: `src/Domain/ArchetypeTemplate.php`
- Create: `src/Domain/ArchetypeCatalog.php`
- Test: `tests/Unit/Domain/ArchetypeCatalogTest.php`
- Test: `tests/Unit/Domain/ArchetypeTemplateTest.php`
**Interfaces:**
- Consumes: none
- Produces:
- `enum Archetype: string { case Defender; case Striker; case Support; case Scout; }`
- `final readonly class ArchetypeTemplate` with public int fields `minHealth`, `maxHealth`, `minAttack`, `maxAttack`, `minDefense`, `maxDefense`, `minSpeed`, `maxSpeed`, and public `array $allowedAbilities` (list of `AbilityId` — forward-references the enum created in Task 2), plus a `defaultStats(): array` helper returning a `['maxHealth'=>int, 'attack'=>int, 'defense'=>int, 'speed'=>int]` associative array.
- `final class ArchetypeCatalog` with `public static function templates(): array` returning `array<string, ArchetypeTemplate>` keyed by `Archetype->value`.
- [ ] **Step 1: Write the failing archetype catalog test**
Create `tests/Unit/Domain/ArchetypeCatalogTest.php`:
```php
<?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()),
);
}
}
```
- [ ] **Step 2: Run the test to verify it fails**
Run: `vendor/bin/phpunit tests/Unit/Domain/ArchetypeCatalogTest.php`
Expected: FAIL because `BattleForge\Domain\ArchetypeCatalog` does not exist.
- [ ] **Step 3: Write the failing archetype template test**
Create `tests/Unit/Domain/ArchetypeTemplateTest.php`:
```php
<?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);
}
}
```
- [ ] **Step 4: Run the test to verify it fails**
Run: `vendor/bin/phpunit tests/Unit/Domain/ArchetypeTemplateTest.php`
Expected: FAIL because `BattleForge\Domain\ArchetypeTemplate` does not exist.
- [ ] **Step 5: Implement the archetype enum and template**
Create `src/Domain/Archetype.php`:
```php
<?php
declare(strict_types=1);
namespace BattleForge\Domain;
enum Archetype: string
{
case Defender = 'defender';
case Striker = 'striker';
case Support = 'support';
case Scout = 'scout';
}
```
Create `src/Domain/ArchetypeTemplate.php`:
```php
<?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),
];
}
}
```
- [ ] **Step 6: Implement the curated archetype catalog**
Create `src/Domain/ArchetypeCatalog.php`:
```php
<?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;
}
}
```
- [ ] **Step 7: Run the archetype tests**
Run: `vendor/bin/phpunit tests/Unit/Domain/ArchetypeCatalogTest.php tests/Unit/Domain/ArchetypeTemplateTest.php`
Expected: PASS, 7 tests.
- [ ] **Step 8: Run the full quality suite and commit**
Run: `composer check`
Expected: all checks green.
```powershell
git add src/Domain/Archetype.php src/Domain/ArchetypeTemplate.php src/Domain/ArchetypeCatalog.php tests/Unit/Domain/ArchetypeCatalogTest.php tests/Unit/Domain/ArchetypeTemplateTest.php
git commit -m "feat: catalog curated unit archetypes and templates"
```
### Task 2: Model ability identifiers, definitions, and the curated ability catalog
**Files:**
- Create: `src/Domain/AbilityId.php`
- Create: `src/Domain/AbilityDefinition.php`
- Create: `src/Domain/AbilityCatalog.php`
- Test: `tests/Unit/Domain/AbilityCatalogTest.php`
**Interfaces:**
- Consumes: `AbilityCatalog` is read by Tasks 3 and 6.
- Produces:
- `enum AbilityId: string { case Heal = 'heal'; case AreaDamage = 'area_damage'; case Buff = 'buff'; }`
- `final readonly class AbilityDefinition` with:
- `AbilityId $id`
- `string $label` (human-readable name used in the action log)
- `int $range` (max Manhattan distance from caster to target or center; 0 = self only)
- `int $areaRadius` (0 = single tile, ≥1 = includes tiles within that Manhattan radius; 0 for heal/buff)
- `string $target` — one of `self`, `ally`, `enemy`, `tile`
- `string $effect` — one of `restore_health`, `deal_damage`, `apply_buff`
- `int $effectValue` (numeric parameter; restore amount, base damage, or buff attack bonus)
- `final class AbilityCatalog` with `public static function definitions(): array` returning `array<string, AbilityDefinition>` keyed by `AbilityId->value`.
- [ ] **Step 1: Write the failing ability catalog test**
Create `tests/Unit/Domain/AbilityCatalogTest.php`:
```php
<?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);
}
}
```
- [ ] **Step 2: Run the test to verify it fails**
Run: `vendor/bin/phpunit tests/Unit/Domain/AbilityCatalogTest.php`
Expected: FAIL because `BattleForge\Domain\AbilityCatalog` does not exist.
- [ ] **Step 3: Implement the ability enum, definition, and catalog**
Create `src/Domain/AbilityId.php`:
```php
<?php
declare(strict_types=1);
namespace BattleForge\Domain;
enum AbilityId: string
{
case Heal = 'heal';
case AreaDamage = 'area_damage';
case Buff = 'buff';
}
```
Create `src/Domain/AbilityDefinition.php`:
```php
<?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}.");
}
}
}
```
Create `src/Domain/AbilityCatalog.php`:
```php
<?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;
}
}
```
- [ ] **Step 4: Run the ability tests**
Run: `vendor/bin/phpunit tests/Unit/Domain/AbilityCatalogTest.php`
Expected: PASS, 4 tests.
- [ ] **Step 5: Run the quality suite and commit**
Run: `composer check`
Expected: all checks green.
```powershell
git add src/Domain/AbilityId.php src/Domain/AbilityDefinition.php src/Domain/AbilityCatalog.php tests/Unit/Domain/AbilityCatalogTest.php
git commit -m "feat: catalog curated abilities and definitions"
```
### Task 3: Extend UnitState with archetype, ability list, and buff flag
**Files:**
- Modify: `src/Domain/UnitState.php`
- Modify: `tests/Unit/Domain/UnitStateTest.php`
**Interfaces:**
- Consumes: `Archetype`, `ArchetypeCatalog`, `AbilityId`, `AbilityCatalog`.
- Produces:
- `UnitState` now carries:
- `Archetype $archetype`
- `array $abilities` (list of `AbilityId->value` strings)
- `int $attackBonus` (default 0, set by `withAttackBonus()` for the buff effect)
- `bool $hasUsedAbility` (default false; reset by `startTurn()`)
- New public methods:
- `withAttackBonus(int $bonus): self` — returns a copy with `$attackBonus` set; rejects negative values.
- `spendAbility(): self` — decreases `actionsRemaining` and sets `hasUsedAbility = true`; rejects if `hasUsedAbility` already true.
- `allowedAbilities(): array` — returns the template's `allowedAbilities` from the catalog.
- [ ] **Step 1: Add failing tests to `UnitStateTest`**
Append these test methods inside `final class UnitStateTest` in `tests/Unit/Domain/UnitStateTest.php`, immediately before the closing brace of the class:
```php
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 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);
}
```
Append a corresponding `$attackBonus = 0, bool $hasUsedAbility = false` parameter to the existing `private function unit(...)` helper so the existing tests still pass and the new tests can pass values; its full body becomes:
```php
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,
);
}
```
- [ ] **Step 2: Run the tests to verify they fail**
Run: `vendor/bin/phpunit tests/Unit/Domain/UnitStateTest.php`
Expected: FAIL because the new constructor parameters do not exist.
- [ ] **Step 3: Extend `UnitState` with the new fields and methods**
Replace `src/Domain/UnitState.php` with:
```php
<?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) {
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 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);
}
}
```
- [ ] **Step 4: Run the unit state tests**
Run: `vendor/bin/phpunit tests/Unit/Domain/UnitStateTest.php`
Expected: PASS, all tests.
- [ ] **Step 5: Run the full quality suite and commit**
Run: `composer check`
Expected: all checks green. The other domain tests should still pass because every new property has a default value.
```powershell
git add src/Domain/UnitState.php tests/Unit/Domain/UnitStateTest.php
git commit -m "feat: track archetype, abilities, and buff on unit state"
```
### Task 4: Model objectives, victory conditions, and deployment zones
**Files:**
- Create: `src/Domain/ObjectiveMarker.php`
- Create: `src/Domain/ObjectiveControl.php`
- Create: `src/Domain/VictoryCondition.php`
- Create: `src/Domain/DeploymentZone.php`
**Interfaces:**
- Consumes: `Position`, `Battlefield`.
- Produces:
- `final readonly class ObjectiveMarker` with `string $id`, `Position $position`. Keyed by id, used as the unit of "which tile" an objective sits on.
- `enum VictoryCondition: string { case EliminateAll = 'eliminate_all'; case HoldObjective = 'hold_objective'; }`
- `final readonly class DeploymentZone` with `string $teamId` and `array $positions` (list of `Position`). Constructed from a team id and a list of positions; rejects empty lists.
- `final readonly class ObjectiveControl` with `array $roundsByTeam` (map of teamId → int, the number of full rounds that team controlled the objective) and a `recordRound(string $controllerTeamId): self` method that increments that team's tally and returns a new instance.
- [ ] **Step 1: Implement the four new types**
Create `src/Domain/ObjectiveMarker.php`:
```php
<?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;
}
}
```
Create `src/Domain/VictoryCondition.php`:
```php
<?php
declare(strict_types=1);
namespace BattleForge\Domain;
enum VictoryCondition: string
{
case EliminateAll = 'eliminate_all';
case HoldObjective = 'hold_objective';
}
```
Create `src/Domain/DeploymentZone.php`:
```php
<?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)) {
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;
}
}
```
Create `src/Domain/ObjectiveControl.php`:
```php
<?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<string, int> */
public function forTeam(string $teamId): array
{
return ['teamId' => $teamId, 'rounds' => $this->roundsByTeam[$teamId] ?? 0];
}
}
```
- [ ] **Step 2: Run the full quality suite**
Run: `composer check`
Expected: all checks still green. The new types are unused so far but compile cleanly.
- [ ] **Step 3: Commit**
```powershell
git add src/Domain/ObjectiveMarker.php src/Domain/ObjectiveControl.php src/Domain/VictoryCondition.php src/Domain/DeploymentZone.php
git commit -m "feat: model objectives, deployment zones, and victory conditions"
```
### Task 5: Model the Scenario aggregate and `startMatch` snapshot
**Files:**
- Create: `src/Domain/Scenario.php`
- Test: `tests/Unit/Domain/ScenarioTest.php`
**Interfaces:**
- Consumes: `Battlefield`, `UnitState`, `Archetype`, `DeploymentZone`, `ObjectiveMarker`, `VictoryCondition`.
- Produces:
- `final readonly class Scenario` with:
- `string $id`
- `string $name`
- `Battlefield $battlefield`
- `array $units` (list of `UnitState`)
- `array $deploymentZones` (map of teamId → `DeploymentZone`)
- `array $objectives` (map of objectiveId → `ObjectiveMarker`)
- `VictoryCondition $victoryCondition`
- `int $holdRoundsRequired` (only meaningful for `HoldObjective`, must be ≥ 1)
- `startMatch(string $activeTeamId): MatchState` — returns a `MatchState` whose units are clones of `$this->units` with `actionsRemaining = 2`, `hasAttacked = false`, `attackBonus = 0`, `hasUsedAbility = false`, and whose objective control map is empty.
- The constructor enforces:
- 36 units per team, exactly two teams
- Each unit's archetype is in the catalog
- Each unit's stats fall within its archetype template's bounds
- Each unit's abilities are listed in its archetype's `allowedAbilities`
- Each unit sits inside its team's deployment zone
- If `victoryCondition = HoldObjective`, exactly one objective is placed; if `EliminateAll`, zero objectives
- [ ] **Step 1: Write the failing scenario test**
Create `tests/Unit/Domain/ScenarioTest.php`:
```php
<?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\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))],
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']);
$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, $bravo, $bravo2, $bravo3],
deploymentZones: [
'alpha' => new DeploymentZone('alpha', [new Position(0, 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(0, 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, $bravo, $bravo2, $bravo3],
deploymentZones: [
'alpha' => new DeploymentZone('alpha', [new Position(0, 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);
}
/** @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,
);
}
private function unit(
string $id,
string $team,
Position $position,
Archetype $archetype = Archetype::Defender,
array $abilities = [],
int $attack = 4,
int $defense = 2,
int $speed = 4,
int $maxHealth = 10,
): UnitState {
return new UnitState(
$id,
$team,
$position,
$maxHealth,
$maxHealth,
$attack,
$defense,
$speed,
2,
false,
$archetype,
$abilities,
0,
false,
);
}
}
```
- [ ] **Step 2: Run the scenario tests to verify they fail**
Run: `vendor/bin/phpunit tests/Unit/Domain/ScenarioTest.php`
Expected: FAIL because `BattleForge\Domain\Scenario` does not exist.
- [ ] **Step 3: Implement `Scenario`**
Create `src/Domain/Scenario.php`:
```php
<?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)) {
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.");
}
if (!ArchetypeCatalog::templates()[$unit->archetype->value] ?? false) {
throw new InvalidArgumentException("Unit {$unit->id} uses an unknown archetype.");
}
$template = ArchetypeCatalog::templates()[$unit->archetype->value];
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;
}
}
```
- [ ] **Step 4: Run the scenario tests to verify they fail (compile error expected)**
Run: `vendor/bin/phpunit tests/Unit/Domain/ScenarioTest.php`
Expected: FAIL with a compile error because `MatchState` does not yet accept the new `objectives`, `victoryCondition`, and `holdRoundsRequired` parameters.
- [ ] **Step 5: Extend `MatchState` to carry objectives and victory settings**
This task partially implements Task 6's `MatchState` changes. Replace `src/Domain/MatchState.php` with:
```php
<?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) {
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 (!is_string($teamId) || $teamId === '') {
throw new InvalidArgumentException('Objective control team id must be a non-empty string.');
}
if (!is_int($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 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,
);
}
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,
);
}
}
```
- [ ] **Step 6: Run the scenario tests**
Run: `vendor/bin/phpunit tests/Unit/Domain/ScenarioTest.php`
Expected: PASS, 7 tests.
- [ ] **Step 7: Run the full quality suite**
Run: `composer check`
Expected: all checks green. (Existing `MatchStateTest`, `BattlefieldTest`, and `UnitStateTest` continue to pass because every new parameter has a default value.)
- [ ] **Step 8: Commit**
```powershell
git add src/Domain/Scenario.php src/Domain/MatchState.php tests/Unit/Domain/ScenarioTest.php
git commit -m "feat: model scenarios and start match snapshots"
```
### Task 6: Generalize `CombatEngine` teams and add `useAbility` with range and area effects
**Files:**
- Modify: `src/Domain/CombatEngine.php`
- Modify: `tests/Unit/Domain/CombatEngineTest.php`
**Interfaces:**
- Consumes: `AbilityCatalog`, `AbilityDefinition`, `MatchState` (now carrying `objectives` and `victoryCondition`).
- Produces:
- `CombatEngine::useAbility(MatchState $match, string $unitId, string $abilityId, ?Position $target): MatchState`
- Validates: match active, unit belongs to active team, unit is alive, unit has actions remaining, unit has not yet used an ability this turn, ability is in the unit's archetype allowlist, ability is in the unit's `abilities` list, target is in range, target is the correct type for the ability (self, ally, enemy, tile), and the area tiles exist.
- Applies the effect:
- `restore_health`: heal the target unit up to its `maxHealth` by `effectValue` (does not overflow; never damages).
- `deal_damage`: for every enemy unit on a tile within `areaRadius` of the center (Manhattan distance), apply `max(1, effectValue - target.defense - terrainDefense)` damage; terrain defense still applies to area damage.
- `apply_buff`: target unit's `attackBonus` becomes `effectValue` (replacing any previous buff).
- Decrements `actionsRemaining` by 1 and sets `hasUsedAbility = true` on the caster.
- Appends a descriptive log entry.
- `CombatEngine::endTurn` generalized: it now resolves whichever victory condition is configured (`EliminateAll` keeps current behavior; `HoldObjective` tallies per-round control and declares a winner when a team's tally reaches `holdRoundsRequired`).
- `CombatEngine::attack` now also accounts for the attacker's `attackBonus` when computing damage.
- The hard-coded `alpha`/`bravo` requirement in `endTurn` is removed; team identity comes from `MatchState::units`.
- [ ] **Step 1: Write the failing ability tests**
Append the following tests inside `final class CombatEngineTest` in `tests/Unit/Domain/CombatEngineTest.php`, immediately before the closing brace of the class:
```php
public function testHealAbilityRestoresAlliedHealthAndConsumesOneAction(): void
{
$caster = new UnitState(
id: 'support-1',
teamId: 'alpha',
position: new Position(0, 0),
maxHealth: 8,
health: 8,
attack: 2,
defense: 2,
speed: 3,
actionsRemaining: 2,
hasAttacked: false,
archetype: Archetype::Support,
abilities: ['heal'],
attackBonus: 0,
hasUsedAbility: false,
);
$wounded = new UnitState(
id: 'alpha-1',
teamId: 'alpha',
position: new Position(0, 1),
maxHealth: 10,
health: 3,
attack: 4,
defense: 2,
speed: 4,
actionsRemaining: 2,
hasAttacked: false,
);
$bravo = $this->unit('bravo-1', 'bravo', new Position(7, 7));
$match = new MatchState(
new Battlefield(8, 8),
[$caster, $wounded, $bravo],
'alpha',
);
$next = (new CombatEngine())->useAbility($match, 'support-1', 'heal', new Position(0, 1));
self::assertSame(8, $next->unit('alpha-1')->health);
self::assertSame(1, $next->unit('support-1')->actionsRemaining);
self::assertTrue($next->unit('support-1')->hasUsedAbility);
self::assertSame(['support-1 used Heal on alpha-1 for 5'], $next->actionLog);
}
public function testAreaDamageAbilityHitsEveryEnemyInsideTheRadius(): void
{
$caster = new UnitState(
id: 'striker-1',
teamId: 'alpha',
position: new Position(0, 0),
maxHealth: 8,
health: 8,
attack: 5,
defense: 1,
speed: 3,
actionsRemaining: 2,
hasAttacked: false,
archetype: Archetype::Striker,
abilities: ['area_damage'],
attackBonus: 0,
hasUsedAbility: false,
);
$alpha = $this->unit('alpha-2', 'alpha', new Position(2, 0));
$bravoA = $this->unit('bravo-1', 'bravo', new Position(1, 1));
$bravoB = $this->unit('bravo-2', 'bravo', new Position(2, 1));
$bravoC = $this->unit('bravo-3', 'bravo', new Position(3, 1));
$bravoD = $this->unit('bravo-4', 'bravo', new Position(5, 5));
$match = new MatchState(
new Battlefield(8, 8),
[$caster, $alpha, $bravoA, $bravoB, $bravoC, $bravoD],
'alpha',
);
$next = (new CombatEngine())->useAbility($match, 'striker-1', 'area_damage', new Position(2, 1));
self::assertSame(8, $next->unit('bravo-1')->health);
self::assertSame(8, $next->unit('bravo-2')->health);
self::assertSame(8, $next->unit('bravo-3')->health);
self::assertSame(10, $next->unit('bravo-4')->health);
self::assertSame(10, $next->unit('alpha-2')->health);
self::assertSame(1, $next->unit('striker-1')->actionsRemaining);
}
public function testBuffAbilityReplacesTheAttackBonusOnAnAlly(): void
{
$caster = new UnitState(
id: 'support-1',
teamId: 'alpha',
position: new Position(0, 0),
maxHealth: 8,
health: 8,
attack: 2,
defense: 2,
speed: 3,
actionsRemaining: 2,
hasAttacked: false,
archetype: Archetype::Support,
abilities: ['buff'],
attackBonus: 0,
hasUsedAbility: false,
);
$alpha = $this->unit('alpha-2', 'alpha', new Position(0, 1), attackBonus: 1);
$bravo = $this->unit('bravo-1', 'bravo', new Position(7, 7));
$match = new MatchState(
new Battlefield(8, 8),
[$caster, $alpha, $bravo],
'alpha',
);
$next = (new CombatEngine())->useAbility($match, 'support-1', 'buff', new Position(0, 1));
self::assertSame(2, $next->unit('alpha-2')->attackBonus);
}
public function testUseAbilityRejectsAUnitThatHasAlreadyUsedOne(): void
{
$caster = new UnitState(
id: 'support-1',
teamId: 'alpha',
position: new Position(0, 0),
maxHealth: 8,
health: 8,
attack: 2,
defense: 2,
speed: 3,
actionsRemaining: 2,
hasAttacked: false,
archetype: Archetype::Support,
abilities: ['heal'],
attackBonus: 0,
hasUsedAbility: true,
);
$alpha = $this->unit('alpha-2', 'alpha', new Position(0, 1));
$bravo = $this->unit('bravo-1', 'bravo', new Position(7, 7));
$match = new MatchState(
new Battlefield(8, 8),
[$caster, $alpha, $bravo],
'alpha',
);
$this->expectException(CombatException::class);
$this->expectExceptionMessage('Unit has already used an ability this turn.');
(new CombatEngine())->useAbility($match, 'support-1', 'heal', new Position(0, 1));
}
public function testUseAbilityRejectsTargetsOutsideRange(): void
{
$caster = new UnitState(
id: 'support-1',
teamId: 'alpha',
position: new Position(0, 0),
maxHealth: 8,
health: 8,
attack: 2,
defense: 2,
speed: 3,
actionsRemaining: 2,
hasAttacked: false,
archetype: Archetype::Support,
abilities: ['heal'],
attackBonus: 0,
hasUsedAbility: false,
);
$alpha = $this->unit('alpha-2', 'alpha', new Position(5, 5));
$bravo = $this->unit('bravo-1', 'bravo', new Position(7, 7));
$match = new MatchState(
new Battlefield(8, 8),
[$caster, $alpha, $bravo],
'alpha',
);
$this->expectException(CombatException::class);
$this->expectExceptionMessage('Ability target is out of range.');
(new CombatEngine())->useAbility($match, 'support-1', 'heal', new Position(5, 5));
}
public function testUseAbilityRejectsAnAbilityTheArchetypeForbids(): void
{
$caster = new UnitState(
id: 'scout-1',
teamId: 'alpha',
position: new Position(0, 0),
maxHealth: 6,
health: 6,
attack: 3,
defense: 1,
speed: 5,
actionsRemaining: 2,
hasAttacked: false,
archetype: Archetype::Scout,
abilities: ['heal'],
attackBonus: 0,
hasUsedAbility: false,
);
$alpha = $this->unit('alpha-2', 'alpha', new Position(0, 1));
$bravo = $this->unit('bravo-1', 'bravo', new Position(7, 7));
$match = new MatchState(
new Battlefield(8, 8),
[$caster, $alpha, $bravo],
'alpha',
);
$this->expectException(CombatException::class);
$this->expectExceptionMessage('Unit does not know that ability.');
(new CombatEngine())->useAbility($match, 'scout-1', 'heal', new Position(0, 1));
}
```
- [ ] **Step 2: Run the new tests to verify they fail**
Run: `vendor/bin/phpunit tests/Unit/Domain/CombatEngineTest.php --filter Ability`
Expected: FAIL because `CombatEngine::useAbility` does not exist.
- [ ] **Step 3: Implement `useAbility` and update `attack`/`endTurn`**
Replace `src/Domain/CombatEngine.php` with:
```php
<?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);
foreach ($next->units as $unit) {
if ($unit->teamId !== $attacker->teamId && !$unit->isDefeated()) {
return $next;
}
}
return $next->withWinner($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->abilities, true)) {
throw new CombatException('Unit does not know that ability.');
}
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->copy(health: min(
$current->maxHealth,
$current->health + $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",
default => $affectedUnit['id'],
};
}
$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_values(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"],
);
$winner = $this->resolveObjectiveVictory($next);
if ($winner !== null) {
$next = $next->withWinner($winner);
}
return $next;
}
/**
* @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) {
foreach ([[1, 0], [-1, 0], [0, 1], [0, -1]] as $offset) {
$candidate = new Position($target->x + $offset[0], $target->y + $offset[1]);
if ($match->battlefield->contains($candidate) && $candidate->key() !== $target->key()) {
$tileKeys[] = $candidate->key();
}
}
}
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);
}
private function resolveObjectiveVictory(MatchState $match): ?string
{
if ($match->victoryCondition !== VictoryCondition::HoldObjective || $match->objectives === []) {
return null;
}
$objective = reset($match->objectives);
$controller = $this->objectiveController($match, $objective->position);
if ($controller === null) {
return null;
}
$control = (new ObjectiveControl($match->objectiveControl))->recordRound($controller);
$next = $match->withObjectiveControl($control->roundsByTeam);
if (($control->roundsByTeam[$controller] ?? 0) >= $next->holdRoundsRequired) {
return $controller;
}
return null;
}
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.');
}
}
private function assertCanAct(MatchState $match, UnitState $unit): void
{
if ($unit->teamId !== $match->activeTeamId) {
throw new CombatException("It is not this unit's turn.");
}
if ($unit->isDefeated()) {
throw new CombatException('Defeated units cannot act.');
}
if ($unit->actionsRemaining === 0) {
throw new CombatException('Unit has no actions remaining.');
}
}
}
```
- [ ] **Step 4: Run the new ability tests and the full suite**
Run: `vendor/bin/phpunit tests/Unit/Domain/CombatEngineTest.php`
Expected: all combat-engine tests pass, including the six new ability tests (heal, area damage, buff, already-used, out-of-range, and forbidden-ability).
Run: `composer check`
Expected: all checks green.
- [ ] **Step 5: Commit**
```powershell
git add src/Domain/CombatEngine.php tests/Unit/Domain/CombatEngineTest.php
git commit -m "feat: support curated abilities and objective victory"
```
### Task 7: Resolve objective control and the second victory condition
**Files:**
- Modify: `src/Domain/CombatEngine.php`
- Modify: `tests/Unit/Domain/CombatEngineTest.php`
**Interfaces:**
- Produces: `CombatEngine` now resolves `HoldObjective` at the end of a complete round (after `endTurn` advances back to the first team) by tallying one round of control for whichever team is solely standing on the objective. The winner is declared when the tally reaches `holdRoundsRequired`.
- [ ] **Step 1: Add failing objective-victory tests**
Append these tests inside `final class CombatEngineTest` in `tests/Unit/Domain/CombatEngineTest.php`, immediately before the closing brace of the class:
```php
public function testEndTurnTalliesObjectiveControlAfterAFullRound(): void
{
$alpha = $this->unit('alpha-1', 'alpha', new Position(4, 4));
$bravo = $this->unit('bravo-1', 'bravo', new Position(7, 7));
$match = new MatchState(
new Battlefield(8, 8),
[$alpha, $bravo],
'alpha',
objectives: ['objective-1' => new ObjectiveMarker('objective-1', new Position(4, 4))],
victoryCondition: VictoryCondition::HoldObjective,
holdRoundsRequired: 2,
);
$afterAlphaEnd = (new CombatEngine())->endTurn($match);
$afterBravoEnd = (new CombatEngine())->endTurn($afterAlphaEnd);
self::assertSame(['alpha' => 1], $afterAlphaEnd->objectiveControl);
self::assertSame(['alpha' => 1], $afterBravoEnd->objectiveControl);
self::assertNull($afterBravoEnd->winnerTeamId);
}
public function testEndTurnDeclaresWinnerWhenHoldRoundsReached(): void
{
$alpha = $this->unit('alpha-1', 'alpha', new Position(4, 4));
$bravo = $this->unit('bravo-1', 'bravo', new Position(7, 7));
$match = new MatchState(
new Battlefield(8, 8),
[$alpha, $bravo],
'alpha',
objectives: ['objective-1' => new ObjectiveMarker('objective-1', new Position(4, 4))],
victoryCondition: VictoryCondition::HoldObjective,
holdRoundsRequired: 1,
objectiveControl: ['alpha' => 0],
);
$afterAlphaEnd = (new CombatEngine())->endTurn($match);
$afterBravoEnd = (new CombatEngine())->endTurn($afterAlphaEnd);
self::assertSame('alpha', $afterBravoEnd->winnerTeamId);
self::assertSame(['alpha' => 1], $afterBravoEnd->objectiveControl);
}
public function testEndTurnDoesNotAwardControlWhenContested(): void
{
$alpha = $this->unit('alpha-1', 'alpha', new Position(0, 0));
$bravo = $this->unit('bravo-1', 'bravo', new Position(7, 7));
$match = new MatchState(
new Battlefield(8, 8),
[$alpha, $bravo],
'alpha',
objectives: ['objective-1' => new ObjectiveMarker('objective-1', new Position(4, 4))],
victoryCondition: VictoryCondition::HoldObjective,
holdRoundsRequired: 1,
);
$next = (new CombatEngine())->endTurn($match);
self::assertSame([], $next->objectiveControl);
self::assertNull($next->winnerTeamId);
}
```
- [ ] **Step 2: Run the new tests to verify they pass on the current engine**
Run: `vendor/bin/phpunit tests/Unit/Domain/CombatEngineTest.php --filter Objective`
Expected: PASS for all three tests. The contested-tile test asserts that control stays empty when neither team is on the objective, which is the only state the engine can encounter given `MatchState` rejects two living units sharing a tile.
- [ ] **Step 3: Run the full quality suite and commit**
Run: `composer check`
Expected: all checks green.
```powershell
git add src/Domain/CombatEngine.php tests/Unit/Domain/CombatEngineTest.php
git commit -m "feat: resolve objective control and objective victory"
```
### Task 8: Build the `ScenarioValidator`
**Files:**
- Create: `src/Domain/ScenarioValidator.php`
- Test: `tests/Unit/Domain/ScenarioValidatorTest.php`
**Interfaces:**
- Consumes: `Scenario`, `Battlefield`.
- Produces:
- `final class ScenarioValidator` with `public static function validate(Scenario $scenario): array` returning a `list<string>` of human-readable error messages (empty when the scenario is valid). Validation rules:
- Battlefield is square (already enforced by `Battlefield`).
- Each deployment zone's positions are non-overlapping across teams.
- Every objective (when present) lies on a tile reachable from at least one deployment zone of either team (a defender must be able to reach the objective, and an attacker must be able to reach the objective to contest it).
- Every objective is not on impassable terrain.
- Deployment zone positions are passable.
- [ ] **Step 1: Write the failing validator test**
Create `tests/Unit/Domain/ScenarioValidatorTest.php`:
```php
<?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\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)]),
'bravo' => new DeploymentZone('bravo', [new Position(1, 0), new Position(2, 0)]),
],
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')));
}
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, 2, 4, 2, false, Archetype::Defender),
new UnitState('alpha-2', 'alpha', new Position(1, 0), 10, 10, 4, 2, 4, 2, false, Archetype::Striker),
new UnitState('alpha-3', 'alpha', new Position(2, 0), 10, 10, 4, 2, 4, 2, false, Archetype::Support),
new UnitState('bravo-1', 'bravo', new Position(7, 7), 10, 10, 4, 2, 4, 2, false, Archetype::Defender),
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, 2, 4, 2, false, Archetype::Scout),
];
}
}
```
- [ ] **Step 2: Run the validator tests to verify they fail**
Run: `vendor/bin/phpunit tests/Unit/Domain/ScenarioValidatorTest.php`
Expected: FAIL because `BattleForge\Domain\ScenarioValidator` does not exist.
- [ ] **Step 3: Implement `ScenarioValidator`**
Create `src/Domain/ScenarioValidator.php`:
```php
<?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;
}
}
```
- [ ] **Step 4: Run the validator tests**
Run: `vendor/bin/phpunit tests/Unit/Domain/ScenarioValidatorTest.php`
Expected: PASS, 5 tests.
- [ ] **Step 5: Run the full quality suite and commit**
Run: `composer check`
Expected: all checks green.
```powershell
git add src/Domain/ScenarioValidator.php tests/Unit/Domain/ScenarioValidatorTest.php
git commit -m "feat: validate scenario deployment zones and objectives"
```
### Task 9: Add a `Scenario` and `ScenarioValidator` integration test
**Files:**
- Modify: `tests/Unit/Domain/ScenarioTest.php`
- Modify: `tests/Unit/Domain/ScenarioValidatorTest.php`
- [ ] **Step 1: Add an integration test that builds a full scenario and starts a match**
Append this test inside `final class ScenarioTest` in `tests/Unit/Domain/ScenarioTest.php`, immediately before the closing brace of the class:
```php
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), 10, 10, 4, 2, 4, 2, false, Archetype::Support, ['heal', 'buff']),
new UnitState('alpha-3', 'alpha', new Position(2, 0), 10, 10, 4, 2, 4, 2, false, Archetype::Defender),
new UnitState('bravo-1', 'bravo', new Position(7, 7), 10, 10, 4, 2, 4, 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, 2, 4, 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(4, 4))],
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);
}
```
- [ ] **Step 2: Run the integration test**
Run: `vendor/bin/phpunit tests/Unit/Domain/ScenarioTest.php --filter StartingAMatchWithAbilityAndObjective`
Expected: PASS.
- [ ] **Step 3: Run the full quality suite and commit**
Run: `composer check`
Expected: all checks green. The full test count should now be at least 105 tests (91 from Plan 1 + 14 new tests across the new test files plus expanded `CombatEngineTest`).
```powershell
git add tests/Unit/Domain/ScenarioTest.php
git commit -m "test: cover scenario to match flow with ability and objective"
```
### Task 10: Update CI plan to refresh PHPStan baseline
**Files:**
- Modify: `.github/workflows/ci.yml` (no change expected — already runs `composer check`)
- [ ] **Step 1: Verify CI still exercises the same commands**
The Plan 1 CI workflow runs `composer validate --strict`, `composer install --no-interaction --prefer-dist`, and `composer check`. This plan does not introduce new dependencies or scripts, so no CI change is required. Confirm by reading `.github/workflows/ci.yml` and verifying the steps match.
Run: `composer validate --strict`
Expected: Composer reports the manifest is valid.
- [ ] **Step 2: No code change is required; document the verification in the commit log**
If the workflow already exists and is unchanged, no commit is required. If a workflow change is required (it is not), follow Plan 1 Task 7's pattern.
## Completion Check
Run:
```powershell
composer validate --strict
composer check
git status --short
```
Expected:
- Composer reports a valid manifest.
- PHPCS reports no coding-standard violations.
- PHPStan reports no errors at level 6.
- PHPUnit passes at least 105 tests across the domain suite.
- Git reports no unexpected changes from this plan.
- Archetypes, abilities, objectives, deployment zones, scenario validation, and both victory conditions are exercised by the unit tests.
- The combat engine no longer references `alpha`/`bravo` by name; team identity is fully scenario-driven.
- The presentation layer still has no dependencies; `src/Domain` continues to import nothing outside its own namespace plus `InvalidArgumentException` / `SplQueue` from the standard library.