expectException(InvalidArgumentException::class); $createUnit(); } /** * @return iterable */ public static function invalidUnitProvider(): iterable { yield 'empty unit id' => [ static fn (): UnitState => new UnitState('', 'alpha', new Position(0, 0), 10, 10, 4, 2, 4, 2), ]; yield 'empty team id' => [ static fn (): UnitState => new UnitState('unit-1', '', new Position(0, 0), 10, 10, 4, 2, 4, 2), ]; yield 'zero maximum health' => [ static fn (): UnitState => new UnitState('unit-1', 'alpha', new Position(0, 0), 0, 0, 4, 2, 4, 2), ]; yield 'negative health' => [ static fn (): UnitState => new UnitState('unit-1', 'alpha', new Position(0, 0), 10, -1, 4, 2, 4, 2), ]; yield 'health above maximum' => [ static fn (): UnitState => new UnitState('unit-1', 'alpha', new Position(0, 0), 10, 11, 4, 2, 4, 2), ]; yield 'negative attack' => [ static fn (): UnitState => new UnitState('unit-1', 'alpha', new Position(0, 0), 10, 10, -1, 2, 4, 2), ]; yield 'negative defense' => [ static fn (): UnitState => new UnitState('unit-1', 'alpha', new Position(0, 0), 10, 10, 4, -1, 4, 2), ]; yield 'zero speed' => [ static fn (): UnitState => new UnitState('unit-1', 'alpha', new Position(0, 0), 10, 10, 4, 2, 0, 2), ]; yield 'negative actions remaining' => [ static fn (): UnitState => new UnitState('unit-1', 'alpha', new Position(0, 0), 10, 10, 4, 2, 4, -1), ]; yield 'too many actions remaining' => [ static fn (): UnitState => new UnitState('unit-1', 'alpha', new Position(0, 0), 10, 10, 4, 2, 4, 3), ]; } public function testItReportsWhetherItIsDefeated(): void { self::assertTrue($this->unit(health: 0)->isDefeated()); self::assertFalse($this->unit(health: 1)->isDefeated()); } public function testMovingReturnsAChangedCopy(): void { $unit = $this->unit(); $moved = $unit->moveTo(new Position(2, 3)); self::assertNotSame($unit, $moved); self::assertSame('0:0', $unit->position->key()); self::assertSame('2:3', $moved->position->key()); } public function testSpendingAnActionReturnsAChangedCopyAndRejectsExhaustedUnits(): void { $unit = $this->unit(actionsRemaining: 1); $spent = $unit->spendAction(); self::assertSame(1, $unit->actionsRemaining); self::assertSame(0, $spent->actionsRemaining); $this->expectException(InvalidArgumentException::class); $spent->spendAction(); } public function testDamageCannotReduceHealthBelowZero(): void { $unit = $this->unit(health: 3); $damaged = $unit->takeDamage(5); self::assertSame(3, $unit->health); self::assertSame(0, $damaged->health); } public function testNegativeDamageIsANoOp(): void { $unit = $this->unit(health: 7); $damaged = $unit->takeDamage(-3); self::assertNotSame($unit, $damaged); self::assertSame(7, $damaged->health); } public function testMarkingAttackedReturnsAChangedCopy(): void { $unit = $this->unit(); $attacked = $unit->markAttacked(); self::assertFalse($unit->hasAttacked); self::assertTrue($attacked->hasAttacked); } public function testStartingTurnResetsALivingUnit(): void { $unit = $this->unit(actionsRemaining: 0, hasAttacked: true); $started = $unit->startTurn(); self::assertNotSame($unit, $started); self::assertSame(2, $started->actionsRemaining); self::assertFalse($started->hasAttacked); } public function testStartingTurnReturnsTheSameDefeatedUnit(): void { $unit = $this->unit(health: 0, actionsRemaining: 0, hasAttacked: true); self::assertSame($unit, $unit->startTurn()); } public function testItCarriesArchetypeAbilitiesAndBonus(): void { $unit = new UnitState( id: 'unit-1', teamId: 'alpha', position: new Position(0, 0), maxHealth: 10, health: 10, attack: 4, defense: 2, speed: 4, actionsRemaining: 2, hasAttacked: false, archetype: \BattleForge\Domain\Archetype::Support, abilities: ['heal', 'buff'], attackBonus: 2, hasUsedAbility: false, ); self::assertSame(\BattleForge\Domain\Archetype::Support, $unit->archetype); self::assertSame(['heal', 'buff'], $unit->abilities); self::assertSame(2, $unit->attackBonus); self::assertFalse($unit->hasUsedAbility); } public function testItRejectsEmptyStringAbilities(): void { $this->expectException(InvalidArgumentException::class); $this->expectExceptionMessage('Unit abilities must be non-empty strings.'); new UnitState( id: 'unit-1', teamId: 'alpha', position: new Position(0, 0), maxHealth: 10, health: 10, attack: 4, defense: 2, speed: 4, actionsRemaining: 2, hasAttacked: false, archetype: \BattleForge\Domain\Archetype::Defender, abilities: [''], ); } public function testItExposesAllowedAbilitiesFromTheArchetypeCatalog(): void { $unit = $this->unit(); self::assertSame( \BattleForge\Domain\ArchetypeCatalog::templates()[$unit->archetype->value]->allowedAbilities, $unit->allowedAbilities(), ); } public function testWithAttackBonusReturnsAChangedCopyAndRejectsNegativeBonus(): void { $unit = $this->unit(); $buffed = $unit->withAttackBonus(3); self::assertNotSame($unit, $buffed); self::assertSame(0, $unit->attackBonus); self::assertSame(3, $buffed->attackBonus); $this->expectException(\InvalidArgumentException::class); $unit->withAttackBonus(-1); } public function testSpendAbilityMarksAndConsumesOneActionButNotTwo(): void { $unit = $this->unit(); $spent = $unit->spendAbility(); self::assertSame(1, $spent->actionsRemaining); self::assertTrue($spent->hasUsedAbility); } public function testSpendAbilityRejectsAUnitThatAlreadyUsedAnAbility(): void { $unit = $this->unit(hasUsedAbility: true); $this->expectException(\InvalidArgumentException::class); $unit->spendAbility(); } public function testStartTurnClearsAttackBonusAndAbilityUse(): void { $unit = $this->unit(actionsRemaining: 0, hasAttacked: true, attackBonus: 3, hasUsedAbility: true); $started = $unit->startTurn(); self::assertSame(2, $started->actionsRemaining); self::assertFalse($started->hasAttacked); self::assertSame(0, $started->attackBonus); self::assertFalse($started->hasUsedAbility); } private function unit( int $health = 10, int $actionsRemaining = 2, bool $hasAttacked = false, int $attackBonus = 0, bool $hasUsedAbility = false, ): UnitState { return new UnitState( 'unit-1', 'alpha', new Position(0, 0), 10, $health, 4, 2, 4, $actionsRemaining, $hasAttacked, archetype: \BattleForge\Domain\Archetype::Defender, abilities: [], attackBonus: $attackBonus, hasUsedAbility: $hasUsedAbility, ); } }