feat: model scenarios and start match snapshots
This commit is contained in:
@@ -11,6 +11,8 @@ final readonly class MatchState
|
||||
/**
|
||||
* @param list<UnitState> $units
|
||||
* @param list<string> $actionLog
|
||||
* @param array<string, ObjectiveMarker> $objectives
|
||||
* @param array<string, int> $objectiveControl
|
||||
*/
|
||||
public function __construct(
|
||||
public Battlefield $battlefield,
|
||||
@@ -19,6 +21,10 @@ final readonly class MatchState
|
||||
public int $round = 1,
|
||||
public ?string $winnerTeamId = null,
|
||||
public array $actionLog = [],
|
||||
public array $objectives = [],
|
||||
public VictoryCondition $victoryCondition = VictoryCondition::EliminateAll,
|
||||
public int $holdRoundsRequired = 1,
|
||||
public array $objectiveControl = [],
|
||||
) {
|
||||
if (!self::isList($units)) {
|
||||
throw new InvalidArgumentException('Match units must be a list.');
|
||||
@@ -92,6 +98,28 @@ final readonly class MatchState
|
||||
if ($winnerTeamId !== null && !isset($teamIds[$winnerTeamId])) {
|
||||
throw new InvalidArgumentException("Winner team has no units: {$winnerTeamId}.");
|
||||
}
|
||||
|
||||
if ($holdRoundsRequired < 1) {
|
||||
throw new InvalidArgumentException('Hold rounds required must be at least 1.');
|
||||
}
|
||||
|
||||
foreach ($objectives as $objective) {
|
||||
if (!$objective instanceof ObjectiveMarker) { // @phpstan-ignore instanceof.alwaysTrue (Runtime guard: PHPDoc is not a runtime contract for JSON-sourced arrays in Plan 4.)
|
||||
throw new InvalidArgumentException('Objectives must be ObjectiveMarker instances.');
|
||||
}
|
||||
if (!$battlefield->contains($objective->position)) {
|
||||
throw new InvalidArgumentException("Objective {$objective->id} is outside the battlefield.");
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($objectiveControl as $teamId => $count) {
|
||||
if (!self::isString($teamId) || $teamId === '') {
|
||||
throw new InvalidArgumentException('Objective control team id must be a non-empty string.');
|
||||
}
|
||||
if (!self::isInt($count) || $count < 0) {
|
||||
throw new InvalidArgumentException('Objective control count must be a non-negative integer.');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static function isUnitState(mixed $unit): bool
|
||||
@@ -104,6 +132,11 @@ final readonly class MatchState
|
||||
return is_string($value);
|
||||
}
|
||||
|
||||
private static function isInt(mixed $value): bool
|
||||
{
|
||||
return is_int($value);
|
||||
}
|
||||
|
||||
private static function isList(mixed $value): bool
|
||||
{
|
||||
return is_array($value) && array_is_list($value);
|
||||
@@ -144,18 +177,45 @@ final readonly class MatchState
|
||||
$this->round,
|
||||
$winnerTeamId,
|
||||
$this->actionLog,
|
||||
$this->objectives,
|
||||
$this->victoryCondition,
|
||||
$this->holdRoundsRequired,
|
||||
$this->objectiveControl,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, int> $objectiveControl
|
||||
*/
|
||||
public function withObjectiveControl(array $objectiveControl): self
|
||||
{
|
||||
return new self(
|
||||
$this->battlefield,
|
||||
$this->units,
|
||||
$this->activeTeamId,
|
||||
$this->round,
|
||||
$this->winnerTeamId,
|
||||
$this->actionLog,
|
||||
$this->objectives,
|
||||
$this->victoryCondition,
|
||||
$this->holdRoundsRequired,
|
||||
$objectiveControl,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<UnitState>|null $units
|
||||
* @param list<string>|null $actionLog
|
||||
* @param array<string, ObjectiveMarker>|null $objectives
|
||||
* @param array<string, int>|null $objectiveControl
|
||||
*/
|
||||
public function copy(
|
||||
?array $units = null,
|
||||
?string $activeTeamId = null,
|
||||
?int $round = null,
|
||||
?array $actionLog = null,
|
||||
?array $objectives = null,
|
||||
?array $objectiveControl = null,
|
||||
): self {
|
||||
return new self(
|
||||
$this->battlefield,
|
||||
@@ -164,6 +224,10 @@ final readonly class MatchState
|
||||
$round ?? $this->round,
|
||||
$this->winnerTeamId,
|
||||
$actionLog ?? $this->actionLog,
|
||||
$objectives ?? $this->objectives,
|
||||
$this->victoryCondition,
|
||||
$this->holdRoundsRequired,
|
||||
$objectiveControl ?? $this->objectiveControl,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace BattleForge\Domain;
|
||||
|
||||
use InvalidArgumentException;
|
||||
|
||||
final readonly class Scenario
|
||||
{
|
||||
/**
|
||||
* @param list<UnitState> $units
|
||||
* @param array<string, DeploymentZone> $deploymentZones
|
||||
* @param array<string, ObjectiveMarker> $objectives
|
||||
*/
|
||||
public function __construct(
|
||||
public string $id,
|
||||
public string $name,
|
||||
public Battlefield $battlefield,
|
||||
public array $units,
|
||||
public array $deploymentZones,
|
||||
public array $objectives,
|
||||
public VictoryCondition $victoryCondition,
|
||||
public int $holdRoundsRequired,
|
||||
) {
|
||||
if ($id === '') {
|
||||
throw new InvalidArgumentException('Scenario id cannot be empty.');
|
||||
}
|
||||
|
||||
if ($name === '') {
|
||||
throw new InvalidArgumentException('Scenario name cannot be empty.');
|
||||
}
|
||||
|
||||
if (!array_is_list($units)) { // @phpstan-ignore function.alreadyNarrowedType (Runtime guard: PHPDoc is not a runtime contract for JSON-sourced arrays in Plan 4.)
|
||||
throw new InvalidArgumentException('Scenario units must be a list.');
|
||||
}
|
||||
|
||||
if ($deploymentZones === []) {
|
||||
throw new InvalidArgumentException('Scenario must declare at least one deployment zone.');
|
||||
}
|
||||
|
||||
if (count($deploymentZones) !== 2) {
|
||||
throw new InvalidArgumentException('Scenarios must declare exactly two deployment zones.');
|
||||
}
|
||||
|
||||
if ($holdRoundsRequired < 1) {
|
||||
throw new InvalidArgumentException('Hold rounds required must be at least 1.');
|
||||
}
|
||||
|
||||
if ($victoryCondition === VictoryCondition::HoldObjective && count($this->objectives) !== 1) {
|
||||
throw new InvalidArgumentException('Hold objective victory requires exactly one objective.');
|
||||
}
|
||||
|
||||
if ($victoryCondition === VictoryCondition::EliminateAll && $this->objectives !== []) {
|
||||
throw new InvalidArgumentException('Eliminate all victory does not allow objectives.');
|
||||
}
|
||||
|
||||
$unitsByTeam = $this->groupUnitsByTeam($units);
|
||||
|
||||
foreach ($unitsByTeam as $teamId => $teamUnits) {
|
||||
if (count($teamUnits) < 3 || count($teamUnits) > 6) {
|
||||
throw new InvalidArgumentException("Team {$teamId} must have between 3 and 6 units.");
|
||||
}
|
||||
|
||||
if (!isset($deploymentZones[$teamId])) {
|
||||
throw new InvalidArgumentException("Team {$teamId} is missing a deployment zone.");
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($units as $unit) {
|
||||
if (!$this->battlefield->contains($unit->position)) {
|
||||
throw new InvalidArgumentException("Unit {$unit->id} is outside the battlefield.");
|
||||
}
|
||||
|
||||
if (!ArchetypeCatalog::templates()[$unit->archetype->value] ?? false) { // @phpstan-ignore nullCoalesce.expr, if.alwaysFalse, booleanNot.alwaysFalse (Runtime guard: defense in depth against an unknown Archetype enum value.)
|
||||
throw new InvalidArgumentException("Unit {$unit->id} uses an unknown archetype.");
|
||||
}
|
||||
|
||||
$template = ArchetypeCatalog::templates()[$unit->archetype->value];
|
||||
|
||||
if ($unit->maxHealth < $template->minHealth || $unit->maxHealth > $template->maxHealth) {
|
||||
throw new InvalidArgumentException("Unit {$unit->id} health is outside archetype bounds.");
|
||||
}
|
||||
if ($unit->attack < $template->minAttack || $unit->attack > $template->maxAttack) {
|
||||
throw new InvalidArgumentException("Unit {$unit->id} attack is outside archetype bounds.");
|
||||
}
|
||||
if ($unit->defense < $template->minDefense || $unit->defense > $template->maxDefense) {
|
||||
throw new InvalidArgumentException("Unit {$unit->id} defense is outside archetype bounds.");
|
||||
}
|
||||
if ($unit->speed < $template->minSpeed || $unit->speed > $template->maxSpeed) {
|
||||
throw new InvalidArgumentException("Unit {$unit->id} speed is outside archetype bounds.");
|
||||
}
|
||||
|
||||
foreach ($unit->abilities as $ability) {
|
||||
if (!in_array($ability, $template->allowedAbilities, true)) {
|
||||
throw new InvalidArgumentException("Unit {$unit->id} has an ability that its archetype forbids.");
|
||||
}
|
||||
}
|
||||
|
||||
$zone = $deploymentZones[$unit->teamId] ?? null;
|
||||
if ($zone === null || !$zone->contains($unit->position)) {
|
||||
throw new InvalidArgumentException("Unit {$unit->id} must start inside team {$unit->teamId} deployment zone.");
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($objectives as $objective) {
|
||||
if (!$this->battlefield->contains($objective->position)) {
|
||||
throw new InvalidArgumentException("Objective {$objective->id} is outside the battlefield.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public function startMatch(string $activeTeamId): MatchState
|
||||
{
|
||||
$resetUnits = [];
|
||||
|
||||
foreach ($this->units as $unit) {
|
||||
$resetUnits[] = new UnitState(
|
||||
$unit->id,
|
||||
$unit->teamId,
|
||||
$unit->position,
|
||||
$unit->maxHealth,
|
||||
$unit->maxHealth,
|
||||
$unit->attack,
|
||||
$unit->defense,
|
||||
$unit->speed,
|
||||
2,
|
||||
false,
|
||||
$unit->archetype,
|
||||
$unit->abilities,
|
||||
0,
|
||||
false,
|
||||
);
|
||||
}
|
||||
|
||||
return new MatchState(
|
||||
battlefield: $this->battlefield,
|
||||
units: $resetUnits,
|
||||
activeTeamId: $activeTeamId,
|
||||
objectives: $this->objectives,
|
||||
victoryCondition: $this->victoryCondition,
|
||||
holdRoundsRequired: $this->holdRoundsRequired,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<UnitState> $units
|
||||
* @return array<string, list<UnitState>>
|
||||
*/
|
||||
private function groupUnitsByTeam(array $units): array
|
||||
{
|
||||
$grouped = [];
|
||||
|
||||
foreach ($units as $unit) {
|
||||
$grouped[$unit->teamId] ??= [];
|
||||
$grouped[$unit->teamId][] = $unit;
|
||||
}
|
||||
|
||||
return $grouped;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user