102 KiB
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:
- Foundation and combat core — completed.
- Curated content, abilities, objectives, and scenario validation — this plan.
- Anonymous-browser persistence, secure image handling, and scenario editors.
- 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
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 ArchetypeTemplatewith public int fieldsminHealth,maxHealth,minAttack,maxAttack,minDefense,maxDefense,minSpeed,maxSpeed, and publicarray $allowedAbilities(list ofAbilityId— forward-references the enum created in Task 2), plus adefaultStats(): arrayhelper returning a['maxHealth'=>int, 'attack'=>int, 'defense'=>int, 'speed'=>int]associative array.final class ArchetypeCatalogwithpublic static function templates(): arrayreturningarray<string, ArchetypeTemplate>keyed byArchetype->value.
-
Step 1: Write the failing archetype catalog test
Create tests/Unit/Domain/ArchetypeCatalogTest.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
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
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
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
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.
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:
AbilityCatalogis read by Tasks 3 and 6. -
Produces:
enum AbilityId: string { case Heal = 'heal'; case AreaDamage = 'area_damage'; case Buff = 'buff'; }final readonly class AbilityDefinitionwith:AbilityId $idstring $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 ofself,ally,enemy,tilestring $effect— one ofrestore_health,deal_damage,apply_buffint $effectValue(numeric parameter; restore amount, base damage, or buff attack bonus)
final class AbilityCatalogwithpublic static function definitions(): arrayreturningarray<string, AbilityDefinition>keyed byAbilityId->value.
-
Step 1: Write the failing ability catalog test
Create tests/Unit/Domain/AbilityCatalogTest.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
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
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
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.
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:
UnitStatenow carries:Archetype $archetypearray $abilities(list ofAbilityId->valuestrings)int $attackBonus(default 0, set bywithAttackBonus()for the buff effect)bool $hasUsedAbility(default false; reset bystartTurn())
- New public methods:
withAttackBonus(int $bonus): self— returns a copy with$attackBonusset; rejects negative values.spendAbility(): self— decreasesactionsRemainingand setshasUsedAbility = true; rejects ifhasUsedAbilityalready true.allowedAbilities(): array— returns the template'sallowedAbilitiesfrom 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:
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:
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
UnitStatewith the new fields and methods
Replace src/Domain/UnitState.php with:
<?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.
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 ObjectiveMarkerwithstring $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 DeploymentZonewithstring $teamIdandarray $positions(list ofPosition). Constructed from a team id and a list of positions; rejects empty lists.final readonly class ObjectiveControlwitharray $roundsByTeam(map of teamId → int, the number of full rounds that team controlled the objective) and arecordRound(string $controllerTeamId): selfmethod that increments that team's tally and returns a new instance.
-
Step 1: Implement the four new types
Create src/Domain/ObjectiveMarker.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
declare(strict_types=1);
namespace BattleForge\Domain;
enum VictoryCondition: string
{
case EliminateAll = 'eliminate_all';
case HoldObjective = 'hold_objective';
}
Create src/Domain/DeploymentZone.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
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
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 Scenariowith:string $idstring $nameBattlefield $battlefieldarray $units(list ofUnitState)array $deploymentZones(map of teamId →DeploymentZone)array $objectives(map of objectiveId →ObjectiveMarker)VictoryCondition $victoryConditionint $holdRoundsRequired(only meaningful forHoldObjective, must be ≥ 1)startMatch(string $activeTeamId): MatchState— returns aMatchStatewhose units are clones of$this->unitswithactionsRemaining = 2,hasAttacked = false,attackBonus = 0,hasUsedAbility = false, and whose objective control map is empty.
- The constructor enforces:
- 3–6 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; ifEliminateAll, zero objectives
-
Step 1: Write the failing scenario test
Create tests/Unit/Domain/ScenarioTest.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
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
MatchStateto carry objectives and victory settings
This task partially implements Task 6's MatchState changes. Replace src/Domain/MatchState.php with:
<?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
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 carryingobjectivesandvictoryCondition). -
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
abilitieslist, 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 itsmaxHealthbyeffectValue(does not overflow; never damages).deal_damage: for every enemy unit on a tile withinareaRadiusof the center (Manhattan distance), applymax(1, effectValue - target.defense - terrainDefense)damage; terrain defense still applies to area damage.apply_buff: target unit'sattackBonusbecomeseffectValue(replacing any previous buff).
- Decrements
actionsRemainingby 1 and setshasUsedAbility = trueon the caster. - Appends a descriptive log entry.
- 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
CombatEngine::endTurngeneralized: it now resolves whichever victory condition is configured (EliminateAllkeeps current behavior;HoldObjectivetallies per-round control and declares a winner when a team's tally reachesholdRoundsRequired).CombatEngine::attacknow also accounts for the attacker'sattackBonuswhen computing damage.- The hard-coded
alpha/bravorequirement inendTurnis removed; team identity comes fromMatchState::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:
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
useAbilityand updateattack/endTurn
Replace src/Domain/CombatEngine.php with:
<?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
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:
CombatEnginenow resolvesHoldObjectiveat the end of a complete round (afterendTurnadvances 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 reachesholdRoundsRequired. -
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:
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.
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 ScenarioValidatorwithpublic static function validate(Scenario $scenario): arrayreturning alist<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.
- Battlefield is square (already enforced by
-
Step 1: Write the failing validator test
Create tests/Unit/Domain/ScenarioValidatorTest.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
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.
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:
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).
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 runscomposer 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:
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/bravoby name; team identity is fully scenario-driven. - The presentation layer still has no dependencies;
src/Domaincontinues to import nothing outside its own namespace plusInvalidArgumentException/SplQueuefrom the standard library.