feat: model scenarios and start match snapshots
This commit is contained in:
@@ -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