fix: enforce immutable match state invariants
This commit is contained in:
@@ -20,15 +20,74 @@ final readonly class MatchState
|
|||||||
public ?string $winnerTeamId = null,
|
public ?string $winnerTeamId = null,
|
||||||
public array $actionLog = [],
|
public array $actionLog = [],
|
||||||
) {
|
) {
|
||||||
|
if (!self::isList($units)) {
|
||||||
|
throw new InvalidArgumentException('Match units must be a list.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($units === []) {
|
||||||
|
throw new InvalidArgumentException('Match must contain at least one unit.');
|
||||||
|
}
|
||||||
|
|
||||||
$unitIds = [];
|
$unitIds = [];
|
||||||
|
$teamIds = [];
|
||||||
|
|
||||||
foreach ($units as $unit) {
|
foreach ($units as $unit) {
|
||||||
|
if (!self::isUnitState($unit)) {
|
||||||
|
throw new InvalidArgumentException('Match units must contain only UnitState instances.');
|
||||||
|
}
|
||||||
|
|
||||||
if (isset($unitIds[$unit->id])) {
|
if (isset($unitIds[$unit->id])) {
|
||||||
throw new InvalidArgumentException("Duplicate unit id: {$unit->id}.");
|
throw new InvalidArgumentException("Duplicate unit id: {$unit->id}.");
|
||||||
}
|
}
|
||||||
|
|
||||||
$unitIds[$unit->id] = true;
|
$unitIds[$unit->id] = true;
|
||||||
|
$teamIds[$unit->teamId] = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if ($activeTeamId === '') {
|
||||||
|
throw new InvalidArgumentException('Active team id cannot be empty.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isset($teamIds[$activeTeamId])) {
|
||||||
|
throw new InvalidArgumentException("Active team has no units: {$activeTeamId}.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($round < 1) {
|
||||||
|
throw new InvalidArgumentException('Match round must be at least 1.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!self::isList($actionLog)) {
|
||||||
|
throw new InvalidArgumentException('Match action log must be a list.');
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($actionLog as $entry) {
|
||||||
|
if (!self::isString($entry)) {
|
||||||
|
throw new InvalidArgumentException('Match action log entries must be strings.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($winnerTeamId !== null && $winnerTeamId === '') {
|
||||||
|
throw new InvalidArgumentException('Winner team id cannot be empty.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($winnerTeamId !== null && !isset($teamIds[$winnerTeamId])) {
|
||||||
|
throw new InvalidArgumentException("Winner team has no units: {$winnerTeamId}.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function isUnitState(mixed $unit): bool
|
||||||
|
{
|
||||||
|
return $unit instanceof UnitState;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function isString(mixed $value): bool
|
||||||
|
{
|
||||||
|
return is_string($value);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function isList(mixed $value): bool
|
||||||
|
{
|
||||||
|
return is_array($value) && array_is_list($value);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function unit(string $id): UnitState
|
public function unit(string $id): UnitState
|
||||||
@@ -57,6 +116,18 @@ final readonly class MatchState
|
|||||||
throw new InvalidArgumentException("Unknown unit id: {$replacement->id}.");
|
throw new InvalidArgumentException("Unknown unit id: {$replacement->id}.");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function withWinner(?string $winnerTeamId): self
|
||||||
|
{
|
||||||
|
return new self(
|
||||||
|
$this->battlefield,
|
||||||
|
$this->units,
|
||||||
|
$this->activeTeamId,
|
||||||
|
$this->round,
|
||||||
|
$winnerTeamId,
|
||||||
|
$this->actionLog,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param list<UnitState>|null $units
|
* @param list<UnitState>|null $units
|
||||||
* @param list<string>|null $actionLog
|
* @param list<string>|null $actionLog
|
||||||
@@ -65,7 +136,6 @@ final readonly class MatchState
|
|||||||
?array $units = null,
|
?array $units = null,
|
||||||
?string $activeTeamId = null,
|
?string $activeTeamId = null,
|
||||||
?int $round = null,
|
?int $round = null,
|
||||||
?string $winnerTeamId = null,
|
|
||||||
?array $actionLog = null,
|
?array $actionLog = null,
|
||||||
): self {
|
): self {
|
||||||
return new self(
|
return new self(
|
||||||
@@ -73,7 +143,7 @@ final readonly class MatchState
|
|||||||
$units ?? $this->units,
|
$units ?? $this->units,
|
||||||
$activeTeamId ?? $this->activeTeamId,
|
$activeTeamId ?? $this->activeTeamId,
|
||||||
$round ?? $this->round,
|
$round ?? $this->round,
|
||||||
$winnerTeamId ?? $this->winnerTeamId,
|
$this->winnerTeamId,
|
||||||
$actionLog ?? $this->actionLog,
|
$actionLog ?? $this->actionLog,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,182 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace BattleForge\Tests\Unit\Domain;
|
||||||
|
|
||||||
|
use BattleForge\Domain\Battlefield;
|
||||||
|
use BattleForge\Domain\MatchState;
|
||||||
|
use BattleForge\Domain\Position;
|
||||||
|
use BattleForge\Domain\UnitState;
|
||||||
|
use InvalidArgumentException;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
|
||||||
|
final class MatchStateTest extends TestCase
|
||||||
|
{
|
||||||
|
public function testItRejectsDuplicateUnitIds(): void
|
||||||
|
{
|
||||||
|
$this->expectException(InvalidArgumentException::class);
|
||||||
|
|
||||||
|
new MatchState(
|
||||||
|
new Battlefield(8, 8),
|
||||||
|
[$this->unit('unit-1', 'alpha'), $this->unit('unit-1', 'bravo')],
|
||||||
|
'alpha',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testItRejectsAnUnknownUnitLookup(): void
|
||||||
|
{
|
||||||
|
$match = $this->match();
|
||||||
|
|
||||||
|
$this->expectException(InvalidArgumentException::class);
|
||||||
|
$match->unit('unknown');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testItRejectsAnUnknownReplacement(): void
|
||||||
|
{
|
||||||
|
$match = $this->match();
|
||||||
|
|
||||||
|
$this->expectException(InvalidArgumentException::class);
|
||||||
|
$match->withUnit($this->unit('unknown', 'alpha'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testReplacingAUnitDoesNotMutateTheMatch(): void
|
||||||
|
{
|
||||||
|
$match = $this->match();
|
||||||
|
$replacement = $match->unit('alpha-1')->moveTo(new Position(2, 0));
|
||||||
|
$replaced = $match->withUnit($replacement);
|
||||||
|
|
||||||
|
self::assertSame('0:0', $match->unit('alpha-1')->position->key());
|
||||||
|
self::assertSame('2:0', $replaced->unit('alpha-1')->position->key());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testCopyReplacesMutableMatchProgressIncludingAnEmptyLog(): void
|
||||||
|
{
|
||||||
|
$match = $this->match(actionLog: ['started']);
|
||||||
|
$copied = $match->copy(activeTeamId: 'bravo', round: 2, actionLog: []);
|
||||||
|
|
||||||
|
self::assertSame('alpha', $match->activeTeamId);
|
||||||
|
self::assertSame(1, $match->round);
|
||||||
|
self::assertSame(['started'], $match->actionLog);
|
||||||
|
self::assertSame('bravo', $copied->activeTeamId);
|
||||||
|
self::assertSame(2, $copied->round);
|
||||||
|
self::assertSame([], $copied->actionLog);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testWinnerCanBeSetAndClearedWithoutChangingOtherState(): void
|
||||||
|
{
|
||||||
|
$match = $this->match(actionLog: ['started']);
|
||||||
|
$won = $match->withWinner('alpha');
|
||||||
|
$cleared = $won->withWinner(null);
|
||||||
|
|
||||||
|
self::assertSame('alpha', $won->winnerTeamId);
|
||||||
|
self::assertNull($cleared->winnerTeamId);
|
||||||
|
self::assertSame($match->battlefield, $cleared->battlefield);
|
||||||
|
self::assertSame($match->units, $cleared->units);
|
||||||
|
self::assertSame($match->activeTeamId, $cleared->activeTeamId);
|
||||||
|
self::assertSame($match->round, $cleared->round);
|
||||||
|
self::assertSame($match->actionLog, $cleared->actionLog);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testItRejectsANonListUnitsArray(): void
|
||||||
|
{
|
||||||
|
$this->expectException(InvalidArgumentException::class);
|
||||||
|
|
||||||
|
// @phpstan-ignore argument.type
|
||||||
|
new MatchState(new Battlefield(8, 8), ['first' => $this->unit('alpha-1', 'alpha')], 'alpha');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testItRejectsAnEmptyUnitsArray(): void
|
||||||
|
{
|
||||||
|
$this->expectException(InvalidArgumentException::class);
|
||||||
|
|
||||||
|
new MatchState(new Battlefield(8, 8), [], 'alpha');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testItRejectsANonUnitMember(): void
|
||||||
|
{
|
||||||
|
$this->expectException(InvalidArgumentException::class);
|
||||||
|
|
||||||
|
// @phpstan-ignore argument.type
|
||||||
|
new MatchState(new Battlefield(8, 8), ['not-a-unit'], 'alpha');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testItRejectsAnEmptyActiveTeam(): void
|
||||||
|
{
|
||||||
|
$this->expectException(InvalidArgumentException::class);
|
||||||
|
|
||||||
|
new MatchState(new Battlefield(8, 8), [$this->unit('alpha-1', 'alpha')], '');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testItRejectsAnActiveTeamWithoutAUnit(): void
|
||||||
|
{
|
||||||
|
$this->expectException(InvalidArgumentException::class);
|
||||||
|
|
||||||
|
new MatchState(new Battlefield(8, 8), [$this->unit('alpha-1', 'alpha')], 'bravo');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testItRejectsARoundBelowOne(): void
|
||||||
|
{
|
||||||
|
$this->expectException(InvalidArgumentException::class);
|
||||||
|
|
||||||
|
new MatchState(new Battlefield(8, 8), [$this->unit('alpha-1', 'alpha')], 'alpha', 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testItRejectsANonListActionLog(): void
|
||||||
|
{
|
||||||
|
$this->expectException(InvalidArgumentException::class);
|
||||||
|
|
||||||
|
new MatchState(
|
||||||
|
new Battlefield(8, 8),
|
||||||
|
[$this->unit('alpha-1', 'alpha')],
|
||||||
|
'alpha',
|
||||||
|
// @phpstan-ignore argument.type
|
||||||
|
actionLog: ['first' => 'started'],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testItRejectsANonStringActionLogEntry(): void
|
||||||
|
{
|
||||||
|
$this->expectException(InvalidArgumentException::class);
|
||||||
|
|
||||||
|
// @phpstan-ignore argument.type
|
||||||
|
new MatchState(new Battlefield(8, 8), [$this->unit('alpha-1', 'alpha')], 'alpha', actionLog: [1]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testItRejectsAnEmptyWinnerTeam(): void
|
||||||
|
{
|
||||||
|
$this->expectException(InvalidArgumentException::class);
|
||||||
|
|
||||||
|
new MatchState(new Battlefield(8, 8), [$this->unit('alpha-1', 'alpha')], 'alpha', winnerTeamId: '');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testItRejectsAWinnerWithoutAUnit(): void
|
||||||
|
{
|
||||||
|
$this->expectException(InvalidArgumentException::class);
|
||||||
|
|
||||||
|
new MatchState(
|
||||||
|
new Battlefield(8, 8),
|
||||||
|
[$this->unit('alpha-1', 'alpha')],
|
||||||
|
'alpha',
|
||||||
|
winnerTeamId: 'bravo',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param list<string> $actionLog
|
||||||
|
*/
|
||||||
|
private function match(array $actionLog = []): MatchState
|
||||||
|
{
|
||||||
|
return new MatchState(
|
||||||
|
new Battlefield(8, 8),
|
||||||
|
[$this->unit('alpha-1', 'alpha'), $this->unit('bravo-1', 'bravo')],
|
||||||
|
'alpha',
|
||||||
|
actionLog: $actionLog,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function unit(string $id, string $teamId): UnitState
|
||||||
|
{
|
||||||
|
return new UnitState($id, $teamId, new Position(0, 0), 10, 10, 4, 2, 4, 2);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,153 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace BattleForge\Tests\Unit\Domain;
|
||||||
|
|
||||||
|
use BattleForge\Domain\Position;
|
||||||
|
use BattleForge\Domain\UnitState;
|
||||||
|
use InvalidArgumentException;
|
||||||
|
use PHPUnit\Framework\Attributes\DataProvider;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
|
||||||
|
final class UnitStateTest extends TestCase
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @param callable(): UnitState $createUnit
|
||||||
|
*/
|
||||||
|
#[DataProvider('invalidUnitProvider')]
|
||||||
|
public function testItRejectsInvalidConstructorArguments(callable $createUnit): void
|
||||||
|
{
|
||||||
|
$this->expectException(InvalidArgumentException::class);
|
||||||
|
|
||||||
|
$createUnit();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return iterable<string, array{callable(): UnitState}>
|
||||||
|
*/
|
||||||
|
public static function invalidUnitProvider(): iterable
|
||||||
|
{
|
||||||
|
yield 'empty unit id' => [
|
||||||
|
static fn (): UnitState => new UnitState('', 'alpha', new Position(0, 0), 10, 10, 4, 2, 4, 2),
|
||||||
|
];
|
||||||
|
yield 'empty team id' => [
|
||||||
|
static fn (): UnitState => new UnitState('unit-1', '', new Position(0, 0), 10, 10, 4, 2, 4, 2),
|
||||||
|
];
|
||||||
|
yield 'zero maximum health' => [
|
||||||
|
static fn (): UnitState => new UnitState('unit-1', 'alpha', new Position(0, 0), 0, 0, 4, 2, 4, 2),
|
||||||
|
];
|
||||||
|
yield 'negative health' => [
|
||||||
|
static fn (): UnitState => new UnitState('unit-1', 'alpha', new Position(0, 0), 10, -1, 4, 2, 4, 2),
|
||||||
|
];
|
||||||
|
yield 'health above maximum' => [
|
||||||
|
static fn (): UnitState => new UnitState('unit-1', 'alpha', new Position(0, 0), 10, 11, 4, 2, 4, 2),
|
||||||
|
];
|
||||||
|
yield 'negative attack' => [
|
||||||
|
static fn (): UnitState => new UnitState('unit-1', 'alpha', new Position(0, 0), 10, 10, -1, 2, 4, 2),
|
||||||
|
];
|
||||||
|
yield 'negative defense' => [
|
||||||
|
static fn (): UnitState => new UnitState('unit-1', 'alpha', new Position(0, 0), 10, 10, 4, -1, 4, 2),
|
||||||
|
];
|
||||||
|
yield 'zero speed' => [
|
||||||
|
static fn (): UnitState => new UnitState('unit-1', 'alpha', new Position(0, 0), 10, 10, 4, 2, 0, 2),
|
||||||
|
];
|
||||||
|
yield 'negative actions remaining' => [
|
||||||
|
static fn (): UnitState => new UnitState('unit-1', 'alpha', new Position(0, 0), 10, 10, 4, 2, 4, -1),
|
||||||
|
];
|
||||||
|
yield 'too many actions remaining' => [
|
||||||
|
static fn (): UnitState => new UnitState('unit-1', 'alpha', new Position(0, 0), 10, 10, 4, 2, 4, 3),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testItReportsWhetherItIsDefeated(): void
|
||||||
|
{
|
||||||
|
self::assertTrue($this->unit(health: 0)->isDefeated());
|
||||||
|
self::assertFalse($this->unit(health: 1)->isDefeated());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testMovingReturnsAChangedCopy(): void
|
||||||
|
{
|
||||||
|
$unit = $this->unit();
|
||||||
|
$moved = $unit->moveTo(new Position(2, 3));
|
||||||
|
|
||||||
|
self::assertNotSame($unit, $moved);
|
||||||
|
self::assertSame('0:0', $unit->position->key());
|
||||||
|
self::assertSame('2:3', $moved->position->key());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testSpendingAnActionReturnsAChangedCopyAndRejectsExhaustedUnits(): void
|
||||||
|
{
|
||||||
|
$unit = $this->unit(actionsRemaining: 1);
|
||||||
|
$spent = $unit->spendAction();
|
||||||
|
|
||||||
|
self::assertSame(1, $unit->actionsRemaining);
|
||||||
|
self::assertSame(0, $spent->actionsRemaining);
|
||||||
|
|
||||||
|
$this->expectException(InvalidArgumentException::class);
|
||||||
|
$spent->spendAction();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testDamageCannotReduceHealthBelowZero(): void
|
||||||
|
{
|
||||||
|
$unit = $this->unit(health: 3);
|
||||||
|
$damaged = $unit->takeDamage(5);
|
||||||
|
|
||||||
|
self::assertSame(3, $unit->health);
|
||||||
|
self::assertSame(0, $damaged->health);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testNegativeDamageIsANoOp(): void
|
||||||
|
{
|
||||||
|
$unit = $this->unit(health: 7);
|
||||||
|
$damaged = $unit->takeDamage(-3);
|
||||||
|
|
||||||
|
self::assertNotSame($unit, $damaged);
|
||||||
|
self::assertSame(7, $damaged->health);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testMarkingAttackedReturnsAChangedCopy(): void
|
||||||
|
{
|
||||||
|
$unit = $this->unit();
|
||||||
|
$attacked = $unit->markAttacked();
|
||||||
|
|
||||||
|
self::assertFalse($unit->hasAttacked);
|
||||||
|
self::assertTrue($attacked->hasAttacked);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testStartingTurnResetsALivingUnit(): void
|
||||||
|
{
|
||||||
|
$unit = $this->unit(actionsRemaining: 0, hasAttacked: true);
|
||||||
|
$started = $unit->startTurn();
|
||||||
|
|
||||||
|
self::assertNotSame($unit, $started);
|
||||||
|
self::assertSame(2, $started->actionsRemaining);
|
||||||
|
self::assertFalse($started->hasAttacked);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testStartingTurnReturnsTheSameDefeatedUnit(): void
|
||||||
|
{
|
||||||
|
$unit = $this->unit(health: 0, actionsRemaining: 0, hasAttacked: true);
|
||||||
|
|
||||||
|
self::assertSame($unit, $unit->startTurn());
|
||||||
|
}
|
||||||
|
|
||||||
|
private function unit(
|
||||||
|
int $health = 10,
|
||||||
|
int $actionsRemaining = 2,
|
||||||
|
bool $hasAttacked = false,
|
||||||
|
): UnitState {
|
||||||
|
return new UnitState(
|
||||||
|
'unit-1',
|
||||||
|
'alpha',
|
||||||
|
new Position(0, 0),
|
||||||
|
10,
|
||||||
|
$health,
|
||||||
|
4,
|
||||||
|
2,
|
||||||
|
4,
|
||||||
|
$actionsRemaining,
|
||||||
|
$hasAttacked,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user