diff --git a/src/Application/ScenarioSerializer.php b/src/Application/ScenarioSerializer.php index 49a95dc..fa4e4ff 100644 --- a/src/Application/ScenarioSerializer.php +++ b/src/Application/ScenarioSerializer.php @@ -209,8 +209,11 @@ final class ScenarioSerializer 'attack' => $unit->attack, 'defense' => $unit->defense, 'speed' => $unit->speed, + 'actionsRemaining' => $unit->actionsRemaining, + 'hasAttacked' => $unit->hasAttacked, 'archetype' => $unit->archetype->value, 'abilities' => $unit->abilities, + 'wadedThisTurn' => $unit->wadedThisTurn, ]; } @@ -239,12 +242,13 @@ final class ScenarioSerializer attack: (int) $row['attack'], defense: (int) $row['defense'], speed: (int) $row['speed'], - actionsRemaining: 2, - hasAttacked: false, + actionsRemaining: (int) ($row['actionsRemaining'] ?? 2), + hasAttacked: (bool) ($row['hasAttacked'] ?? false), archetype: $archetype, abilities: $abilities ?? [], attackBonus: 0, hasUsedAbility: false, + wadedThisTurn: (bool) ($row['wadedThisTurn'] ?? false), ); } diff --git a/src/Domain/CombatEngine.php b/src/Domain/CombatEngine.php index 88434fc..81a8591 100644 --- a/src/Domain/CombatEngine.php +++ b/src/Domain/CombatEngine.php @@ -38,11 +38,27 @@ final class CombatEngine throw new CombatException('Destination is not reachable.'); } - $movedUnit = $unit->moveTo($destination)->spendAction(); - $next = $match->withUnit($movedUnit); - $actionLog = [...$match->actionLog, "{$unit->id} moved to {$destination->key()}"]; + $moved = $unit->moveTo($destination); + $logParts = ["{$unit->id} moved to {$destination->key()}"]; - return $next->copy(actionLog: $actionLog); + $updatedUnit = $moved; + if ($match->battlefield->terrainAt($moved->position) === Terrain::Water) { + // Water entry ends the turn: spend both actions to zero them out, + // then flag the wade. (UnitState::copy is private, so chain public + // spenders instead of editing fields directly.) + $updatedUnit = $moved + ->spendAction() + ->spendAction() + ->withWaded(true); + $logParts[] = '— wading'; + } else { + $updatedUnit = $moved->spendAction(); + } + + $next = $match->withUnit($updatedUnit); + $next = $next->copy(actionLog: [...$next->actionLog, implode(' ', $logParts)]); + + return $this->checkVictoryAfterAction($next, $unit->teamId); } public function attack(MatchState $match, string $attackerId, string $targetId): MatchState diff --git a/src/Domain/Scenario.php b/src/Domain/Scenario.php index 9d9efb6..3fa9410 100644 --- a/src/Domain/Scenario.php +++ b/src/Domain/Scenario.php @@ -129,6 +129,7 @@ final readonly class Scenario $unit->abilities, 0, false, + false, ); } diff --git a/src/Domain/Terrain.php b/src/Domain/Terrain.php index ebb8e67..c0b2a04 100644 --- a/src/Domain/Terrain.php +++ b/src/Domain/Terrain.php @@ -17,7 +17,8 @@ enum Terrain: string return match ($this) { self::Open => 1, self::Forest, self::Rough => 2, - self::Water, self::Blocking => null, + self::Water => 3, + self::Blocking => null, }; } diff --git a/src/Domain/UnitState.php b/src/Domain/UnitState.php index 04f1dc0..2d78630 100644 --- a/src/Domain/UnitState.php +++ b/src/Domain/UnitState.php @@ -24,6 +24,7 @@ final readonly class UnitState public array $abilities = [], public int $attackBonus = 0, public bool $hasUsedAbility = false, + public bool $wadedThisTurn = false, ) { if ($id === '') { throw new InvalidArgumentException('Unit id cannot be empty.'); @@ -135,6 +136,11 @@ final readonly class UnitState return $this->copy(attackBonus: $bonus); } + public function withWaded(bool $waded): self + { + return $this->copy(wadedThisTurn: $waded); + } + public function startTurn(): self { if ($this->isDefeated()) { @@ -146,6 +152,7 @@ final readonly class UnitState hasAttacked: false, attackBonus: 0, hasUsedAbility: false, + wadedThisTurn: false, ); } @@ -156,6 +163,7 @@ final readonly class UnitState ?bool $hasAttacked = null, ?int $attackBonus = null, ?bool $hasUsedAbility = null, + ?bool $wadedThisTurn = null, ): self { return new self( $this->id, @@ -172,6 +180,7 @@ final readonly class UnitState $this->abilities, $attackBonus ?? $this->attackBonus, $hasUsedAbility ?? $this->hasUsedAbility, + wadedThisTurn: $wadedThisTurn ?? $this->wadedThisTurn, ); } diff --git a/tests/Integration/PostMatchActionTest.php b/tests/Integration/PostMatchActionTest.php index 12eb824..f25b8e2 100644 --- a/tests/Integration/PostMatchActionTest.php +++ b/tests/Integration/PostMatchActionTest.php @@ -249,6 +249,59 @@ final class PostMatchActionTest extends TestCase self::assertSame(409, $response->status); } + public function testMoveIntoWaterEndsTurn(): void + { + // Build a match where alpha-1 is on dry ground next to a water tile; + // alpha-2 and alpha-3 are parked out of the way so the move path is + // clear, and the move target (0,1) becomes a water tile whose cost (3) + // matches alpha-1's speed budget. + $match = $this->freshMatch(); + $matchArray = ScenarioSerializer::matchToArray($match); + $matchArray['units'][0]['position'] = ['x' => 0, 'y' => 0]; + $matchArray['units'][0]['speed'] = 3; + $matchArray['units'][1]['position'] = ['x' => 0, 'y' => 3]; // alpha-2 clear of alpha-1's path + $matchArray['units'][2]['position'] = ['x' => 0, 'y' => 2]; // alpha-3 clear of alpha-1's path + $matchArray['units'][3]['position'] = ['x' => 7, 'y' => 7]; // bravo-1 stays put + $matchArray['units'][4]['position'] = ['x' => 6, 'y' => 7]; // bravo-2 + $matchArray['units'][5]['position'] = ['x' => 5, 'y' => 7]; // bravo-3 + $matchArray['battlefield']['terrain'] = ['0:1' => 'water']; + $match = ScenarioSerializer::matchFromArray($matchArray); + + $token = $this->tokenFor($match); + [$csrf, $cookie] = CsrfToken::issue(self::SECRET); + + $response = (new PostMatchMove(self::SECRET))->handle( + $this->buildRequest( + match: $match, + csrf: $csrf, + turnToken: $token, + cookies: ['__csrf' => $cookie, '__csrf_token' => $token], + body: [ + 'matchId' => self::MATCH_ID, + 'match' => ScenarioSerializer::matchToArray($match), + 'unitId' => 'alpha-1', + 'x' => 0, + 'y' => 1, + ], + ), + [], + ); + + self::assertSame(200, $response->status); + + $body = json_decode($response->body, true); + $moved = null; + foreach ($body['match']['units'] as $u) { + if ($u['id'] === 'alpha-1') { + $moved = $u; + } + } + self::assertNotNull($moved); + self::assertSame(['x' => 0, 'y' => 1], $moved['position']); + self::assertSame(0, $moved['actionsRemaining'], 'wading ends the turn'); + self::assertTrue($moved['wadedThisTurn'], 'wade flag is set after water entry'); + } + public function testEndTurnHappyPathSwitchesTheActiveTeamAndIncrementsTheRoundAfterBothSides(): void { $match = $this->freshMatch(); diff --git a/tests/Unit/Domain/MatchStateTest.php b/tests/Unit/Domain/MatchStateTest.php index f22f634..80d5f35 100644 --- a/tests/Unit/Domain/MatchStateTest.php +++ b/tests/Unit/Domain/MatchStateTest.php @@ -56,7 +56,6 @@ final class MatchStateTest extends TestCase */ public static function impassableTerrainProvider(): iterable { - yield 'water' => [Terrain::Water]; yield 'blocking' => [Terrain::Blocking]; } diff --git a/tests/Unit/Domain/ScenarioValidatorTest.php b/tests/Unit/Domain/ScenarioValidatorTest.php index 27f4342..691fb42 100644 --- a/tests/Unit/Domain/ScenarioValidatorTest.php +++ b/tests/Unit/Domain/ScenarioValidatorTest.php @@ -28,7 +28,7 @@ final class ScenarioValidatorTest extends TestCase public function testItRejectsAnObjectiveOnImpassableTerrain(): void { $scenario = $this->validScenario( - terrain: ['4:4' => Terrain::Water], + terrain: ['4:4' => Terrain::Blocking], objectivePosition: new Position(4, 4), ); @@ -81,7 +81,7 @@ final class ScenarioValidatorTest extends TestCase public function testItRejectsDeploymentPositionsOnImpassableTerrain(): void { $units = $this->unitSet(); - $battlefield = new Battlefield(8, 8, ['0:0' => Terrain::Water]); + $battlefield = new Battlefield(8, 8, ['0:0' => Terrain::Blocking]); $scenario = new Scenario( id: 'demo', name: 'Demo', diff --git a/tests/Unit/Domain/TerrainTest.php b/tests/Unit/Domain/TerrainTest.php new file mode 100644 index 0000000..a135d08 --- /dev/null +++ b/tests/Unit/Domain/TerrainTest.php @@ -0,0 +1,40 @@ +movementCost()); + self::assertSame(0, Terrain::Open->defenseBonus()); + } + + public function testForestCostsTwoAndGivesOneDefense(): void + { + self::assertSame(2, Terrain::Forest->movementCost()); + self::assertSame(1, Terrain::Forest->defenseBonus()); + } + + public function testRoughCostsTwoAndGivesNoDefense(): void + { + self::assertSame(2, Terrain::Rough->movementCost()); + self::assertSame(0, Terrain::Rough->defenseBonus()); + } + + public function testWaterCostsThreeAndGivesNoDefense(): void + { + self::assertSame(3, Terrain::Water->movementCost()); + self::assertSame(0, Terrain::Water->defenseBonus()); + } + + public function testBlockingIsImpassable(): void + { + self::assertNull(Terrain::Blocking->movementCost()); + } +} diff --git a/tests/Unit/Domain/UnitStateTest.php b/tests/Unit/Domain/UnitStateTest.php index c28072c..eba8cad 100644 --- a/tests/Unit/Domain/UnitStateTest.php +++ b/tests/Unit/Domain/UnitStateTest.php @@ -229,12 +229,32 @@ final class UnitStateTest extends TestCase self::assertFalse($started->hasUsedAbility); } + public function testStartTurnResetsWadedFlag(): void + { + $unit = $this->unit(wadedThisTurn: true); + $started = $unit->startTurn(); + self::assertFalse($started->wadedThisTurn); + } + + public function testWithWadedSetsAndClearsFlag(): void + { + $unit = $this->unit(); + self::assertFalse($unit->wadedThisTurn); + + $wading = $unit->withWaded(true); + self::assertTrue($wading->wadedThisTurn); + + $dry = $wading->withWaded(false); + self::assertFalse($dry->wadedThisTurn); + } + private function unit( int $health = 10, int $actionsRemaining = 2, bool $hasAttacked = false, int $attackBonus = 0, bool $hasUsedAbility = false, + bool $wadedThisTurn = false, ): UnitState { return new UnitState( 'unit-1',