Final-review-driven changes (Tasks 6/M1, I1 from the ledger): - Drop the redundant abilities-list check in CombatEngine::useAbility. By construction (Scenario::__construct enforces the allowlist), any ability the unit 'knows' is also archetype-allowed; the second check alone is sufficient and removes a dead code path. - Update testUseAbilityRejectsAnAbilityTheArchetypeForbids to assert the 'Unit archetype forbids that ability.' message it actually exercises. - Compute $template once in Scenario::__construct's per-unit loop and drop the redundant catalog lookup on the next line.
161 lines
5.8 KiB
PHP
161 lines
5.8 KiB
PHP
<?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.");
|
|
}
|
|
|
|
$template = ArchetypeCatalog::templates()[$unit->archetype->value] ?? null;
|
|
if ($template === null) {
|
|
throw new InvalidArgumentException("Unit {$unit->id} uses an unknown archetype.");
|
|
}
|
|
|
|
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;
|
|
}
|
|
}
|