feat(combat): water traversal costs 3 and ends the turn

This commit is contained in:
Keith Solomon
2026-07-26 23:38:13 -05:00
parent fb06da3f28
commit 09f2bda924
10 changed files with 153 additions and 10 deletions
+6 -2
View File
@@ -209,8 +209,11 @@ final class ScenarioSerializer
'attack' => $unit->attack, 'attack' => $unit->attack,
'defense' => $unit->defense, 'defense' => $unit->defense,
'speed' => $unit->speed, 'speed' => $unit->speed,
'actionsRemaining' => $unit->actionsRemaining,
'hasAttacked' => $unit->hasAttacked,
'archetype' => $unit->archetype->value, 'archetype' => $unit->archetype->value,
'abilities' => $unit->abilities, 'abilities' => $unit->abilities,
'wadedThisTurn' => $unit->wadedThisTurn,
]; ];
} }
@@ -239,12 +242,13 @@ final class ScenarioSerializer
attack: (int) $row['attack'], attack: (int) $row['attack'],
defense: (int) $row['defense'], defense: (int) $row['defense'],
speed: (int) $row['speed'], speed: (int) $row['speed'],
actionsRemaining: 2, actionsRemaining: (int) ($row['actionsRemaining'] ?? 2),
hasAttacked: false, hasAttacked: (bool) ($row['hasAttacked'] ?? false),
archetype: $archetype, archetype: $archetype,
abilities: $abilities ?? [], abilities: $abilities ?? [],
attackBonus: 0, attackBonus: 0,
hasUsedAbility: false, hasUsedAbility: false,
wadedThisTurn: (bool) ($row['wadedThisTurn'] ?? false),
); );
} }
+20 -4
View File
@@ -38,11 +38,27 @@ final class CombatEngine
throw new CombatException('Destination is not reachable.'); throw new CombatException('Destination is not reachable.');
} }
$movedUnit = $unit->moveTo($destination)->spendAction(); $moved = $unit->moveTo($destination);
$next = $match->withUnit($movedUnit); $logParts = ["{$unit->id} moved to {$destination->key()}"];
$actionLog = [...$match->actionLog, "{$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 public function attack(MatchState $match, string $attackerId, string $targetId): MatchState
+1
View File
@@ -129,6 +129,7 @@ final readonly class Scenario
$unit->abilities, $unit->abilities,
0, 0,
false, false,
false,
); );
} }
+2 -1
View File
@@ -17,7 +17,8 @@ enum Terrain: string
return match ($this) { return match ($this) {
self::Open => 1, self::Open => 1,
self::Forest, self::Rough => 2, self::Forest, self::Rough => 2,
self::Water, self::Blocking => null, self::Water => 3,
self::Blocking => null,
}; };
} }
+9
View File
@@ -24,6 +24,7 @@ final readonly class UnitState
public array $abilities = [], public array $abilities = [],
public int $attackBonus = 0, public int $attackBonus = 0,
public bool $hasUsedAbility = false, public bool $hasUsedAbility = false,
public bool $wadedThisTurn = false,
) { ) {
if ($id === '') { if ($id === '') {
throw new InvalidArgumentException('Unit id cannot be empty.'); throw new InvalidArgumentException('Unit id cannot be empty.');
@@ -135,6 +136,11 @@ final readonly class UnitState
return $this->copy(attackBonus: $bonus); return $this->copy(attackBonus: $bonus);
} }
public function withWaded(bool $waded): self
{
return $this->copy(wadedThisTurn: $waded);
}
public function startTurn(): self public function startTurn(): self
{ {
if ($this->isDefeated()) { if ($this->isDefeated()) {
@@ -146,6 +152,7 @@ final readonly class UnitState
hasAttacked: false, hasAttacked: false,
attackBonus: 0, attackBonus: 0,
hasUsedAbility: false, hasUsedAbility: false,
wadedThisTurn: false,
); );
} }
@@ -156,6 +163,7 @@ final readonly class UnitState
?bool $hasAttacked = null, ?bool $hasAttacked = null,
?int $attackBonus = null, ?int $attackBonus = null,
?bool $hasUsedAbility = null, ?bool $hasUsedAbility = null,
?bool $wadedThisTurn = null,
): self { ): self {
return new self( return new self(
$this->id, $this->id,
@@ -172,6 +180,7 @@ final readonly class UnitState
$this->abilities, $this->abilities,
$attackBonus ?? $this->attackBonus, $attackBonus ?? $this->attackBonus,
$hasUsedAbility ?? $this->hasUsedAbility, $hasUsedAbility ?? $this->hasUsedAbility,
wadedThisTurn: $wadedThisTurn ?? $this->wadedThisTurn,
); );
} }
+53
View File
@@ -249,6 +249,59 @@ final class PostMatchActionTest extends TestCase
self::assertSame(409, $response->status); 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 public function testEndTurnHappyPathSwitchesTheActiveTeamAndIncrementsTheRoundAfterBothSides(): void
{ {
$match = $this->freshMatch(); $match = $this->freshMatch();
-1
View File
@@ -56,7 +56,6 @@ final class MatchStateTest extends TestCase
*/ */
public static function impassableTerrainProvider(): iterable public static function impassableTerrainProvider(): iterable
{ {
yield 'water' => [Terrain::Water];
yield 'blocking' => [Terrain::Blocking]; yield 'blocking' => [Terrain::Blocking];
} }
+2 -2
View File
@@ -28,7 +28,7 @@ final class ScenarioValidatorTest extends TestCase
public function testItRejectsAnObjectiveOnImpassableTerrain(): void public function testItRejectsAnObjectiveOnImpassableTerrain(): void
{ {
$scenario = $this->validScenario( $scenario = $this->validScenario(
terrain: ['4:4' => Terrain::Water], terrain: ['4:4' => Terrain::Blocking],
objectivePosition: new Position(4, 4), objectivePosition: new Position(4, 4),
); );
@@ -81,7 +81,7 @@ final class ScenarioValidatorTest extends TestCase
public function testItRejectsDeploymentPositionsOnImpassableTerrain(): void public function testItRejectsDeploymentPositionsOnImpassableTerrain(): void
{ {
$units = $this->unitSet(); $units = $this->unitSet();
$battlefield = new Battlefield(8, 8, ['0:0' => Terrain::Water]); $battlefield = new Battlefield(8, 8, ['0:0' => Terrain::Blocking]);
$scenario = new Scenario( $scenario = new Scenario(
id: 'demo', id: 'demo',
name: 'Demo', name: 'Demo',
+40
View File
@@ -0,0 +1,40 @@
<?php
declare(strict_types=1);
namespace BattleForge\Tests\Unit\Domain;
use BattleForge\Domain\Terrain;
use PHPUnit\Framework\TestCase;
final class TerrainTest extends TestCase
{
public function testOpenCostsOne(): void
{
self::assertSame(1, Terrain::Open->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());
}
}
+20
View File
@@ -229,12 +229,32 @@ final class UnitStateTest extends TestCase
self::assertFalse($started->hasUsedAbility); 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( private function unit(
int $health = 10, int $health = 10,
int $actionsRemaining = 2, int $actionsRemaining = 2,
bool $hasAttacked = false, bool $hasAttacked = false,
int $attackBonus = 0, int $attackBonus = 0,
bool $hasUsedAbility = false, bool $hasUsedAbility = false,
bool $wadedThisTurn = false,
): UnitState { ): UnitState {
return new UnitState( return new UnitState(
'unit-1', 'unit-1',