feat: track archetype, abilities, and buff on unit state
This commit is contained in:
@@ -8,6 +8,7 @@ use InvalidArgumentException;
|
|||||||
|
|
||||||
final readonly class UnitState
|
final readonly class UnitState
|
||||||
{
|
{
|
||||||
|
/** @param list<string> $abilities */
|
||||||
public function __construct(
|
public function __construct(
|
||||||
public string $id,
|
public string $id,
|
||||||
public string $teamId,
|
public string $teamId,
|
||||||
@@ -19,6 +20,10 @@ final readonly class UnitState
|
|||||||
public int $speed,
|
public int $speed,
|
||||||
public int $actionsRemaining,
|
public int $actionsRemaining,
|
||||||
public bool $hasAttacked = false,
|
public bool $hasAttacked = false,
|
||||||
|
public Archetype $archetype = Archetype::Defender,
|
||||||
|
public array $abilities = [],
|
||||||
|
public int $attackBonus = 0,
|
||||||
|
public bool $hasUsedAbility = false,
|
||||||
) {
|
) {
|
||||||
if ($id === '') {
|
if ($id === '') {
|
||||||
throw new InvalidArgumentException('Unit id cannot be empty.');
|
throw new InvalidArgumentException('Unit id cannot be empty.');
|
||||||
@@ -47,6 +52,21 @@ final readonly class UnitState
|
|||||||
if ($actionsRemaining < 0 || $actionsRemaining > 2) {
|
if ($actionsRemaining < 0 || $actionsRemaining > 2) {
|
||||||
throw new InvalidArgumentException('Unit actions remaining must be between 0 and 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
|
public function isDefeated(): bool
|
||||||
@@ -54,6 +74,12 @@ final readonly class UnitState
|
|||||||
return $this->health === 0;
|
return $this->health === 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** @return list<string> */
|
||||||
|
public function allowedAbilities(): array
|
||||||
|
{
|
||||||
|
return ArchetypeCatalog::templates()[$this->archetype->value]->allowedAbilities;
|
||||||
|
}
|
||||||
|
|
||||||
public function moveTo(Position $position): self
|
public function moveTo(Position $position): self
|
||||||
{
|
{
|
||||||
return $this->copy(position: $position);
|
return $this->copy(position: $position);
|
||||||
@@ -68,6 +94,19 @@ final readonly class UnitState
|
|||||||
return $this->copy(actionsRemaining: $this->actionsRemaining - 1);
|
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
|
public function takeDamage(int $damage): self
|
||||||
{
|
{
|
||||||
return $this->copy(health: max(0, $this->health - max(0, $damage)));
|
return $this->copy(health: max(0, $this->health - max(0, $damage)));
|
||||||
@@ -78,13 +117,27 @@ final readonly class UnitState
|
|||||||
return $this->copy(hasAttacked: true);
|
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
|
public function startTurn(): self
|
||||||
{
|
{
|
||||||
if ($this->isDefeated()) {
|
if ($this->isDefeated()) {
|
||||||
return $this;
|
return $this;
|
||||||
}
|
}
|
||||||
|
|
||||||
return $this->copy(actionsRemaining: 2, hasAttacked: false);
|
return $this->copy(
|
||||||
|
actionsRemaining: 2,
|
||||||
|
hasAttacked: false,
|
||||||
|
attackBonus: 0,
|
||||||
|
hasUsedAbility: false,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
private function copy(
|
private function copy(
|
||||||
@@ -92,6 +145,8 @@ final readonly class UnitState
|
|||||||
?int $health = null,
|
?int $health = null,
|
||||||
?int $actionsRemaining = null,
|
?int $actionsRemaining = null,
|
||||||
?bool $hasAttacked = null,
|
?bool $hasAttacked = null,
|
||||||
|
?int $attackBonus = null,
|
||||||
|
?bool $hasUsedAbility = null,
|
||||||
): self {
|
): self {
|
||||||
return new self(
|
return new self(
|
||||||
$this->id,
|
$this->id,
|
||||||
@@ -104,6 +159,15 @@ final readonly class UnitState
|
|||||||
$this->speed,
|
$this->speed,
|
||||||
$actionsRemaining ?? $this->actionsRemaining,
|
$actionsRemaining ?? $this->actionsRemaining,
|
||||||
$hasAttacked ?? $this->hasAttacked,
|
$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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -132,10 +132,109 @@ final class UnitStateTest extends TestCase
|
|||||||
self::assertSame($unit, $unit->startTurn());
|
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(
|
private function unit(
|
||||||
int $health = 10,
|
int $health = 10,
|
||||||
int $actionsRemaining = 2,
|
int $actionsRemaining = 2,
|
||||||
bool $hasAttacked = false,
|
bool $hasAttacked = false,
|
||||||
|
int $attackBonus = 0,
|
||||||
|
bool $hasUsedAbility = false,
|
||||||
): UnitState {
|
): UnitState {
|
||||||
return new UnitState(
|
return new UnitState(
|
||||||
'unit-1',
|
'unit-1',
|
||||||
@@ -148,6 +247,10 @@ final class UnitStateTest extends TestCase
|
|||||||
4,
|
4,
|
||||||
$actionsRemaining,
|
$actionsRemaining,
|
||||||
$hasAttacked,
|
$hasAttacked,
|
||||||
|
archetype: \BattleForge\Domain\Archetype::Defender,
|
||||||
|
abilities: [],
|
||||||
|
attackBonus: $attackBonus,
|
||||||
|
hasUsedAbility: $hasUsedAbility,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user