feat: model immutable combat state
This commit is contained in:
@@ -0,0 +1,80 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace BattleForge\Domain;
|
||||
|
||||
use InvalidArgumentException;
|
||||
|
||||
final readonly class MatchState
|
||||
{
|
||||
/**
|
||||
* @param list<UnitState> $units
|
||||
* @param list<string> $actionLog
|
||||
*/
|
||||
public function __construct(
|
||||
public Battlefield $battlefield,
|
||||
public array $units,
|
||||
public string $activeTeamId,
|
||||
public int $round = 1,
|
||||
public ?string $winnerTeamId = null,
|
||||
public array $actionLog = [],
|
||||
) {
|
||||
$unitIds = [];
|
||||
|
||||
foreach ($units as $unit) {
|
||||
if (isset($unitIds[$unit->id])) {
|
||||
throw new InvalidArgumentException("Duplicate unit id: {$unit->id}.");
|
||||
}
|
||||
|
||||
$unitIds[$unit->id] = true;
|
||||
}
|
||||
}
|
||||
|
||||
public function unit(string $id): UnitState
|
||||
{
|
||||
foreach ($this->units as $unit) {
|
||||
if ($unit->id === $id) {
|
||||
return $unit;
|
||||
}
|
||||
}
|
||||
|
||||
throw new InvalidArgumentException("Unknown unit id: {$id}.");
|
||||
}
|
||||
|
||||
public function withUnit(UnitState $replacement): self
|
||||
{
|
||||
$units = $this->units;
|
||||
|
||||
foreach ($units as $index => $unit) {
|
||||
if ($unit->id === $replacement->id) {
|
||||
$units[$index] = $replacement;
|
||||
|
||||
return $this->copy(units: $units);
|
||||
}
|
||||
}
|
||||
|
||||
throw new InvalidArgumentException("Unknown unit id: {$replacement->id}.");
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<UnitState>|null $units
|
||||
* @param list<string>|null $actionLog
|
||||
*/
|
||||
public function copy(
|
||||
?array $units = null,
|
||||
?string $activeTeamId = null,
|
||||
?int $round = null,
|
||||
?string $winnerTeamId = null,
|
||||
?array $actionLog = null,
|
||||
): self {
|
||||
return new self(
|
||||
$this->battlefield,
|
||||
$units ?? $this->units,
|
||||
$activeTeamId ?? $this->activeTeamId,
|
||||
$round ?? $this->round,
|
||||
$winnerTeamId ?? $this->winnerTeamId,
|
||||
$actionLog ?? $this->actionLog,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace BattleForge\Domain;
|
||||
|
||||
use InvalidArgumentException;
|
||||
|
||||
final readonly class UnitState
|
||||
{
|
||||
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,
|
||||
) {
|
||||
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.');
|
||||
}
|
||||
}
|
||||
|
||||
public function isDefeated(): bool
|
||||
{
|
||||
return $this->health === 0;
|
||||
}
|
||||
|
||||
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 takeDamage(int $damage): self
|
||||
{
|
||||
return $this->copy(health: max(0, $this->health - max(0, $damage)));
|
||||
}
|
||||
|
||||
public function markAttacked(): self
|
||||
{
|
||||
return $this->copy(hasAttacked: true);
|
||||
}
|
||||
|
||||
public function startTurn(): self
|
||||
{
|
||||
if ($this->isDefeated()) {
|
||||
return $this;
|
||||
}
|
||||
|
||||
return $this->copy(actionsRemaining: 2, hasAttacked: false);
|
||||
}
|
||||
|
||||
private function copy(
|
||||
?Position $position = null,
|
||||
?int $health = null,
|
||||
?int $actionsRemaining = null,
|
||||
?bool $hasAttacked = 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,
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user