183 lines
5.3 KiB
PHP
183 lines
5.3 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace BattleForge\Domain;
|
|
|
|
use InvalidArgumentException;
|
|
|
|
final readonly class UnitState
|
|
{
|
|
/** @param list<string> $abilities */
|
|
public function __construct(
|
|
public string $id,
|
|
public string $teamId,
|
|
public Position $position,
|
|
public int $maxHealth,
|
|
public int $health,
|
|
public int $attack,
|
|
public int $defense,
|
|
public int $speed,
|
|
public int $actionsRemaining,
|
|
public bool $hasAttacked = false,
|
|
public Archetype $archetype = Archetype::Defender,
|
|
public array $abilities = [],
|
|
public int $attackBonus = 0,
|
|
public bool $hasUsedAbility = false,
|
|
) {
|
|
if ($id === '') {
|
|
throw new InvalidArgumentException('Unit id cannot be empty.');
|
|
}
|
|
|
|
if ($teamId === '') {
|
|
throw new InvalidArgumentException('Unit team id cannot be empty.');
|
|
}
|
|
|
|
if ($maxHealth < 1) {
|
|
throw new InvalidArgumentException('Unit maximum health must be at least 1.');
|
|
}
|
|
|
|
if ($health < 0 || $health > $maxHealth) {
|
|
throw new InvalidArgumentException('Unit health must be between 0 and maximum health.');
|
|
}
|
|
|
|
if ($attack < 0 || $defense < 0) {
|
|
throw new InvalidArgumentException('Unit attack and defense cannot be negative.');
|
|
}
|
|
|
|
if ($speed < 1) {
|
|
throw new InvalidArgumentException('Unit speed must be at least 1.');
|
|
}
|
|
|
|
if ($actionsRemaining < 0 || $actionsRemaining > 2) {
|
|
throw new InvalidArgumentException('Unit actions remaining must be between 0 and 2.');
|
|
}
|
|
|
|
if ($attackBonus < 0) {
|
|
throw new InvalidArgumentException('Unit attack bonus cannot be negative.');
|
|
}
|
|
|
|
if (!self::isList($abilities)) {
|
|
throw new InvalidArgumentException('Unit abilities must be a list.');
|
|
}
|
|
|
|
foreach ($abilities as $ability) {
|
|
// @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<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 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);
|
|
}
|
|
}
|