feat: model scenarios and start match snapshots

This commit is contained in:
Keith Solomon
2026-07-05 19:27:22 -05:00
parent 06632ff9b3
commit 088e85c9b4
3 changed files with 470 additions and 0 deletions
+161
View File
@@ -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;
}
}