feat(combat): water traversal costs 3 and ends the turn
This commit is contained in:
@@ -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),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -129,6 +129,7 @@ final readonly class Scenario
|
|||||||
$unit->abilities,
|
$unit->abilities,
|
||||||
0,
|
0,
|
||||||
false,
|
false,
|
||||||
|
false,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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',
|
||||||
|
|||||||
Reference in New Issue
Block a user