$abilities */ public function __construct( public string $id, public string $teamId, public Position $position, public int $maxHealth, public int $health, public int $attack, public int $defense, public int $speed, public int $actionsRemaining, public bool $hasAttacked = false, public Archetype $archetype = Archetype::Defender, public array $abilities = [], public int $attackBonus = 0, public bool $hasUsedAbility = false, ) { if ($id === '') { throw new InvalidArgumentException('Unit id cannot be empty.'); } if ($teamId === '') { throw new InvalidArgumentException('Unit team id cannot be empty.'); } if ($maxHealth < 1) { throw new InvalidArgumentException('Unit maximum health must be at least 1.'); } if ($health < 0 || $health > $maxHealth) { throw new InvalidArgumentException('Unit health must be between 0 and maximum health.'); } if ($attack < 0 || $defense < 0) { throw new InvalidArgumentException('Unit attack and defense cannot be negative.'); } if ($speed < 1) { throw new InvalidArgumentException('Unit speed must be at least 1.'); } if ($actionsRemaining < 0 || $actionsRemaining > 2) { throw new InvalidArgumentException('Unit actions remaining must be between 0 and 2.'); } if ($attackBonus < 0) { throw new InvalidArgumentException('Unit attack bonus cannot be negative.'); } if (!self::isList($abilities)) { throw new InvalidArgumentException('Unit abilities must be a list.'); } foreach ($abilities as $ability) { // @phpstan-ignore function.alreadyNarrowedType (Runtime guard: PHPDoc is not a runtime contract for JSON-sourced arrays in Plan 4.) if (!is_string($ability) || $ability === '') { throw new InvalidArgumentException('Unit abilities must be non-empty strings.'); } } } public function isDefeated(): bool { return $this->health === 0; } /** @return list */ public function allowedAbilities(): array { return ArchetypeCatalog::templates()[$this->archetype->value]->allowedAbilities; } public function moveTo(Position $position): self { return $this->copy(position: $position); } public function spendAction(): self { if ($this->actionsRemaining === 0) { throw new InvalidArgumentException('Unit has no actions remaining.'); } return $this->copy(actionsRemaining: $this->actionsRemaining - 1); } public function spendAbility(): self { if ($this->hasUsedAbility) { throw new InvalidArgumentException('Unit has already used an ability this turn.'); } if ($this->actionsRemaining === 0) { throw new InvalidArgumentException('Unit has no actions remaining.'); } return $this->copy(actionsRemaining: $this->actionsRemaining - 1, hasUsedAbility: true); } public function takeDamage(int $damage): self { return $this->copy(health: max(0, $this->health - max(0, $damage))); } public function restoreHealth(int $amount): self { if ($amount < 0) { throw new InvalidArgumentException('Restore health amount cannot be negative.'); } return $this->copy(health: min($this->maxHealth, $this->health + $amount)); } public function markAttacked(): self { return $this->copy(hasAttacked: true); } public function withAttackBonus(int $bonus): self { if ($bonus < 0) { throw new InvalidArgumentException('Attack bonus cannot be negative.'); } return $this->copy(attackBonus: $bonus); } public function startTurn(): self { if ($this->isDefeated()) { return $this; } return $this->copy( actionsRemaining: 2, hasAttacked: false, attackBonus: 0, hasUsedAbility: false, ); } private function copy( ?Position $position = null, ?int $health = null, ?int $actionsRemaining = null, ?bool $hasAttacked = null, ?int $attackBonus = null, ?bool $hasUsedAbility = null, ): self { return new self( $this->id, $this->teamId, $position ?? $this->position, $this->maxHealth, $health ?? $this->health, $this->attack, $this->defense, $this->speed, $actionsRemaining ?? $this->actionsRemaining, $hasAttacked ?? $this->hasAttacked, $this->archetype, $this->abilities, $attackBonus ?? $this->attackBonus, $hasUsedAbility ?? $this->hasUsedAbility, ); } private static function isList(mixed $value): bool { return is_array($value) && array_is_list($value); } }