From 0c5e8249428717b10c1c4d3460d0d577513b4a5c Mon Sep 17 00:00:00 2001 From: Keith Solomon Date: Sat, 4 Jul 2026 15:23:00 -0500 Subject: [PATCH] feat: model immutable combat state --- src/Domain/MatchState.php | 80 ++++++++++++++++++ src/Domain/UnitState.php | 109 +++++++++++++++++++++++++ tests/Unit/Domain/CombatEngineTest.php | 32 ++++++++ 3 files changed, 221 insertions(+) create mode 100644 src/Domain/MatchState.php create mode 100644 src/Domain/UnitState.php create mode 100644 tests/Unit/Domain/CombatEngineTest.php diff --git a/src/Domain/MatchState.php b/src/Domain/MatchState.php new file mode 100644 index 0000000..5542bac --- /dev/null +++ b/src/Domain/MatchState.php @@ -0,0 +1,80 @@ + $units + * @param list $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|null $units + * @param list|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, + ); + } +} diff --git a/src/Domain/UnitState.php b/src/Domain/UnitState.php new file mode 100644 index 0000000..76f8a1a --- /dev/null +++ b/src/Domain/UnitState.php @@ -0,0 +1,109 @@ + $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, + ); + } +} diff --git a/tests/Unit/Domain/CombatEngineTest.php b/tests/Unit/Domain/CombatEngineTest.php new file mode 100644 index 0000000..d700481 --- /dev/null +++ b/tests/Unit/Domain/CombatEngineTest.php @@ -0,0 +1,32 @@ +unit('unit-1', 'alpha', new Position(0, 0)); + $match = new MatchState(new Battlefield(8, 8), [$unit], 'alpha'); + + $moved = $unit->moveTo(new Position(1, 0))->spendAction(); + $next = $match->withUnit($moved); + + self::assertSame('0:0', $unit->position->key()); + self::assertSame('1:0', $next->unit('unit-1')->position->key()); + self::assertSame(1, $next->unit('unit-1')->actionsRemaining); + } + + private function unit(string $id, string $team, Position $position): UnitState + { + return new UnitState($id, $team, $position, 10, 10, 4, 2, 4, 2); + } +}