feat: model immutable combat state

This commit is contained in:
Keith Solomon
2026-07-04 15:23:00 -05:00
parent 4421c5ae32
commit 0c5e824942
3 changed files with 221 additions and 0 deletions
+80
View File
@@ -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,
);
}
}
+109
View File
@@ -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,
);
}
}