feat: track archetype, abilities, and buff on unit state

This commit is contained in:
Keith Solomon
2026-07-05 19:05:03 -05:00
parent 88149ab1cf
commit e70b60696c
2 changed files with 168 additions and 1 deletions
+65 -1
View File
@@ -8,6 +8,7 @@ use InvalidArgumentException;
final readonly class UnitState
{
/** @param list<string> $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<string> */
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);
}
}