diff --git a/src/Domain/UnitState.php b/src/Domain/UnitState.php index 76f8a1a..5ae486c 100644 --- a/src/Domain/UnitState.php +++ b/src/Domain/UnitState.php @@ -8,6 +8,7 @@ use InvalidArgumentException; final readonly class UnitState { + /** @param list $abilities */ public function __construct( public string $id, public string $teamId, @@ -19,6 +20,10 @@ final readonly class UnitState 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.'); @@ -47,6 +52,21 @@ final readonly class UnitState if ($actionsRemaining < 0 || $actionsRemaining > 2) { throw new InvalidArgumentException('Unit actions remaining must be between 0 and 2.'); } + + if ($attackBonus < 0) { + throw new InvalidArgumentException('Unit attack bonus cannot be negative.'); + } + + if (!self::isList($abilities)) { + throw new InvalidArgumentException('Unit abilities must be a list.'); + } + + foreach ($abilities as $ability) { + // @phpstan-ignore function.alreadyNarrowedType (Runtime guard: PHPDoc is not a runtime contract for JSON-sourced arrays in Plan 4.) + if (!is_string($ability) || $ability === '') { + throw new InvalidArgumentException('Unit abilities must be non-empty strings.'); + } + } } public function isDefeated(): bool @@ -54,6 +74,12 @@ final readonly class UnitState return $this->health === 0; } + /** @return list */ + public function allowedAbilities(): array + { + return ArchetypeCatalog::templates()[$this->archetype->value]->allowedAbilities; + } + public function moveTo(Position $position): self { return $this->copy(position: $position); @@ -68,6 +94,19 @@ final readonly class UnitState 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))); @@ -78,13 +117,27 @@ final readonly class UnitState 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); + return $this->copy( + actionsRemaining: 2, + hasAttacked: false, + attackBonus: 0, + hasUsedAbility: false, + ); } private function copy( @@ -92,6 +145,8 @@ final readonly class UnitState ?int $health = null, ?int $actionsRemaining = null, ?bool $hasAttacked = null, + ?int $attackBonus = null, + ?bool $hasUsedAbility = null, ): self { return new self( $this->id, @@ -104,6 +159,15 @@ final readonly class UnitState $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); + } } diff --git a/tests/Unit/Domain/UnitStateTest.php b/tests/Unit/Domain/UnitStateTest.php index 0bf48f9..c28072c 100644 --- a/tests/Unit/Domain/UnitStateTest.php +++ b/tests/Unit/Domain/UnitStateTest.php @@ -132,10 +132,109 @@ final class UnitStateTest extends TestCase self::assertSame($unit, $unit->startTurn()); } + public function testItCarriesArchetypeAbilitiesAndBonus(): void + { + $unit = new UnitState( + id: 'unit-1', + teamId: 'alpha', + position: new Position(0, 0), + maxHealth: 10, + health: 10, + attack: 4, + defense: 2, + speed: 4, + actionsRemaining: 2, + hasAttacked: false, + archetype: \BattleForge\Domain\Archetype::Support, + abilities: ['heal', 'buff'], + attackBonus: 2, + hasUsedAbility: false, + ); + + self::assertSame(\BattleForge\Domain\Archetype::Support, $unit->archetype); + self::assertSame(['heal', 'buff'], $unit->abilities); + self::assertSame(2, $unit->attackBonus); + self::assertFalse($unit->hasUsedAbility); + } + + public function testItRejectsEmptyStringAbilities(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Unit abilities must be non-empty strings.'); + + new UnitState( + id: 'unit-1', + teamId: 'alpha', + position: new Position(0, 0), + maxHealth: 10, + health: 10, + attack: 4, + defense: 2, + speed: 4, + actionsRemaining: 2, + hasAttacked: false, + archetype: \BattleForge\Domain\Archetype::Defender, + abilities: [''], + ); + } + + public function testItExposesAllowedAbilitiesFromTheArchetypeCatalog(): void + { + $unit = $this->unit(); + + self::assertSame( + \BattleForge\Domain\ArchetypeCatalog::templates()[$unit->archetype->value]->allowedAbilities, + $unit->allowedAbilities(), + ); + } + + public function testWithAttackBonusReturnsAChangedCopyAndRejectsNegativeBonus(): void + { + $unit = $this->unit(); + $buffed = $unit->withAttackBonus(3); + + self::assertNotSame($unit, $buffed); + self::assertSame(0, $unit->attackBonus); + self::assertSame(3, $buffed->attackBonus); + + $this->expectException(\InvalidArgumentException::class); + $unit->withAttackBonus(-1); + } + + public function testSpendAbilityMarksAndConsumesOneActionButNotTwo(): void + { + $unit = $this->unit(); + $spent = $unit->spendAbility(); + + self::assertSame(1, $spent->actionsRemaining); + self::assertTrue($spent->hasUsedAbility); + } + + public function testSpendAbilityRejectsAUnitThatAlreadyUsedAnAbility(): void + { + $unit = $this->unit(hasUsedAbility: true); + + $this->expectException(\InvalidArgumentException::class); + $unit->spendAbility(); + } + + public function testStartTurnClearsAttackBonusAndAbilityUse(): void + { + $unit = $this->unit(actionsRemaining: 0, hasAttacked: true, attackBonus: 3, hasUsedAbility: true); + $started = $unit->startTurn(); + + self::assertSame(2, $started->actionsRemaining); + self::assertFalse($started->hasAttacked); + self::assertSame(0, $started->attackBonus); + self::assertFalse($started->hasUsedAbility); + } + private function unit( int $health = 10, int $actionsRemaining = 2, bool $hasAttacked = false, + int $attackBonus = 0, + bool $hasUsedAbility = false, ): UnitState { return new UnitState( 'unit-1', @@ -148,6 +247,10 @@ final class UnitStateTest extends TestCase 4, $actionsRemaining, $hasAttacked, + archetype: \BattleForge\Domain\Archetype::Defender, + abilities: [], + attackBonus: $attackBonus, + hasUsedAbility: $hasUsedAbility, ); } }